Skip to content

Commit 2259efe

Browse files
committed
Mypy Compatible tests for cycler
- Convert to package instead of single file module - add py.typed (including setup.py changes) - add a CI workflow to run mypy - Add the actual type hints inline Type hints are generic over both the key type (most commonly strings) and the value type. I initially tried to be extra cute and encompass the type expansion that can happen when cyclers of different types are added/multiplied, but that proved to be more of a hassle to deal with than I was willing to deal with initially. As such, the example from the `concat` docstring will actually fail to properly type check with implicit types (as Cycler[str, int] cannot be concat'd with Cycler[str, str] and properly typecheck) This may be solvable (resulting in a Cycler[str, int|str]) but that is not yet implemented. It may be resolved in downstream code somewhat crudely by typing both operands explicitly as Cycler[str, int|str] from the outset. Outside of that corner case, should be entirely correct, I believe.
1 parent 48b6968 commit 2259efe

4 files changed

Lines changed: 99 additions & 42 deletions

File tree

.github/workflows/mypy.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
3+
name: Mypy
4+
5+
on:
6+
push:
7+
branches-ignore:
8+
- auto-backport-of-pr-[0-9]+
9+
- v[0-9]+.[0-9]+.[0-9x]+-doc
10+
pull_request:
11+
12+
jobs:
13+
test:
14+
name: "Mypy"
15+
runs-on: ubuntu-20.04
16+
17+
steps:
18+
- uses: actions/checkout@v3
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Set up Python
23+
uses: actions/setup-python@v4
24+
25+
- name: Install Python dependencies
26+
run: |
27+
python -m pip install --upgrade pip setuptools wheel
28+
python -m pip install --upgrade mypy
29+
30+
- name: Install cycler
31+
run: |
32+
python -m pip install --no-deps .
33+
34+
- name: Run mypy
35+
run: |
36+
mypy cycler test_cycler.py

cycler.py renamed to cycler/__init__.py

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,21 @@
4141
"""
4242

4343

44+
from __future__ import annotations
45+
46+
from collections.abc import Hashable, Iterable, Callable
4447
import copy
4548
from functools import reduce
4649
from itertools import product, cycle
4750
from operator import mul, add
51+
from typing import TypeVar, Generic, Generator, Any, overload
4852

4953
__version__ = '0.12.0.dev0'
5054

55+
K = TypeVar("K", bound=Hashable)
56+
V = TypeVar("V")
5157

52-
def _process_keys(left, right):
58+
def _process_keys(left: Cycler[K, V]|Iterable[dict[K,V]]|None, right: Cycler[K, V]|Iterable[dict[K,V]]|None) -> set[K]:
5359
"""
5460
Helper function to compose cycler keys.
5561
@@ -63,16 +69,16 @@ def _process_keys(left, right):
6369
keys : set
6470
The keys in the composition of the two cyclers.
6571
"""
66-
l_peek = next(iter(left)) if left is not None else {}
67-
r_peek = next(iter(right)) if right is not None else {}
68-
l_key = set(l_peek.keys())
69-
r_key = set(r_peek.keys())
72+
l_peek: dict[K, V] = next(iter(left)) if left is not None else {}
73+
r_peek: dict[K, V] = next(iter(right)) if right is not None else {}
74+
l_key: set[K] = set(l_peek.keys())
75+
r_key: set[K] = set(r_peek.keys())
7076
if l_key & r_key:
7177
raise ValueError("Can not compose overlapping cycles")
7278
return l_key | r_key
7379

7480

75-
def concat(left, right):
81+
def concat(left: Cycler[K, V], right: Cycler[K, V]) -> Cycler[K, V]:
7682
r"""
7783
Concatenate `Cycler`\s, as if chained using `itertools.chain`.
7884
@@ -100,8 +106,7 @@ def concat(left, right):
100106
_r = right.by_key()
101107
return reduce(add, (_cycler(k, _l[k] + _r[k]) for k in left.keys))
102108

103-
104-
class Cycler:
109+
class Cycler(Generic[K, V]):
105110
"""
106111
Composable cycles.
107112
@@ -132,14 +137,14 @@ class Cycler:
132137
def __call__(self):
133138
return cycle(self)
134139

135-
def __init__(self, left, right=None, op=None):
140+
def __init__(self, left: Cycler[K, V] | Iterable[dict[K,V]] | None, right: Cycler[K, V] | Iterable[dict[K,V]] | None=None, op: Any=None):
136141
"""
137142
Semi-private init.
138143
139144
Do not use this directly, use `cycler` function instead.
140145
"""
141146
if isinstance(left, Cycler):
142-
self._left = Cycler(left._left, left._right, left._op)
147+
self._left: Cycler[K, V] | list[dict[K,V]] | None = Cycler(left._left, left._right, left._op)
143148
elif left is not None:
144149
# Need to copy the dictionary or else that will be a residual
145150
# mutable that could lead to strange errors
@@ -148,26 +153,26 @@ def __init__(self, left, right=None, op=None):
148153
self._left = None
149154

150155
if isinstance(right, Cycler):
151-
self._right = Cycler(right._left, right._right, right._op)
156+
self._right: Cycler[K, V] | list[dict[K,V]] | None = Cycler(right._left, right._right, right._op)
152157
elif right is not None:
153158
# Need to copy the dictionary or else that will be a residual
154159
# mutable that could lead to strange errors
155160
self._right = [copy.copy(v) for v in right]
156161
else:
157162
self._right = None
158163

159-
self._keys = _process_keys(self._left, self._right)
160-
self._op = op
164+
self._keys: set[K] = _process_keys(self._left, self._right)
165+
self._op: Any = op
161166

162167
def __contains__(self, k):
163168
return k in self._keys
164169

165170
@property
166-
def keys(self):
171+
def keys(self) -> set[K]:
167172
"""The keys this Cycler knows about."""
168173
return set(self._keys)
169174

170-
def change_key(self, old, new):
175+
def change_key(self, old: K, new: K) -> None:
171176
"""
172177
Change a key in this cycler to a new name.
173178
Modification is performed in-place.
@@ -190,11 +195,12 @@ def change_key(self, old, new):
190195
self._keys.remove(old)
191196
self._keys.add(new)
192197

193-
if self._right is not None and old in self._right.keys:
198+
if self._right is not None and isinstance(self._right, Cycler) and old in self._right.keys:
194199
self._right.change_key(old, new)
195200

196201
# self._left should always be non-None
197202
# if self._keys is non-empty.
203+
elif self._left is None: pass
198204
elif isinstance(self._left, Cycler):
199205
self._left.change_key(old, new)
200206
else:
@@ -204,15 +210,15 @@ def change_key(self, old, new):
204210
self._left = [{new: entry[old]} for entry in self._left]
205211

206212
@classmethod
207-
def _from_iter(cls, label, itr):
213+
def _from_iter(cls, label: K, itr: Iterable[V]) -> Cycler[K, V]:
208214
"""
209215
Class method to create 'base' Cycler objects
210216
that do not have a 'right' or 'op' and for which
211217
the 'left' object is not another Cycler.
212218
213219
Parameters
214220
----------
215-
label : str
221+
label : hashable
216222
The property key.
217223
218224
itr : iterable
@@ -223,31 +229,34 @@ def _from_iter(cls, label, itr):
223229
`Cycler`
224230
New 'base' cycler.
225231
"""
226-
ret = cls(None)
232+
ret: Cycler[K, V] = cls(None)
227233
ret._left = list({label: v} for v in itr)
228234
ret._keys = {label}
229235
return ret
230236

231-
def __getitem__(self, key):
237+
def __getitem__(self, key: slice) -> Cycler[K, V]:
232238
# TODO : maybe add numpy style fancy slicing
233239
if isinstance(key, slice):
234240
trans = self.by_key()
235241
return reduce(add, (_cycler(k, v[key]) for k, v in trans.items()))
236242
else:
237243
raise ValueError("Can only use slices with Cycler.__getitem__")
238244

239-
def __iter__(self):
240-
if self._right is None:
241-
for left in self._left:
242-
yield dict(left)
245+
def __iter__(self) -> Generator[dict[K, V], None, None]:
246+
if self._right is None or self._left is None:
247+
if self._left is not None:
248+
for left in self._left:
249+
yield dict(left)
243250
else:
251+
if self._op is None:
252+
raise TypeError("Operation cannot be None when both left and right are defined")
244253
for a, b in self._op(self._left, self._right):
245254
out = {}
246255
out.update(a)
247256
out.update(b)
248257
yield out
249258

250-
def __add__(self, other):
259+
def __add__(self, other: Cycler[K, V]) -> Cycler[K, V]:
251260
"""
252261
Pair-wise combine two equal length cyclers (zip).
253262
@@ -260,7 +269,7 @@ def __add__(self, other):
260269
f"not {len(self)} and {len(other)}")
261270
return Cycler(self, other, zip)
262271

263-
def __mul__(self, other):
272+
def __mul__(self, other: Cycler[K, V] | int) -> Cycler[K, V]:
264273
"""
265274
Outer product of two cyclers (`itertools.product`) or integer
266275
multiplication.
@@ -277,18 +286,22 @@ def __mul__(self, other):
277286
else:
278287
return NotImplemented
279288

280-
def __rmul__(self, other):
289+
def __rmul__(self, other: Cycler[K, V]) -> Cycler[K, V]:
281290
return self * other
282291

283-
def __len__(self):
292+
def __len__(self) -> int:
284293
op_dict = {zip: min, product: mul}
294+
if self._left is None:
295+
if self._left is None:
296+
return 0
297+
return 0
285298
if self._right is None:
286299
return len(self._left)
287300
l_len = len(self._left)
288301
r_len = len(self._right)
289-
return op_dict[self._op](l_len, r_len)
302+
return op_dict[self._op](l_len, r_len) # type: ignore
290303

291-
def __iadd__(self, other):
304+
def __iadd__(self, other: Cycler[K, V]) -> Cycler[K, V]:
292305
"""
293306
In-place pair-wise combine two equal length cyclers (zip).
294307
@@ -306,7 +319,7 @@ def __iadd__(self, other):
306319
self._right = Cycler(other._left, other._right, other._op)
307320
return self
308321

309-
def __imul__(self, other):
322+
def __imul__(self, other: Cycler[K,V]|int) -> Cycler[K, V]:
310323
"""
311324
In-place outer product of two cyclers (`itertools.product`).
312325
@@ -324,16 +337,18 @@ def __imul__(self, other):
324337
self._right = Cycler(other._left, other._right, other._op)
325338
return self
326339

327-
def __eq__(self, other):
340+
def __eq__(self, other: object) -> bool:
341+
if not isinstance(other, Cycler):
342+
return False
328343
if len(self) != len(other):
329344
return False
330345
if self.keys ^ other.keys:
331346
return False
332347
return all(a == b for a, b in zip(self, other))
333348

334-
__hash__ = None
349+
__hash__ = None # type: ignore
335350

336-
def __repr__(self):
351+
def __repr__(self) -> str:
337352
op_map = {zip: '+', product: '*'}
338353
if self._right is None:
339354
lab = self.keys.pop()
@@ -344,7 +359,7 @@ def __repr__(self):
344359
msg = "({left!r} {op} {right!r})"
345360
return msg.format(left=self._left, op=op, right=self._right)
346361

347-
def _repr_html_(self):
362+
def _repr_html_(self) -> str:
348363
# an table showing the value of each key through a full cycle
349364
output = "<table>"
350365
sorted_keys = sorted(self.keys, key=repr)
@@ -358,7 +373,7 @@ def _repr_html_(self):
358373
output += "</table>"
359374
return output
360375

361-
def by_key(self):
376+
def by_key(self) -> dict[K, list[V]]:
362377
"""
363378
Values by key.
364379
@@ -380,7 +395,7 @@ def by_key(self):
380395
# and if we care.
381396

382397
keys = self.keys
383-
out = {k: list() for k in keys}
398+
out: dict[K, list[V]] = {k: list() for k in keys}
384399

385400
for d in self:
386401
for k in keys:
@@ -390,7 +405,7 @@ def by_key(self):
390405
# for back compatibility
391406
_transpose = by_key
392407

393-
def simplify(self):
408+
def simplify(self) -> Cycler[K, V]:
394409
"""
395410
Simplify the cycler into a sum (but no products) of cyclers.
396411
@@ -408,7 +423,12 @@ def simplify(self):
408423

409424
concat = concat
410425

411-
426+
@overload
427+
def cycler(args: Cycler[K, V]) -> Cycler[K, V]: ...
428+
@overload
429+
def cycler(**kwargs: Iterable[V]) -> Cycler[str, V]: ...
430+
@overload
431+
def cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]: ...
412432
def cycler(*args, **kwargs):
413433
"""
414434
Create a new `Cycler` object from a single positional argument,
@@ -468,7 +488,7 @@ def cycler(*args, **kwargs):
468488
raise TypeError("Must have at least a positional OR keyword arguments")
469489

470490

471-
def _cycler(label, itr):
491+
def _cycler(label: K, itr: Iterable[V]) -> Cycler[K, V]:
472492
"""
473493
Create a new `Cycler` object from a property name and iterable of values.
474494

cycler/py.typed

Whitespace-only changes.

setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from setuptools import setup
1+
from setuptools import setup, find_packages
22

33
setup(name='cycler',
44
version='0.12.0.dev0',
55
author='Thomas A Caswell',
66
author_email='matplotlib-users@python.org',
7-
py_modules=['cycler'],
7+
packages=find_packages(),
8+
package_data = {"cycler": ["py.typed"]},
89
description='Composable style cycles',
910
url='https://github.com/matplotlib/cycler',
1011
platforms='Cross platform (Linux, macOS, Windows)',

0 commit comments

Comments
 (0)