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
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4bb3f90
add .gitignore
cj81499 Jul 23, 2023
226ed10
list testing dependencies in pyproject.toml
cj81499 Jul 23, 2023
d0300bb
add type annotations
cj81499 Jul 23, 2023
9001f79
wip
cj81499 Jul 23, 2023
42beb06
wip
cj81499 Jul 23, 2023
0be9870
wip
cj81499 Jul 23, 2023
5833326
udpate gitignore
cj81499 Jul 23, 2023
2cdd669
enable ignore_missing_imports for browser_cookie3
cj81499 Jul 23, 2023
f79fa36
update trove classifiers
cj81499 Jul 23, 2023
6ba0c82
ignore bc3
cj81499 Jul 23, 2023
d364823
improve coverage report exclusion rules
cj81499 Jul 23, 2023
39ea8c1
wip
cj81499 Jul 24, 2023
803c2fc
wip
cj81499 Jul 26, 2023
c782e2f
wip
cj81499 Jul 26, 2023
85e3e8b
wip
cj81499 Jul 26, 2023
eae8d2e
wip
cj81499 Jul 26, 2023
5749296
wip
cj81499 Aug 29, 2023
bea135f
fix pyproject.toml oops
cj81499 Aug 29, 2023
d1dfc65
wip
cj81499 Aug 29, 2023
aca3c5d
wip
cj81499 Aug 29, 2023
840a196
wip
cj81499 Aug 29, 2023
288920f
wip
cj81499 Aug 30, 2023
60dcb44
wip
cj81499 Aug 30, 2023
c61b3e3
improve coverage exclusion rules
cj81499 Aug 30, 2023
a75a223
python 3.12
cj81499 Nov 6, 2023
0cfcfba
revert add .gitignore
cj81499 Nov 6, 2023
b66dc7f
re-add modules to `__all__`
cj81499 Nov 6, 2023
9fc0e29
urllib3 branch seems to have merged
cj81499 Nov 6, 2023
d15e818
Merge remote-tracking branch 'upstream/main' into add-type-annotations
cj81499 Nov 24, 2023
7717874
wip
cj81499 Nov 24, 2023
622e783
type check tests
cj81499 Nov 25, 2023
fa37a44
Update aocd/__init__.py
wimglenn Nov 25, 2023
766807b
Update aocd/__init__.py
wimglenn Nov 26, 2023
14d4adf
non-destructive
wimglenn Nov 26, 2023
bf9e19a
Update aocd/models.py
wimglenn Nov 26, 2023
58198a2
make `get_day_and_year` raise if it can not introspect both day and year
cj81499 Nov 26, 2023
dad87b0
revert example parser regression
cj81499 Nov 26, 2023
408c14e
refactor: extract version dependent logic to `_compat` module
cj81499 Nov 26, 2023
2bf5021
remove classifiers
cj81499 Nov 26, 2023
0d87e5b
remove unused imports
cj81499 Nov 26, 2023
d3ea107
use Self instead of _TUser
cj81499 Nov 26, 2023
8bd78b0
refactor compat module
cj81499 Nov 26, 2023
44e9635
run through https://github.com/asottile/reorder-python-imports
wimglenn Nov 26, 2023
0cc874c
rework coercion
cj81499 Nov 27, 2023
54dcec4
support bytes, Fraction, and Decimal types
cj81499 Nov 27, 2023
5747886
add comments to coercion test cases
cj81499 Nov 27, 2023
bd15325
Update aocd/utils.py
wimglenn Dec 7, 2023
495519e
Update aocd/models.py
wimglenn Dec 7, 2023
db4fe6a
Update aocd/models.py
wimglenn Dec 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 .[all] && 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 .
24 changes: 22 additions & 2 deletions aocd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys
from functools import partial
from typing import TYPE_CHECKING

from . import _ipykernel
from . import cli
from . import cookies
from . import examples
Expand All @@ -16,8 +16,28 @@
from .get import get_day_and_year
from .post import submit as _impartial_submit

