Skip to content

Commit

Permalink
expose models.Puzzle._coerce_val as utils.coerce
Browse files Browse the repository at this point in the history
  • Loading branch information
wimglenn committed Jan 3, 2025
1 parent d0d9dca commit 2c5c8cd
Show file tree
Hide file tree
Showing 8 changed files with 113 additions and 127 deletions.
4 changes: 2 additions & 2 deletions aocd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
import json
import os
import sys
import typing as t
from functools import partial
Expand All @@ -17,8 +17,8 @@
from . import utils
from .exceptions import AocdError
from .get import get_data
from .get import get_puzzle
from .get import get_day_and_year
from .get import get_puzzle
from .post import submit as _impartial_submit

__all__ = [
Expand Down
4 changes: 4 additions & 0 deletions aocd/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ class UnknownUserError(AocdError):

class ExampleParserError(AocdError):
"""for problems specific to the example extraction code"""


class CoercionError(AocdError):
"""failed to coerce a value to string safely"""
58 changes: 6 additions & 52 deletions aocd/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import contextlib
import json
import logging
import os
Expand All @@ -11,8 +10,6 @@
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
Expand All @@ -36,6 +33,7 @@
from .utils import _get_soup
from .utils import AOC_TZ
from .utils import atomic_write_file
from .utils import coerce
from .utils import colored
from .utils import get_owner
from .utils import get_plugins
Expand Down Expand Up @@ -308,52 +306,6 @@ def _repr_pretty_(self, p, cycle):
txt = f"<Puzzle({self.year}, {self.day}) at {hex(id(self))} - {self.title}>"
p.text(txt)

def _coerce_val(self, val):
# technically adventofcode.com will only accept strings as answers.
# 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
coerced = False
# 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 = 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 to %r",
type(orig_val).__name__,
orig_val,
self.year,
self.day,
val,
)
return val

@property
def answer_a(self) -> str:
"""
Expand All @@ -376,7 +328,8 @@ def answer_a(self, val: AnswerValue) -> None:
The result of the submission will be printed to the terminal. It will only POST
to the server if necessary.
"""
val = self._coerce_val(val)
if not isinstance(val, str):
val = coerce(val)
if getattr(self, "answer_a", None) == val:
return
self._submit(value=val, part="a")
Expand Down Expand Up @@ -408,7 +361,8 @@ def answer_b(self, val: AnswerValue) -> None:
The result of the submission will be printed to the terminal. It will only POST
to the server if necessary.
"""
val = self._coerce_val(val)
if not isinstance(val, str):
val = coerce(val)
if getattr(self, "answer_b", None) == val:
return
self._submit(value=val, part="b")
Expand Down Expand Up @@ -468,7 +422,7 @@ def _submit(self, value, part, reopen=True, quiet=False, precheck=True):
if value in NON_ANSWER:
raise AocdError(f"cowardly refusing to submit non-answer: {value!r}")
if not isinstance(value, str):
value = self._coerce_val(value)
value = coerce(value)
part = str(part).replace("1", "a").replace("2", "b").lower()
if part not in {"a", "b"}:
raise AocdError('part must be "a" or "b"')
Expand Down
52 changes: 52 additions & 0 deletions aocd/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import contextlib
import logging
import os
import platform
Expand All @@ -10,6 +11,8 @@
import typing as t
from collections import deque
from datetime import datetime
from decimal import Decimal
from fractions import Fraction
from functools import cache
from importlib.metadata import entry_points
from importlib.metadata import version
Expand All @@ -21,6 +24,7 @@
import bs4
import urllib3

from .exceptions import CoercionError
from .exceptions import DeadTokenError

if sys.version_info >= (3, 10):
Expand Down Expand Up @@ -266,3 +270,51 @@ def get_plugins(group: str = "adventofcode.user") -> _EntryPointsType:
@cache
def _get_soup(html):
return bs4.BeautifulSoup(html, "html.parser")


def coerce(val: t.Any, warn: bool = True) -> str:
"""
Convert answer `val` into a string suitable for HTTP submission.
Technically adventofcode.com will only accept strings as answers,
but it's convenient to be able to submit numbers since many of the answers
are numeric strings.
"""
orig_val = val
coerced = False
# 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 = 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 CoercionError(f"Failed to coerce {type(orig_val).__name__} value {orig_val!r} to str")
if coerced and warn:
log.warning(
"coerced %s value %r to %r",
type(orig_val).__name__,
orig_val,
val,
)
return val
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies = [
"pebble",
"urllib3",
'tzdata ; platform_system == "Windows"',
"aocd-example-parser >= 2023.12.21",
"aocd-example-parser >= 2023.12.24",
]

[[project.authors]]
Expand Down
71 changes: 0 additions & 71 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import decimal
import fractions
import logging
from datetime import datetime
from datetime import timedelta

import numpy as np
import pytest

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


@pytest.mark.parametrize(
"v_raw,v_expected,len_logs",
[
("123", "123", 0),
(123, "123", 0),
("xxx", "xxx", 0),
(123.5, 123.5, 0),
(123.0 + 123.0j, 123.0 + 123.0j, 0),
(123.0, "123", 1),
(123.0 + 0.0j, "123", 1),
(np.int32(123), "123", 1),
(np.uint32(123), "123", 1),
(np.double(123.0), "123", 1),
(np.complex64(123.0 + 0.0j), "123", 1),
(np.complex64(123.0 + 0.5j), np.complex64(123.0 + 0.5j), 0),
],
)
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(
"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()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 to '1234'")
record = ("aocd.utils", logging.WARNING, "coerced float value 1234.0 to '1234'")
assert record in caplog.record_tuples
47 changes: 47 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import decimal
import fractions
import platform

import numpy as np
import pytest
from freezegun import freeze_time

from aocd.exceptions import CoercionError
from aocd.exceptions import DeadTokenError
from aocd.utils import atomic_write_file
from aocd.utils import blocker
from aocd.utils import coerce
from aocd.utils import get_owner


Expand Down Expand Up @@ -71,3 +76,45 @@ def test_atomic_write_file(aocd_data_dir):
assert target.read_text() == "123"
atomic_write_file(target, "456") # clobber existing
assert target.read_text() == "456"


@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):
v_actual = coerce(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 to str"), # non-integer float
(123.0 + 123.0j, "Failed to coerce complex value (123+123j) to str"), # complex w/ imag
(np.complex64(123.0 + 0.5j), "Failed to coerce complex64 value np.complex64(123+0.5j) to str"), # np.complex w/ imag
(np.array([1, 2]), "Failed to coerce ndarray value array([1, 2]) to str"), # 1D np.array with size != 1
(np.array([[1], [2]]), "Failed to coerce ndarray value array([[1],\n [2]]) to str"), # 2D np.array with size != 1
(fractions.Fraction(123, 2), "Failed to coerce Fraction value Fraction(123, 2) to str"), # Fraction
(decimal.Decimal("123.5"), "Failed to coerce Decimal value Decimal('123.5') to str"), # Decimal
]
)
def test_type_coercions_fail(val, error_msg):
with pytest.raises(CoercionError(error_msg)):
coerce(val)

0 comments on commit 2c5c8cd

Please sign in to comment.