|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
| 3 | +import contextlib |
3 | 4 | import json
|
4 | 5 | import logging
|
5 | 6 | import os
|
|
10 | 11 | import webbrowser
|
11 | 12 | from datetime import datetime
|
12 | 13 | from datetime import timedelta
|
| 14 | +from decimal import Decimal |
| 15 | +from fractions import Fraction |
13 | 16 | from functools import cache
|
14 | 17 | from functools import cached_property
|
15 | 18 | from importlib.metadata import entry_points
|
@@ -113,10 +116,14 @@ def get_stats(
|
113 | 116 | """
|
114 | 117 | aoc_now = datetime.now(tz=AOC_TZ)
|
115 | 118 | 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): |
117 | 120 | years = (years,)
|
118 | 121 | if years is None:
|
119 | 122 | 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}") |
120 | 127 | days = {str(i) for i in range(1, 26)}
|
121 | 128 | results = {}
|
122 | 129 | ur_broke = "You haven't collected any stars"
|
@@ -248,7 +255,7 @@ def examples(self) -> list[Example]:
|
248 | 255 | html, and they're the same for every user id. This list might be empty (not
|
249 | 256 | every puzzle has usable examples), or it might have several examples, but it
|
250 | 257 | 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 |
252 | 259 | unlocked (i.e. part a has already been solved correctly).
|
253 | 260 | """
|
254 | 261 | return self._get_examples()
|
@@ -301,30 +308,44 @@ def _coerce_val(self, val):
|
301 | 308 | # but it's convenient to be able to submit numbers, since many of the answers
|
302 | 309 | # are numeric strings. coerce the values to string safely.
|
303 | 310 | orig_val = val
|
304 |
| - orig_type = type(val) |
305 | 311 | 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(): |
313 | 322 | 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)) |
319 | 324 | if isinstance(val, int):
|
320 | 325 | 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}.") |
321 | 341 | if coerced:
|
322 | 342 | 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__, |
325 | 345 | orig_val,
|
326 | 346 | self.year,
|
327 | 347 | self.day,
|
| 348 | + val, |
328 | 349 | )
|
329 | 350 | return val
|
330 | 351 |
|
|
0 commit comments