__all__ = [
"AocdError",
wimglenn marked this conversation as resolved.
Show resolved Hide resolved
"cli",
"cookies",
"data",
"examples",
"exceptions",
"get_data",
"get",
"models",
"post",
"runner",
"submit",
"utils",
]

def __getattr__(name):
data: str

if TYPE_CHECKING:
submit = _impartial_submit

def __getattr__(name): # type: ignore[no-untyped-def] # no idea how to provide meaningful types here
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use Any. The correct type hints are already provided for all names in __all__ so the type checker doesn't have to care about what __getattr__ returns.

Suggested change
def __getattr__(name): # type: ignore[no-untyped-def] # no idea how to provide meaningful types here
def __getattr__(name: str) -> Any:

(make sure to import Any).

if name == "data":
day, year = get_day_and_year()
return get_data(day=day, year=year)
Expand Down
40 changes: 40 additions & 0 deletions aocd/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import sys
from importlib.metadata import entry_points

# typing.Self added in 3.11
if sys.version_info >= (3, 11):
from typing import Self as Self # import using same name to tell the type checker we intend to export this (so other modules can import it)
else:
from typing_extensions import Self as Self # import using same name to tell the type checker we intend to export this (so other modules can import it)
Comment on lines +5 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overkill. Just use from typing_extensions import Self, where you need it in the modules itself, and unconditionally depend on typing_extensions.

typing_extensions should be kept as a dependency even on 3.11 because it is, by now, used by a lot of projects so is not likely to be a new addition. typing_extensions itself will use typing.Self on Python 3.11 so checking for the Python version here won't buy you anything.

advent-of-code could instead use pyupgrade to keep the codebase clean; if in future the minimum release for the project is moved to Python 3.11, that tool would automatically move the import from typing_extensions to typing.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. As well as moving the import, is pyupgrade also smart enough to know when the pyproject.toml metadata no longer needs to specify a dependency on typing-extensions?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pyupgrade won't (dependency management is out of scope), but a tool like deptry can be used to flag obsolete dependencies (as well as flag missing deps).


# typing.ParamSpec added in 3.10
if sys.version_info >= (3, 10):
from typing import ParamSpec as ParamSpec # import using same name to tell the type checker we intend to export this (so other modules can import it)
else:
from typing_extensions import ParamSpec as ParamSpec # import using same name to tell the type checker we intend to export this (so other modules can import it)
Comment on lines +11 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto here. Just use from typing_extensions import ParamSpec in the module that needs to use a ParamSpec annotation.


# importlib.metadata.EntryPoints added in 3.10
if sys.version_info >= (3, 10):
from importlib.metadata import EntryPoints

# Python 3.10+ - group/name selectable entry points
def get_entry_points(group: str, name: str) -> EntryPoints:
return entry_points().select(group=group, name=name)

def get_plugins(group: str = "adventofcode.user") -> EntryPoints:
"""
Currently installed plugins for user solves.
"""
return entry_points(group=group)
else:
from importlib.metadata import EntryPoint

# Python 3.9 - dict interface
def get_entry_points(group: str, name: str) -> list[EntryPoint]:
return [ep for ep in entry_points()[group] if ep.name == name]

def get_plugins(group: str = "adventofcode.user") -> list[EntryPoint]:
"""
Currently installed plugins for user solves.
"""
return entry_points().get(group, [])
16 changes: 12 additions & 4 deletions aocd/_ipykernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,31 @@

import urllib3

_IPYNB_PATTERN = re.compile(r"(?<=kernel-)[\w\-]+(?=\.json)")

def get_ipynb_path():
def get_ipynb_path() -> str:
# helper function so that "from aocd import data" can introspect the year/day from
# the .ipynb filename. inline imports to avoid hard-dependency on IPython/jupyter
import IPython
from jupyter_server import serverapp
from jupyter_server.utils import url_path_join

