Skip to content

Commit 7da7eb9

Browse files
authored
Merge pull request #146 from wimglenn/cj
Reimplement Cal Jacobson's coercion work. Co-authored-by: Cal Jacobson [email protected]
2 parents 7a16c4a + 7cc5c66 commit 7da7eb9

7 files changed

+92
-24
lines changed

aocd/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from .post import submit as _impartial_submit
2020

2121
__all__ = [
22-
"_ipykernel",
22+
"AocdError",
2323
"cli",
2424
"cookies",
2525
"data",

aocd/_ipykernel.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ def get_ipynb_path():
1717
for serv in serverapp.list_running_servers():
1818
url = url_path_join(serv["url"], "api/sessions")
1919
resp = http.request("GET", url, fields={"token": serv["token"]})
20-
resp.raise_for_status()
20+
if resp.status >= 400:
21+
raise urllib3.exceptions.ResponseError(f"HTTP {resp.status}")
2122
for sess in resp.json():
2223
if kernel_id == sess["kernel"]["id"]:
2324
path = serv["root_dir"]

aocd/cookies.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ def get_working_tokens() -> dict[str, str]:
5858
edge = [c for c in edge if c.name == "session"]
5959
log.info("%d candidates from edge", len(edge))
6060

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

aocd/models.py

+38-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import json
45
import logging
56
import os
@@ -10,6 +11,8 @@
1011
import webbrowser
1112
from datetime import datetime
1213
from datetime import timedelta
14+
from decimal import Decimal
15+
from fractions import Fraction
1316
from functools import cache
1417
from functools import cached_property
1518
from importlib.metadata import entry_points
@@ -113,10 +116,14 @@ def get_stats(
113116
"""
114117
aoc_now = datetime.now(tz=AOC_TZ)
115118
all_years = range(2015, aoc_now.year + int(aoc_now.month == 12))
116-
if isinstance(years, int) and years in all_years:
119+
if isinstance(years, int):
117120
years = (years,)
118121
if years is None:
119122
years = all_years
123+
invalid_years = sorted([y for y in years if y not in all_years])
124+
if invalid_years:
125+
bad = ', '.join(map(str, invalid_years))
126+
raise ValueError(f"Invalid years: {bad}")
120127
days = {str(i) for i in range(1, 26)}
121128
results = {}
122129
ur_broke = "You haven't collected any stars"
@@ -248,7 +255,7 @@ def examples(self) -> list[Example]:
248255
html, and they're the same for every user id. This list might be empty (not
249256
every puzzle has usable examples), or it might have several examples, but it
250257
will usually have one element. The list, and the examples themselves, may be
251-
different depending on whether or not part b of the puzzle prose has been
258+
different regardless of whether part b of the puzzle prose has been
252259
unlocked (i.e. part a has already been solved correctly).
253260
"""
254261
return self._get_examples()
@@ -301,30 +308,44 @@ def _coerce_val(self, val):
301308
# but it's convenient to be able to submit numbers, since many of the answers
302309
# are numeric strings. coerce the values to string safely.
303310
orig_val = val
304-
orig_type = type(val)
305311
coerced = False
306-
floatish = isinstance(val, (float, complex))
307-
if floatish and val.imag == 0.0 and val.real.is_integer():
308-
coerced = True
309-
val = int(val.real)
310-
elif orig_type.__module__ == "numpy" and getattr(val, "ndim", None) == 0:
311-
# deal with numpy scalars
312-
if orig_type.__name__.startswith(("int", "uint", "long", "ulong")):
312+
# A user can't be submitting a numpy type if numpy is not installed, so skip
313+
# handling of those types
314+
with contextlib.suppress(ImportError):
315+
import numpy as np
316+
317+
# "unwrap" arrays that contain a single element
318+
if isinstance(val, np.ndarray) and val.size == 1:
319+
coerced = True
320+
val = val.item()
321+
if isinstance(val, (np.integer, np.floating, np.complexfloating)) and val.imag == 0 and val.real.is_integer():
313322
coerced = True
314-
val = int(orig_val)
315-
elif orig_type.__name__.startswith(("float", "complex")):
316-
if val.imag == 0.0 and float(val.real).is_integer():
317-
coerced = True
318-
val = int(val.real)
323+
val = str(int(val.real))
319324
if isinstance(val, int):
320325
val = str(val)
326+
elif isinstance(val, (float, complex)) and val.imag == 0 and val.real.is_integer():
327+
coerced = True
328+
val = str(int(val.real))
329+
elif isinstance(val, bytes):
330+
coerced = True
331+
val = val.decode()
332+
elif isinstance(val, (Decimal, Fraction)):
333+
# if val can be represented as an integer ratio where the denominator is 1
334+
# val is an integer and val == numerator
335+
numerator, denominator = val.as_integer_ratio()
336+
if denominator == 1:
337+
coerced = True
338+
val = str(numerator)
339+
if not isinstance(val, str):
340+
raise AocdError(f"Failed to coerce {type(orig_val).__name__} value {orig_val!r} for {self.year}/{self.day:02}.")
321341
if coerced:
322342
log.warning(
323-
"coerced %s value %r for %d/%02d",
324-
orig_type.__name__,
343+
"coerced %s value %r for %d/%02d to %r",
344+
type(orig_val).__name__,
325345
orig_val,
326346
self.year,
327347
self.day,
348+
val,
328349
)
329350
return val
330351

aocd/runner.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ def run_for(
373373
else:
374374
for answer, part in zip((a, b), "ab"):
375375
if day == 25 and part == "b":
376-
# there's no part b on christmas day, skip
376+
# there's no part b on Christmas day, skip
377377
continue
378378
expected = None
379379
try:

tests/test_models.py

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import decimal
2+
import fractions
13
import logging
24
from datetime import datetime
35
from datetime import timedelta
4-
from textwrap import dedent
56

67
import numpy as np
78
import pytest
@@ -445,6 +446,50 @@ def test_type_coercions(v_raw, v_expected, len_logs, caplog):
445446
assert len(caplog.records) == len_logs
446447

447448

449+
@pytest.mark.parametrize(
450+
"v_raw, v_expected, len_logs",
451+
[
452+
("xxx", "xxx", 0), # str -> str
453+
(b"123", "123", 1), # bytes -> str
454+
(123, "123", 0), # int -> str
455+
(123.0, "123", 1), # float -> str
456+
(123.0 + 0.0j, "123", 1), # complex -> str
457+
(np.int32(123), "123", 1), # np.int -> str
458+
(np.uint32(123), "123", 1), # np.uint -> str
459+
(np.double(123.0), "123", 1), # np.double -> str
460+
(np.complex64(123.0 + 0.0j), "123", 1), # np.complex -> str
461+
(np.array([123]), "123", 1), # 1D np.array of int -> str
462+
(np.array([[123.0]]), "123", 1), # 2D np.array of float -> str
463+
(np.array([[[[[[123.0 + 0j]]]]]]), "123", 1), # deep np.array of complex -> str
464+
(fractions.Fraction(123 * 2, 2), "123", 1), # Fraction -> int
465+
(decimal.Decimal("123"), "123", 1), # Decimal -> int
466+
],
467+
)
468+
def test_type_coercions(v_raw, v_expected, len_logs, caplog):
469+
p = Puzzle(2022, 1)
470+
v_actual = p._coerce_val(v_raw)
471+
assert v_actual == v_expected, f"{type(v_raw)} {v_raw})"
472+
assert len(caplog.records) == len_logs
473+
474+
475+
@pytest.mark.parametrize(
476+
"val, error_msg",
477+
[
478+
(123.5, "Failed to coerce float value 123.5 for 2022/01."), # non-integer float
479+
(123.0 + 123.0j, "Failed to coerce complex value (123+123j) for 2022/01."), # complex w/ imag
480+
(np.complex64(123.0 + 0.5j), "Failed to coerce complex64 value np.complex64(123+0.5j) for 2022/01."), # np.complex w/ imag
481+
(np.array([1, 2]), "Failed to coerce ndarray value array([1, 2]) for 2022/01."), # 1D np.array with size != 1
482+
(np.array([[1], [2]]), "Failed to coerce ndarray value array([[1],\n [2]]) for 2022/01."), # 2D np.array with size != 1
483+
(fractions.Fraction(123, 2), "Failed to coerce Fraction value Fraction(123, 2) for 2022/01."), # Fraction
484+
(decimal.Decimal("123.5"), "Failed to coerce Decimal value Decimal('123.5') for 2022/01."), # Decimal
485+
]
486+
)
487+
def test_type_coercions_fail(val, error_msg):
488+
p = Puzzle(2022, 1)
489+
with pytest.raises(AocdError(error_msg)):
490+
p._coerce_val(val)
491+
492+
448493
def test_get_prose_cache(aocd_data_dir):
449494
cached = aocd_data_dir / "other-user-id" / "2022_01_prose.2.html"
450495
cached.parent.mkdir()

tests/test_submit.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -280,5 +280,5 @@ def test_submit_float_warns(pook, capsys, caplog):
280280
)
281281
submit(1234.0, part="a", day=8, year=2022, session="whatever", reopen=False)
282282
assert post.calls == 1
283-
record = ("aocd.models", logging.WARNING, "coerced float value 1234.0 for 2022/08")
283+
record = ("aocd.models", logging.WARNING, "coerced float value 1234.0 for 2022/08 to '1234'")
284284
assert record in caplog.record_tuples

0 commit comments

Comments
 (0)