Skip to content

Commit

Permalink
add type annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
cj81499 committed Jul 23, 2023
1 parent 0160078 commit a0ee4ac
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 58 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
32 changes: 32 additions & 0 deletions aocd/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion aocd/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion aocd/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion aocd/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions aocd/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import re
import traceback
import typing as t
from logging import getLogger

from ._ipykernel import get_ipynb_path
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
57 changes: 35 additions & 22 deletions aocd/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
import sys
import time
import typing as t
import webbrowser
from datetime import datetime
from datetime import timedelta
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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/<year>/leaderboard/self when logged in.
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -804,23 +813,27 @@ 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)
h, m, s = [int(x) for x in s.split(":")]
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
Expand Down
Empty file added aocd/py.typed
Empty file.
5 changes: 3 additions & 2 deletions aocd/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit a0ee4ac

Please sign in to comment.