app = IPython.get_ipython().config["IPKernelApp"]
kernel_id = re.search(r"(?<=kernel-)[\w\-]+(?=\.json)", app["connection_file"])[0]
ipython = IPython.get_ipython() # type: ignore[attr-defined,no-untyped-call] # IPython doesn't explicitly export the get_ipython function
app = ipython.config["IPKernelApp"]
match = _IPYNB_PATTERN.search(app["connection_file"])
assert match is not None
kernel_id = match[0]
http = urllib3.PoolManager()
for serv in serverapp.list_running_servers():
url = url_path_join(serv["url"], "api/sessions")
resp = http.request("GET", url, fields={"token": serv["token"]})
resp.raise_for_status()
if resp.status >= 400:
raise urllib3.exceptions.ResponseError(f"Bad HTTP response status ({resp.status})")
cj81499 marked this conversation as resolved.
Show resolved Hide resolved
for sess in resp.json():
if kernel_id == sess["kernel"]["id"]:
path = serv["root_dir"]
assert isinstance(path, str)
fname = sess["notebook"]["path"]
assert isinstance(fname, str)
return os.path.join(path, fname)
assert False, "unreachable"
19 changes: 19 additions & 0 deletions aocd/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from decimal import Decimal
from fractions import Fraction
from typing import Literal
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union

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.

"_Answer",
"_Part",
"_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 👍

_Part = Literal["a", "b"]
_LoosePart = Union[_Part, Literal[1, "1", 2, "2"]]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have my reservations about the use of _LoosePart (voiced below), but if you are going to use it, then at least also include A and B; the code that accepts _LoosePart uses case folding on strings.

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
18 changes: 10 additions & 8 deletions aocd/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
import logging
import os
import sys
from typing import TYPE_CHECKING

from .exceptions import DeadTokenError
from .models import AOCD_CONFIG_DIR
from .utils import _ensure_intermediate_dirs
from .utils import colored
from .utils import get_owner

if TYPE_CHECKING:
import http.cookiejar

log = 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:
Expand All @@ -25,9 +28,8 @@ def get_working_tokens():

log.info("checking browser storage for tokens, this might pop up an auth dialog!")
log.info("checking chrome cookie jar...")
cookie_files = glob.glob(os.path.expanduser("~/.config/google-chrome/*/Cookies"))
cookie_files.append(None)
chrome_cookies = []
cookie_files = (*glob.glob(os.path.expanduser("~/.config/google-chrome/*/Cookies")), None)
chrome_cookies: list["http.cookiejar.Cookie"] = []
for cf in cookie_files:
try:
chrome = bc3.chrome(cookie_file=cf, domain_name=".adventofcode.com")
Expand All @@ -49,12 +51,12 @@ def get_working_tokens():
log.info("%d candidates from firefox", len(firefox))

# order preserving de-dupe
tokens = list({}.fromkeys([c.value for c in chrome + firefox]))
tokens = list({}.fromkeys([c.value for c in chrome + firefox if c.value is not None]))
removed = len(chrome + firefox) - len(tokens)
if removed:
log.info("Removed %d duplicate%s", removed, "s"[: removed - 1])

result = {} # map of {token: auth source}
result: dict[str, str] = {} # map of {token: auth source}
for token in tokens:
try:
owner = get_owner(token)
Expand All @@ -66,7 +68,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 Expand Up @@ -104,7 +106,7 @@ def scrape_session_tokens():
if aocd_token_path.is_file():
txt = aocd_token_path.read_text(encoding="utf-8").strip()
if txt:
tokens[aocd_token_path] = txt.split()[0]
tokens[str(aocd_token_path)] = txt.split()[0]
if aocd_tokens_path.is_file():
tokens.update(json.loads(aocd_tokens_path.read_text(encoding="utf-8")))
else:
Expand Down
63 changes: 36 additions & 27 deletions aocd/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
from dataclasses import dataclass
from datetime import datetime
from itertools import zip_longest
from typing import Callable
from typing import NamedTuple
from typing import Optional
from typing import Union

import bs4

Expand All @@ -15,7 +18,6 @@
from aocd.utils import AOC_TZ
from aocd.utils import get_plugins


log = logging.getLogger(__name__)


