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" 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 cf9f28f..e38bf62 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: @@ -63,7 +78,9 @@ def _limiter(self): self._cooloff = min(self._cooloff, 10) 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 @@ -74,7 +91,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() @@ -89,14 +108,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. @@ -143,7 +167,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. @@ -180,7 +204,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 @@ -210,12 +234,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()) @@ -223,7 +250,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:" +]