-
Notifications
You must be signed in to change notification settings - Fork 56
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
Add type annotations #123
Add type annotations #123
Conversation
browser_cookie3 does not have type hints available
Codecov ReportAttention:
Additional details and impacted files@@ Coverage Diff @@
## main #123 +/- ##
==========================================
+ Coverage 91.63% 91.71% +0.08%
==========================================
Files 24 25 +1
Lines 2653 2739 +86
Branches 356 362 +6
==========================================
+ Hits 2431 2512 +81
- Misses 158 163 +5
Partials 64 64 ☔ View full report in Codecov by Sentry. |
post = pook.post(url="https://adventofcode.com/2018/day/1/answer") | ||
submit(1234, part="a", day=1, year=2018, session="whatever", reopen=False) | ||
assert post.calls == 0 | ||
|
||
|
||
def test_submit_float_warns(pook, capsys, caplog): | ||
def test_submit_float_warns(pook: pook_mod, caplog: pytest.LogCaptureFixture) -> 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.
@cj81499 I don't understand why pook fixture (which happens to also yield the pook module) is annotated as the module itself. Why shouldn't it be annotated as types.ModuleType
?
test_token.unlink() | ||
with pytest.raises(AocdError("Missing session ID")): | ||
with pytest.raises(AocdError("Missing session ID")): # type: ignore[call-overload] # using pytest-raisin |
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.
There are 41 occurences of this pragma added
# type: ignore[call-overload] # using pytest-raisin
Is there a way to just globally disable the type of overload which pytest-raisin does in mypy configuration or something? It adds a lot of noise.
Alternatively, is there some annotation that can be made in the plugin pytest-raisin
directly to help mypy understand that pytest_raisin.raises
is a wrapper around pytest.raises
, and it has extended the supported types in the call signature?
original_val = val | ||
def fail() -> NoReturn: | ||
msg = f"Failed to coerce {type(original_val).__name__} value {original_val!r} for {self.year}/{self.day:02}." | ||
raise AocdError(msg) |
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.
fail
is only used once. Is there a good reason for defining a nested function here, instead of just raising directly inline?
class _Results(TypedDict): | ||
part: _Part | ||
value: str | ||
when: str | ||
message: str |
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 this be moved into _types.py
?
class _SolverCallable(Protocol): | ||
def __call__(self, year: int, day:int, data: str) -> _Answer: | ||
... | ||
|
||
_ExampleParserCallable = Callable[[_examples.Page, list[str]], list[_examples.Example]] | ||
|
||
class _Result(TypedDict): | ||
time: timedelta | ||
rank: int | ||
score: int |
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 these be moved into _types.py
?
raise giveup("Failed introspection of year") | ||
year = years.pop() if years else None | ||
year = years.pop() |
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.
Unfortunately this stricter parsing is no bueno - I have it written in the README here that a filename like q03.py
would work, and it works by returning year=None
then letting some later code check for None
and call most_recent_year()
instead.
Even though that behavior was questionable (the aocd introspection will only work currently when run within the same year as the event, and those scripts will start to break a week after the event finishes), I think it's best to keep this behavior, because it means you can omit mentioning the current year in your filenames entirely. I'll bet a lot of new users / people playing AoC for the first time (during the event) are doing just that.
So, let's go with something along these lines:
if len(years) > 1:
raise giveup("Failed introspection of year")
elif len(years) == 1:
[year] = years
else:
year = get_current_year()
log.debug(f"year could not be determined, assuming current year ({year})")
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 general, I prefer using from __future__ import annotations
and then use more modern syntax in type annotations such as |
for unions and optional values. This is a lot more readable than using Union
and Optional
everywhere. See the typing best practices documentation.
I'd also break this PR up into at least 2, preferably more PRs, where you introduce typing more gradually. This is a huge PR to review and that makes it all the harder for the maintainer to review and accept this.
E.g. you could move type annotations for the tests to a separate PR, and focus on just the public APIs and adding py.typed
first in a first PR. Or, just use mine (#131) for that first step ;-)
"_LoosePart", | ||
] | ||
|
||
_Answer = Optional[Union[str, bytes, int, float, complex, Fraction, Decimal, "np.number[np.typing.NBitBase]"]] |
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.
All the number types are (virtual) subclasses of numbers.Number
, there is no need to pull in numpy for this:
from decimal import Decimal
from fractions import Fraction
from numbers import Number
import numpy as np
for tp in (int, float, complex, Fraction, Decimal, np.int_, np.float_, np.complex_):
print(tp, issubclass(tp, Number))
# output:
# <class 'int'> True
# <class 'float'> True
# <class 'complex'> True
# <class 'fractions.Fraction'> True
# <class 'decimal.Decimal'> True
# <class 'numpy.int64'> True
# <class 'numpy.float64'> True
# <class 'numpy.complex128'> True
Even if this wasn't the case, I'd have used np.number[Any]
, as it doesn't matter to aocd
what type of generic argument np.number[]
accepts.
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.
oh, nice 👍
if TYPE_CHECKING: | ||
import numpy as np | ||
|
||
__all__ = [ |
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.
Why give these objects private names and then export them? The types would be useful for consumers of the library too; e.g. some 3rd party library might use:
from aocd.types import Answer, Part
def process_answer(answer: Answer, part: Part | None = None, **kwargs: Any) -> None:
"""Process an AOC answer before submitting"""
...
and you may want to store some of these types:
from aocd.types import Answer
# puzzle is a aocd.models.Puzzle instance.
answers: tuple[Answer, Answer] = puzzle.solve()
Note that the aocd._types
module is already itself private. The names in the module should then be public; their 'visibility' then extends only to the aocd
package and no further. Unless you explicitly export them, of course.
def get_stats( | ||
self, | ||
years: Optional[Union[Iterable[int], int]] = None | ||
) -> dict[str, dict[Literal['a', 'b'], _Result]]: |
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.
Why wasn't the _types._Part
type alias used here, instead of Literal['a', 'b']
?
if isinstance(years, int) and years in all_years: | ||
if isinstance(years, int): | ||
if years not in all_years: | ||
raise ValueError(f"Valid years are {all_years.start} through {all_years.stop - 1}. Got {years}.") |
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 had found this too, which is why type checking is so valuable! I was going to raise a separate issue about this one :-P
It'd be better if there was type validation for any years
argument passed in however, not just when it's a single year. E.g. user.get_stats((2013, 2014))
is just as wrong as user.get_stats(2014)
.
progress: Optional[str], | ||
dt: float = 0.1, | ||
capture: bool = False, | ||
**kwargs: object |
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.
Do not use object
here, this is definitely a place to use Any
.
**kwargs: object | |
**kwargs: Any |
See the typing best practices documentation on Any
vs. object
. Here kwargs
are keyword arguments passed to _timeout_wrapper
and you can't express what types are acceptable to the function that is obtained by entry_point.load()
inside this function.
reopen=False, | ||
capture=False, | ||
): | ||
plugs: Iterable[str], |
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.
Plugs can't be an iterable, because it needs to support containment and iteration.
Using plugs: Iterable[str]
here is wrong because you can't just pass in a generator yielding plugs to this function and expect it to work. Generators are valid iterables (they implement __iter__
), but using a generator would lead to unexpected side effects. Further down, the line eps = {ep.name: ep for ep in get_plugins() if ep.name in plugs}
uses a containment test on plugs
, and if plugs
is a generator then the in
test would iterate over the values until it found a match. And that in turn leads to the itertools.product()
function being passed a partially or fully iterated plugs
iterator. Try passing in (ep.name for ep in get_plugins())
for example; all the plugins would end up being skipped
Use typing.Collection[str]
instead:
plugs: Iterable[str], | |
plugs: Collection[str], |
Collections are iterable and support containment directly. Generators are not collections.
classifiers = [ | ||
classifiers = [ # https://pypi.org/classifiers/ | ||
"Intended Audience :: Developers", | ||
"Programming Language :: Python :: 3", | ||
"License :: OSI Approved :: MIT License", | ||
"Topic :: Software Development :: Libraries", | ||
"Programming Language :: Python :: 3", | ||
"Topic :: Games/Entertainment :: Puzzle Games", | ||
"Topic :: Software Development :: Libraries", |
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.
These changes are not really part of the type hinting goal, are they?
"aocd-example-parser >= 2023.2", | ||
'typing-extensions; python_version < "3.11"', |
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.
As stated above, I'd just make this unconditional. typing_extensions
is basically part of the reality of Python libraries now.
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.
The reason for this was that typing.Self
was not avail in stdlib until 3.11.
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 know why typing_extensions
is used. :-) My point is that the whole dance in _compat
plus the condition on python_version < "3.11"
here is overkill.
It is far simpler to just use from typing_extensions import Self
in your code and to install typing_extensions
unconditionally. aocd
is far from the only library that would have a dependency ontyping_extensions
, in any given Python version, at the moment.
Co-authored-by: Martijn Pieters <github.com@zopatista.com>
Co-authored-by: Martijn Pieters <github.com@zopatista.com>
Co-authored-by: Martijn Pieters <github.com@zopatista.com>
Thank you for the in-depth review, @mjpieters, a lot of good suggestions here! |
@cj81499 Have you lost interest in this PR? There are many conflicts now, but if it's alright with you I'll likely use some of the ideas from the PR - particularly around the refactoring of the submission types. |
I wouldn't say I've lost interest completely, but I have a hard time making time to contribute to OSS, and my motivation is lower when I'm not actively using the library during (or shortly after) December. You're free to close this PR and pick out the parts you think are valuable. I would appreciate a "Co-authored-by: Cal Jacobson cj81499@users.noreply.github.com" at the end of the commit if you use my work in that way. |
Thanks @wimglenn! |
Apologies for the awful commit history. We'll want to squash merge (or rebase it into a single commit before merge).
resolves #78