Expand All @@ -35,18 +37,19 @@ class Page:
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 <article> in the page, i.e. part a
article_b: bs4.element.Tag # The bs4 tag for the second <article> in the page, i.e. part b. It will be `None` if part b locked
article_b: Optional[bs4.element.Tag] # The bs4 tag for the second <article> in the page, i.e. part b. It will be `None` if part b locked
a_raw: str # The first <article> html as a string
b_raw: str # The second <article> html as a string. Will be `None` if part b locked
b_raw: Optional[str] # The second <article> 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"<Page({self.year}, {self.day}){part_a_only} at {hex(id(self))}>"

@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})$"
assert soup.title is not None
title_text = soup.title.text
if (match := re.match(title_pat, title_text)) is None:
msg = f"failed to extract year/day from title {title_text!r}"
Expand All @@ -55,16 +58,19 @@ def from_raw(cls, html):
articles = soup.find_all("article")
if len(articles) == 0:
raise ExampleParserError("no <article> found in html")
elif len(articles) == 1:
[article_a] = articles
a_raw = str(article_a)
article_b = b_raw = None
elif len(articles) == 2:
article_a, article_b = articles
a_raw = str(article_a)
b_raw = str(article_b)
else:
if len(articles) > 2:
raise ExampleParserError("too many <article> found in html")

article_a = articles[0]
assert isinstance(article_a, bs4.Tag)
a_raw = str(article_a)

article_b = b_raw = None
if len(articles) == 2:
article_b = articles[1]
assert isinstance(article_b, bs4.Tag)
b_raw = str(article_b)

page = Page(
raw_html=html,
soup=soup,
Expand All @@ -77,7 +83,7 @@ def from_raw(cls, html):
)
return page

def __getattr__(self, name):
def __getattr__(self, name: str) -> Union[list[bs4.Tag], list[str]]:
if not name.startswith(("a_", "b_")):
raise AttributeError(name)
part, sep, tag = name.partition("_")
Expand All @@ -91,13 +97,16 @@ def __getattr__(self, name):
# actually used by an example parser
raise AttributeError(name)
article = self.article_a if part == "a" else self.article_b
assert article is not None
tags: list[bs4.Tag] = article.find_all(tag)
result: Union[list[bs4.Tag], list[str]] = tags
if tag == "li":
# list items usually need further drill-down
result = article.find_all("li")
for li in result:
li.codes = [code.text for code in li.find_all("code")]
for li in tags:
code_tags: list[bs4.Tag] = li.find_all("code")
li.codes = [code.text for code in code_tags] # type: ignore[attr-defined]
else:
result = [t.text for t in article.find_all(tag)]
result = [t.text for t in tags]
cj81499 marked this conversation as resolved.
Show resolved Hide resolved
setattr(self, name, result) # cache the result
msg = "cached %s accessors for puzzle %d/%02d part %s page (%d hits)"
log.debug(msg, tag, self.year, self.day, part, len(result))
Expand All @@ -118,23 +127,23 @@ class Example(NamedTuple):
"""

input_data: str
answer_a: str = None
answer_b: str = None
extra: str = None
answer_a: Optional[str] = None
answer_b: Optional[str] = None
extra: Optional[str] = None

@property
def answers(self):
def answers(self) -> tuple[Optional[str], Optional[str]]:
return self.answer_a, self.answer_b


def _trunc(s, maxlen=50):
def _trunc(s: Optional[str], maxlen: int = 50) -> Optional[str]:
# don't print massive strings and mess up the table rendering
if s is None or len(s) <= maxlen:
return s
return s[:maxlen] + f" ... ({len(s)} bytes)"


def _get_unique_real_inputs(year, day):
def _get_unique_real_inputs(year: int, day: int) -> list[str]:
# these are passed to example parsers, in case the shape/content of the real
# input(s) is in some way useful for extracting the example input(s). it is
# not currently used by the default example parser implementation.
Expand All @@ -144,7 +153,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 Expand Up @@ -205,7 +214,7 @@ def main():
file=sys.stderr,
)
sys.exit(1)
plugin = plugins[args.example_parser].load()
plugin: Callable[[Page, list[str]], list[Example]] = plugins[args.example_parser].load()
console = Console()
parser_wants_real_datas = getattr(plugin, "uses_real_datas", True)

Expand Down
Loading