Skip to content
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

Closed
wants to merge 49 commits into from
Closed

Conversation

cj81499
Copy link
Contributor

@cj81499 cj81499 commented Aug 29, 2023

Apologies for the awful commit history. We'll want to squash merge (or rebase it into a single commit before merge).

resolves #78

@codecov
Copy link

codecov bot commented Aug 29, 2023

Codecov Report

Attention: 22 lines in your changes are missing coverage. Please review.

Comparison is base (c94c844) 91.63% compared to head (c61b3e3) 91.71%.
Report is 7 commits behind head on main.

❗ Current head c61b3e3 differs from pull request most recent head db4fe6a. Consider uploading reports for the commit db4fe6a to get more accurate results

Files Patch % Lines
aocd/_ipykernel.py 15.38% 11 Missing ⚠️
aocd/models.py 95.90% 3 Missing and 2 partials ⚠️
aocd/cookies.py 50.00% 4 Missing ⚠️
aocd/utils.py 93.93% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

aocd/__init__.py Outdated Show resolved Hide resolved
aocd/_ipykernel.py Show resolved Hide resolved
aocd/examples.py Outdated Show resolved Hide resolved
aocd/models.py Outdated Show resolved Hide resolved
pyproject.toml Outdated Show resolved Hide resolved
.gitignore Outdated Show resolved Hide resolved
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:
Copy link
Owner

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
Copy link
Owner

@wimglenn wimglenn Nov 28, 2023

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)
Copy link
Owner

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?

Comment on lines +58 to +62
class _Results(TypedDict):
part: _Part
value: str
when: str
message: str
Copy link
Owner

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?

Comment on lines +74 to +83
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
Copy link
Owner

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()
Copy link
Owner

@wimglenn wimglenn Nov 28, 2023

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})")

Copy link
Contributor

@mjpieters mjpieters left a 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 ;-)

aocd/__init__.py Show resolved Hide resolved
"_LoosePart",
]

_Answer = Optional[Union[str, bytes, int, float, complex, Fraction, Decimal, "np.number[np.typing.NBitBase]"]]
Copy link
Contributor

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.

Copy link
Owner

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__ = [
Copy link
Contributor

@mjpieters mjpieters Dec 7, 2023

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]]:
Copy link
Contributor

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']?

Comment on lines -106 to +153
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}.")
Copy link
Contributor

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
Copy link
Contributor

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.

Suggested change
**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],
Copy link
Contributor

@mjpieters mjpieters Dec 7, 2023

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:

Suggested change
plugs: Iterable[str],
plugs: Collection[str],

Collections are iterable and support containment directly. Generators are not collections.

aocd/utils.py Outdated Show resolved Hide resolved
Comment on lines -10 to +15
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",
Copy link
Contributor

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"',
Copy link
Contributor

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.

Copy link
Owner

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.

Copy link
Contributor

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.

wimglenn and others added 3 commits December 7, 2023 13:12
Co-authored-by: Martijn Pieters <[email protected]>
Co-authored-by: Martijn Pieters <[email protected]>
Co-authored-by: Martijn Pieters <[email protected]>
@wimglenn
Copy link
Owner

wimglenn commented Dec 7, 2023

Thank you for the in-depth review, @mjpieters, a lot of good suggestions here!

@wimglenn
Copy link
Owner

wimglenn commented Aug 9, 2024

@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.

@cj81499
Copy link
Contributor Author

cj81499 commented Aug 10, 2024

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 [email protected]" at the end of the commit if you use my work in that way.

@wimglenn
Copy link
Owner

wimglenn commented Nov 3, 2024

Replaced with 8422243 and released in v2.0.2.

@wimglenn wimglenn closed this Nov 3, 2024
@cj81499
Copy link
Contributor Author

cj81499 commented Nov 3, 2024

Thanks @wimglenn!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Include type information
3 participants