From 97da2dad6dc85468424751ed2a129e24fdbd904f Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Thu, 7 Dec 2023 15:03:22 +0000 Subject: [PATCH 1/2] Provide type annotations for the public API The package now includes a PEP 561 `py.typed` flag file to let type checkers know that this package provides type annotations. The annotations provide full coverage for all public functions and classes in the aocd package, with the library reaching 100% coverage as per `pyright --ignoreexternal --verifytypes`. --- aocd/__init__.py | 24 ++++++++++++- aocd/cli.py | 2 +- aocd/cookies.py | 6 ++-- aocd/examples.py | 31 ++++++++++------- aocd/get.py | 20 +++++++---- aocd/models.py | 90 +++++++++++++++++++++++++++--------------------- aocd/post.py | 20 +++++++++-- aocd/py.typed | 0 aocd/runner.py | 49 +++++++++++++++++--------- aocd/types.py | 33 ++++++++++++++++++ aocd/utils.py | 49 ++++++++++++++++++++------ pyproject.toml | 9 +++++ 12 files changed, 240 insertions(+), 93 deletions(-) create mode 100644 aocd/py.typed create mode 100644 aocd/types.py diff --git a/aocd/__init__.py b/aocd/__init__.py index 160ca03..4b06d63 100644 --- a/aocd/__init__.py +++ b/aocd/__init__.py @@ -1,4 +1,5 @@ import sys +import typing as t from functools import partial from . import _ipykernel @@ -11,13 +12,34 @@ from . import post from . import runner from . import utils +from . import types from .exceptions import AocdError from .get import get_data from .get import get_day_and_year from .post import submit as _impartial_submit +__all__ = [ + "_ipykernel", + "cli", + "cookies", + "data", + "examples", + "exceptions", + "get", + "models", + "post", + "runner", + "submit", + "types", + "utils", +] -def __getattr__(name): +if t.TYPE_CHECKING: + data: str + submit = _impartial_submit + + +def __getattr__(name: str) -> t.Any: if name == "data": day, year = get_day_and_year() return get_data(day=day, year=year) 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..a57db51 100644 --- a/aocd/cookies.py +++ b/aocd/cookies.py @@ -12,10 +12,10 @@ from .utils import get_owner -log = logging.getLogger(__name__) +log: logging.Logger = logging.getLogger(__name__) -def get_working_tokens(): +def get_working_tokens() -> dict[str, str]: """Check browser cookie storage for session tokens from .adventofcode.com domain.""" log.debug("checking for installation of browser-cookie3 package") try: @@ -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..a987e85 100644 --- a/aocd/examples.py +++ b/aocd/examples.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import argparse import logging import re import sys +import typing as t from dataclasses import dataclass from datetime import datetime from itertools import zip_longest @@ -16,7 +19,11 @@ from aocd.utils import get_plugins -log = logging.getLogger(__name__) +log: logging.Logger = logging.getLogger(__name__) + +_AnswerElem = t.Literal[ + "a_code", "a_li", "a_pre", "a_em", "b_code", "b_li", "b_pre", "b_em" +] @dataclass @@ -34,17 +41,17 @@ class Page: soup: bs4.BeautifulSoup # The raw_html string parsed into a bs4.BeautifulSoup instance year: int # AoC puzzle year (2015+) parsed from html title day: int # AoC puzzle day (1-25) parsed from html title - article_a: bs4.element.Tag # The bs4 tag for the first
in the page, i.e. part a - article_b: bs4.element.Tag # The bs4 tag for the second
in the page, i.e. part b. It will be `None` if part b locked + article_a: bs4.Tag # The bs4 tag for the first
in the page, i.e. part a + article_b: bs4.Tag | None # The bs4 tag for the second
in the page, i.e. part b. It will be `None` if part b locked a_raw: str # The first
html as a string - b_raw: str # The second
html as a string. Will be `None` if part b locked + b_raw: str | None # The second
html as a string. Will be `None` if part b locked - def __repr__(self): + def __repr__(self) -> str: part_a_only = "*" if self.article_b is None else "" return f"" @classmethod - def from_raw(cls, html): + def from_raw(cls, html: str) -> Page: soup = _get_soup(html) title_pat = r"^Day (\d{1,2}) - Advent of Code (\d{4})$" title_text = soup.title.text @@ -77,7 +84,7 @@ def from_raw(cls, html): ) return page - def __getattr__(self, name): + def __getattr__(self, name: _AnswerElem) -> t.Sequence[str]: if not name.startswith(("a_", "b_")): raise AttributeError(name) part, sep, tag = name.partition("_") @@ -118,12 +125,12 @@ class Example(NamedTuple): """ input_data: str - answer_a: str = None - answer_b: str = None - extra: str = None + answer_a: str | None = None + answer_b: str | None = None + extra: str | None = None @property - def answers(self): + def answers(self) -> tuple[str | None, str | None]: return self.answer_a, self.answer_b @@ -144,7 +151,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..8a466cc 100644 --- a/aocd/get.py +++ b/aocd/get.py @@ -1,8 +1,11 @@ +from __future__ import annotations + import datetime import os import re import traceback -from logging import getLogger +import typing as t +from logging import Logger, getLogger from ._ipykernel import get_ipynb_path from .exceptions import AocdError @@ -14,10 +17,15 @@ from .utils import blocker -log = getLogger(__name__) +log: Logger = getLogger(__name__) -def get_data(session=None, day=None, year=None, block=False): +def get_data( + session: str | None = None, + day: int | None = None, + year: int | None = None, + block: bool = 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 +53,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 +68,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. @@ -73,7 +81,7 @@ def current_day(): return day -def get_day_and_year(): +def get_day_and_year() -> tuple[int, int | None]: """ Returns tuple (day, year). diff --git a/aocd/models.py b/aocd/models.py index 732087e..ba1bbcf 100644 --- a/aocd/models.py +++ b/aocd/models.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import json import logging import os import re import sys import time +import typing as t import webbrowser from datetime import datetime from datetime import timedelta @@ -15,6 +18,7 @@ from textwrap import dedent from . import examples +from .examples import Example from .exceptions import AocdError from .exceptions import DeadTokenError from .exceptions import ExampleParserError @@ -29,9 +33,13 @@ from .utils import get_owner from .utils import get_plugins from .utils import http +from .types import AnswerValue +from .types import PuzzlePart +from .types import PuzzleStats +from .types import Submission -log = logging.getLogger(__name__) +log: logging.Logger = logging.getLogger(__name__) AOCD_DATA_DIR = Path(os.environ.get("AOCD_DIR", Path("~", ".config", "aocd"))) @@ -43,12 +51,12 @@ class User: _token2id = 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, id: str) -> User: users = _load_users() if id not in users: raise UnknownUserError(f"User with id '{id}' is not known") @@ -57,7 +65,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 +94,19 @@ 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: int | t.Iterable[int] | None = None + ) -> dict[str, dict[PuzzlePart, PuzzleStats]]: """ Parsed version of your personal stats (rank, solve time, score). See https://adventofcode.com//leaderboard/self when logged in. @@ -144,7 +154,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,30 +188,30 @@ def default_user(): class Puzzle: - def __init__(self, year, day, user=None): + def __init__(self, year: int, day: int, user: User | None = None) -> None: self.year = year self.day = day if user is None: user = default_user() self._user = user - self.input_data_url = self.url + "/input" - self.submit_url = self.url + "/answer" + self.input_data_url: str = self.url + "/input" + self.submit_url: str = self.url + "/answer" pre = self.user.memo_dir / f"{self.year}_{self.day:02d}" - self.input_data_path = pre.with_name(pre.name + "_input.txt") - self.answer_a_path = pre.with_name(pre.name + "a_answer.txt") - self.answer_b_path = pre.with_name(pre.name + "b_answer.txt") - self.submit_results_path = pre.with_name(pre.name + "_post.json") - self.prose0_path = AOCD_DATA_DIR / "prose" / (pre.name + "_prose.0.html") - self.prose1_path = pre.with_name(pre.name + "_prose.1.html") # part a solved - self.prose2_path = pre.with_name(pre.name + "_prose.2.html") # part b solved + self.input_data_path: Path = pre.with_name(pre.name + "_input.txt") + self.answer_a_path: Path = pre.with_name(pre.name + "a_answer.txt") + self.answer_b_path: Path = pre.with_name(pre.name + "b_answer.txt") + self.submit_results_path: Path = pre.with_name(pre.name + "_post.json") + self.prose0_path: Path = AOCD_DATA_DIR / "prose" / (pre.name + "_prose.0.html") + self.prose1_path: Path = pre.with_name(pre.name + "_prose.1.html") # part a solved + self.prose2_path: 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 @@ -231,7 +241,7 @@ def input_data(self): return data.rstrip("\r\n") @property - def examples(self): + def examples(self) -> list[Example]: """ Sample data and answers associated with this puzzle, as a list of `aocd.examples.Example` instances. These are extracted from the puzzle prose @@ -262,7 +272,7 @@ def _get_examples(self, parser_name="reference"): return result @cached_property - def title(self): + def title(self) -> str: """ Title of the puzzle, used in the pretty repr (IPython etc) and also displayed by aocd.runner. @@ -319,7 +329,7 @@ def _coerce_val(self, val): return val @property - def answer_a(self): + def answer_a(self) -> str: """ The correct answer for the first part of the puzzle. This attribute hides itself if the first part has not yet been solved. @@ -330,7 +340,7 @@ def answer_a(self): raise AttributeError("answer_a") @answer_a.setter - def answer_a(self, val): + def answer_a(self, val: AnswerValue) -> None: """ You can submit your answer to adventofcode.com by setting the answer attribute on a puzzle instance, e.g. @@ -346,12 +356,12 @@ def answer_a(self, val): self._submit(value=val, part="a") @property - def answered_a(self): + def answered_a(self) -> bool: """Has the first part of this puzzle been solved correctly yet?""" return bool(getattr(self, "answer_a", None)) @property - def answer_b(self): + def answer_b(self) -> str: """ The correct answer for the second part of the puzzle. This attribute hides itself if the second part has not yet been solved. @@ -362,7 +372,7 @@ def answer_b(self): raise AttributeError("answer_b") @answer_b.setter - def answer_b(self, val): + def answer_b(self, val: AnswerValue) -> None: """ You can submit your answer to adventofcode.com by setting the answer attribute on a puzzle instance, e.g. @@ -378,11 +388,11 @@ def answer_b(self, val): self._submit(value=val, part="b") @property - def answered_b(self): + def answered_b(self) -> bool: """Has the second part of this puzzle been solved correctly yet?""" return bool(getattr(self, "answer_b", None)) - def answered(self, part): + def answered(self, part: PuzzlePart) -> bool: """Has the specified part of this puzzle been solved correctly yet?""" if part == "a": return bool(getattr(self, "answer_a", None)) @@ -391,7 +401,7 @@ def answered(self, part): raise AocdError('part must be "a" or "b"') @property - def answers(self): + def answers(self) -> tuple[str, str]: """ Returns a tuple of the correct answers for this puzzle. Will raise an AttributeError if either part is yet to be solved by the associated user. @@ -399,7 +409,7 @@ def answers(self): return self.answer_a, self.answer_b @answers.setter - def answers(self, val): + def answers(self, val: tuple[AnswerValue, AnswerValue]) -> None: """ Submit both answers at once. Pretty much impossible in practice, unless you've seen the puzzle before. @@ -407,7 +417,7 @@ def answers(self, val): self.answer_a, self.answer_b = val @property - def submit_results(self): + def submit_results(self) -> list[Submission]: """ Record of all previous submissions to adventofcode.com for this user/puzzle. Submissions made by typing answers directly into the website will not be @@ -653,7 +663,7 @@ def _get_answer(self, part): msg = f"Answer {self.year}-{self.day}{part} is not available" raise PuzzleUnsolvedError(msg) - def solve(self): + def solve(self) -> tuple[AnswerValue, AnswerValue]: """ If there is a unique entry-point in the "adventofcode.user" group, load it and invoke it using this puzzle's input data. It is expected to return a tuple of @@ -668,7 +678,7 @@ def solve(self): f = ep.load() return f(year=self.year, day=self.day, data=self.input_data) - def solve_for(self, plugin): + def solve_for(self, plugin: str) -> tuple[AnswerValue, AnswerValue]: """ Load the entry-point from the "adventofcode.user" plugin group with the specified name, and invoke it using this puzzle's input data. The entry-point @@ -685,16 +695,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[PuzzlePart, PuzzleStats]: """ Your personal stats (rank, solve time, score) for this particular puzzle. Raises `PuzzleUnsolvedError` if you haven't actually solved it yet. @@ -766,7 +776,7 @@ def _get_prose(self): raise AocdError(f"Could not get prose for {self.year}/{self.day:02d}") @property - def easter_eggs(self): + def easter_eggs(self) -> list[str]: """ Return a list of Easter eggs in the puzzle's description page. When you've completed all 25 days, adventofcode.com will reveal the Easter eggs directly in @@ -779,7 +789,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. @@ -792,7 +802,7 @@ def unlock_time(self, local=True): return result @staticmethod - def all(user=None): + def all(user: User | None = None) -> t.Iterator[Puzzle]: """ Return an iterator over all known puzzles that are currently playable. """ diff --git a/aocd/post.py b/aocd/post.py index c2fa45a..07399f9 100644 --- a/aocd/post.py +++ b/aocd/post.py @@ -1,18 +1,32 @@ +from __future__ import annotations + import logging +import typing as t from .get import current_day from .get import most_recent_year from .models import default_user from .models import Puzzle from .models import User +from .types import AnswerValue +from .types import PuzzlePart + +if t.TYPE_CHECKING: + from urllib3 import BaseHTTPResponse -log = logging.getLogger(__name__) +log: logging.Logger = logging.getLogger(__name__) def submit( - answer, part=None, day=None, year=None, session=None, reopen=True, quiet=False -): + answer: AnswerValue, + part: PuzzlePart | None = None, + day: int | None = None, + year: int | None = None, + session: str | None = None, + reopen: bool = True, + quiet: bool = False, +) -> BaseHTTPResponse | None: """ Submit your answer to adventofcode.com, and print the response to the terminal. The only required argument is `answer`, all others can usually be introspected 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..b060940 100644 --- a/aocd/runner.py +++ b/aocd/runner.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import itertools import logging @@ -5,9 +7,11 @@ import sys import tempfile import time +import typing as t from argparse import ArgumentParser from datetime import datetime from functools import partial +from importlib.metadata import EntryPoint from pathlib import Path import pebble.concurrent @@ -27,10 +31,10 @@ DEFAULT_TIMEOUT = 60 -log = logging.getLogger(__name__) +log: logging.Logger = 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. @@ -199,7 +203,14 @@ def _process_wrapper(f, capture=False, **kwargs): return f(**kwargs) -def run_with_timeout(entry_point, timeout, progress, dt=0.1, capture=False, **kwargs): +def run_with_timeout( + entry_point: EntryPoint, + timeout: float, + progress: str | None, + dt: float = 0.1, + capture: bool = False, + **kwargs: t.Any, +) -> tuple[str, str, float, str]: """ Execute a user solve, and display a progress spinner as it's running. Kill it if the runtime exceeds `timeout` seconds. @@ -234,7 +245,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: float, timeout: float = DEFAULT_TIMEOUT) -> str: """ Used for rendering the puzzle solve time in color: - green, if you're under a quarter of the timeout (15s default) @@ -252,8 +263,14 @@ def format_time(t, timeout=DEFAULT_TIMEOUT): def run_one( - year, day, data, entry_point, timeout=DEFAULT_TIMEOUT, progress=None, capture=False -): + year: int, + day: int, + data: str, + entry_point: EntryPoint, + timeout: float = DEFAULT_TIMEOUT, + progress: str | None = None, + capture: bool = False, +) -> tuple[str, str, float, str]: """ Creates a temporary dir and change directory into it (restores cwd on exit). Lays down puzzle input in a file called "input.txt" in this directory - user code @@ -293,16 +310,16 @@ def run_one( def run_for( - plugs, - years, - days, - datasets, - example=False, - timeout=DEFAULT_TIMEOUT, - autosubmit=True, - reopen=False, - capture=False, -): + plugs: t.Collection[str], + years: t.Iterable[int], + days: t.Iterable[int], + datasets: t.Mapping[str, str], + example: bool = False, + timeout: float = DEFAULT_TIMEOUT, + autosubmit: bool = True, + reopen: bool = False, + capture: bool = False, +) -> int: """ Run with multiple users, multiple datasets, multiple years/days, and render the results. """ diff --git a/aocd/types.py b/aocd/types.py new file mode 100644 index 0000000..b627394 --- /dev/null +++ b/aocd/types.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import timedelta +from numbers import Number +from typing import Literal, TypedDict, Union + +AnswerValue = Union[str, Number] +"""The answer to a puzzle, either a string or a number. Numbers are coerced to a string""" +PuzzlePart = Literal["a", "b"] +"""The part of a given puzzle, a or b""" + + +class PuzzleStats(TypedDict): + """Your personal stats for a given puzzle + + See https://adventofcode.com//leaderboard/self when logged in. + """ + + time: timedelta + rank: int + score: int + + +class Submission(TypedDict): + """Record of a previous submission made for a given puzzle + + Only applies to answers submitted with aocd. + """ + + part: PuzzlePart + value: str + when: str + message: str diff --git a/aocd/utils.py b/aocd/utils.py index 833716b..5cf0a72 100644 --- a/aocd/utils.py +++ b/aocd/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import logging import os @@ -5,12 +7,14 @@ import shutil import sys import time +import typing as t from collections import deque from datetime import datetime from functools import cache from importlib.metadata import entry_points from importlib.metadata import version from itertools import cycle +from pathlib import Path from tempfile import NamedTemporaryFile from zoneinfo import ZoneInfo @@ -19,8 +23,16 @@ from .exceptions import DeadTokenError +if sys.version_info >= (3, 10): + # Python 3.10+ + from importlib.metadata import EntryPoints as _EntryPointsType +else: + # Python 3.9 + from importlib.metadata import EntryPoint + + _EntryPointsType = list[EntryPoint] -log = logging.getLogger(__name__) +log: logging.Logger = logging.getLogger(__name__) AOC_TZ = ZoneInfo("America/New_York") _v = version("advent-of-code-data") USER_AGENT = f"github.com/wimglenn/advent-of-code-data v{_v} by hey@wimglenn.com" @@ -31,7 +43,10 @@ class HttpClient: # 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): + pool_manager: urllib3.PoolManager + req_count: dict[t.Literal["GET", "POST"], int] + + def __init__(self) -> None: proxy_url = os.environ.get('http_proxy') or os.environ.get('https_proxy') if proxy_url: @@ -62,7 +77,9 @@ 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: str | None = None, redirect: bool = True + ) -> urllib3.BaseHTTPResponse: # getting user inputs, puzzle prose, etc if token is None: headers = self.pool_manager.headers @@ -73,7 +90,9 @@ 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: str, fields: t.Mapping[str, str] + ) -> urllib3.BaseHTTPResponse: # submitting answers headers = self.pool_manager.headers | {"Cookie": f"session={token}"} self._limiter() @@ -88,14 +107,19 @@ def post(self, url, token, fields): return resp -http = HttpClient() +http: HttpClient = HttpClient() def _ensure_intermediate_dirs(path): 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: str | None = None, + until: tuple[int, int] | None = None, +) -> None: """ This function just blocks until the next puzzle unlocks. Pass `quiet=True` to disable the spinner etc. @@ -142,7 +166,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. @@ -179,7 +203,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 @@ -209,12 +233,15 @@ def _cli_guess(choice, choices): return result -_ansi_colors = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] +_ANSIColor = t.Literal[ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white" +] +_ansi_colors = t.get_args(_ANSIColor) if platform.system() == "Windows": os.system("color") # hack - makes ANSI colors work in the windows cmd window -def colored(txt, color): +def colored(txt: str, color: _ANSIColor | None) -> str: if color is None: return txt code = _ansi_colors.index(color.casefold()) @@ -222,7 +249,7 @@ 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") -> _EntryPointsType: """ Currently installed plugins for user solves. """ diff --git a/pyproject.toml b/pyproject.toml index ae0d028..d77b0f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ aoce = "aocd.examples:main" [tool.setuptools] packages = ["aocd"] +[tool.setuptools.package-data] +aocd = ["py.typed"] + + [project.entry-points] "adventofcode.user" = {} # for user solvers "adventofcode.examples" = {} # for example-parser implementations @@ -62,3 +66,8 @@ addopts = """ --cov-report=html --cov-report=term-missing:skip-covered""" xfail_strict = true markers = "answer_not_cached" + +[tool.coverage.report] +exclude_also = [ + "if t\\.TYPE_CHECKING:" +] From 589325a4b54a6c66ad4049f8760ecf9ff0e69be5 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Thu, 7 Dec 2023 16:07:01 +0000 Subject: [PATCH 2/2] CI: Verify public API types with pyright --- .github/workflows/tests.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7be1ea4..f1b9be1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,3 +43,29 @@ jobs: uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} + + typesafety: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.12' + cache: 'pip' + + - run: | + python -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + # Tell setuptools to *not* create a PEP 660 import hook and to use + # symlinks instead, so that pyright can still find the package. See + # https://microsoft.github.io/pyright/#/import-resolution?id=editable-installs + pip install --editable . --config-settings editable_mode=strict + + - run: echo "$PWD/.venv/bin" >> $GITHUB_PATH + + - uses: jakebailey/pyright-action@v1 + with: + ignore-external: true + verify-types: "aocd"