-
Couldn't load subscription status.
- Fork 51
Mypy Compatible type hints for cycler #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- 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.
|
You should split out the other changes to separate PRs probably. It's hard to see exactly what's for type hinting here. |
|
Not a whole lot to be pulled out, honestly... Yeah, I ran a formatter, but 90%+ of what that changed were signature lines that were changed anyway. The exceptions to this are:
I did relax the flake8 CI a little, willing to adjust that, but most of it was needed for this to pass CI. There are a few small code changes, mostly just covering the theoretically possible types so mypy was happy (many of which have inter-variable relationships that in a valid cycler will never be true cases, but the checks themselves are small and needed for the type checking |
| self, | ||
| left: Cycler[K, V] | Iterable[dict[K, V]] | None, | ||
| right: Cycler[K, V] | Iterable[dict[K, V]] | None = None, | ||
| op: Any = None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
slightly lazy to use Any here, but this is pretty much zip|np.product|None, exactly but I could not find a good way to type hint that (tried using Literals, but those didn't work with functions and are not available on all supported python versions anyway)
cycler/__init__.py
Outdated
| if self._right is not None and old in self._right.keys: | ||
| if ( | ||
| self._right is not None | ||
| and isinstance(self._right, Cycler) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
explicitly checking isinstance was required for mypy to pass as the option of list[dict[K, V]] which is a seemingly valid type (at least tests blew up when I removed the lines from init which added that type for _right)
That said, if it is valid for _right to be this flattened list of dict instead of a full cycler object, should probably handle the rename like we do for _left
And if it is invalid, should probably figure out why the code is there for it in __init__
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Upon more careful inspection, the only test which failed was the one that was called test_starange_init [sic] which is not the recommended way of initializing a cycler, and the cycler object created in that test fails when you try to rename the key of the right child.
So we can:
- explicitly disallow right from being an iterable of dicts (while left can be for various reasons)
- this would simplify this and some other checks as the only options are cycler or None
- change the test to reflect that this is disallowed
- explicitly allow this formation, and add tests and correct code where _right is accessed, including at least
change_key- add to this test or the change key test a test for this case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sidenote: this is actually a pretty reasonable example of the kind of problem that mypy can make much more obvious and are not caught, even with "100% of lines coverage" as we have here.
Failing code (adding one line to the strange init test, run on main, this PR would actually make it fall through):
>>> from cycler import cycler, Cycler
>>> c = cycler('r', 'rgb')
>>> c2 = cycler('lw', range(3))
>>> cy = Cycler(list(c), list(c2), zip)
>>> cy.change_key("lw", "ls")
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ <stdin>:1 in <module> │
│ │
│ /home/kyle/src/scipy/cycler/cycler.py:193 in change_key │
│ │
│ 190 │ │ self._keys.remove(old) │
│ 191 │ │ self._keys.add(new) │
│ 192 │ │ │
│ ❱ 193 │ │ if self._right is not None and old in self._right.keys: │
│ 194 │ │ │ self._right.change_key(old, new) │
│ 195 │ │ │
│ 196 │ │ # self._left should always be non-None │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
AttributeError: 'list' object has no attribute 'keys'There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually do think this is still unresolved... it passes tests, but the above test case would have the unexpected result of not actually changing the key.
Can right be disallowed from being iterable[dict]? (therefore changing that test)
Or, should the case of right being iterable[dict] be handled as it is for left?
cycler/__init__.py
Outdated
| # self._left should always be non-None | ||
| # if self._keys is non-empty. | ||
| elif self._left is None: | ||
| pass |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This explicit check is to type narrow for mypy, it should not ever evaluate to True, could raise a more informative error message instead of just passing, though I suspect even in cases where it would evaluate to true it will blow up earlier.
Could adjust the comment to reflect that the check has been added
| for left in self._left: | ||
| yield dict(left) | ||
| else: | ||
| if self._op is None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mypy was not happy at certain points that op was potentially None... I suspect that since I made op just Any, mypy would not actually complain here anymore, but this and the extra check for the trivial cycler with _left and _right both None were added for mypy reasons
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
modulo moving the flake8 config to a config file.
cycler/__init__.py
Outdated
| if self._right is None or self._left is None: | ||
| if self._left is not None: | ||
| for left in self._left: | ||
| yield dict(left) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if self._left is None and self._right is not None? Can that not happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe that a Cycler with a right child but no left child is invalid, i.e. that all cyclers with only one child (the leaves), it must be the left child.
Whether that is enforced or not, is another question.
Certainly, the recommended constructor (cycler, the function, rather than Cycler, the class) only allows positional args and treats keyword args as label: iterable pairs. As such, that constructor will never yeild a "right only" object
e.g. the _from_iter method directly modifies _left
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the previous implementation just directly started iterating on _left
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I realized that most of the code changes in total could be reverted by simply not allowing _left to be None at all. The only way to get the constructor to do it was to do Cycler(None, None). That case was handled in __init__, but even if you do it, it fails to even make a repr, let alone use any of the methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly trickier than I thought, as the left=None pathway is used, if only temporarily, in _from_iter, though that is is the only place where _left should be None, and it remedies it right away.
Using empty list ([]) as the sentinel for left instead of None works, though...
153b7e0 to
91f32a1
Compare
…s with different types py39 compat py38 compat flake8
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So one other question; if you have Dict[K, V], does that not mean that all values have the same type? I'm not sure if that's supposed to be true, depending on how exact a type you mean.
| def _process_keys(left, right): | ||
| def _process_keys( | ||
| left: Cycler[K, V] | Iterable[dict[K, V]], | ||
| right: Cycler[K, V] | Iterable[dict[K, V]] | None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should more rights be using K, U now to match the other one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case its called only internally and it does kind of need to be consistent here
|
Yes they need to have the "same" type, but only in the same sense as dict values or lists have the "same" type. It can be a union type, and the built in operators do manage that union expanding (now, that is actually only true as of last night) |
|
OK, if it can automatically do unions, then that seems okay. Do you still need to fix #86 (comment) before merging? |
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
concatdocstring will actually fail toproperly 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.