From a0ee4ac2cdf3c12e30d066e468fadaf979e5d2b1 Mon Sep 17 00:00:00 2001 From: Cal Jacobson Date: Sun, 30 Oct 2022 17:28:46 -0500 Subject: [PATCH] add type annotations --- .github/workflows/tests.yml | 5 +++- aocd/__init__.pyi | 32 +++++++++++++++++++++ aocd/cli.py | 2 +- aocd/cookies.py | 2 +- aocd/examples.py | 2 +- aocd/get.py | 12 ++++++-- aocd/models.py | 57 +++++++++++++++++++++++-------------- aocd/py.typed | 0 aocd/runner.py | 5 ++-- aocd/utils.py | 48 ++++++++++++++++++++----------- pyproject.toml | 22 ++++++++++++++ tests/requirements.txt | 10 ------- 12 files changed, 139 insertions(+), 58 deletions(-) create mode 100644 aocd/__init__.pyi create mode 100644 aocd/py.typed delete mode 100644 tests/requirements.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89c7fbb..dd241f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,10 +34,13 @@ jobs: architecture: x64 - name: "Install" - run: pip install -q -r tests/requirements.txt && pip freeze --all + run: pip install -q -e .[test,type-check] && pip freeze --all - name: "Run tests for ${{ matrix.python-version }} on ${{ matrix.os }}" run: python -m pytest --durations=10 - name: Upload coverage to Codecov uses: "codecov/codecov-action@main" + + - name: "Run type check for ${{ matrix.python-version }} on ${{ matrix.os }}" + run: python -m mypy . diff --git a/aocd/__init__.pyi b/aocd/__init__.pyi new file mode 100644 index 0000000..0489d32 --- /dev/null +++ b/aocd/__init__.pyi @@ -0,0 +1,32 @@ +# TODO: is this still needed? +# import typing as t + +# from aocd import (cli, cookies, examples, exceptions, get, models, post, runner, utils, +# version) +# from aocd.exceptions import AocdError, PuzzleUnsolvedError +# from aocd.get import get_data +# from aocd.post import submit +# from aocd.utils import AOC_TZ + +# __all__ = [ +# "cli", +# "cookies", +# "examples", +# "exceptions", +# "get", +# "models", +# "post", +# "runner", +# "utils", +# "version", +# "AocdError", +# "PuzzleUnsolvedError", +# "get_data", +# "submit", +# "AOC_TZ", +# "data", +# ] + +# data: t.Text +# lines: list[t.Text] +# numbers: list[list[int]] | list[int] | int diff --git a/aocd/cli.py b/aocd/cli.py index 475a658..a134238 100644 --- a/aocd/cli.py +++ b/aocd/cli.py @@ -13,7 +13,7 @@ from .utils import get_plugins -def main(): +def main() -> None: """Get your puzzle input data, caching it if necessary, and print it on stdout.""" aoc_now = datetime.datetime.now(tz=AOC_TZ) days = range(1, 26) diff --git a/aocd/cookies.py b/aocd/cookies.py index 26b8dc0..862f7ee 100644 --- a/aocd/cookies.py +++ b/aocd/cookies.py @@ -66,7 +66,7 @@ def get_working_tokens(): return result -def scrape_session_tokens(): +def scrape_session_tokens() -> None: """Scrape AoC session tokens from your browser's cookie storage.""" aocd_token_path = AOCD_CONFIG_DIR / "token" aocd_tokens_path = AOCD_CONFIG_DIR / "tokens.json" diff --git a/aocd/examples.py b/aocd/examples.py index 10ab925..55d258a 100644 --- a/aocd/examples.py +++ b/aocd/examples.py @@ -144,7 +144,7 @@ def _get_unique_real_inputs(year, day): return list({}.fromkeys(strs)) -def main(): +def main() -> None: """ Summarize an example parser's results with historical puzzles' prose, and compare the performance against a reference implementation diff --git a/aocd/get.py b/aocd/get.py index 6b45434..31e2022 100644 --- a/aocd/get.py +++ b/aocd/get.py @@ -2,6 +2,7 @@ import os import re import traceback +import typing as t from logging import getLogger from ._ipykernel import get_ipynb_path @@ -17,7 +18,12 @@ log = getLogger(__name__) -def get_data(session=None, day=None, year=None, block=False): +def get_data( + session: t.Optional[str] = None, + day: t.Optional[int] = None, + year: t.Optional[int] = None, + block: t.Union[bool, t.Literal["q"]] = False +) -> str: """ Get data for day (1-25) and year (2015+). User's session cookie (str) is needed - puzzle inputs differ by user. @@ -45,7 +51,7 @@ def get_data(session=None, day=None, year=None, block=False): return puzzle.input_data -def most_recent_year(): +def most_recent_year() -> int: """ This year, if it's December. The most recent year, otherwise. @@ -60,7 +66,7 @@ def most_recent_year(): return year -def current_day(): +def current_day() -> int: """ Most recent day, if it's during the Advent of Code. Happy Holidays! Day 1 is assumed, otherwise. diff --git a/aocd/models.py b/aocd/models.py index 732087e..614f69c 100644 --- a/aocd/models.py +++ b/aocd/models.py @@ -4,6 +4,7 @@ import re import sys import time +import typing as t import webbrowser from datetime import datetime from datetime import timedelta @@ -39,16 +40,24 @@ AOCD_CONFIG_DIR = Path(os.environ.get("AOCD_CONFIG_DIR", AOCD_DATA_DIR)).expanduser() URL = "https://adventofcode.com/{year}/day/{day}" +# TODO: Use typing.Self when support for < 3.11 is dropped +_Answer = t.Optional[t.Union[int,str]] +_TUser = t.TypeVar("_TUser", bound="User") + +class _Result(t.TypedDict): + time: timedelta + rank: int + score: int class User: - _token2id = None + _token2id: t.Optional[dict[str, str]] = None - def __init__(self, token): + def __init__(self, token: str) -> None: self.token = token self._owner = "unknown.unknown.0" @classmethod - def from_id(cls, id): + def from_id(cls: type[_TUser], id: str) -> _TUser: users = _load_users() if id not in users: raise UnknownUserError(f"User with id '{id}' is not known") @@ -57,7 +66,7 @@ def from_id(cls, id): return user @property - def id(self): + def id(self) -> str: """ User's token might change (they expire eventually) but the id found on AoC's settings page for a logged-in user is as close as we can get to a primary key. @@ -86,17 +95,17 @@ def id(self): self._owner = owner return owner - def __str__(self): + def __str__(self) -> str: return f"<{type(self).__name__} {self._owner} (token=...{self.token[-4:]})>" @property - def memo_dir(self): + def memo_dir(self) -> Path: """ Directory where this user's puzzle inputs, answers etc. are stored on filesystem. """ return AOCD_DATA_DIR / self.id - def get_stats(self, years=None): + def get_stats(self, years: t.Optional[t.Union[t.Iterable[int], int]] = None): """ Parsed version of your personal stats (rank, solve time, score). See https://adventofcode.com//leaderboard/self when logged in. @@ -108,7 +117,7 @@ def get_stats(self, years=None): if years is None: years = all_years days = {str(i) for i in range(1, 26)} - results = {} + results: dict[tuple[int, int], dict[t.Literal["a", "b"], _Result]] = {} ur_broke = "You haven't collected any stars" for year in years: url = f"https://adventofcode.com/{year}/leaderboard/self" @@ -144,7 +153,7 @@ def get_stats(self, years=None): return results -def default_user(): +def default_user() -> User: """ Discover user's token from the environment or file, and exit with a diagnostic message if none can be found. This default user is used whenever a token or user id @@ -178,7 +187,7 @@ def default_user(): class Puzzle: - def __init__(self, year, day, user=None): + def __init__(self, year:int, day:int, user: t.Optional[User] =None): self.year = year self.day = day if user is None: @@ -196,12 +205,12 @@ def __init__(self, year, day, user=None): self.prose2_path = pre.with_name(pre.name + "_prose.2.html") # part b solved @property - def user(self): + def user(self) -> User: # this is a property to make it clear that it's read-only return self._user @property - def input_data(self): + def input_data(self) -> str: """ This puzzle's input data, specific to puzzle.user. It will usually be retrieved from caches, but if this is the first time it was accessed it will be requested @@ -685,16 +694,16 @@ def solve_for(self, plugin): return f(year=self.year, day=self.day, data=self.input_data) @property - def url(self): + def url(self) -> str: """A link to the puzzle's description page on adventofcode.com.""" return URL.format(year=self.year, day=self.day) - def view(self): + def view(self) -> None: """Open this puzzle's description page in a new browser tab""" webbrowser.open(self.url) @property - def my_stats(self): + def my_stats(self) -> dict[t.Literal['a', 'b'], _Result]: """ Your personal stats (rank, solve time, score) for this particular puzzle. Raises `PuzzleUnsolvedError` if you haven't actually solved it yet. @@ -706,7 +715,7 @@ def my_stats(self): result = stats[key] return result - def _request_puzzle_page(self): + def _request_puzzle_page(self) -> None: # hit the server to get the prose # cache the results so we don't have to get them again response = http.get(self.url, token=self.user.token) @@ -779,7 +788,7 @@ def easter_eggs(self): eggs = soup.find_all(["span", "em", "code"], class_=None, attrs={"title": bool}) return eggs - def unlock_time(self, local=True): + def unlock_time(self, local: bool =True) -> datetime: """ The time this puzzle unlocked. Might be in the future. If local is True (default), returns a datetime in your local zone. @@ -804,7 +813,7 @@ def all(user=None): yield puzzle -def _parse_duration(s): +def _parse_duration(s: str) -> timedelta: """Parse a string like 01:11:16 (hours, minutes, seconds) into a timedelta""" if s == ">24h": return timedelta(hours=24) @@ -812,15 +821,19 @@ def _parse_duration(s): return timedelta(hours=h, minutes=m, seconds=s) -def _load_users(): +def _load_users() -> dict[str, str]: # loads the mapping between user ids and tokens. one user can have many tokens, # so we can't key the caches off of the token itself. path = AOCD_CONFIG_DIR / "tokens.json" try: - users = json.loads(path.read_text(encoding="utf-8")) + txt = path.read_text(encoding="utf-8") except FileNotFoundError: - users = {"default": default_user().token} - return users + return {"default": default_user().token} + else: + users = json.loads(txt) + assert isinstance(users, dict) + assert all(isinstance(k, str) and isinstance(v, str) for k, v in users.items()) + return t.cast(dict[str, str], users) @cache diff --git a/aocd/py.typed b/aocd/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/aocd/runner.py b/aocd/runner.py index bbcfb05..e6c9c5d 100644 --- a/aocd/runner.py +++ b/aocd/runner.py @@ -5,6 +5,7 @@ import sys import tempfile import time +import typing as t from argparse import ArgumentParser from datetime import datetime from functools import partial @@ -30,7 +31,7 @@ log = logging.getLogger(__name__) -def main(): +def main() -> t.NoReturn: """ Run user solver(s) against their inputs and render the results. Can use multiple tokens to validate your code against multiple input datas. @@ -234,7 +235,7 @@ def run_with_timeout(entry_point, timeout, progress, dt=0.1, capture=False, **kw return a, b, walltime, error -def format_time(t, timeout=DEFAULT_TIMEOUT): +def format_time(t, timeout=DEFAULT_TIMEOUT) -> str: """ Used for rendering the puzzle solve time in color: - green, if you're under a quarter of the timeout (15s default) diff --git a/aocd/utils.py b/aocd/utils.py index fe6838a..990a65e 100644 --- a/aocd/utils.py +++ b/aocd/utils.py @@ -1,10 +1,13 @@ import argparse +import importlib import logging import os +from pathlib import Path import platform import shutil import sys import time +import typing as t from collections import deque from datetime import datetime from functools import cache @@ -25,20 +28,26 @@ _v = version("advent-of-code-data") USER_AGENT = f"github.com/wimglenn/advent-of-code-data v{_v} by hey@wimglenn.com" +if sys.version_info >= (3, 10): + from importlib.metadata import EntryPoints # type: ignore[attr-defined] # EntryPoints does not exist for Python < 3.10 + _Plugins = EntryPoints +else: + from importlib.metadata import EntryPoint + _Plugins = list[EntryPoint] class HttpClient: # every request to adventofcode.com goes through this wrapper # so that we can put in user agent header, rate-limit, etc. # aocd users should not need to use this class directly. - def __init__(self): + def __init__(self) -> None: self.pool_manager = urllib3.PoolManager(headers={"User-Agent": USER_AGENT}) self.req_count = {"GET": 0, "POST": 0} self._max_t = 3.0 self._cooloff = 0.16 self._history = deque([time.time() - self._max_t] * 4, maxlen=4) - def _limiter(self): + def _limiter(self) -> None: now = time.time() t0 = self._history[0] if now - t0 < self._max_t: @@ -53,7 +62,7 @@ def _limiter(self): self._cooloff *= 2 # double it for repeat offenders self._history.append(now) - def get(self, url, token=None, redirect=True): + def get(self, url: str, token: object = None, redirect: object = True) -> urllib3.BaseHTTPResponse: # getting user inputs, puzzle prose, etc if token is None: headers = self.pool_manager.headers @@ -64,7 +73,7 @@ def get(self, url, token=None, redirect=True): self.req_count["GET"] += 1 return resp - def post(self, url, token, fields): + def post(self, url: str, token: object, fields: t.Mapping[str, str]) -> urllib3.BaseHTTPResponse: # submitting answers headers = self.pool_manager.headers | {"Cookie": f"session={token}"} self._limiter() @@ -82,11 +91,16 @@ def post(self, url, token, fields): http = HttpClient() -def _ensure_intermediate_dirs(path): +def _ensure_intermediate_dirs(path: Path) -> None: path.expanduser().parent.mkdir(parents=True, exist_ok=True) -def blocker(quiet=False, dt=0.1, datefmt=None, until=None): +def blocker( + quiet: bool = False, + dt: float = 0.1, + datefmt: t.Optional[str] = None, + until: t.Optional[tuple[int, int]] = None +) -> None: """ This function just blocks until the next puzzle unlocks. Pass `quiet=True` to disable the spinner etc. @@ -111,12 +125,12 @@ def blocker(quiet=False, dt=0.1, datefmt=None, until=None): return spinner = cycle(r"\|/-") localzone = datetime.now().astimezone().tzinfo - local_unlock = unlock.astimezone(tz=localzone) + local_unlock_tz = unlock.astimezone(tz=localzone) if datefmt is None: # %-I does not work on Windows, strip leading zeros manually - local_unlock = local_unlock.strftime("%I:%M %p").lstrip("0") + local_unlock = local_unlock_tz.strftime("%I:%M %p").lstrip("0") else: - local_unlock = local_unlock.strftime(datefmt) + local_unlock = local_unlock_tz.strftime(datefmt) msg = "{} Unlock day %s at %s ({} remaining)" % (unlock.day, local_unlock) while datetime.now(tz=AOC_TZ) < unlock: remaining = unlock - datetime.now(tz=AOC_TZ) @@ -133,7 +147,7 @@ def blocker(quiet=False, dt=0.1, datefmt=None, until=None): sys.stdout.flush() -def get_owner(token): +def get_owner(token: str) -> str: """ Find owner of the token. Raises `DeadTokenError` if the token is expired/invalid. @@ -148,6 +162,7 @@ def get_owner(token): soup = _get_soup(response.data) auth_source = "unknown" username = "unknown" + assert soup.code is not None userid = soup.code.text.split("-")[1] for span in soup.find_all("span"): if span.text.startswith("Link to "): @@ -170,7 +185,7 @@ def get_owner(token): return result -def atomic_write_file(path, contents_str): +def atomic_write_file(path: Path, contents_str: str) -> None: """ Atomically write a string to a file by writing it to a temporary file, and then renaming it to the final destination name. This solves a race condition where existence @@ -183,8 +198,7 @@ def atomic_write_file(path, contents_str): log.debug("moving %s -> %s", f.name, path) shutil.move(f.name, path) - -def _cli_guess(choice, choices): +def _cli_guess(choice: str, choices: list[str]) -> str: # used by the argument parser so that you can specify user ids with a substring # (for example just specifying `-u git` instead of `--users github.wimglenn.119932` if choice in choices: @@ -205,7 +219,7 @@ def _cli_guess(choice, choices): os.system("color") # hack - makes ANSI colors work in the windows cmd window -def colored(txt, color): +def colored(txt: str, color: t.Optional[str]) -> str: if color is None: return txt code = _ansi_colors.index(color.casefold()) @@ -213,18 +227,18 @@ def colored(txt, color): return f"\x1b[{code + 30}m{txt}{reset}" -def get_plugins(group="adventofcode.user"): +def get_plugins(group: str ="adventofcode.user") -> _Plugins: """ Currently installed plugins for user solves. """ try: # Python 3.10+ - return entry_points(group=group) + return entry_points(group=group) # type: ignore[call-arg, return-value] # group argument does not exist for Python < 3.10 except TypeError: # Python 3.9 return entry_points().get(group, []) @cache -def _get_soup(html): +def _get_soup(html: t.Union[str, bytes]) -> bs4.BeautifulSoup: return bs4.BeautifulSoup(html, "html.parser") diff --git a/pyproject.toml b/pyproject.toml index 1b0293a..6f6aad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,18 @@ Homepage = "https://github.com/wimglenn/advent-of-code-data" [project.optional-dependencies] nb = ["IPython", "jupyter-server"] +type-check = ["mypy", "types-beautifulsoup4"] +test = [ + "numpy", + "pook @ git+https://github.com/wimglenn/pook@urllib3", + "pytest", + "pytest-cov", + "pytest-mock", + "pytest-raisin", + "pytest-freezer", + "pytest-socket", + "rich", +] [project.scripts] aocd = "aocd.cli:main" @@ -48,6 +60,16 @@ aoce = "aocd.examples:main" [tool.setuptools] packages = ["aocd"] +[tool.setuptools.package-data] +aocd = ["py.typed"] # include py.typed file in distributions + [project.entry-points] "adventofcode.user" = {} # for user solvers "adventofcode.examples" = {} # for example-parser implementations + +[tool.mypy] +strict = true +allow_redefinition = true +exclude = [ + 'tests/.*\.py$', # don't type check tests +] diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 4397c4b..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ ---editable . -numpy -git+https://github.com/wimglenn/pook@urllib3 -pytest -pytest-cov -pytest-mock -pytest-raisin -pytest-freezer -pytest-socket -rich