Skip to content

Commit c4b091e

Browse files
authored
Merge pull request #158 from wimglenn/utils.coerce
expose models.Puzzle._coerce_val as utils.coerce
2 parents d0d9dca + fc50880 commit c4b091e

File tree

9 files changed

+116
-130
lines changed

9 files changed

+116
-130
lines changed

aocd/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import os
21
import json
2+
import os
33
import sys
44
import typing as t
55
from functools import partial
@@ -17,8 +17,8 @@
1717
from . import utils
1818
from .exceptions import AocdError
1919
from .get import get_data
20-
from .get import get_puzzle
2120
from .get import get_day_and_year
21+
from .get import get_puzzle
2222
from .post import submit as _impartial_submit
2323

2424
__all__ = [

aocd/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,7 @@ class UnknownUserError(AocdError):
2020

2121
class ExampleParserError(AocdError):
2222
"""for problems specific to the example extraction code"""
23+
24+
25+
class CoercionError(AocdError):
26+
"""failed to coerce a value to string safely"""

aocd/models.py

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import contextlib
43
import json
54
import logging
65
import os
@@ -11,8 +10,6 @@
1110
import webbrowser
1211
from datetime import datetime
1312
from datetime import timedelta
14-
from decimal import Decimal
15-
from fractions import Fraction
1613
from functools import cache
1714
from functools import cached_property
1815
from importlib.metadata import entry_points
@@ -36,6 +33,7 @@
3633
from .utils import _get_soup
3734
from .utils import AOC_TZ
3835
from .utils import atomic_write_file
36+
from .utils import coerce
3937
from .utils import colored
4038
from .utils import get_owner
4139
from .utils import get_plugins
@@ -308,52 +306,6 @@ def _repr_pretty_(self, p, cycle):
308306
txt = f"<Puzzle({self.year}, {self.day}) at {hex(id(self))} - {self.title}>"
309307
p.text(txt)
310308

311-
def _coerce_val(self, val):
312-
# technically adventofcode.com will only accept strings as answers.
313-
# but it's convenient to be able to submit numbers, since many of the answers
314-
# are numeric strings. coerce the values to string safely.
315-
orig_val = val
316-
coerced = False
317-
# A user can't be submitting a numpy type if numpy is not installed, so skip
318-
# handling of those types
319-
with contextlib.suppress(ImportError):
320-
import numpy as np
321-
322-
# "unwrap" arrays that contain a single element
323-
if isinstance(val, np.ndarray) and val.size == 1:
324-
coerced = True
325-
val = val.item()
326-
if isinstance(val, (np.integer, np.floating, np.complexfloating)) and val.imag == 0 and val.real.is_integer():
327-
coerced = True
328-
val = str(int(val.real))
329-
if isinstance(val, int):
330-
val = str(val)
331-
elif isinstance(val, (float, complex)) and val.imag == 0 and val.real.is_integer():
332-
coerced = True
333-
val = str(int(val.real))
334-
elif isinstance(val, bytes):
335-
coerced = True
336-
val = val.decode()
337-
elif isinstance(val, (Decimal, Fraction)):
338-
# if val can be represented as an integer ratio where the denominator is 1
339-
# val is an integer and val == numerator
340-
numerator, denominator = val.as_integer_ratio()
341-
if denominator == 1:
342-
coerced = True
343-
val = str(numerator)
344-
if not isinstance(val, str):
345-
raise AocdError(f"Failed to coerce {type(orig_val).__name__} value {orig_val!r} for {self.year}/{self.day:02}.")
346-
if coerced:
347-
log.warning(
348-
"coerced %s value %r for %d/%02d to %r",
349-
type(orig_val).__name__,
350-
orig_val,
351-
self.year,
352-
self.day,
353-
val,
354-
)
355-
return val
356-
357309
@property
358310
def answer_a(self) -> str:
359311
"""
@@ -376,7 +328,8 @@ def answer_a(self, val: AnswerValue) -> None:
376328
The result of the submission will be printed to the terminal. It will only POST
377329
to the server if necessary.
378330
"""
379-
val = self._coerce_val(val)
331+
if not isinstance(val, str):
332+
val = coerce(val, warn=True)
380333
if getattr(self, "answer_a", None) == val:
381334
return
382335
self._submit(value=val, part="a")
@@ -408,7 +361,8 @@ def answer_b(self, val: AnswerValue) -> None:
408361
The result of the submission will be printed to the terminal. It will only POST
409362
to the server if necessary.
410363
"""
411-
val = self._coerce_val(val)
364+
if not isinstance(val, str):
365+
val = coerce(val, warn=True)
412366
if getattr(self, "answer_b", None) == val:
413367
return
414368
self._submit(value=val, part="b")
@@ -468,7 +422,7 @@ def _submit(self, value, part, reopen=True, quiet=False, precheck=True):
468422
if value in NON_ANSWER:
469423
raise AocdError(f"cowardly refusing to submit non-answer: {value!r}")
470424
if not isinstance(value, str):
471-
value = self._coerce_val(value)
425+
value = coerce(value, warn=True)
472426
part = str(part).replace("1", "a").replace("2", "b").lower()
473427
if part not in {"a", "b"}:
474428
raise AocdError('part must be "a" or "b"')

aocd/types.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from __future__ import annotations
22

33
from datetime import timedelta
4-
from numbers import Number
4+
from typing import Any
55
from typing import Literal
66
from typing import TypedDict
7-
from typing import Union
87

9-
AnswerValue = Union[str, Number]
8+
9+
AnswerValue = Any
1010
"""The answer to a puzzle, either a string or a number. Numbers are coerced to a string"""
1111
PuzzlePart = Literal["a", "b"]
1212
"""The part of a given puzzle, a or b"""

aocd/utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import contextlib
45
import logging
56
import os
67
import platform
@@ -10,6 +11,8 @@
1011
import typing as t
1112
from collections import deque
1213
from datetime import datetime
14+
from decimal import Decimal
15+
from fractions import Fraction
1316
from functools import cache
1417
from importlib.metadata import entry_points
1518
from importlib.metadata import version
@@ -21,6 +24,7 @@
2124
import bs4
2225
import urllib3
2326

27+
from .exceptions import CoercionError
2428
from .exceptions import DeadTokenError
2529

2630
if sys.version_info >= (3, 10):
@@ -266,3 +270,51 @@ def get_plugins(group: str = "adventofcode.user") -> _EntryPointsType:
266270
@cache
267271
def _get_soup(html):
268272
return bs4.BeautifulSoup(html, "html.parser")
273+
274+
275+
def coerce(val: t.Any, warn: bool = False) -> str:
276+
"""
277+
Convert answer `val` into a string suitable for HTTP submission.
278+
Technically adventofcode.com will only accept strings as answers,
279+
but it's convenient to be able to submit numbers since many of the answers
280+
are numeric strings.
281+
"""
282+
orig_val = val
283+
coerced = False
284+
# A user can't be submitting a numpy type if numpy is not installed, so skip
285+
# handling of those types
286+
with contextlib.suppress(ImportError):
287+
import numpy as np
288+
289+
# "unwrap" arrays that contain a single element
290+
if isinstance(val, np.ndarray) and val.size == 1:
291+
coerced = True
292+
val = val.item()
293+
if isinstance(val, (np.integer, np.floating, np.complexfloating)) and val.imag == 0 and val.real.is_integer():
294+
coerced = True
295+
val = str(int(val.real))
296+
if isinstance(val, int):
297+
val = str(val)
298+
elif isinstance(val, (float, complex)) and val.imag == 0 and val.real.is_integer():
299+
coerced = True
300+
val = str(int(val.real))
301+
elif isinstance(val, bytes):
302+
coerced = True
303+
val = val.decode()
304+
elif isinstance(val, (Decimal, Fraction)):
305+
# if val can be represented as an integer ratio where the denominator is 1
306+
# val is an integer and val == numerator
307+
numerator, denominator = val.as_integer_ratio()
308+
if denominator == 1:
309+
coerced = True
310+
val = str(numerator)
311+
if not isinstance(val, str):
312+
raise CoercionError(f"Failed to coerce {type(orig_val).__name__} value {orig_val!r} to str")
313+
if coerced and warn:
314+
log.warning(
315+
"coerced %s value %r to %r",
316+
type(orig_val).__name__,
317+
orig_val,
318+
val,
319+
)
320+
return val

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ dependencies = [
2020
"pebble",
2121
"urllib3",
2222
'tzdata ; platform_system == "Windows"',
23-
"aocd-example-parser >= 2023.12.21",
23+
"aocd-example-parser >= 2023.12.24",
2424
]
2525

2626
[[project.authors]]

tests/test_models.py

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import decimal
2-
import fractions
31
import logging
42
from datetime import datetime
53
from datetime import timedelta
64

7-
import numpy as np
85
import pytest
96

107
from aocd.exceptions import AocdError
@@ -422,74 +419,6 @@ def test_example_data_crash(pook, caplog):
422419
assert ("aocd.models", logging.WARNING, msg) in caplog.record_tuples
423420

424421

425-
@pytest.mark.parametrize(
426-
"v_raw,v_expected,len_logs",
427-
[
428-
("123", "123", 0),
429-
(123, "123", 0),
430-
("xxx", "xxx", 0),
431-
(123.5, 123.5, 0),
432-
(123.0 + 123.0j, 123.0 + 123.0j, 0),
433-
(123.0, "123", 1),
434-
(123.0 + 0.0j, "123", 1),
435-
(np.int32(123), "123", 1),
436-
(np.uint32(123), "123", 1),
437-
(np.double(123.0), "123", 1),
438-
(np.complex64(123.0 + 0.0j), "123", 1),
439-
(np.complex64(123.0 + 0.5j), np.complex64(123.0 + 0.5j), 0),
440-
],
441-
)
442-
def test_type_coercions(v_raw, v_expected, len_logs, caplog):
443-
p = Puzzle(2022, 1)
444-
v_actual = p._coerce_val(v_raw)
445-
assert v_actual == v_expected, f"{type(v_raw)} {v_raw})"
446-
assert len(caplog.records) == len_logs
447-
448-
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-
493422
def test_get_prose_cache(aocd_data_dir):
494423
cached = aocd_data_dir / "other-user-id" / "2022_01_prose.2.html"
495424
cached.parent.mkdir()

tests/test_submit.py

Lines changed: 1 addition & 1 deletion
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 to '1234'")
283+
record = ("aocd.utils", logging.WARNING, "coerced float value 1234.0 to '1234'")
284284
assert record in caplog.record_tuples

tests/test_utils.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import decimal
2+
import fractions
13
import platform
24

5+
import numpy as np
36
import pytest
47
from freezegun import freeze_time
58

9+
from aocd.exceptions import CoercionError
610
from aocd.exceptions import DeadTokenError
711
from aocd.utils import atomic_write_file
812
from aocd.utils import blocker
13+
from aocd.utils import coerce
914
from aocd.utils import get_owner
1015

1116

@@ -71,3 +76,45 @@ def test_atomic_write_file(aocd_data_dir):
7176
assert target.read_text() == "123"
7277
atomic_write_file(target, "456") # clobber existing
7378
assert target.read_text() == "456"
79+
80+
81+
@pytest.mark.parametrize(
82+
"v_raw, v_expected, len_logs",
83+
[
84+
("xxx", "xxx", 0), # str -> str
85+
(b"123", "123", 1), # bytes -> str
86+
(123, "123", 0), # int -> str
87+
(123.0, "123", 1), # float -> str
88+
(123.0 + 0.0j, "123", 1), # complex -> str
89+
(np.int32(123), "123", 1), # np.int -> str
90+
(np.uint32(123), "123", 1), # np.uint -> str
91+
(np.double(123.0), "123", 1), # np.double -> str
92+
(np.complex64(123.0 + 0.0j), "123", 1), # np.complex -> str
93+
(np.array([123]), "123", 1), # 1D np.array of int -> str
94+
(np.array([[123.0]]), "123", 1), # 2D np.array of float -> str
95+
(np.array([[[[[[123.0 + 0j]]]]]]), "123", 1), # deep np.array of complex -> str
96+
(fractions.Fraction(123 * 2, 2), "123", 1), # Fraction -> int
97+
(decimal.Decimal("123"), "123", 1), # Decimal -> int
98+
],
99+
)
100+
def test_type_coercions(v_raw, v_expected, len_logs, caplog):
101+
v_actual = coerce(v_raw, warn=True)
102+
assert v_actual == v_expected, f"{type(v_raw)} {v_raw})"
103+
assert len(caplog.records) == len_logs
104+
105+
106+
@pytest.mark.parametrize(
107+
"val, error_msg",
108+
[
109+
(123.5, "Failed to coerce float value 123.5 to str"), # non-integer float
110+
(123.0 + 123.0j, "Failed to coerce complex value (123+123j) to str"), # complex w/ imag
111+
(np.complex64(123.0 + 0.5j), "Failed to coerce complex64 value np.complex64(123+0.5j) to str"), # np.complex w/ imag
112+
(np.array([1, 2]), "Failed to coerce ndarray value array([1, 2]) to str"), # 1D np.array with size != 1
113+
(np.array([[1], [2]]), "Failed to coerce ndarray value array([[1],\n [2]]) to str"), # 2D np.array with size != 1
114+
(fractions.Fraction(123, 2), "Failed to coerce Fraction value Fraction(123, 2) to str"), # Fraction
115+
(decimal.Decimal("123.5"), "Failed to coerce Decimal value Decimal('123.5') to str"), # Decimal
116+
]
117+
)
118+
def test_type_coercions_fail(val, error_msg):
119+
with pytest.raises(CoercionError(error_msg)):
120+
coerce(val)

0 commit comments

Comments
 (0)