From 8422243a79fab399e801548562342da78ae7e1c9 Mon Sep 17 00:00:00 2001 From: Wim Jeantine-Glenn Date: Sat, 2 Nov 2024 19:27:05 -0500 Subject: [PATCH] Reimplement Cal Jacobson's coercion work. Co-authored-by: Cal Jacobson --- aocd/__init__.py | 2 +- aocd/_ipykernel.py | 3 ++- aocd/cookies.py | 5 ++-- aocd/models.py | 55 ++++++++++++++++++++++++++++++-------------- aocd/runner.py | 2 +- tests/test_models.py | 47 ++++++++++++++++++++++++++++++++++++- tests/test_submit.py | 2 +- 7 files changed, 92 insertions(+), 24 deletions(-) diff --git a/aocd/__init__.py b/aocd/__init__.py index cd85b9d..30abf77 100644 --- a/aocd/__init__.py +++ b/aocd/__init__.py @@ -19,7 +19,7 @@ from .post import submit as _impartial_submit __all__ = [ - "_ipykernel", + "AocdError", "cli", "cookies", "data", diff --git a/aocd/_ipykernel.py b/aocd/_ipykernel.py index 5f6d317..f532de1 100644 --- a/aocd/_ipykernel.py +++ b/aocd/_ipykernel.py @@ -17,7 +17,8 @@ def get_ipynb_path(): 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"HTTP {resp.status}") for sess in resp.json(): if kernel_id == sess["kernel"]["id"]: path = serv["root_dir"] diff --git a/aocd/cookies.py b/aocd/cookies.py index 7e04b37..a23a753 100644 --- a/aocd/cookies.py +++ b/aocd/cookies.py @@ -58,9 +58,10 @@ def get_working_tokens() -> dict[str, str]: edge = [c for c in edge if c.name == "session"] log.info("%d candidates from edge", len(edge)) + cookies = chrome + firefox + edge # order preserving de-dupe - tokens = list({}.fromkeys([c.value for c in chrome + firefox + edge])) - removed = len(chrome + firefox + edge) - len(tokens) + tokens = list({}.fromkeys([c.value for c in cookies if c.value is not None])) + removed = len(cookies) - len(tokens) if removed: log.info("Removed %d duplicate%s", removed, "s"[: removed - 1]) diff --git a/aocd/models.py b/aocd/models.py index 825e20e..6fd4a1a 100644 --- a/aocd/models.py +++ b/aocd/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import json import logging import os @@ -10,6 +11,8 @@ import webbrowser from datetime import datetime from datetime import timedelta +from decimal import Decimal +from fractions import Fraction from functools import cache from functools import cached_property from importlib.metadata import entry_points @@ -113,10 +116,14 @@ def get_stats( """ aoc_now = datetime.now(tz=AOC_TZ) all_years = range(2015, aoc_now.year + int(aoc_now.month == 12)) - if isinstance(years, int) and years in all_years: + if isinstance(years, int): years = (years,) if years is None: years = all_years + invalid_years = sorted([y for y in years if y not in all_years]) + if invalid_years: + bad = ', '.join(map(str, invalid_years)) + raise ValueError(f"Invalid years: {bad}") days = {str(i) for i in range(1, 26)} results = {} ur_broke = "You haven't collected any stars" @@ -248,7 +255,7 @@ def examples(self) -> list[Example]: html, and they're the same for every user id. This list might be empty (not every puzzle has usable examples), or it might have several examples, but it will usually have one element. The list, and the examples themselves, may be - different depending on whether or not part b of the puzzle prose has been + different regardless of whether part b of the puzzle prose has been unlocked (i.e. part a has already been solved correctly). """ return self._get_examples() @@ -301,30 +308,44 @@ def _coerce_val(self, val): # but it's convenient to be able to submit numbers, since many of the answers # are numeric strings. coerce the values to string safely. orig_val = val - orig_type = type(val) coerced = False - floatish = isinstance(val, (float, complex)) - if floatish and val.imag == 0.0 and val.real.is_integer(): - coerced = True - val = int(val.real) - elif orig_type.__module__ == "numpy" and getattr(val, "ndim", None) == 0: - # deal with numpy scalars - if orig_type.__name__.startswith(("int", "uint", "long", "ulong")): + # A user can't be submitting a numpy type if numpy is not installed, so skip + # handling of those types + with contextlib.suppress(ImportError): + import numpy as np + + # "unwrap" arrays that contain a single element + if isinstance(val, np.ndarray) and val.size == 1: + coerced = True + val = val.item() + if isinstance(val, (np.integer, np.floating, np.complexfloating)) and val.imag == 0 and val.real.is_integer(): coerced = True - val = int(orig_val) - elif orig_type.__name__.startswith(("float", "complex")): - if val.imag == 0.0 and float(val.real).is_integer(): - coerced = True - val = int(val.real) + val = str(int(val.real)) if isinstance(val, int): val = str(val) + elif isinstance(val, (float, complex)) and val.imag == 0 and val.real.is_integer(): + coerced = True + val = str(int(val.real)) + elif isinstance(val, bytes): + coerced = True + val = val.decode() + elif isinstance(val, (Decimal, Fraction)): + # if val can be represented as an integer ratio where the denominator is 1 + # val is an integer and val == numerator + numerator, denominator = val.as_integer_ratio() + if denominator == 1: + coerced = True + val = str(numerator) + if not isinstance(val, str): + raise AocdError(f"Failed to coerce {type(orig_val).__name__} value {orig_val!r} for {self.year}/{self.day:02}.") if coerced: log.warning( - "coerced %s value %r for %d/%02d", - orig_type.__name__, + "coerced %s value %r for %d/%02d to %r", + type(orig_val).__name__, orig_val, self.year, self.day, + val, ) return val diff --git a/aocd/runner.py b/aocd/runner.py index b060940..586fffb 100644 --- a/aocd/runner.py +++ b/aocd/runner.py @@ -373,7 +373,7 @@ def run_for( else: for answer, part in zip((a, b), "ab"): if day == 25 and part == "b": - # there's no part b on christmas day, skip + # there's no part b on Christmas day, skip continue expected = None try: diff --git a/tests/test_models.py b/tests/test_models.py index 13f6fed..ca04d9a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,8 @@ +import decimal +import fractions import logging from datetime import datetime from datetime import timedelta -from textwrap import dedent import numpy as np import pytest @@ -445,6 +446,50 @@ def test_type_coercions(v_raw, v_expected, len_logs, caplog): assert len(caplog.records) == len_logs +@pytest.mark.parametrize( + "v_raw, v_expected, len_logs", + [ + ("xxx", "xxx", 0), # str -> str + (b"123", "123", 1), # bytes -> str + (123, "123", 0), # int -> str + (123.0, "123", 1), # float -> str + (123.0 + 0.0j, "123", 1), # complex -> str + (np.int32(123), "123", 1), # np.int -> str + (np.uint32(123), "123", 1), # np.uint -> str + (np.double(123.0), "123", 1), # np.double -> str + (np.complex64(123.0 + 0.0j), "123", 1), # np.complex -> str + (np.array([123]), "123", 1), # 1D np.array of int -> str + (np.array([[123.0]]), "123", 1), # 2D np.array of float -> str + (np.array([[[[[[123.0 + 0j]]]]]]), "123", 1), # deep np.array of complex -> str + (fractions.Fraction(123 * 2, 2), "123", 1), # Fraction -> int + (decimal.Decimal("123"), "123", 1), # Decimal -> int + ], +) +def test_type_coercions(v_raw, v_expected, len_logs, caplog): + p = Puzzle(2022, 1) + v_actual = p._coerce_val(v_raw) + assert v_actual == v_expected, f"{type(v_raw)} {v_raw})" + assert len(caplog.records) == len_logs + + +@pytest.mark.parametrize( + "val, error_msg", + [ + (123.5, "Failed to coerce float value 123.5 for 2022/01."), # non-integer float + (123.0 + 123.0j, "Failed to coerce complex value (123+123j) for 2022/01."), # complex w/ imag + (np.complex64(123.0 + 0.5j), "Failed to coerce complex64 value np.complex64(123+0.5j) for 2022/01."), # np.complex w/ imag + (np.array([1, 2]), "Failed to coerce ndarray value array([1, 2]) for 2022/01."), # 1D np.array with size != 1 + (np.array([[1], [2]]), "Failed to coerce ndarray value array([[1],\n [2]]) for 2022/01."), # 2D np.array with size != 1 + (fractions.Fraction(123, 2), "Failed to coerce Fraction value Fraction(123, 2) for 2022/01."), # Fraction + (decimal.Decimal("123.5"), "Failed to coerce Decimal value Decimal('123.5') for 2022/01."), # Decimal + ] +) +def test_type_coercions_fail(val, error_msg): + p = Puzzle(2022, 1) + with pytest.raises(AocdError(error_msg)): + p._coerce_val(val) + + def test_get_prose_cache(aocd_data_dir): cached = aocd_data_dir / "other-user-id" / "2022_01_prose.2.html" cached.parent.mkdir() diff --git a/tests/test_submit.py b/tests/test_submit.py index 3425988..a5a2d7f 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -280,5 +280,5 @@ def test_submit_float_warns(pook, capsys, caplog): ) submit(1234.0, part="a", day=8, year=2022, session="whatever", reopen=False) assert post.calls == 1 - record = ("aocd.models", logging.WARNING, "coerced float value 1234.0 for 2022/08") + record = ("aocd.models", logging.WARNING, "coerced float value 1234.0 for 2022/08 to '1234'") assert record in caplog.record_tuples