├── elf ├── py.typed ├── constants.py ├── _version.py ├── open.py ├── __init__.py ├── cache.py ├── helpers.py ├── exceptions.py ├── messages.py ├── guesses.py ├── input.py ├── leaderboard.py ├── models.py ├── utils.py ├── aoc_client.py ├── client.py ├── cli.py ├── status.py └── answer.py ├── .python-version ├── tests ├── test_cli.py ├── test_answer.py ├── test_models.py ├── test_aoc_client.py ├── test_parsing.py ├── test_answer_guardrails.py ├── test_cli_commands.py ├── test_guess_cache.py └── test_client_api.py ├── LICENSE ├── pyproject.toml ├── .gitignore ├── README.md └── uv.lock /elf/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /elf/constants.py: -------------------------------------------------------------------------------- 1 | from zoneinfo import ZoneInfo 2 | 3 | from ._version import get_package_version 4 | 5 | AOC_TZ = ZoneInfo("America/New_York") 6 | VERSION = get_package_version() 7 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from elf.cli import app 4 | 5 | 6 | def test_cache_command_reports_missing_directory(monkeypatch, tmp_path): 7 | cache_dir = tmp_path / "cache" 8 | monkeypatch.setenv("ELF_CACHE_DIR", str(cache_dir)) 9 | 10 | runner = CliRunner() 11 | result = runner.invoke(app, ["cache"]) 12 | 13 | assert result.exit_code == 0 14 | assert "No cache directory found yet" in result.stdout 15 | assert str(cache_dir) in result.stdout.replace("\n", "") 16 | -------------------------------------------------------------------------------- /elf/_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib.metadata import PackageNotFoundError 4 | from importlib.metadata import version as pkg_version 5 | 6 | 7 | def _package_name() -> str: 8 | """Return the root package name for metadata lookup.""" 9 | return (__package__ or __name__).split(".")[0] 10 | 11 | 12 | def get_package_version() -> str: 13 | """Return the installed package version, falling back for local development.""" 14 | try: 15 | return pkg_version(_package_name()) 16 | except PackageNotFoundError: 17 | return "0.0.0+local" 18 | -------------------------------------------------------------------------------- /elf/open.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | 3 | from .models import OpenKind 4 | 5 | 6 | def open_page(year: int, day: int, kind: OpenKind) -> str: 7 | if year < 2015: 8 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 9 | if not 1 <= day <= 25: 10 | raise ValueError(f"Invalid day {day!r}. Advent of Code days are 1–25.") 11 | 12 | url = "https://adventofcode.com/" 13 | 14 | match kind: 15 | case OpenKind.PUZZLE: 16 | url = f"https://adventofcode.com/{year}/day/{day}" 17 | case OpenKind.INPUT: 18 | url = f"https://adventofcode.com/{year}/day/{day}/input" 19 | case OpenKind.WEBSITE: 20 | url = "https://adventofcode.com/" 21 | 22 | webbrowser.open_new_tab(url) 23 | 24 | msg = f"🌟 Opened {kind.value} page: [blue underline]{url}[/blue underline]" 25 | 26 | return msg 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 snally-labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /elf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Elf: a small Advent of Code helper library. 3 | 4 | Public API: 5 | 6 | - get_puzzle_input(year, day, session=None) -> str 7 | - submit_puzzle_answer(year, day, part, answer, session=None) -> SubmissionResult 8 | - get_private_leaderboard(year, session, board_id, view_key, fmt=OutputFormat.MODEL) 9 | - get_user_status(year, session=None, fmt=OutputFormat.MODEL) 10 | 11 | The session token can be passed explicitly or via the AOC_SESSION environment variable. 12 | """ 13 | 14 | from ._version import get_package_version 15 | 16 | __version__ = get_package_version() 17 | 18 | from .client import ( 19 | get_private_leaderboard, 20 | get_puzzle_input, 21 | get_user_status, 22 | submit_puzzle_answer, 23 | ) 24 | from .models import ( 25 | DayStatus, 26 | Leaderboard, 27 | OutputFormat, 28 | SubmissionResult, 29 | YearStatus, 30 | ) 31 | 32 | __all__ = [ 33 | "get_puzzle_input", 34 | "submit_puzzle_answer", 35 | "get_private_leaderboard", 36 | "get_user_status", 37 | "OutputFormat", 38 | "SubmissionResult", 39 | "DayStatus", 40 | "YearStatus", 41 | "Leaderboard", 42 | ] 43 | -------------------------------------------------------------------------------- /elf/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from pathlib import Path 4 | 5 | 6 | def get_cache_dir() -> Path: 7 | """Return the platform-appropriate cache directory for elf. 8 | 9 | Priority: 10 | 1. ELF_CACHE_DIR environment variable 11 | 2. Windows LOCALAPPDATA 12 | 3. XDG_CACHE_HOME 13 | 4. ~/.cache/elf 14 | """ 15 | if env_cache_dir := os.getenv("ELF_CACHE_DIR"): 16 | return Path(os.path.expandvars(os.path.expanduser(env_cache_dir))).resolve() 17 | 18 | system = platform.system() 19 | 20 | if system == "Windows": 21 | base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local")) 22 | else: 23 | base = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) 24 | 25 | return base / "elf" 26 | 27 | 28 | def get_cache_guess_file(year: int, day: int) -> Path: 29 | """Return path to guesses cache CSV.""" 30 | return get_cache_dir() / f"{year:04d}" / f"{day:02d}" / "guesses.csv" 31 | 32 | 33 | def get_cache_input_file(year: int, day: int) -> Path: 34 | """Return path to input cache file.""" 35 | return get_cache_dir() / f"{year:04d}" / f"{day:02d}" / "input.txt" 36 | -------------------------------------------------------------------------------- /tests/test_answer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from elf.answer import check_cached_guesses, write_guess_cache 4 | from elf.models import SubmissionStatus 5 | 6 | 7 | @pytest.fixture 8 | def cache_dir(monkeypatch, tmp_path): 9 | path = tmp_path / "cache" 10 | monkeypatch.setenv("ELF_CACHE_DIR", str(path)) 11 | return path 12 | 13 | 14 | def test_duplicate_guess_short_circuits(cache_dir): 15 | write_guess_cache(2025, 1, 1, 42, SubmissionStatus.INCORRECT) 16 | 17 | result = check_cached_guesses( 18 | year=2025, 19 | day=1, 20 | level=1, 21 | answer=42, 22 | numeric_answer=42, 23 | ) 24 | 25 | assert result.status is SubmissionStatus.INCORRECT 26 | assert result.previous_guess == 42 27 | assert "You already tried" in result.message 28 | 29 | 30 | def test_wait_cache_prevents_retry(cache_dir): 31 | write_guess_cache(2025, 1, 1, 7, SubmissionStatus.WAIT) 32 | 33 | result = check_cached_guesses( 34 | year=2025, 35 | day=1, 36 | level=1, 37 | answer=7, 38 | numeric_answer=7, 39 | ) 40 | 41 | assert result.status is SubmissionStatus.WAIT 42 | assert "try again after" in result.message 43 | 44 | 45 | def test_bounds_inferred_from_cache(cache_dir): 46 | write_guess_cache(2025, 1, 1, 10, SubmissionStatus.TOO_LOW) 47 | write_guess_cache(2025, 1, 1, 20, SubmissionStatus.TOO_HIGH) 48 | 49 | result = check_cached_guesses( 50 | year=2025, 51 | day=1, 52 | level=1, 53 | answer=9, 54 | numeric_answer=9, 55 | ) 56 | 57 | assert result.status is SubmissionStatus.TOO_LOW 58 | assert result.previous_guess == 10 59 | assert "highest low" in result.message 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "elf" 3 | version = "1.1.1" 4 | description = "Advent of Code helper for Python: fetch and cache inputs, submit answers, and pull private leaderboards." 5 | readme = "README.md" 6 | license = "MIT" 7 | license-files = ["LICENSE"] 8 | authors = [ 9 | { name = "cak", email = "cak@typeerror.com" } 10 | ] 11 | requires-python = ">=3.11" 12 | 13 | dependencies = [ 14 | "beautifulsoup4>=4.14.2", 15 | "httpx>=0.28.1", 16 | "pydantic>=2.12.4", 17 | "rich>=14.2.0", 18 | "typer>=0.20.0", 19 | ] 20 | 21 | keywords = [ 22 | "advent of code", 23 | "aoc", 24 | "cli", 25 | "python", 26 | "puzzles", 27 | ] 28 | 29 | classifiers = [ 30 | "Intended Audience :: Developers", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Programming Language :: Python :: 3.14", 36 | "Topic :: Games/Entertainment :: Puzzle Games", 37 | "Topic :: Software Development :: Libraries", 38 | "Topic :: Utilities", 39 | "Typing :: Typed", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/cak/elf" 44 | Documentation = "https://github.com/cak/elf/" 45 | Repository = "https://github.com/cak/elf" 46 | Issue-Tracker = "https://github.com/cak/elf/issues" 47 | 48 | [project.scripts] 49 | elf = "elf.cli:main" 50 | 51 | [tool.setuptools.packages.find] 52 | include = ["elf*"] 53 | 54 | [tool.setuptools.package-data] 55 | elf = ["py.typed"] 56 | 57 | [build-system] 58 | requires = ["setuptools>=77", "wheel"] 59 | build-backend = "setuptools.build_meta" 60 | 61 | [dependency-groups] 62 | dev = [ 63 | "pytest>=9.0.1", 64 | ] 65 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from elf.models import OutputFormat, SubmissionResult, SubmissionStatus 2 | 3 | 4 | def test_output_format_members_cover_known_values(): 5 | # Your existing code uses TABLE, JSON, and MODEL. 6 | members = {fmt for fmt in OutputFormat} 7 | assert OutputFormat.JSON in members 8 | assert OutputFormat.MODEL in members 9 | # TABLE may or may not be used in the CLI yet, but it exists. 10 | assert OutputFormat.TABLE in members 11 | 12 | 13 | def test_submission_status_has_known_members(): 14 | # These are the statuses already referenced by your tests. 15 | members = {status for status in SubmissionStatus} 16 | 17 | assert SubmissionStatus.CORRECT in members 18 | assert SubmissionStatus.INCORRECT in members 19 | assert SubmissionStatus.TOO_LOW in members 20 | # WAIT is used in the CLI exit-code logic. 21 | assert SubmissionStatus.WAIT in members 22 | 23 | 24 | def test_submission_result_correct_flag_consistency(): 25 | """ 26 | Sanity check: a SubmissionResult constructed with CORRECT should be marked 27 | as correct, and anything else should not be. 28 | (This matches how you already construct SubmissionResult in CLI tests.) 29 | """ 30 | correct = SubmissionResult( 31 | guess="42", 32 | result=SubmissionStatus.CORRECT, 33 | message="ok", 34 | is_correct=True, 35 | is_cached=False, 36 | ) 37 | wait = SubmissionResult( 38 | guess="42", 39 | result=SubmissionStatus.WAIT, 40 | message="wait", 41 | is_correct=False, 42 | is_cached=False, 43 | ) 44 | 45 | assert correct.result == SubmissionStatus.CORRECT 46 | assert correct.is_correct is True 47 | 48 | assert wait.result == SubmissionStatus.WAIT 49 | assert wait.is_correct is False 50 | -------------------------------------------------------------------------------- /tests/test_aoc_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import httpx 4 | import pytest 5 | 6 | import elf.aoc_client 7 | from elf.aoc_client import ( 8 | AOCClient, 9 | _default_user_agent, 10 | _user_agent_prefix, 11 | _validate_user_agent, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def aoc_client(monkeypatch): 17 | def _make(): 18 | monkeypatch.setattr(elf.aoc_client, "_shared_client", None) 19 | return AOCClient("fake_session") 20 | 21 | return _make 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ("aoc_user_agent", "ok"), 26 | [("", False), ("invalid", False), ("valid@email.com", True)], 27 | ) 28 | def test_user_agent_header_validation(monkeypatch, aoc_user_agent, ok): 29 | monkeypatch.setenv("AOC_USER_AGENT", aoc_user_agent) 30 | user_agent = os.getenv("AOC_USER_AGENT") 31 | assert _validate_user_agent(user_agent) == ok 32 | 33 | 34 | def test_user_agent_set_in_AOCClient(monkeypatch, aoc_client): 35 | user_agent = "valid@email.com" 36 | monkeypatch.setenv("AOC_USER_AGENT", user_agent) 37 | client = aoc_client() 38 | httpx_client: httpx.Client = client._client 39 | user_agent_header = httpx_client.headers.get("User-Agent", "") 40 | assert user_agent in user_agent_header 41 | assert _user_agent_prefix in user_agent_header 42 | 43 | 44 | def test_default_user_agent_set_in_AOCClient(monkeypatch, aoc_client): 45 | # Simulate no AOC_USER_AGENT set in the environment so we hit the default UA path 46 | monkeypatch.delenv("AOC_USER_AGENT", raising=False) 47 | 48 | # Ensure we get a fresh client instead of reusing a previously-initialized one 49 | monkeypatch.setattr(elf.aoc_client, "_shared_client", None) 50 | 51 | with pytest.warns(RuntimeWarning): 52 | client = aoc_client() 53 | 54 | httpx_client: httpx.Client = client._client 55 | user_agent_header = httpx_client.headers.get("User-Agent", "") 56 | assert _default_user_agent in user_agent_header 57 | assert _user_agent_prefix in user_agent_header 58 | -------------------------------------------------------------------------------- /elf/helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | from collections.abc import Callable 3 | from functools import wraps 4 | from pathlib import Path 5 | from typing import ParamSpec, TypeVar 6 | 7 | P = ParamSpec("P") 8 | R = TypeVar("R") 9 | 10 | 11 | def parse_input(input_str: str) -> list[str]: 12 | """Split the raw input into lines without trimming whitespace or blank rows.""" 13 | return input_str.splitlines() 14 | 15 | 16 | def timer( 17 | enabled: bool = True, logger: Callable[[str], None] | None = None 18 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: 19 | """Decorator to measure the execution time of functions. 20 | 21 | Args: 22 | enabled (bool): Whether to enable timing. 23 | logger (Optional[Callable[[str], None]]): A logging function to output the timing message. 24 | If `None`, the message will be printed to the console. 25 | 26 | Returns: 27 | Callable[[Callable[..., Any]], Callable[..., Any]]: The decorator that wraps the function. 28 | """ 29 | 30 | def decorator(func: Callable[P, R]) -> Callable[P, R]: 31 | @wraps(func) 32 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 33 | start_time: float | None = None 34 | if enabled: 35 | start_time = time.perf_counter() 36 | 37 | result = func(*args, **kwargs) 38 | if enabled: 39 | end_time = time.perf_counter() 40 | duration = end_time - start_time if start_time is not None else 0.0 41 | message = ( 42 | f"⏱️ Function '{func.__name__}' took {duration:.6f}s to complete 🎅." 43 | ) 44 | if logger: 45 | logger(message) 46 | else: 47 | print(message) 48 | return result 49 | 50 | return wrapper 51 | 52 | return decorator 53 | 54 | 55 | def read_input(path: Path) -> str: 56 | """Read input from a file and return its contents.""" 57 | if not path.is_file(): 58 | raise FileNotFoundError( 59 | f"Input file not found: {path}. Please add the missing file." 60 | ) 61 | 62 | return path.read_text(encoding="utf-8").strip() 63 | -------------------------------------------------------------------------------- /elf/exceptions.py: -------------------------------------------------------------------------------- 1 | class ElfError(Exception): 2 | """Base exception for elf package errors.""" 3 | 4 | def __init__(self, message: str | None = None) -> None: 5 | super().__init__(message or "An error occurred") 6 | 7 | 8 | class InputFetchError(ElfError): 9 | """Raised when there is an issue fetching the puzzle input.""" 10 | 11 | def __init__(self, message: str | None = None) -> None: 12 | default = "Failed to fetch Advent of Code puzzle input." 13 | super().__init__(message or default) 14 | 15 | 16 | class LeaderboardFetchError(ElfError): 17 | """Raised when there is an issue fetching a private leaderboard.""" 18 | 19 | def __init__(self, message: str | None = None) -> None: 20 | default = "Failed to fetch Advent of Code leaderboard." 21 | super().__init__(message or default) 22 | 23 | 24 | class StatusFetchError(ElfError): 25 | """Raised when there is an issue fetching a user's status page.""" 26 | 27 | def __init__(self, message: str | None = None) -> None: 28 | default = "Failed to fetch Advent of Code status." 29 | super().__init__(message or default) 30 | 31 | 32 | class SubmissionError(ElfError): 33 | """Raised when there is an issue submitting the answer.""" 34 | 35 | def __init__(self, message: str | None = None) -> None: 36 | default = "Failed to submit Advent of Code answer." 37 | super().__init__(message or default) 38 | 39 | 40 | class MissingSessionTokenError(ElfError): 41 | """Raised when the Advent of Code session token is missing.""" 42 | 43 | def __init__(self, env_var: str = "AOC_SESSION") -> None: 44 | default = ( 45 | f"Session token is missing. Set the '{env_var}' environment variable " 46 | "or pass the session token explicitly." 47 | ) 48 | super().__init__(default) 49 | 50 | 51 | class PuzzleLockedError(ElfError): 52 | """Raised when a puzzle has not yet unlocked in AoC time.""" 53 | 54 | def __init__(self, year: int, day: int, now, unlock_time) -> None: 55 | self.year = year 56 | self.day = day 57 | self.now = now 58 | self.unlock_time = unlock_time 59 | super().__init__( 60 | f"Puzzle {year}-12-{day:02d} is not unlocked yet. " 61 | f"Current AoC time is {now.isoformat()}, unlocks at {unlock_time.isoformat()}." 62 | ) 63 | -------------------------------------------------------------------------------- /elf/messages.py: -------------------------------------------------------------------------------- 1 | from .models import Guess 2 | 3 | 4 | def get_correct_answer_message(answer: int | str) -> str: 5 | """When AoC confirms the answer is correct and awards a star.""" 6 | return f"{answer} is correct. Star awarded." 7 | 8 | 9 | def get_answer_too_high_message(answer: int | str) -> str: 10 | """For numeric guesses that are too high.""" 11 | return f"{answer} is too high." 12 | 13 | 14 | def get_answer_too_low_message(answer: int | str) -> str: 15 | """For numeric guesses that are too low.""" 16 | return f"{answer} is too low." 17 | 18 | 19 | def get_recent_submission_message() -> str: 20 | """When AoC says you’re in the cooldown window.""" 21 | return "You submitted an answer recently. Please wait before trying again." 22 | 23 | 24 | def get_already_completed_message() -> str: 25 | """When the user has already completed this part on AoC.""" 26 | return "You have already completed this part." 27 | 28 | 29 | def get_incorrect_answer_message(answer: int | str) -> str: 30 | """When AoC says the answer is wrong but doesn’t specify high/low.""" 31 | return f"{answer} is not correct." 32 | 33 | 34 | def get_wrong_level_message() -> str: 35 | """When AoC says the submission is for the wrong puzzle level.""" 36 | return "You don't seem to be solving the right level. Double-check the part number." 37 | 38 | 39 | def get_unexpected_response_message() -> str: 40 | """When the AoC server responds with something the tool didn’t expect.""" 41 | return "Received an unexpected response from Advent of Code. Check the website for details." 42 | 43 | 44 | def get_cached_low_message( 45 | answer: int | str, 46 | highest_low_guess: Guess, 47 | ) -> str: 48 | """User guessed too low and we know their previous highest “too low” guess.""" 49 | time_str = highest_low_guess.timestamp.strftime("%B %d at %I:%M %p") 50 | return f"{answer} is still too low. Your highest low was {highest_low_guess.guess} on {time_str}." 51 | 52 | 53 | def get_cached_high_message( 54 | answer: int | str, 55 | lowest_high_guess: Guess, 56 | ) -> str: 57 | """User guessed too high and we know their previous lowest “too high” guess.""" 58 | time_str = lowest_high_guess.timestamp.strftime("%B %d at %I:%M %p") 59 | return f"{answer} is too high. Your lowest high was {lowest_high_guess.guess} on {time_str}." 60 | 61 | 62 | def get_cached_duplicate_message( 63 | answer: int | str, 64 | previous_guess: Guess, 65 | ) -> str: 66 | """User guessed exactly the same thing as a prior attempt.""" 67 | time_str = previous_guess.timestamp.strftime("%B %d at %I:%M %p") 68 | return f"You already tried {answer} on {time_str}. Please choose a different guess." 69 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | from elf.leaderboard import format_leaderboard_as_table 2 | from elf.models import Leaderboard 3 | from elf.status import build_year_status_table, parse_login_state, parse_year_status 4 | 5 | 6 | def test_leaderboard_model_accepts_string_keys_and_formats_table(): 7 | payload = { 8 | "num_days": 25, 9 | "event": "Advent of Code 2025", 10 | "day1_ts": 1700000000, 11 | "owner_id": 123, 12 | "members": { 13 | "123": { 14 | "id": 123, 15 | "name": "Elf One", 16 | "last_star_ts": 1700000123, 17 | "stars": 45, 18 | "local_score": 900, 19 | "completion_day_level": { 20 | "1": { 21 | "1": {"get_star_ts": 1700000001, "star_index": 1}, 22 | } 23 | }, 24 | }, 25 | "456": { 26 | "id": 456, 27 | "name": "Elf Two", 28 | "last_star_ts": 1700000456, 29 | "stars": 38, 30 | "local_score": 750, 31 | "completion_day_level": {}, 32 | }, 33 | }, 34 | } 35 | 36 | leaderboard = Leaderboard.model_validate(payload) 37 | 38 | assert list(leaderboard.members.keys()) == [123, 456] 39 | table = format_leaderboard_as_table(leaderboard) 40 | assert table.title and "Advent of Code 2025" in table.title 41 | assert table.row_count == 2 42 | 43 | 44 | def test_status_parsing_handles_basic_calendar_html(): 45 | html = """ 46 | 47 |
48 |
49 | elf@example.com 50 | AoC++ 51 | 5* 52 |
53 |
54 |

55 | 2025 56 |

57 |
58 |         
59 |           1
60 |         
61 |         
62 |           2
63 |         
64 |         
65 |           3
66 |         
67 |       
68 | Settings 69 | 70 | """ 71 | 72 | assert parse_login_state(html) 73 | 74 | status = parse_year_status(html) 75 | assert status.year == 2025 76 | assert status.total_stars == 5 77 | assert len(status.days) == 3 78 | 79 | table = build_year_status_table(status) 80 | assert table.row_count == 3 81 | assert table.title and "2025" in table.title 82 | -------------------------------------------------------------------------------- /elf/guesses.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from rich.console import Group 4 | from rich.table import Table 5 | from rich.text import Text 6 | 7 | from .cache import get_cache_guess_file 8 | from .exceptions import ElfError 9 | from .models import Guess, SubmissionStatus 10 | from .utils import read_guesses 11 | 12 | 13 | def get_guesses(year: int, day: int) -> Group: 14 | cache_file = get_cache_guess_file(year, day) 15 | if not cache_file.exists(): 16 | raise ElfError( 17 | f"No cached guesses found yet for {year}-12-{day:02d}. " 18 | "Submit an answer before viewing guess history." 19 | ) 20 | cached_guesses = read_guesses(year, day) 21 | 22 | return render_guess_tables(cached_guesses) 23 | 24 | 25 | def render_guess_tables(guesses: list[Guess]) -> Group: 26 | # normalize datetimes 27 | guesses = sorted(guesses, key=lambda g: _ensure_aware(g.timestamp)) 28 | 29 | # split 30 | part1 = [g for g in guesses if g.part == 1] 31 | part2 = [g for g in guesses if g.part == 2] 32 | 33 | table1 = _render_single_table(part1, title="Guess History – Part 1") 34 | table2 = _render_single_table(part2, title="Guess History – Part 2") 35 | 36 | # Rich will print these back-to-back in order 37 | return Group(table1, table2) 38 | 39 | 40 | def _render_single_table(guesses: list[Guess], title: str) -> Table: 41 | table = Table(title=title) 42 | 43 | table.add_column("Time (UTC)", style="cyan") 44 | table.add_column("Guess", justify="right", style="yellow") 45 | table.add_column("Status", style="green") 46 | 47 | for guess in guesses: 48 | ts = _ensure_aware(guess.timestamp).strftime("%Y-%m-%d %H:%M:%S") 49 | 50 | if guess.status is SubmissionStatus.CORRECT: 51 | status_text = Text("Correct", style="bold green") 52 | elif guess.status is SubmissionStatus.COMPLETED: 53 | status_text = Text("Completed", style="bold green") 54 | elif guess.status is SubmissionStatus.UNKNOWN: 55 | status_text = Text("Unknown", style="yellow") 56 | elif guess.status is SubmissionStatus.INCORRECT: 57 | status_text = Text("Incorrect", style="red") 58 | elif guess.status is SubmissionStatus.WAIT: 59 | status_text = Text("Wait", style="magenta") 60 | elif guess.status is SubmissionStatus.TOO_HIGH: 61 | status_text = Text("Too High", style="purple") 62 | elif guess.status is SubmissionStatus.TOO_LOW: 63 | status_text = Text("Too Low", style="blue") 64 | else: 65 | status_text = Text(guess.status.value) 66 | 67 | table.add_row(ts, str(guess.guess), status_text) 68 | 69 | return table 70 | 71 | 72 | def _ensure_aware(dt: datetime) -> datetime: 73 | """Make any datetime UTC-aware.""" 74 | if dt.tzinfo is None: 75 | return dt.replace(tzinfo=timezone.utc) 76 | return dt.astimezone(timezone.utc) 77 | -------------------------------------------------------------------------------- /tests/test_answer_guardrails.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from elf.answer import check_cached_guesses 5 | from elf.cache import get_cache_guess_file 6 | from elf.models import SubmissionStatus 7 | 8 | 9 | def _write_guess_csv(cache_file, rows): 10 | cache_file.parent.mkdir(parents=True, exist_ok=True) 11 | with cache_file.open("w", newline="", encoding="utf-8") as f: 12 | writer = csv.writer(f) 13 | writer.writerow(["timestamp", "part", "guess", "status"]) 14 | writer.writerows(rows) 15 | 16 | 17 | def test_check_cached_guesses_respects_wait_ttl(monkeypatch, tmp_path): 18 | monkeypatch.setenv("ELF_CACHE_DIR", str(tmp_path / "cache")) 19 | cache_file = get_cache_guess_file(2025, 1) 20 | 21 | wait_timestamp = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat() 22 | _write_guess_csv( 23 | cache_file, 24 | [ 25 | ( 26 | wait_timestamp, 27 | "1", 28 | "500", 29 | "WAIT", 30 | ), 31 | ], 32 | ) 33 | 34 | result = check_cached_guesses( 35 | year=2025, 36 | day=1, 37 | level=1, 38 | answer="600", 39 | numeric_answer=600, 40 | ) 41 | 42 | assert result.status == SubmissionStatus.WAIT 43 | assert "Cached locally" in result.message 44 | 45 | 46 | def test_check_cached_guesses_short_circuits_completed(monkeypatch, tmp_path): 47 | monkeypatch.setenv("ELF_CACHE_DIR", str(tmp_path / "cache")) 48 | cache_file = get_cache_guess_file(2025, 2) 49 | 50 | _write_guess_csv( 51 | cache_file, 52 | [ 53 | ( 54 | datetime.now(timezone.utc).isoformat(), 55 | "1", 56 | "123", 57 | "COMPLETED", 58 | ), 59 | ], 60 | ) 61 | 62 | result = check_cached_guesses( 63 | year=2025, 64 | day=2, 65 | level=1, 66 | answer="999", 67 | numeric_answer=999, 68 | ) 69 | 70 | assert result.status == SubmissionStatus.COMPLETED 71 | 72 | 73 | def test_check_cached_guesses_infers_bounds(monkeypatch, tmp_path): 74 | monkeypatch.setenv("ELF_CACHE_DIR", str(tmp_path / "cache")) 75 | cache_file = get_cache_guess_file(2025, 3) 76 | 77 | _write_guess_csv( 78 | cache_file, 79 | [ 80 | ( 81 | datetime.now(timezone.utc).isoformat(), 82 | "1", 83 | "40", 84 | "TOO_LOW", 85 | ), 86 | ( 87 | (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), 88 | "1", 89 | "100", 90 | "TOO_HIGH", 91 | ), 92 | ], 93 | ) 94 | 95 | low_result = check_cached_guesses( 96 | year=2025, 97 | day=3, 98 | level=1, 99 | answer="20", 100 | numeric_answer=20, 101 | ) 102 | assert low_result.status == SubmissionStatus.TOO_LOW 103 | assert low_result.previous_guess == 40 104 | 105 | high_result = check_cached_guesses( 106 | year=2025, 107 | day=3, 108 | level=1, 109 | answer="250", 110 | numeric_answer=250, 111 | ) 112 | assert high_result.status == SubmissionStatus.TOO_HIGH 113 | assert high_result.previous_guess == 100 114 | -------------------------------------------------------------------------------- /elf/input.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from .aoc_client import AOCClient 4 | from .cache import get_cache_input_file 5 | from .exceptions import InputFetchError, PuzzleLockedError 6 | from .utils import ( 7 | current_aoc_year, 8 | get_unlock_status, 9 | handle_http_errors, 10 | looks_like_login_page, 11 | resolve_session, 12 | ) 13 | 14 | 15 | def get_input(year: int, day: int, session: str | None) -> str: 16 | """ 17 | Fetch and cache the Advent of Code puzzle input for a specific year and day. 18 | 19 | Args: 20 | year: The year of the Advent of Code challenge. 21 | day: The day of the Advent of Code challenge. 22 | session: Your Advent of Code session token, or None to signal missing. 23 | 24 | Returns: 25 | The puzzle input for the specified day. Uses the cache if available. 26 | 27 | Raises: 28 | MissingSessionTokenError: If no session token was provided. 29 | InputFetchError: If there is an issue fetching the puzzle input. 30 | ValueError: If the year/day are out of range. 31 | """ 32 | if not 1 <= day <= 25: 33 | raise ValueError(f"Invalid day {day!r}. Advent of Code days are 1–25.") 34 | 35 | if year < 2015: 36 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 37 | 38 | cache_file = get_cache_input_file(year, day) 39 | if cache_file.exists(): 40 | return cache_file.read_text(encoding="utf-8") 41 | 42 | if year >= current_aoc_year(): 43 | status = get_unlock_status(year, day) 44 | if not status.unlocked: 45 | raise PuzzleLockedError( 46 | year=year, 47 | day=day, 48 | now=status.now, 49 | unlock_time=status.unlock_time, 50 | ) 51 | 52 | session_token = resolve_session(session) 53 | 54 | # --- Network layer -------------------------------------------------------- 55 | 56 | try: 57 | with AOCClient(session_token=session_token) as client: 58 | response = client.fetch_input(year, day) 59 | 60 | except httpx.TimeoutException as exc: 61 | raise InputFetchError( 62 | "Timed out while fetching puzzle input. Try again or check your network." 63 | ) from exc 64 | 65 | except httpx.RequestError as exc: 66 | raise InputFetchError( 67 | f"Network error while connecting to Advent of Code: {exc}" 68 | ) from exc 69 | 70 | # --- HTTP status handling ------------------------------------------------- 71 | handle_http_errors( 72 | response, 73 | exc_cls=InputFetchError, 74 | not_found_message=f"Input not found for year={year}, day={day} (HTTP 404).", 75 | bad_request_message="Bad request (HTTP 400). Your session token may be invalid.", 76 | server_error_message="Server error from Advent of Code (HTTP {status_code}). Your session token may be invalid.", 77 | unexpected_status_message="Unexpected HTTP error: {status_code}.", 78 | ) 79 | 80 | text = response.text 81 | 82 | if looks_like_login_page(response): 83 | raise InputFetchError( 84 | "Session cookie invalid or expired. " 85 | "Update AOC_SESSION with a valid 'session' cookie from your browser." 86 | ) 87 | 88 | input_data = text 89 | 90 | # Ensure the cache directory exists 91 | cache_file.parent.mkdir(parents=True, exist_ok=True) 92 | cache_file.write_text(input_data, encoding="utf-8") 93 | 94 | return input_data 95 | -------------------------------------------------------------------------------- /tests/test_cli_commands.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from elf.cli import app 4 | from elf.models import OutputFormat, SubmissionResult, SubmissionStatus 5 | 6 | runner = CliRunner() 7 | 8 | 9 | def test_input_cmd_invokes_get_input(monkeypatch): 10 | captured = {} 11 | 12 | def fake_get_input(year, day, session): 13 | captured.update({"year": year, "day": day, "session": session}) 14 | return "cached-input" 15 | 16 | monkeypatch.setattr("elf.cli.get_input", fake_get_input) 17 | 18 | result = runner.invoke(app, ["input", "2024", "2", "--session", "session-token"]) 19 | 20 | assert result.exit_code == 0 21 | assert result.stdout == "cached-input" 22 | assert captured == {"year": 2024, "day": 2, "session": "session-token"} 23 | 24 | 25 | def test_answer_cmd_exit_codes(monkeypatch): 26 | def make_result(status): 27 | return SubmissionResult( 28 | guess="42", 29 | result=status, 30 | message=f"result-{status.value}", 31 | is_correct=status == SubmissionStatus.CORRECT, 32 | is_cached=False, 33 | ) 34 | 35 | monkeypatch.setattr( 36 | "elf.cli.submit_answer", lambda **kwargs: make_result(SubmissionStatus.CORRECT) 37 | ) 38 | success = runner.invoke( 39 | app, 40 | ["answer", "2024", "2", "1", "123", "--session", "token"], 41 | ) 42 | assert success.exit_code == 0 43 | assert "result-correct" in success.stdout 44 | 45 | monkeypatch.setattr( 46 | "elf.cli.submit_answer", lambda **kwargs: make_result(SubmissionStatus.WAIT) 47 | ) 48 | wait = runner.invoke( 49 | app, 50 | ["answer", "2024", "2", "1", "123", "--session", "token"], 51 | ) 52 | assert wait.exit_code == 2 53 | 54 | 55 | def test_leaderboard_cmd_forwards_options(monkeypatch): 56 | captured = {} 57 | 58 | def fake_get_leaderboard(year, session, board_id, view_key, fmt): 59 | captured.update( 60 | { 61 | "year": year, 62 | "session": session, 63 | "board_id": board_id, 64 | "view_key": view_key, 65 | "fmt": fmt, 66 | } 67 | ) 68 | return "leaderboard-output" 69 | 70 | monkeypatch.setattr("elf.cli.get_leaderboard", fake_get_leaderboard) 71 | 72 | result = runner.invoke( 73 | app, 74 | [ 75 | "leaderboard", 76 | "2024", 77 | "12345", 78 | "--view-key", 79 | "secret", 80 | "--format", 81 | "json", 82 | "--session", 83 | "token", 84 | ], 85 | ) 86 | 87 | assert result.exit_code == 0 88 | assert "leaderboard-output" in result.stdout 89 | assert captured["fmt"] == OutputFormat.JSON 90 | 91 | 92 | def test_status_cmd_outputs(monkeypatch): 93 | def fake_get_status(year, session, fmt): 94 | return f"status-{year}-{fmt.value}" 95 | 96 | monkeypatch.setattr("elf.cli.get_status", fake_get_status) 97 | 98 | result = runner.invoke( 99 | app, ["status", "2023", "--format", "table", "--session", "token"] 100 | ) 101 | assert result.exit_code == 0 102 | assert "status-2023-table" in result.stdout 103 | 104 | 105 | def test_cache_cmd_handles_empty_dir(monkeypatch, tmp_path): 106 | cache_dir = tmp_path / "cache" 107 | monkeypatch.setattr("elf.cli.get_cache_dir", lambda: cache_dir) 108 | 109 | result = runner.invoke(app, ["cache"]) 110 | 111 | assert result.exit_code == 0 112 | assert "No cache directory found yet" in result.stdout 113 | 114 | 115 | def test_guesses_cmd_invokes_reader(monkeypatch): 116 | monkeypatch.setattr("elf.cli.get_guesses", lambda year, day: "guess-history") 117 | 118 | result = runner.invoke(app, ["guesses", "2024", "3"]) 119 | 120 | assert result.exit_code == 0 121 | assert "guess-history" in result.stdout 122 | 123 | 124 | def test_open_cmd_prints_message(monkeypatch): 125 | monkeypatch.setattr("elf.cli.open_page", lambda year, day, kind: "opened") 126 | 127 | result = runner.invoke(app, ["open", "2024", "1", "--kind", "input"]) 128 | 129 | assert result.exit_code == 0 130 | assert "opened" in result.stdout 131 | -------------------------------------------------------------------------------- /tests/test_guess_cache.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | import pytest 4 | 5 | from elf.answer import check_cached_guesses, write_guess_cache 6 | from elf.cache import get_cache_guess_file 7 | from elf.models import SubmissionStatus 8 | from elf.utils import read_guesses 9 | 10 | 11 | @pytest.fixture 12 | def guess_file_factory(tmp_path, monkeypatch): 13 | """ 14 | Factory for creating guess CSVs in the real cache path 15 | used by get_cache_guess_file. 16 | """ 17 | # Ensure ELF_CACHE_DIR points under tmp_path so the 18 | # production helper uses a throwaway directory. 19 | monkeypatch.setenv("ELF_CACHE_DIR", str(tmp_path / "cache")) 20 | 21 | def _create(year: int, day: int, rows: list[tuple[str, str, str, str]]): 22 | cache_file = get_cache_guess_file(year, day) 23 | cache_file.parent.mkdir(parents=True, exist_ok=True) 24 | with cache_file.open("w", newline="", encoding="utf-8") as f: 25 | writer = csv.writer(f) 26 | writer.writerow(["timestamp", "part", "guess", "status"]) 27 | writer.writerows(rows) 28 | return cache_file 29 | 30 | return _create 31 | 32 | 33 | def test_write_guess_cache_normalizes_guess(monkeypatch, tmp_path): 34 | monkeypatch.setenv("ELF_CACHE_DIR", str(tmp_path / "cache")) 35 | 36 | write_guess_cache( 37 | year=2024, 38 | day=5, 39 | part=1, 40 | guess=" 123 ", 41 | status=SubmissionStatus.INCORRECT, 42 | ) 43 | 44 | cache_file = get_cache_guess_file(2024, 5) 45 | with cache_file.open("r", encoding="utf-8", newline="") as f: 46 | reader = csv.DictReader(f) 47 | rows = list(reader) 48 | 49 | # We only care that: 50 | # - a row was written 51 | # - guess whitespace was trimmed 52 | # - status is stored consistently 53 | assert len(rows) == 1 54 | assert rows == [ 55 | { 56 | "timestamp": rows[0]["timestamp"], 57 | "part": "1", 58 | "guess": "123", 59 | "status": "INCORRECT", 60 | } 61 | ] 62 | 63 | 64 | def test_read_guesses_strips_guess_whitespace(guess_file_factory): 65 | guess_file_factory( 66 | 2024, 67 | 6, 68 | [ 69 | ( 70 | "2024-12-05T00:00:00+00:00", 71 | "1", 72 | " 045 ", 73 | "TOO_LOW", 74 | ) 75 | ], 76 | ) 77 | 78 | # The function under test reads from the generated CSV 79 | guesses = read_guesses(2024, 6) 80 | 81 | assert len(guesses) == 1 82 | # Normalized string / numeric representation is handled in read_guesses; 83 | # we only assert that whitespace from the raw CSV is stripped. 84 | assert str(guesses[0].guess) == "45" 85 | assert guesses[0].status == SubmissionStatus.TOO_LOW 86 | 87 | 88 | def test_read_guesses_handles_invalid_timestamp_gracefully(guess_file_factory): 89 | """ 90 | If a timestamp cannot be parsed, read_guesses should not crash. 91 | It should still return the guess with normalized value and status. 92 | """ 93 | guess_file_factory( 94 | 2024, 95 | 8, 96 | [ 97 | ( 98 | "not-a-timestamp", 99 | "1", 100 | " 999 ", 101 | "INCORRECT", 102 | ) 103 | ], 104 | ) 105 | 106 | guesses = read_guesses(2024, 8) 107 | 108 | assert len(guesses) == 1 109 | # Even with a bad timestamp, guess normalization should still occur. 110 | assert str(guesses[0].guess) == "999" 111 | assert guesses[0].status == SubmissionStatus.INCORRECT 112 | # We intentionally do NOT assert on the timestamp value, only that 113 | # the function did not raise and returned coherent guess data. 114 | 115 | 116 | def test_check_cached_guesses_detects_duplicate_with_whitespace( 117 | guess_file_factory, 118 | ): 119 | """ 120 | If a previous CORRECT guess exists, even with whitespace, check_cached_guesses 121 | should treat the new answer as already-correct and not trigger a new submission. 122 | """ 123 | guess_file_factory( 124 | 2024, 125 | 7, 126 | [ 127 | ( 128 | "2024-12-05T00:00:00+00:00", 129 | "1", 130 | " 045 ", 131 | "CORRECT", 132 | ) 133 | ], 134 | ) 135 | 136 | result = check_cached_guesses( 137 | year=2024, 138 | day=7, 139 | level=1, 140 | answer="45", 141 | numeric_answer=45, 142 | ) 143 | 144 | assert result.status == SubmissionStatus.CORRECT 145 | -------------------------------------------------------------------------------- /elf/leaderboard.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime, timezone 4 | 5 | import httpx 6 | from pydantic import ValidationError 7 | from rich.table import Table 8 | 9 | from .aoc_client import AOCClient 10 | from .exceptions import LeaderboardFetchError, MissingSessionTokenError 11 | from .models import Leaderboard, OutputFormat 12 | from .utils import handle_http_errors, resolve_session 13 | 14 | 15 | def get_leaderboard( 16 | year: int, 17 | session: str | None, 18 | board_id: int, 19 | view_key: str | None, 20 | fmt: OutputFormat = OutputFormat.MODEL, 21 | ) -> Leaderboard | str | Table: 22 | """ 23 | Fetch a private leaderboard for a specific year. 24 | 25 | Args: 26 | year: The year of the Advent of Code challenge. 27 | session: Your Advent of Code session token (optional if view_key is supplied). 28 | board_id: The ID of the private leaderboard. 29 | view_key: The view key for the private leaderboard, if required. 30 | """ 31 | 32 | if year < 2015: 33 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 34 | 35 | if board_id <= 0: 36 | raise ValueError("Board ID must be a positive integer.") 37 | 38 | # Resolve session token from explicit arg or environment for consistency with other APIs. 39 | session_token = ( 40 | resolve_session(session) 41 | if view_key is None 42 | else session or os.getenv("AOC_SESSION") 43 | ) 44 | if not session_token and not view_key: 45 | raise MissingSessionTokenError(env_var="AOC_SESSION") 46 | 47 | try: 48 | with AOCClient(session_token=session_token) as client: 49 | response = client.fetch_leaderboard(year, board_id, view_key) 50 | except httpx.TimeoutException as exc: 51 | raise LeaderboardFetchError( 52 | "Timed out while fetching leaderboard. Try again or check your network." 53 | ) from exc 54 | except httpx.RequestError as exc: 55 | raise LeaderboardFetchError( 56 | f"Network error while connecting to Advent of Code: {exc}" 57 | ) from exc 58 | 59 | handle_http_errors( 60 | response, 61 | exc_cls=LeaderboardFetchError, 62 | not_found_message=f"Leaderboard not found for year={year}, board_id={board_id} (HTTP 404).", 63 | bad_request_message="Bad request (HTTP 400). Your session token or view key may be invalid.", 64 | server_error_message="Server error from Advent of Code (HTTP {status_code}). Your session token may be invalid.", 65 | unexpected_status_message="Unexpected HTTP error: {status_code}.", 66 | ) 67 | 68 | try: 69 | payload = response.json() 70 | except ValueError as exc: 71 | raise LeaderboardFetchError( 72 | "Failed to decode leaderboard JSON. Your session/view key may be invalid." 73 | ) from exc 74 | 75 | try: 76 | leaderboard = Leaderboard.model_validate(payload) 77 | except ValidationError as exc: 78 | raise LeaderboardFetchError( 79 | "Unexpected leaderboard schema from Advent of Code." 80 | ) from exc 81 | 82 | match fmt: 83 | case OutputFormat.MODEL: 84 | return leaderboard 85 | case OutputFormat.JSON: 86 | return json.dumps(payload, indent=2) 87 | case OutputFormat.TABLE: 88 | return format_leaderboard_as_table(leaderboard) 89 | case _: 90 | raise ValueError(f"Unsupported output format: {fmt}") 91 | 92 | 93 | def format_leaderboard_as_table(leaderboard: Leaderboard) -> Table: 94 | table = Table(title=f"Advent of Code {leaderboard.event} – Private Leaderboard") 95 | 96 | table.add_column("Rank", justify="right", style="bold") 97 | table.add_column("Name", style="cyan") 98 | table.add_column("Stars", justify="right", style="yellow") 99 | table.add_column("Local Score", justify="right", style="green") 100 | table.add_column("Last Star (UTC)", style="magenta") 101 | 102 | # sort: highest local_score, then highest stars 103 | members = sorted( 104 | leaderboard.members.values(), 105 | key=lambda m: (-m.local_score, -m.stars, m.id), 106 | ) 107 | 108 | for rank, member in enumerate(members, start=1): 109 | last_star = ( 110 | datetime.fromtimestamp(member.last_star_ts, tz=timezone.utc).strftime( 111 | "%Y-%m-%d %H:%M:%S" 112 | ) 113 | if member.last_star_ts 114 | else "-" 115 | ) 116 | 117 | table.add_row( 118 | str(rank), 119 | member.name or "", 120 | str(member.stars), 121 | str(member.local_score), 122 | last_star, 123 | ) 124 | 125 | return table 126 | -------------------------------------------------------------------------------- /elf/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from enum import Enum, StrEnum, auto 6 | from typing import Dict 7 | 8 | from pydantic import BaseModel, ConfigDict, Field 9 | 10 | 11 | class StarCompletion(BaseModel): 12 | """Single star completion (per day/part).""" 13 | 14 | model_config = ConfigDict(extra="ignore") 15 | 16 | get_star_ts: int = Field( 17 | ..., 18 | description="Unix timestamp when this star was acquired.", 19 | ) 20 | star_index: int = Field( 21 | ..., 22 | description="Position in the leaderboard when the star was acquired.", 23 | ) 24 | 25 | 26 | class Member(BaseModel): 27 | """A single leaderboard member.""" 28 | 29 | model_config = ConfigDict(extra="ignore") 30 | 31 | id: int 32 | name: str | None = None 33 | last_star_ts: int 34 | stars: int 35 | local_score: int 36 | completion_day_level: Dict[int, Dict[int, StarCompletion]] = Field( 37 | default_factory=dict, 38 | description="Mapping of day -> part -> StarCompletion.", 39 | ) 40 | 41 | 42 | class Leaderboard(BaseModel): 43 | """Top-level Advent of Code leaderboard object.""" 44 | 45 | model_config = ConfigDict(extra="ignore") 46 | 47 | num_days: int 48 | event: str 49 | day1_ts: int 50 | owner_id: int 51 | # member_id -> Member (keys in JSON are numeric strings; parsed as ints) 52 | members: Dict[int, Member] 53 | 54 | 55 | @dataclass(frozen=True, slots=True) 56 | class TestResult: 57 | part: int 58 | passed: bool 59 | expected: str 60 | actual: str 61 | message: str 62 | 63 | def __repr__(self): 64 | return ( 65 | f"TestResult(\n" 66 | f" part={self.part},\n" 67 | f" passed={self.passed},\n" 68 | f" expected='{self.expected}',\n" 69 | f" actual='{self.actual}',\n" 70 | f" message='{self.message}'\n" 71 | f")" 72 | ) 73 | 74 | 75 | class SubmissionStatus(StrEnum): 76 | CORRECT = auto() 77 | INCORRECT = auto() 78 | TOO_HIGH = auto() 79 | TOO_LOW = auto() 80 | WAIT = auto() 81 | COMPLETED = auto() 82 | UNKNOWN = auto() 83 | 84 | 85 | @dataclass(frozen=True, slots=True) 86 | class SubmissionResult: 87 | guess: int | str 88 | result: SubmissionStatus 89 | message: str 90 | is_correct: bool 91 | is_cached: bool 92 | 93 | def __repr__(self): 94 | return ( 95 | f"SubmissionResult(\n" 96 | f" guess={self.guess},\n" 97 | f" result={self.result.name},\n" 98 | f" is_correct={self.is_correct},\n" 99 | f" is_cached={self.is_cached},\n" 100 | f" message='{self.message}'\n" 101 | f")" 102 | ) 103 | 104 | 105 | @dataclass(slots=True) 106 | class Guess: 107 | timestamp: datetime 108 | part: int 109 | guess: int | str 110 | status: SubmissionStatus 111 | 112 | def is_too_low(self, answer: int | str) -> bool: 113 | if isinstance(self.guess, int) and isinstance(answer, int): 114 | return self.guess < answer and self.status == SubmissionStatus.TOO_LOW 115 | return False 116 | 117 | def is_too_high(self, answer: int | str) -> bool: 118 | if isinstance(self.guess, int) and isinstance(answer, int): 119 | return self.guess > answer and self.status == SubmissionStatus.TOO_HIGH 120 | return False 121 | 122 | 123 | @dataclass(frozen=True, slots=True) 124 | class CachedGuessCheck: 125 | guess: int | str 126 | previous_guess: int | str | None 127 | previous_timestamp: datetime | None 128 | status: SubmissionStatus 129 | message: str 130 | 131 | 132 | @dataclass(slots=True) 133 | class UnlockStatus: 134 | unlocked: bool 135 | now: datetime 136 | unlock_time: datetime 137 | 138 | 139 | class OutputFormat(str, Enum): 140 | MODEL = "model" 141 | TABLE = "table" 142 | JSON = "json" 143 | 144 | 145 | class OpenKind(str, Enum): 146 | WEBSITE = "website" 147 | PUZZLE = "puzzle" 148 | INPUT = "input" 149 | 150 | 151 | class DayStatus(BaseModel): 152 | day: int = Field(..., ge=1, le=31) 153 | stars: int = Field(..., ge=0, le=2) 154 | href: str 155 | aria_label: str 156 | 157 | model_config = { 158 | "frozen": True, # makes objects hashable/immutable 159 | "extra": "forbid", # catch unexpected fields 160 | "populate_by_name": True, 161 | } 162 | 163 | 164 | class YearStatus(BaseModel): 165 | year: int = Field(..., ge=2015) 166 | username: str 167 | is_supporter: bool 168 | total_stars: int = Field(..., ge=0) 169 | days: list[DayStatus] 170 | 171 | model_config = { 172 | "frozen": True, 173 | "extra": "forbid", 174 | "populate_by_name": True, 175 | } 176 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Cursor 198 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 199 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 200 | # refer to https://docs.cursor.com/context/ignore-files 201 | .cursorignore 202 | .cursorindexingignore 203 | 204 | # Marimo 205 | marimo/_static/ 206 | marimo/_lsp/ 207 | __marimo__/ 208 | -------------------------------------------------------------------------------- /elf/utils.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import warnings 4 | from datetime import datetime, timezone 5 | 6 | import httpx 7 | 8 | from .cache import get_cache_guess_file 9 | from .constants import AOC_TZ 10 | from .exceptions import ElfError, MissingSessionTokenError 11 | from .models import Guess, SubmissionStatus, UnlockStatus 12 | 13 | 14 | def read_guesses(year: int, day: int) -> list[Guess]: 15 | cache_file = get_cache_guess_file(year, day) 16 | if not cache_file.exists(): 17 | return [] 18 | 19 | guesses: list[Guess] = [] 20 | skipped_rows = 0 21 | 22 | try: 23 | with cache_file.open("r", newline="", encoding="utf-8") as f: 24 | reader = csv.DictReader(f) 25 | for row in reader: 26 | try: 27 | status_raw = (row.get("status") or "UNKNOWN").upper() 28 | status = SubmissionStatus.__members__.get( 29 | status_raw, SubmissionStatus.UNKNOWN 30 | ) 31 | 32 | guess_raw = row.get("guess", "") 33 | if isinstance(guess_raw, str): 34 | guess_str = guess_raw.strip() 35 | else: 36 | guess_str = str(guess_raw) 37 | 38 | if guess_str.lstrip("+-").isdigit(): 39 | guess_val: int | str = int(guess_str) 40 | else: 41 | guess_val = guess_str 42 | 43 | timestamp_raw = row.get("timestamp", "") or "" 44 | try: 45 | if timestamp_raw: 46 | timestamp = datetime.fromisoformat(timestamp_raw) 47 | # Normalize to tz-aware (assume UTC if missing) 48 | if timestamp.tzinfo is None: 49 | timestamp = timestamp.replace(tzinfo=timezone.utc) 50 | else: 51 | timestamp = datetime.now(timezone.utc) 52 | except Exception: 53 | timestamp = datetime.now(timezone.utc) 54 | 55 | part_raw = row.get("part") 56 | if part_raw is None: 57 | raise ValueError("Missing part column") 58 | 59 | guesses.append( 60 | Guess( 61 | timestamp=timestamp, 62 | part=int(part_raw), 63 | guess=guess_val, 64 | status=status, 65 | ) 66 | ) 67 | except Exception: 68 | skipped_rows += 1 69 | continue 70 | except Exception as exc: 71 | raise ElfError(f"Failed reading guess cache {cache_file}: {exc}") from exc 72 | 73 | if skipped_rows: 74 | warnings.warn( 75 | f"Skipped {skipped_rows} malformed guess cache rows in {cache_file}.", 76 | RuntimeWarning, 77 | stacklevel=1, 78 | ) 79 | 80 | sorted_guesses = sorted( 81 | guesses, 82 | key=lambda g: (g.timestamp, g.part, str(g.guess)), 83 | ) 84 | 85 | return sorted_guesses 86 | 87 | 88 | def current_aoc_year() -> int: 89 | """Return the current year in the AoC timezone.""" 90 | return datetime.now(tz=AOC_TZ).year 91 | 92 | 93 | def get_unlock_status(year: int, day: int) -> UnlockStatus: 94 | """ 95 | Return whether the given AoC puzzle is unlocked yet, based on America/New_York. 96 | 97 | AoC unlocks each day at midnight local time (Y-12-D 00:00 in America/New_York). 98 | """ 99 | if not 1 <= day <= 25: 100 | # Let existing validation handle out-of-range days elsewhere 101 | raise ValueError(f"Invalid day {day!r}. Advent of Code days are 1–25.") 102 | 103 | # Current time in AoC timezone 104 | now = datetime.now(tz=AOC_TZ) 105 | 106 | # Official unlock moment for this puzzle (AoC uses December only) 107 | unlock_time = datetime(year=year, month=12, day=day, tzinfo=AOC_TZ) 108 | 109 | return UnlockStatus( 110 | unlocked=now >= unlock_time, 111 | now=now, 112 | unlock_time=unlock_time, 113 | ) 114 | 115 | 116 | def resolve_session(session: str | None, env_var: str = "AOC_SESSION") -> str: 117 | """Resolve a session token from an explicit arg or environment variable.""" 118 | if session: 119 | return session 120 | env_session = os.getenv(env_var) 121 | if env_session: 122 | return env_session 123 | 124 | raise MissingSessionTokenError(env_var=env_var) 125 | 126 | 127 | def handle_http_errors( 128 | response: httpx.Response, 129 | *, 130 | exc_cls: type[ElfError], 131 | not_found_message: str, 132 | bad_request_message: str, 133 | server_error_message: str, 134 | unexpected_status_message: str = "Unexpected HTTP error: {status_code}.", 135 | ) -> None: 136 | """ 137 | Centralized HTTP status handling for AoC endpoints. 138 | """ 139 | 140 | def _fmt(msg: str) -> str: 141 | return msg.format(status_code=response.status_code) 142 | 143 | if response.status_code == 404: 144 | raise exc_cls(_fmt(not_found_message)) 145 | 146 | if response.status_code == 400: 147 | raise exc_cls(_fmt(bad_request_message)) 148 | 149 | if 500 <= response.status_code < 600: 150 | raise exc_cls(_fmt(server_error_message)) 151 | 152 | try: 153 | response.raise_for_status() 154 | except httpx.HTTPStatusError as exc: 155 | raise exc_cls(_fmt(unexpected_status_message)) from exc 156 | 157 | 158 | def looks_like_login_page(response: httpx.Response) -> bool: 159 | """ 160 | Detect when AoC returns the login page (often HTTP 200 with HTML) instead of input. 161 | Prevents caching the login HTML as input when the session is missing/invalid. 162 | """ 163 | content_type = response.headers.get("Content-Type", "").lower() 164 | if "text/plain" in content_type: 165 | return False 166 | 167 | html = response.text 168 | markers = ( 169 | "To play, please identify yourself", 170 | "/auth/login", 171 | 'name="session"', 172 | ) 173 | return any(marker in html for marker in markers) 174 | -------------------------------------------------------------------------------- /elf/aoc_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import atexit 4 | import os 5 | import re 6 | import time 7 | import warnings 8 | from types import TracebackType 9 | 10 | import httpx 11 | 12 | from .constants import VERSION 13 | 14 | _shared_client: httpx.Client | None = None 15 | 16 | _user_agent_prefix = f"elf/{VERSION}" 17 | _default_user_agent = "+https://github.com/cak/elf" 18 | 19 | 20 | def _get_http_client() -> httpx.Client: 21 | """ 22 | Return a process-global httpx.Client with sane limits/timeouts. 23 | 24 | The shared client keeps connections warm for CLI calls. If you make 25 | heavy concurrent requests from multiple threads, construct your own 26 | AOCClient instances to avoid sharing this global session. 27 | """ 28 | global _shared_client 29 | if _shared_client is None: 30 | user_agent = os.getenv("AOC_USER_AGENT") 31 | 32 | # Strip whitespace and control characters if present 33 | if user_agent is not None: 34 | user_agent = user_agent.replace("\r", "").replace("\n", "").strip() 35 | 36 | if not _validate_user_agent(user_agent): 37 | # Missing vs invalid message 38 | if user_agent is None: 39 | warnings.warn( 40 | "User-Agent should include an email address. " 41 | "Please set AOC_USER_AGENT in your environment.", 42 | RuntimeWarning, 43 | ) 44 | else: 45 | warnings.warn( 46 | f"Invalid User-Agent header: {user_agent!r}\n\n" 47 | "User-Agent should include an email address. " 48 | "Please set AOC_USER_AGENT in your environment.", 49 | RuntimeWarning, 50 | ) 51 | user_agent = _default_user_agent 52 | 53 | # Ensure user_agent is not None at this point 54 | assert user_agent is not None 55 | 56 | _shared_client = httpx.Client( 57 | headers={"User-Agent": _construct_user_agent(user_agent)}, 58 | follow_redirects=True, 59 | timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=20.0), 60 | limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), 61 | ) 62 | return _shared_client 63 | 64 | 65 | def _close_http_client() -> None: 66 | if _shared_client is not None: 67 | _shared_client.close() 68 | 69 | 70 | atexit.register(_close_http_client) 71 | 72 | 73 | def _construct_user_agent(user_agent: str) -> str: 74 | return f"{_user_agent_prefix} ({user_agent})" 75 | 76 | 77 | def _validate_user_agent(user_agent: str | None) -> bool: 78 | """ 79 | Very minimal check that the user agent string contains something that 80 | looks like an email address so Eric Wastl can contact the user. 81 | """ 82 | return ( 83 | user_agent is not None and re.search(r"[^@]+@[^.]+\..+", user_agent) is not None 84 | ) 85 | 86 | 87 | class AOCClient: 88 | def __init__(self, session_token: str | None) -> None: 89 | self.base_url = "https://adventofcode.com" 90 | self.session_token = session_token 91 | self._client = _get_http_client() 92 | 93 | def _get(self, path: str, params: dict[str, str] | None = None) -> httpx.Response: 94 | cookies = {"session": self.session_token} if self.session_token else None 95 | return self._client.get( 96 | f"{self.base_url}{path}", params=params, cookies=cookies 97 | ) 98 | 99 | def _post(self, path: str, data: dict[str, str]) -> httpx.Response: 100 | cookies = {"session": self.session_token} if self.session_token else None 101 | return self._client.post(f"{self.base_url}{path}", data=data, cookies=cookies) 102 | 103 | def _get_with_retries( 104 | self, 105 | path: str, 106 | *, 107 | retries: int = 2, 108 | backoff: float = 0.5, 109 | params: dict[str, str] | None = None, 110 | ) -> httpx.Response: 111 | """ 112 | Basic retry wrapper for idempotent GETs to smooth over transient network hiccups. 113 | """ 114 | attempt = 0 115 | while True: 116 | try: 117 | return self._get(path, params=params) 118 | except (httpx.TimeoutException, httpx.TransportError): 119 | if attempt >= retries: 120 | raise 121 | time.sleep(backoff * (2**attempt)) 122 | attempt += 1 123 | 124 | def __enter__(self) -> "AOCClient": 125 | return self 126 | 127 | def __exit__( 128 | self, 129 | exc_type: type[BaseException] | None, 130 | exc: BaseException | None, 131 | tb: TracebackType | None, 132 | ) -> None: 133 | # Shared client is closed via atexit; no-op here to keep connections warm. 134 | return None 135 | 136 | def fetch_input(self, year: int, day: int) -> httpx.Response: 137 | """ 138 | Fetch the puzzle input for a specific year and day. 139 | """ 140 | return self._get_with_retries(f"/{year}/day/{day}/input") 141 | 142 | def submit_answer( 143 | self, year: int, day: int, answer: str, part: int 144 | ) -> httpx.Response: 145 | """ 146 | Submit an answer for a specific year, day, and part. 147 | """ 148 | data = {"level": str(part), "answer": answer} 149 | response = self._post(f"/{year}/day/{day}/answer", data=data) 150 | return response 151 | 152 | def fetch_leaderboard( 153 | self, year: int, board_id: int, view_key: str | None = None 154 | ) -> httpx.Response: 155 | """ 156 | Fetch a private leaderboard for a specific year. 157 | If a view_key is provided, it will be included in the request. 158 | """ 159 | params = {"view_key": view_key} if view_key else None 160 | response = self._get_with_retries( 161 | f"/{year}/leaderboard/private/view/{board_id}.json", 162 | params=params, 163 | ) 164 | return response 165 | 166 | def fetch_event(self, year: int) -> httpx.Response: 167 | """ 168 | Fetch general event information for a specific year (html). 169 | """ 170 | return self._get_with_retries(f"/{year}") 171 | -------------------------------------------------------------------------------- /elf/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from rich.table import Table 4 | 5 | from .answer import submit_answer 6 | from .input import get_input 7 | from .leaderboard import get_leaderboard 8 | from .models import Leaderboard, OutputFormat, SubmissionResult 9 | from .status import get_status 10 | from .utils import resolve_session 11 | 12 | 13 | def get_puzzle_input(year: int, day: int, session: str | None = None) -> str: 14 | """ 15 | Retrieve the raw puzzle input for a specific Advent of Code puzzle. 16 | 17 | This is equivalent to opening the “Input” page on adventofcode.com and 18 | copying the contents. The returned string is exactly what the puzzle 19 | expects you to parse — no transformations are applied. 20 | 21 | Parameters 22 | ---------- 23 | year: 24 | The Advent of Code event year (e.g., ``2024``). 25 | day: 26 | The puzzle day number (``1``–``25``). 27 | session: 28 | An Advent of Code session token. If omitted, the value of the 29 | ``AOC_SESSION`` environment variable will be used instead. 30 | 31 | Returns 32 | ------- 33 | str 34 | The puzzle input as provided by Advent of Code. 35 | 36 | Raises 37 | ------ 38 | MissingSessionTokenError 39 | If no session token is supplied and ``AOC_SESSION`` is not set. 40 | InputFetchError 41 | If the request fails or the page cannot be retrieved. 42 | 43 | Notes 44 | ----- 45 | - This function performs an authenticated HTTP request. 46 | - Advent of Code input pages are personalized per user. 47 | """ 48 | session_token = resolve_session(session) 49 | return get_input(year, day, session_token) 50 | 51 | 52 | def submit_puzzle_answer( 53 | year: int, 54 | day: int, 55 | part: int, 56 | answer: str, 57 | session: str | None = None, 58 | ) -> SubmissionResult: 59 | """ 60 | Submit an answer for a specific puzzle part. 61 | 62 | This mirrors pressing the “Submit” button on the Advent of Code website. 63 | The response indicates whether the answer was correct, incorrect, already 64 | solved, or rate-limited. 65 | 66 | Parameters 67 | ---------- 68 | year: 69 | The event year (e.g., ``2024``). 70 | day: 71 | The puzzle day number. 72 | part: 73 | The puzzle part (``1`` or ``2``). 74 | answer: 75 | The answer to submit, as a string. 76 | session: 77 | An Advent of Code session token. If omitted, ``AOC_SESSION`` is used. 78 | 79 | Returns 80 | ------- 81 | SubmissionResult 82 | A structured result describing the outcome of the submission. 83 | 84 | Raises 85 | ------ 86 | MissingSessionTokenError 87 | If no valid session token is available. 88 | ValueError 89 | If the submission parameters are invalid. 90 | 91 | Notes 92 | ----- 93 | - Advent of Code enforces a submission cooldown after incorrect answers. 94 | - This function does **not** retry automatically. 95 | """ 96 | session_token = resolve_session(session) 97 | return submit_answer(year, day, part, answer, session_token) 98 | 99 | 100 | def get_private_leaderboard( 101 | year: int, 102 | board_id: int, 103 | session: str | None = None, 104 | view_key: str | None = None, 105 | fmt: OutputFormat = OutputFormat.MODEL, 106 | ) -> Leaderboard | str | Table: 107 | """ 108 | Retrieve a private leaderboard for a given year. 109 | 110 | Private leaderboards allow groups to track each other's progress. This 111 | function fetches the leaderboard’s current state and optionally formats it 112 | as a Python object, JSON string, or a human-readable representation. You 113 | can provide either a session token (full access) or just a view key 114 | (read-only share link). 115 | 116 | Parameters 117 | ---------- 118 | year: 119 | The event year. 120 | session: 121 | A session token. Required only when ``view_key`` is not provided. 122 | board_id: 123 | The numeric ID of the private leaderboard. 124 | view_key: 125 | The read-only “share code” for the leaderboard. When present, a session 126 | token is optional. 127 | fmt: 128 | The desired output format (``MODEL`` | ``JSON`` | ``TABLE``). 129 | 130 | Returns 131 | ------- 132 | Any 133 | A structured leaderboard model, a JSON string, or a formatted 134 | table representation, depending on ``fmt``. 135 | 136 | Raises 137 | ------ 138 | MissingSessionTokenError 139 | If no session token is available and no view key is provided. 140 | LeaderboardFetchError 141 | If the leaderboard cannot be retrieved. 142 | 143 | Notes 144 | ----- 145 | - The returned structure depends on ``OutputFormat``. 146 | """ 147 | if year < 2015: 148 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 149 | 150 | if board_id <= 0: 151 | raise ValueError("Board ID must be a positive integer.") 152 | 153 | session_token = ( 154 | resolve_session(session) 155 | if view_key is None 156 | else session or os.getenv("AOC_SESSION") 157 | ) 158 | return get_leaderboard(year, session_token, board_id, view_key, fmt) 159 | 160 | 161 | def get_user_status( 162 | year: int, 163 | session: str | None = None, 164 | fmt: OutputFormat = OutputFormat.MODEL, 165 | ): 166 | """ 167 | Retrieve the user's Advent of Code progress for the specified year. 168 | 169 | This includes the star count for each puzzle day, the total stars earned, 170 | and whether the user is an Advent of Code supporter (AoC++). 171 | 172 | Parameters 173 | ---------- 174 | year: 175 | The event year. 176 | session: 177 | A session token. If omitted, ``AOC_SESSION`` will be used. 178 | fmt: 179 | Output format: ``MODEL`` for Pydantic models, ``JSON`` for a JSON 180 | string, or ``TABLE`` for a Rich-rendered status table. 181 | 182 | Returns 183 | ------- 184 | Any 185 | A ``YearStatus`` model, JSON string, or Rich table depending on 186 | ``fmt``. 187 | 188 | Raises 189 | ------ 190 | MissingSessionTokenError 191 | If no session token can be resolved. 192 | 193 | Notes 194 | ----- 195 | - Status is parsed directly from the calendar HTML on the event page. 196 | - Some features (e.g., star colors) may vary between years. 197 | """ 198 | session_token = resolve_session(session) 199 | return get_status(year, session_token, fmt) 200 | -------------------------------------------------------------------------------- /elf/cli.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from functools import wraps 3 | from typing import Annotated 4 | 5 | import typer 6 | from rich.console import Console 7 | 8 | from .answer import submit_answer 9 | from .cache import get_cache_dir 10 | from .constants import AOC_TZ, VERSION 11 | from .exceptions import ElfError 12 | from .guesses import get_guesses 13 | from .input import get_input 14 | from .leaderboard import get_leaderboard 15 | from .models import OpenKind, OutputFormat, SubmissionStatus 16 | from .open import open_page 17 | from .status import get_status 18 | 19 | app = typer.Typer( 20 | help="Advent of Code CLI", 21 | no_args_is_help=True, 22 | rich_markup_mode="rich", 23 | ) 24 | 25 | console = Console() 26 | error_console = Console(stderr=True) 27 | _DEBUG = False 28 | 29 | 30 | def _current_year() -> int: 31 | return datetime.now(tz=AOC_TZ).year 32 | 33 | 34 | def _current_day() -> int: 35 | today = datetime.now(tz=AOC_TZ) 36 | return min(today.day, 25) if today.month == 12 else 1 37 | 38 | 39 | YearArg = Annotated[ 40 | int, 41 | typer.Argument( 42 | help="Year of the Advent of Code event (2015 or later)", 43 | min=2015, 44 | default_factory=_current_year, 45 | ), 46 | ] 47 | DayArg = Annotated[ 48 | int, 49 | typer.Argument( 50 | help="Day of the event (1–25)", 51 | min=1, 52 | max=25, 53 | default_factory=_current_day, 54 | ), 55 | ] 56 | PartArg = Annotated[ 57 | int, 58 | typer.Argument( 59 | help="Part of the puzzle (1 or 2)", 60 | min=1, 61 | max=2, 62 | ), 63 | ] 64 | AnswerArg = Annotated[str, typer.Argument(help="Your answer to submit")] 65 | FormatOpt = Annotated[ 66 | OutputFormat, 67 | typer.Option( 68 | "--format", 69 | "-f", 70 | help="Output format: table, json, model", 71 | case_sensitive=False, 72 | ), 73 | ] 74 | SessionOpt = Annotated[ 75 | str | None, 76 | typer.Option( 77 | "--session", 78 | "-s", 79 | help="Advent of Code session cookie", 80 | envvar="AOC_SESSION", 81 | ), 82 | ] 83 | 84 | 85 | def handle_cli_errors(func): 86 | @wraps(func) 87 | def wrapper(*args, **kwargs): 88 | try: 89 | return func(*args, **kwargs) 90 | except typer.Exit: 91 | raise 92 | except KeyboardInterrupt: 93 | raise typer.Exit(code=130) 94 | except ElfError as exc: 95 | if _DEBUG: 96 | raise 97 | error_console.print(f"[red]❄️ {exc}[/red]") 98 | except Exception as exc: 99 | if _DEBUG: 100 | raise 101 | error_console.print( 102 | f"[red]Unexpected {exc.__class__.__name__}: {exc}[/red]" 103 | ) 104 | raise typer.Exit(code=1) 105 | 106 | return wrapper 107 | 108 | 109 | def version_callback(value: bool) -> None: 110 | if not value: 111 | return 112 | 113 | v = VERSION 114 | 115 | console.print(f"elf {v}") 116 | raise typer.Exit() 117 | 118 | 119 | @app.callback(invoke_without_command=True) 120 | def cli_root( 121 | _version: bool = typer.Option( 122 | False, 123 | "--version", 124 | "-V", 125 | help="Show the CLI version and exit.", 126 | callback=version_callback, 127 | is_eager=True, 128 | expose_value=False, 129 | ), 130 | debug: bool = typer.Option( 131 | False, 132 | "--debug/--no-debug", 133 | help="Raise errors with tracebacks (also via ELF_DEBUG=1).", 134 | envvar="ELF_DEBUG", 135 | ), 136 | ) -> None: 137 | """ 138 | Global options for the Advent of Code CLI. 139 | """ 140 | global _DEBUG 141 | _DEBUG = debug 142 | 143 | 144 | @app.command("input") 145 | @handle_cli_errors 146 | def input_cmd( 147 | year: YearArg, 148 | day: DayArg, 149 | session: SessionOpt = None, 150 | ) -> None: 151 | """ 152 | Fetch the input for a given year and day. 153 | """ 154 | input_data = get_input(year, day, session) 155 | typer.echo(input_data, nl=False) 156 | 157 | 158 | @app.command("answer") 159 | @handle_cli_errors 160 | def answer_cmd( 161 | year: YearArg, 162 | day: DayArg, 163 | part: PartArg, 164 | answer: AnswerArg, 165 | session: SessionOpt = None, 166 | ) -> None: 167 | """ 168 | Submit an answer for a given year, day, and part. 169 | """ 170 | 171 | submit_result = submit_answer( 172 | year=year, 173 | day=day, 174 | level=part, 175 | answer=answer, 176 | session=session, 177 | ) 178 | console.print(submit_result.message) 179 | exit_code = 0 180 | if submit_result.result is SubmissionStatus.WAIT: 181 | exit_code = 2 182 | elif submit_result.result not in ( 183 | SubmissionStatus.CORRECT, 184 | SubmissionStatus.COMPLETED, 185 | ): 186 | exit_code = 1 187 | raise typer.Exit(code=exit_code) 188 | 189 | 190 | @app.command("leaderboard") 191 | @handle_cli_errors 192 | def leaderboard_cmd( 193 | year: YearArg, 194 | board_id: Annotated[int, typer.Argument(help="Private leaderboard ID")], 195 | view_key: Annotated[ 196 | str | None, 197 | typer.Option(help="View key for the private leaderboard, if required"), 198 | ] = None, 199 | session: SessionOpt = None, 200 | output_format: FormatOpt = OutputFormat.TABLE, 201 | ) -> None: 202 | """ 203 | Fetch and display a private leaderboard for a given year. 204 | """ 205 | leaderboard_data = get_leaderboard( 206 | year=year, 207 | session=session, 208 | board_id=board_id, 209 | view_key=view_key, 210 | fmt=output_format, 211 | ) 212 | 213 | console.print(leaderboard_data) 214 | 215 | 216 | @app.command("guesses") 217 | @handle_cli_errors 218 | def guesses_cmd( 219 | year: YearArg, 220 | day: DayArg, 221 | ) -> None: 222 | """ 223 | Display cached guesses for a given year and day. 224 | """ 225 | guesses_data = get_guesses(year, day) 226 | console.print(guesses_data) 227 | 228 | 229 | @app.command("status") 230 | @handle_cli_errors 231 | def status_cmd( 232 | year: YearArg, 233 | session: SessionOpt = None, 234 | output_format: FormatOpt = OutputFormat.TABLE, 235 | ) -> None: 236 | """ 237 | Fetch and display your Advent of Code status for a given year. 238 | """ 239 | status_data = get_status( 240 | year=year, 241 | session=session, 242 | fmt=output_format, 243 | ) 244 | 245 | console.print(status_data) 246 | 247 | 248 | @app.command("open") 249 | @handle_cli_errors 250 | def open_cmd( 251 | year: YearArg, 252 | day: DayArg, 253 | kind: Annotated[ 254 | OpenKind, 255 | typer.Option( 256 | "--kind", 257 | "-k", 258 | help="Kind of page to open: puzzle, input, website", 259 | case_sensitive=False, 260 | ), 261 | ] = OpenKind.PUZZLE, 262 | ) -> None: 263 | """ 264 | Open an Advent of Code page (puzzle, input, or website) in the default web browser. 265 | """ 266 | open_msg = open_page(year, day, kind) 267 | console.print(open_msg) 268 | 269 | 270 | @app.command("cache") 271 | @handle_cli_errors 272 | def cache_cmd() -> None: 273 | """ 274 | Show information about the local cache. 275 | """ 276 | cache_dir = get_cache_dir() 277 | if not cache_dir.exists(): 278 | console.print("[dim]No cache directory found yet.[/dim]") 279 | console.print(f"[dim]Expected location: {cache_dir}[/dim]") 280 | return 281 | 282 | files = [p for p in cache_dir.rglob("*") if p.is_file()] 283 | console.print(f"[green]Cache directory:[/green] {cache_dir}") 284 | console.print(f"[green]Cached files:[/green] {len(files)}") 285 | console.print("[dim]To clear the cache, delete this directory manually.[/dim]") 286 | 287 | 288 | def main() -> None: 289 | app() 290 | 291 | 292 | if __name__ == "__main__": 293 | main() 294 | -------------------------------------------------------------------------------- /elf/status.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import httpx 5 | from bs4 import BeautifulSoup 6 | from bs4.element import Tag 7 | from rich.table import Table 8 | 9 | from .aoc_client import AOCClient 10 | from .exceptions import StatusFetchError 11 | from .models import DayStatus, OutputFormat, YearStatus 12 | from .utils import current_aoc_year, handle_http_errors, resolve_session 13 | 14 | 15 | def get_status( 16 | year: int, session: str | None, fmt: OutputFormat 17 | ) -> YearStatus | str | Table: 18 | session_token = resolve_session(session) 19 | 20 | now_year = current_aoc_year() 21 | if year > now_year: 22 | raise ValueError( 23 | f"Invalid year {year!r}. Advent of Code years are up to {now_year}." 24 | ) 25 | if year < 2015: 26 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 27 | 28 | with AOCClient(session_token=session_token) as client: 29 | try: 30 | response = client.fetch_event(year) 31 | except httpx.TimeoutException as exc: 32 | raise StatusFetchError( 33 | "Timed out while fetching status. Try again or check your network." 34 | ) from exc 35 | except httpx.RequestError as exc: 36 | raise StatusFetchError( 37 | f"Network error while connecting to Advent of Code: {exc}" 38 | ) from exc 39 | 40 | handle_http_errors( 41 | response, 42 | exc_cls=StatusFetchError, 43 | not_found_message=f"Event page not found for year={year} (HTTP 404).", 44 | bad_request_message="Bad request (HTTP 400). Your session token may be invalid.", 45 | server_error_message="Server error from Advent of Code (HTTP {status_code}). Your session token may be invalid.", 46 | unexpected_status_message="Unexpected HTTP error: {status_code}.", 47 | ) 48 | 49 | logged_in = parse_login_state(response.text) 50 | 51 | if not logged_in: 52 | raise StatusFetchError( 53 | "Session cookie invalid or expired. " 54 | "Update AOC_SESSION with a valid 'session' cookie from your browser." 55 | ) 56 | 57 | try: 58 | year_status = parse_year_status(response.text) 59 | except Exception as exc: 60 | raise StatusFetchError("Failed to parse Advent of Code status page.") from exc 61 | 62 | match fmt: 63 | case OutputFormat.MODEL: 64 | return year_status 65 | case OutputFormat.JSON: 66 | return json.dumps(year_status.model_dump(), indent=2) 67 | case OutputFormat.TABLE: 68 | return build_year_status_table(year_status) 69 | case _: 70 | raise ValueError(f"Unsupported output format: {fmt}") 71 | 72 | 73 | def _parse_total_stars(user_div: Tag) -> int: 74 | """ 75 | Parse total stars from the header, e.g.: 76 | 11* 77 | """ 78 | star_span = user_div.select_one(".star-count") 79 | if not star_span: 80 | return 0 81 | 82 | text = star_span.get_text(strip=True) # "11*" 83 | # strip trailing '*' and anything non-digit 84 | m = re.search(r"(\d+)", text) 85 | return int(m.group(1)) if m else 0 86 | 87 | 88 | def _parse_username_and_supporter(user_div: Tag) -> tuple[str, bool]: 89 | """ 90 | user_div looks like: 91 |
92 | cak ... 11* 93 |
94 | """ 95 | # first text node is the username 96 | username = "" 97 | for child in user_div.contents: 98 | if isinstance(child, str): 99 | username = child.strip() 100 | if username: 101 | break 102 | 103 | is_supporter = user_div.select_one(".supporter-badge") is not None 104 | return username, is_supporter 105 | 106 | 107 | def _stars_from_aria_and_classes( 108 | aria_label: str | None, 109 | classes: list[str], 110 | ) -> int: 111 | """ 112 | Determine star count using only aria-label and day-level classes. 113 | 114 | - aria-label: "Day N, one star" / "Day N, two stars" / "Day N" 115 | - classes: "calendar-complete" (1 star), "calendar-verycomplete" (2 stars) 116 | """ 117 | aria_label = aria_label or "" 118 | 119 | # 1. aria-label is authoritative 120 | if "two stars" in aria_label: 121 | return 2 122 | if "one star" in aria_label: 123 | return 1 124 | 125 | # 2. fall back to classes 126 | if "calendar-verycomplete" in classes: 127 | return 2 128 | if "calendar-complete" in classes: 129 | return 1 130 | 131 | # 3. otherwise: locked / zero stars 132 | return 0 133 | 134 | 135 | def parse_year_status(html: str) -> YearStatus: 136 | soup = BeautifulSoup(html, "html.parser") 137 | 138 | # ---- Header: username, supporter flag, total stars ---- 139 | user_div = soup.select_one("header .user") 140 | if not user_div: 141 | raise ValueError("Could not find user info in header") 142 | 143 | username, is_supporter = _parse_username_and_supporter(user_div) 144 | total_stars = _parse_total_stars(user_div) 145 | 146 | # ---- Year ---- 147 | year_a = soup.select_one("h1.title-event a") 148 | if not year_a: 149 | raise ValueError("Could not find year in header") 150 | year = int(year_a.get_text(strip=True)) 151 | 152 | # ---- Calendar days ---- 153 | calendar = soup.select_one("pre.calendar") 154 | if not calendar: 155 | raise ValueError("Could not find calendar
")
156 | 
157 |     day_statuses: list[DayStatus] = []
158 | 
159 |     # Each day is an  with class "calendar-dayN"
160 |     for a in calendar.select("a[class^='calendar-day']"):
161 |         aria_label = a.get("aria-label", "") or ""
162 |         aria_label_str = str(aria_label) if aria_label else None
163 | 
164 |         # Try day number from aria-label first
165 |         day_num: int | None = None
166 |         m = re.search(r"Day\s+(\d+)", str(aria_label))
167 |         if m:
168 |             day_num = int(m.group(1))
169 | 
170 |         if day_num is None:
171 |             # Fallback to the inner span with class "calendar-day"
172 |             day_span = a.select_one(".calendar-day")
173 |             if not day_span:
174 |                 continue
175 |             day_num = int(day_span.get_text(strip=True))
176 | 
177 |         href_raw = a.get("href", "")
178 |         href = str(href_raw) if href_raw else ""
179 | 
180 |         classes = a.get("class") or []
181 |         if isinstance(classes, str):
182 |             classes = classes.split()
183 |         elif not isinstance(classes, list):
184 |             classes = list(classes) if classes else []
185 |         stars = _stars_from_aria_and_classes(aria_label_str, classes)
186 | 
187 |         day_statuses.append(
188 |             DayStatus(
189 |                 day=day_num,
190 |                 stars=stars,
191 |                 href=href,
192 |                 aria_label=str(aria_label) if aria_label else "",
193 |             )
194 |         )
195 | 
196 |     # Sort by day number to be safe
197 |     day_statuses.sort(key=lambda d: d.day)
198 | 
199 |     return YearStatus(
200 |         year=year,
201 |         username=username,
202 |         is_supporter=is_supporter,
203 |         total_stars=total_stars,
204 |         days=day_statuses,
205 |     )
206 | 
207 | 
208 | def build_year_status_table(status: YearStatus) -> Table:
209 |     """
210 |     Build a Rich Table summarizing a user's AoC progress for a year.
211 |     """
212 |     supporter_suffix = " (AoC++)" if status.is_supporter else ""
213 |     title = f"Advent of Code {status.year} – {status.username}{supporter_suffix} [{status.total_stars}⭐]"
214 | 
215 |     table = Table(title=title, show_lines=False)
216 | 
217 |     table.add_column("Day", justify="right", style="cyan", no_wrap=True)
218 |     table.add_column("Stars", justify="center", style="yellow", no_wrap=True)
219 | 
220 |     for day in sorted(status.days, key=lambda d: d.day):
221 |         # Render stars as a 2-star gauge: ★ for earned, ☆ for missing
222 |         stars_str = "★" * day.stars + "☆" * (2 - day.stars)
223 | 
224 |         # Row coloring based on completion
225 |         if day.stars == 2:
226 |             row_style = "bold green"
227 |         elif day.stars == 1:
228 |             row_style = "bold yellow"
229 |         else:
230 |             row_style = "dim"
231 | 
232 |         table.add_row(str(day.day), stars_str, style=row_style)
233 | 
234 |     return table
235 | 
236 | 
237 | def parse_login_state(html: str) -> bool:
238 |     """
239 |     Returns True if user is logged in.
240 |     """
241 |     has_login = "/auth/login" in html
242 |     has_settings = "/settings" in html
243 | 
244 |     # Logged in = settings visible AND login missing
245 |     if has_settings and not has_login:
246 |         return True
247 | 
248 |     # Otherwise assume logged out
249 |     return False
250 | 


--------------------------------------------------------------------------------
/tests/test_client_api.py:
--------------------------------------------------------------------------------
  1 | import pytest
  2 | 
  3 | from elf.client import (
  4 |     get_private_leaderboard,
  5 |     get_puzzle_input,
  6 |     get_user_status,
  7 |     submit_puzzle_answer,
  8 | )
  9 | from elf.models import OutputFormat
 10 | 
 11 | 
 12 | def test_get_puzzle_input_forwards_explicit_session(monkeypatch):
 13 |     resolved = []
 14 |     called = []
 15 | 
 16 |     def fake_resolve(session):
 17 |         resolved.append(session)
 18 |         return "resolved-token"
 19 | 
 20 |     def fake_get_input(year, day, session):
 21 |         called.append((year, day, session))
 22 |         return "input-data"
 23 | 
 24 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
 25 |     monkeypatch.setattr("elf.client.get_input", fake_get_input)
 26 | 
 27 |     result = get_puzzle_input(2023, 5, session="manual-session")
 28 | 
 29 |     assert result == "input-data"
 30 |     assert resolved == ["manual-session"]
 31 |     assert called == [(2023, 5, "resolved-token")]
 32 | 
 33 | 
 34 | def test_get_puzzle_input_uses_env_session_when_not_provided(monkeypatch):
 35 |     captured = {}
 36 | 
 37 |     def fake_resolve(session):
 38 |         captured["resolved_from"] = session
 39 |         return "env-resolved-token"
 40 | 
 41 |     def fake_get_input(year, day, session):
 42 |         captured.update({"year": year, "day": day, "session": session})
 43 |         return "input-data"
 44 | 
 45 |     monkeypatch.setenv("AOC_SESSION", "env-token")
 46 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
 47 |     monkeypatch.setattr("elf.client.get_input", fake_get_input)
 48 | 
 49 |     result = get_puzzle_input(2024, 1)
 50 | 
 51 |     assert result == "input-data"
 52 |     # High-level helper passes None into resolve_session, letting it
 53 |     # decide how to use env vars.
 54 |     assert captured["resolved_from"] is None
 55 |     assert captured["year"] == 2024
 56 |     assert captured["day"] == 1
 57 |     assert captured["session"] == "env-resolved-token"
 58 | 
 59 | 
 60 | def test_get_puzzle_input_raises_without_session(monkeypatch):
 61 |     def fake_resolve(session):
 62 |         raise RuntimeError("no session available")
 63 | 
 64 |     monkeypatch.delenv("AOC_SESSION", raising=False)
 65 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
 66 | 
 67 |     with pytest.raises(RuntimeError, match="no session available"):
 68 |         get_puzzle_input(2023, 1)
 69 | 
 70 | 
 71 | def test_submit_puzzle_answer_forwards_session_and_args(monkeypatch):
 72 |     resolved = []
 73 |     submitted = []
 74 | 
 75 |     def fake_resolve(session):
 76 |         resolved.append(session)
 77 |         return "resolved-token"
 78 | 
 79 |     def fake_submit(year, day, level, answer, session_token):
 80 |         submitted.append((year, day, level, answer, session_token))
 81 |         return "submission-result"
 82 | 
 83 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
 84 |     monkeypatch.setattr("elf.client.submit_answer", fake_submit)
 85 | 
 86 |     result = submit_puzzle_answer(
 87 |         year=2023,
 88 |         day=2,
 89 |         part=1,
 90 |         answer="12345",
 91 |         session="manual-session",
 92 |     )
 93 | 
 94 |     assert result == "submission-result"
 95 |     assert resolved == ["manual-session"]
 96 |     assert submitted == [(2023, 2, 1, "12345", "resolved-token")]
 97 | 
 98 | 
 99 | def test_get_private_leaderboard_without_view_key(monkeypatch):
100 |     resolved = []
101 |     captured = {}
102 | 
103 |     def fake_resolve(session):
104 |         resolved.append(session)
105 |         return "resolved-token"
106 | 
107 |     def fake_get_leaderboard(year, session_token, board_id, view_key, fmt):
108 |         captured.update(
109 |             {
110 |                 "year": year,
111 |                 "session_token": session_token,
112 |                 "board_id": board_id,
113 |                 "view_key": view_key,
114 |                 "fmt": fmt,
115 |             }
116 |         )
117 |         return "leaderboard-data"
118 | 
119 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
120 |     monkeypatch.setattr("elf.client.get_leaderboard", fake_get_leaderboard)
121 | 
122 |     result = get_private_leaderboard(
123 |         2023, board_id=10, session="explicit-session", fmt=OutputFormat.JSON
124 |     )
125 | 
126 |     assert result == "leaderboard-data"
127 |     assert resolved == ["explicit-session"]
128 |     assert captured == {
129 |         "year": 2023,
130 |         "session_token": "resolved-token",
131 |         "board_id": 10,
132 |         "view_key": None,
133 |         "fmt": OutputFormat.JSON,
134 |     }
135 | 
136 | 
137 | def test_get_private_leaderboard_with_view_key_allows_none_session(monkeypatch):
138 |     """
139 |     When a view_key is provided and no session is supplied, the current
140 |     implementation allows session_token to be None and does NOT call
141 |     resolve_session. This test encodes that behavior.
142 |     """
143 |     captured = {}
144 | 
145 |     def fake_get_leaderboard(year, session_token, board_id, view_key, fmt):
146 |         captured.update(
147 |             {
148 |                 "year": year,
149 |                 "session_token": session_token,
150 |                 "board_id": board_id,
151 |                 "view_key": view_key,
152 |                 "fmt": fmt,
153 |             }
154 |         )
155 |         return "leaderboard-data"
156 | 
157 |     # No env session, no explicit session
158 |     monkeypatch.delenv("AOC_SESSION", raising=False)
159 |     monkeypatch.setattr("elf.client.get_leaderboard", fake_get_leaderboard)
160 | 
161 |     result = get_private_leaderboard(
162 |         2024,
163 |         board_id=123,
164 |         view_key="view-key-abc",
165 |         fmt=OutputFormat.JSON,
166 |     )
167 | 
168 |     assert result == "leaderboard-data"
169 |     assert captured["year"] == 2024
170 |     assert captured["board_id"] == 123
171 |     assert captured["view_key"] == "view-key-abc"
172 |     # Key point: session_token is None in this scenario
173 |     assert captured["session_token"] is None
174 |     assert captured["fmt"] == OutputFormat.JSON
175 | 
176 | 
177 | @pytest.mark.parametrize(
178 |     "year, board_id",
179 |     [
180 |         (None, 123),
181 |         (2023, None),
182 |         (None, None),
183 |     ],
184 | )
185 | def test_get_private_leaderboard_invalid_inputs(year, board_id):
186 |     """
187 |     With the current implementation, passing None for year or board_id
188 |     leads to a TypeError from underlying comparisons (e.g., year < 2015).
189 | 
190 |     If you later add explicit validation in get_private_leaderboard and
191 |     raise ValueError instead, you can update this test to expect ValueError.
192 |     """
193 |     with pytest.raises(TypeError):
194 |         get_private_leaderboard(year, board_id=board_id)
195 | 
196 | 
197 | def test_get_private_leaderboard_default_format_model(monkeypatch):
198 |     captured = {}
199 | 
200 |     def fake_resolve(session):
201 |         return "resolved-token"
202 | 
203 |     def fake_get_leaderboard(year, session_token, board_id, view_key, fmt):
204 |         captured.update(
205 |             {
206 |                 "year": year,
207 |                 "session_token": session_token,
208 |                 "board_id": board_id,
209 |                 "view_key": view_key,
210 |                 "fmt": fmt,
211 |             }
212 |         )
213 |         return "leaderboard-data"
214 | 
215 |     monkeypatch.setenv("AOC_SESSION", "env-token")
216 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
217 |     monkeypatch.setattr("elf.client.get_leaderboard", fake_get_leaderboard)
218 | 
219 |     result = get_private_leaderboard(2024, board_id=42)
220 | 
221 |     assert result == "leaderboard-data"
222 |     # Default for API is currently OutputFormat.MODEL
223 |     assert captured["fmt"] == OutputFormat.MODEL
224 |     assert captured["session_token"] == "resolved-token"
225 | 
226 | 
227 | def test_get_user_status_forwards_explicit_session(monkeypatch):
228 |     resolved = []
229 |     captured = {}
230 | 
231 |     def fake_resolve(session):
232 |         resolved.append(session)
233 |         return "resolved-token"
234 | 
235 |     def fake_get_status(year, session_token, fmt):
236 |         captured.update({"year": year, "session_token": session_token, "fmt": fmt})
237 |         return "status-data"
238 | 
239 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
240 |     monkeypatch.setattr("elf.client.get_status", fake_get_status)
241 | 
242 |     result = get_user_status(2023, session="manual-session", fmt=OutputFormat.JSON)
243 | 
244 |     assert result == "status-data"
245 |     assert resolved == ["manual-session"]
246 |     assert captured["year"] == 2023
247 |     assert captured["session_token"] == "resolved-token"
248 |     assert captured["fmt"] == OutputFormat.JSON
249 | 
250 | 
251 | def test_get_user_status_uses_env_session(monkeypatch):
252 |     captured = {}
253 | 
254 |     def fake_resolve(session):
255 |         captured["resolved_from"] = session
256 |         return "env-resolved-token"
257 | 
258 |     def fake_get_status(year, session_token, fmt):
259 |         captured.update({"year": year, "session_token": session_token, "fmt": fmt})
260 |         return "status-data"
261 | 
262 |     monkeypatch.setenv("AOC_SESSION", "env-token")
263 |     monkeypatch.setattr("elf.client.resolve_session", fake_resolve)
264 |     monkeypatch.setattr("elf.client.get_status", fake_get_status)
265 | 
266 |     result = get_user_status(2023)
267 | 
268 |     assert result == "status-data"
269 |     assert captured["resolved_from"] is None
270 |     assert captured["session_token"] == "env-resolved-token"
271 |     assert captured["fmt"] == OutputFormat.MODEL
272 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | # elf: Advent of Code helper for Python
  2 | 
  3 | 

4 | elf logo 5 |

6 | 7 | A fast, modern Advent of Code CLI with caching, guardrails, leaderboards, and a lightweight Python API. 8 | 9 | Works on macOS, Linux, and Windows. Most networked commands require an AoC session cookie (`AOC_SESSION`). 10 | 11 |

12 | 13 | 14 | 15 | 16 |

17 | 18 | ## Why I Built This 19 | 20 | Advent of Code has become one of my favorite Christmas traditions. I am never the fastest solver, and some puzzles definitely keep me humble, but the challenges always spark new ideas and mark the start of the holiday season for me. 21 | 22 | Thank you to **Eric Wastl**, the creator of [Advent of Code](https://adventofcode.com/). His work brings an incredible community together every December and inspires people around the world to learn, explore new ideas, and enjoy the fun of programming puzzles. 23 | 24 | After refining a small helper tool I have used personally for the past few years, I decided to turn it into a package others can benefit from as well. I originally built an early version for my own workflows in [my personal AoC repo](https://github.com/cak/advent-of-code/tree/dc9c02a5a77a36b725a8e01cff18a6de46e0db0d?tab=readme-ov-file#%EF%B8%8F-automating-tasks-with-the-elf-cli). 25 | 26 | If Advent of Code is part of your December ritual too, I hope this little elf makes the journey smoother, more fun, and a bit more magical. 27 | 28 | ## Highlights 29 | 30 | - Fetch inputs instantly with **automatic caching** 31 | - Submit answers safely with **smart guardrails** (duplicate, low, high, locked, cooldown) 32 | - Explore private leaderboards as **tables**, **JSON**, or **Pydantic models** 33 | - View your **status calendar** (table, JSON, or model) 34 | - Inspect your **guess history** with timestamps and per-part details 35 | - Open any AoC page (puzzle, input, or website) straight from the CLI 36 | - Use elf as a **CLI tool** or a full **Python API** 37 | 38 | ## Installation 39 | 40 | ### Using uv (recommended) 41 | 42 | #### Install as a tool 43 | 44 | ```sh 45 | uv tool install elf 46 | ``` 47 | 48 | #### Inside a project 49 | 50 | ```sh 51 | uv add elf 52 | ``` 53 | 54 | ### Using pip 55 | 56 | ```sh 57 | pip install elf 58 | ``` 59 | 60 | ### Requirements 61 | 62 | - Python 3.11 or newer 63 | - An Advent of Code account 64 | - `AOC_SESSION` cookie set in your environment for most commands 65 | 66 | ## Configure your AoC Session 67 | 68 | Most features in elf require your Advent of Code session cookie so the CLI can access your personal puzzle inputs and progress. 69 | 70 | To get it: 71 | 72 | 1. Log in to https://adventofcode.com using GitHub, Google, or Reddit. 73 | 2. Open your browser’s developer tools. 74 | - Chrome: View → Developer → Developer Tools 75 | - Firefox: Tools → Browser Tools → Web Developer Tools 76 | 3. Go to the **Application** (Chrome) or **Storage** (Firefox) tab. 77 | 4. Look for **Cookies** for the domain `adventofcode.com`. 78 | 5. Find the cookie named **`session`**. 79 | 6. Copy the value (a long hex string). 80 | 7. Set it as an environment variable: 81 | 82 | ```sh 83 | export AOC_SESSION="your-session-token" 84 | ``` 85 | 86 | On Windows you can persist the cookie with PowerShell: 87 | 88 | ```powershell 89 | $env:AOC_SESSION = "your-session-token" 90 | setx AOC_SESSION "your-session-token" 91 | ``` 92 | 93 | Or via CMD: 94 | 95 | ```cmd 96 | setx AOC_SESSION "your-session-token" 97 | ``` 98 | 99 | Most commands require this. You can also pass it via `--session` in the CLI or `session=` in the API. 100 | 101 | ### Your User-Agent (Recommended) 102 | 103 | Elf follows the guidance from Eric Wastl (creator of Advent of Code) to help keep traffic friendly to the site. 104 | 105 | If you run tools that make automated requests, you should provide contact information in your User-Agent header so Eric can reach you if something goes wrong with your traffic. This is optional but strongly recommended. 106 | 107 | Set your email address in the environment variable `AOC_USER_AGENT`: 108 | 109 | ```sh 110 | export AOC_USER_AGENT="you@example.com" 111 | Windows PowerShell: 112 | $env:AOC_USER_AGENT = "you@example.com" 113 | setx AOC_USER_AGENT "you@example.com" 114 | ``` 115 | 116 | If this variable is not set or does not look like an email address, elf falls back to a safe default and prints a warning. 117 | Your User-Agent header will look like: 118 | elf/ (you@example.com) 119 | This helps Eric identify the person sending the traffic while still including the library name. 120 | 121 | If the warning fires, elf instead sends `elf/ (+https://github.com/cak/elf)` so AoC still sees a recognizable identifier even without your email. 122 | 123 | ## Quick Start 124 | 125 | ```sh 126 | export AOC_SESSION="your-session-token" 127 | 128 | elf input # fetch today's input 129 | elf answer 2025 1 1 123 # submit answer for part 1 130 | elf status # view progress 131 | elf leaderboard 2025 123456 --view-key ABCDEF 132 | ``` 133 | 134 | ### Saving puzzle input to files 135 | 136 | If you prefer to keep your own local copies of puzzle inputs, elf works just like a regular Unix command. You can redirect the output to save any day's input: 137 | 138 | ```sh 139 | elf input > input.txt # save today's input 140 | elf input 2025 2 > day02.txt # save a specific day's input 141 | ``` 142 | 143 | ## CLI Documentation 144 | 145 | ### `elf --help` 146 | 147 | ```sh 148 | Usage: elf [OPTIONS] COMMAND [ARGS]... 149 | 150 | Advent of Code CLI 151 | 152 | Options: 153 | --version, -V 154 | --debug 155 | --install-completion 156 | --show-completion 157 | --help 158 | 159 | Commands: 160 | input Fetch the input for a given year/day 161 | answer Submit an answer 162 | leaderboard Fetch/display a private leaderboard 163 | guesses Show cached guesses 164 | status Show yearly star status 165 | open Open puzzle/input/website 166 | cache Show cache information 167 | ``` 168 | 169 | Enable detailed tracebacks with `--debug` or `ELF_DEBUG=1` when troubleshooting. 170 | 171 | --- 172 | 173 | ## Commands 174 | 175 | ### `elf input` 176 | 177 | Fetch puzzle input with caching. Requires a session cookie. 178 | 179 | ```sh 180 | Usage: elf input [YEAR] [DAY] 181 | 182 | Options: 183 | --session TEXT AOC session cookie (env: AOC_SESSION) 184 | ``` 185 | 186 | Defaults: 187 | 188 | - `year`: current year 189 | - `day`: current day in December, otherwise 1 (Dec 1) 190 | - Caches to `~/.cache/elf///input.txt` (or platform equivalent) 191 | 192 | ### `elf answer` 193 | 194 | Submit an answer with safety guardrails. Requires a session cookie. Guardrails use your local guess cache to short-circuit duplicate answers and infer too-high/too-low for integer guesses. 195 | 196 | ```sh 197 | Usage: elf answer YEAR DAY PART ANSWER 198 | 199 | Options: 200 | --session TEXT AOC session cookie 201 | ``` 202 | 203 | Behaviors: 204 | 205 | - Year and day are required to avoid accidental submissions. 206 | - Detects locked puzzles (future days or future years) and shows unlock timestamp. 207 | - Identifies too high / too low / duplicate guesses from local cache 208 | - Caches cooldown responses locally for ~60 seconds to avoid hammering the site 209 | - Writes to guess cache automatically (per part) 210 | 211 | Exit codes: 212 | 213 | - `0` when the answer is correct or already completed on AoC 214 | - `2` when AoC rate-limits you (cooldown/WAIT) 215 | - `1` for incorrect/too-high/too-low/unknown guesses 216 | 217 | Duplicate guesses reuse cached submissions, so the exit code follows the cached result: e.g., a cached correct/completed guess still exits `0`, while cached incorrect/high/low responses return `1`. 218 | 219 | Example errors: 220 | 221 | ```sh 222 | ❄️ Puzzle YYYY‑MM‑DD not unlocked yet 223 | 1234 is not correct. 224 | You submitted an answer recently. Please wait... 225 | 12345 is correct. Star awarded. 226 | ``` 227 | 228 | ### `elf guesses` 229 | 230 | Display local guess history (per part). Requires a cached `guesses.csv` from previous submissions. Displays all guesses for both parts automatically. 231 | 232 | ```sh 233 | Usage: elf guesses [YEAR] [DAY] 234 | ``` 235 | 236 | `elf guesses` loads its data solely from the local `guesses.csv`; it never reaches out to AoC and does not require `AOC_SESSION`, so it works offline as long as the cache already exists. 237 | 238 | Example table: 239 | 240 | ```sh 241 | Time (UTC) Guess Status 242 | 2025‑12‑05 ... 959 too_low 243 | 2025‑12‑05 ... 6951 correct 244 | ``` 245 | 246 | ### `elf leaderboard` 247 | 248 | Fetch private leaderboards. Provide a view key for read-only access or a session cookie for authenticated access. A view key is the read-only share token you can generate on your AoC leaderboard page. 249 | 250 | ```sh 251 | Usage: elf leaderboard YEAR BOARD_ID 252 | 253 | Options: 254 | --view-key TEXT 255 | --session TEXT 256 | --format table|json|model 257 | ``` 258 | 259 | Supports: 260 | 261 | - **table:** pretty Rich table 262 | - **json:** raw JSON 263 | - **model:** structured Pydantic model 264 | 265 | ### `elf status` 266 | 267 | View your Advent of Code star calendar. Requires a session cookie. 268 | 269 | ```sh 270 | Usage: elf status [YEAR] 271 | 272 | Options: 273 | --session TEXT 274 | --format table|json|model 275 | ``` 276 | 277 | Defaults: 278 | 279 | - `year`: current year if omitted 280 | 281 | Prints stars for each day. 282 | 283 | ### `elf open` 284 | 285 | Opens puzzle pages in your browser. 286 | 287 | ```sh 288 | Usage: elf open [YEAR] [DAY] 289 | 290 | Options: 291 | --kind puzzle|input|website 292 | ``` 293 | 294 | ### `elf cache` 295 | 296 | Display cache directory information. 297 | 298 | ```sh 299 | Usage: elf cache 300 | ``` 301 | 302 | Shows: 303 | 304 | - Cache root directory (platform-aware) 305 | - Number of cached files 306 | - Reminder to delete the directory manually to clear cache 307 | 308 | --- 309 | 310 | ## Caching Behavior 311 | 312 | - Default cache dir: macOS/Linux `~/.cache/elf`, Windows `%LOCALAPPDATA%\elf` 313 | - Override location with `ELF_CACHE_DIR` (respects `XDG_CACHE_HOME` on Linux) 314 | - Inputs stored under: `///input.txt` 315 | - Guess history stored as `///guesses.csv` 316 | - Duplicate/high/low guesses are short‑circuited locally when possible 317 | - Cooldown responses are cached locally for ~60 seconds to avoid hammering AoC 318 | - Guardrails rely on your local guess history only (new machines/cleared cache = fresh) 319 | - Delete the cache directory to clear everything 320 | 321 | ### Concurrency note 322 | 323 | The library reuses a process-global `httpx.Client` to keep CLI calls fast. For heavy multi-threaded usage, create separate `elf.aoc_client.AOCClient` instances per thread to avoid sharing the global session. 324 | 325 | --- 326 | 327 | ## Library Usage 328 | 329 | ```python 330 | from elf import ( 331 | get_puzzle_input, 332 | submit_puzzle_answer, 333 | get_private_leaderboard, 334 | get_user_status, 335 | OutputFormat, 336 | ) 337 | 338 | # AOC_SESSION is used automatically if not passed explicitly 339 | input_text = get_puzzle_input(2023, 5) 340 | 341 | result = submit_puzzle_answer(2023, 5, 1, "12345") 342 | print(result.is_correct, result.message) 343 | 344 | leaderboard = get_private_leaderboard( 345 | 2023, session=None, board_id=123456, view_key=None, fmt=OutputFormat.MODEL 346 | ) 347 | 348 | status = get_user_status(2023, fmt=OutputFormat.TABLE) 349 | print(status) 350 | ``` 351 | 352 | ### Puzzle Helpers 353 | 354 | `elf.helpers` includes some small utilities you can use in your own AoC solutions: 355 | 356 | ```python 357 | from elf.helpers import parse_input, read_test_input, timer 358 | ``` 359 | 360 | ### Leaderboard Example 361 | 362 | ```sh 363 | ❯ elf leaderboard 2025 3913340 364 | Advent of Code 2025 – Private Leaderboard 365 | ┏━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓ 366 | ┃ Rank ┃ Name ┃ Stars ┃ Local Score ┃ Last Star (UTC) ┃ 367 | ┡━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩ 368 | │ 1 │ User A │ 45 │ 900 │ 2025-12-26 00:53:17 │ 369 | │ 2 │ User B │ 45 │ 855 │ 2025-12-26 00:53:56 │ 370 | │ 3 │ User C │ 36 │ 622 │ 2025-12-26 03:29:21 │ 371 | └──────┴────────────────┴───────┴─────────────┴─────────────────────┘ 372 | ``` 373 | 374 | JSON format: 375 | 376 | ```sh 377 | elf leaderboard 2025 3982840 --format json 378 | ``` 379 | 380 | Model format (Pydantic): 381 | 382 | ```python 383 | lb = get_private_leaderboard(2025, board_id=3982840, fmt=OutputFormat.MODEL) 384 | members = sorted(lb.members.values(), key=lambda m: (-m.local_score, -m.stars)) 385 | print(members[0].name, members[0].stars) 386 | ``` 387 | 388 | ### Status Example 389 | 390 | ```sh 391 | ❯ elf status 2023 392 | Advent of Code 393 | 2023 – cak 394 | (AoC++) [34⭐] 395 | ┏━━━━━┳━━━━━━━┓ 396 | ┃ Day ┃ Stars ┃ 397 | ┡━━━━━╇━━━━━━━┩ 398 | │ 1 │ ★★ │ 399 | │ 2 │ ★★ │ 400 | │ 3 │ ★★ │ 401 | │ 4 │ ★☆ │ 402 | │ 5 │ ★☆ │ 403 | │ 6 │ ★★ │ 404 | │ 7 │ ★★ │ 405 | │ 8 │ ★☆ │ 406 | │ 9 │ ☆☆ │ 407 | │ 10 │ ☆☆ │ 408 | │ .. │ .. │ 409 | │ 25 │ ☆☆ │ 410 | └─────┴───────┘ 411 | ``` 412 | 413 | JSON format: 414 | 415 | ```sh 416 | elf status 2023 --format json 417 | ``` 418 | 419 | Model format: 420 | 421 | ```python 422 | status = get_user_status(2023, fmt=OutputFormat.MODEL) 423 | print(status.days[0].day, status.days[0].stars) 424 | ``` 425 | 426 | --- 427 | 428 | ## Development / Testing 429 | 430 | Run the test suite via: 431 | 432 | ```sh 433 | uv run pytest 434 | ``` 435 | 436 | Alternatively, you can still run: 437 | 438 | ```sh 439 | python -m pytest 440 | ``` 441 | 442 | Running `python -m pytest` directly requires `pytest` to be installed in your environment. Install it via `pip install pytest>=9.0.1` (or use `uv run pytest` / `pip install .[dev]` if you maintain the dev extras) before running the command outside of `uv`. 443 | 444 | ## Special Thanks 445 | 446 | This project exists thanks to the generosity and creativity of others. 447 | 448 | Thanks to **Solos** for donating the `elf` PyPI name. 449 | -------------------------------------------------------------------------------- /elf/answer.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from datetime import datetime, timedelta, timezone 3 | from enum import Enum, auto 4 | from html.parser import HTMLParser 5 | 6 | import httpx 7 | 8 | from .aoc_client import AOCClient 9 | from .cache import get_cache_guess_file 10 | from .exceptions import PuzzleLockedError, SubmissionError 11 | from .messages import ( 12 | get_already_completed_message, 13 | get_answer_too_high_message, 14 | get_answer_too_low_message, 15 | get_cached_duplicate_message, 16 | get_cached_high_message, 17 | get_cached_low_message, 18 | get_correct_answer_message, 19 | get_incorrect_answer_message, 20 | get_recent_submission_message, 21 | get_unexpected_response_message, 22 | get_wrong_level_message, 23 | ) 24 | from .models import CachedGuessCheck, Guess, SubmissionResult, SubmissionStatus 25 | from .utils import current_aoc_year, get_unlock_status, read_guesses, resolve_session 26 | 27 | WAIT_CACHE_TTL = timedelta(minutes=1) 28 | 29 | 30 | class AocResponseParser(HTMLParser): 31 | """Extract text inside the
tag.""" 32 | 33 | def __init__(self) -> None: 34 | super().__init__() 35 | self.in_article: bool = False 36 | self.content: list[str] = [] 37 | 38 | def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: 39 | if tag == "article": 40 | self.in_article = True 41 | 42 | def handle_endtag(self, tag: str) -> None: 43 | if tag == "article": 44 | self.in_article = False 45 | 46 | def handle_data(self, data: str) -> None: 47 | if self.in_article: 48 | self.content.append(data) 49 | 50 | def text(self) -> str: 51 | return "".join(self.content).strip() 52 | 53 | 54 | class AocMessageType(Enum): 55 | EMPTY = auto() 56 | CORRECT = auto() 57 | TOO_HIGH = auto() 58 | TOO_LOW = auto() 59 | RECENT_SUBMISSION = auto() 60 | ALREADY_COMPLETED = auto() 61 | INCORRECT = auto() 62 | WRONG_LEVEL = auto() 63 | UNEXPECTED = auto() 64 | 65 | 66 | def classify_message(content: str) -> AocMessageType: 67 | if not content: 68 | return AocMessageType.EMPTY 69 | 70 | checks: list[tuple[str, AocMessageType]] = [ 71 | ("That's the right answer", AocMessageType.CORRECT), 72 | ("too high", AocMessageType.TOO_HIGH), 73 | ("too low", AocMessageType.TOO_LOW), 74 | ("You gave an answer too recently", AocMessageType.RECENT_SUBMISSION), 75 | ("Did you already complete it", AocMessageType.ALREADY_COMPLETED), 76 | ("You don't seem to be solving the right level", AocMessageType.WRONG_LEVEL), 77 | ("That's not the right answer", AocMessageType.INCORRECT), 78 | ] 79 | 80 | for needle, msg_type in checks: 81 | if needle in content: 82 | return msg_type 83 | 84 | return AocMessageType.UNEXPECTED 85 | 86 | 87 | def submit_answer( 88 | year: int, 89 | day: int, 90 | level: int, 91 | answer: int | str, 92 | session: str | None, 93 | ) -> SubmissionResult: 94 | """ 95 | Submit an answer for a specific Advent of Code day/part, with guess caching. 96 | 97 | Args: 98 | year: The year of the Advent of Code challenge. 99 | day: The day of the Advent of Code challenge. 100 | level: Puzzle part (1 or 2). 101 | answer: The answer to submit (int or string). 102 | session: Your Advent of Code session token, or None to signal missing. 103 | 104 | Returns: 105 | A SubmissionResult describing the outcome, including whether it was 106 | served from the local guess cache or from Advent of Code. 107 | 108 | Raises: 109 | MissingSessionTokenError: If no session token was provided. 110 | SubmissionError: If there is an issue submitting the answer. 111 | """ 112 | submission_answer, numeric_answer = _normalize_answer(answer) 113 | 114 | if not 1 <= day <= 25: 115 | raise ValueError(f"Invalid day {day!r}. Advent of Code days are 1–25.") 116 | 117 | if year < 2015: 118 | raise ValueError(f"Invalid year {year!r}. Advent of Code started in 2015.") 119 | 120 | if level not in (1, 2): 121 | raise ValueError(f"Invalid level {level!r}. Puzzle parts are 1 or 2.") 122 | 123 | if isinstance(answer, str) and not answer.strip(): 124 | raise ValueError("Answer cannot be empty.") 125 | 126 | if year >= current_aoc_year(): 127 | status = get_unlock_status(year, day) 128 | if not status.unlocked: 129 | raise PuzzleLockedError( 130 | year=year, 131 | day=day, 132 | now=status.now, 133 | unlock_time=status.unlock_time, 134 | ) 135 | 136 | session_token = resolve_session(session) 137 | 138 | cache_file = get_cache_guess_file(year, day) 139 | cache_file.parent.mkdir(parents=True, exist_ok=True) 140 | 141 | # Check cached guesses before hitting network 142 | if cache_file.exists(): 143 | cached = check_cached_guesses( 144 | year=year, 145 | day=day, 146 | level=level, 147 | answer=submission_answer, 148 | numeric_answer=numeric_answer, 149 | ) 150 | 151 | if cached.status != SubmissionStatus.UNKNOWN: 152 | return SubmissionResult( 153 | guess=submission_answer, 154 | result=cached.status, 155 | message=cached.message, 156 | is_correct=cached.status == SubmissionStatus.CORRECT, 157 | is_cached=True, 158 | ) 159 | 160 | return submit_to_aoc(year, day, level, submission_answer, session_token) 161 | 162 | 163 | def submit_to_aoc( 164 | year: int, 165 | day: int, 166 | level: int, 167 | answer: int | str, 168 | session_token: str, 169 | ) -> SubmissionResult: 170 | # --- Network layer -------------------------------------------------------- 171 | 172 | try: 173 | with AOCClient(session_token=session_token) as client: 174 | response = client.submit_answer(year, day, str(answer), level) 175 | except httpx.TimeoutException as exc: 176 | raise SubmissionError( 177 | "Timed out while submitting answer. Try again or check your network." 178 | ) from exc 179 | 180 | except httpx.RequestError as exc: 181 | raise SubmissionError( 182 | f"Network error while connecting to Advent of Code: {exc}" 183 | ) from exc 184 | 185 | # --- HTTP status handling ------------------------------------------------- 186 | 187 | if response.status_code == 404: 188 | if "unlocks it for you" in response.text: 189 | raise SubmissionError("Puzzle not yet unlocked.") 190 | elif "Please don't repeatedly request this endpoint" in response.text: 191 | raise SubmissionError( 192 | "Submitting answers too quickly. Please wait before trying again." 193 | ) 194 | 195 | else: 196 | raise SubmissionError( 197 | f"Input not found for year={year}, day={day} (HTTP 404)." 198 | ) 199 | 200 | if response.status_code == 400: 201 | raise SubmissionError( 202 | "Bad request (HTTP 400). Your session token may be invalid." 203 | ) 204 | 205 | if 500 <= response.status_code < 600: 206 | raise SubmissionError( 207 | f"Server error from Advent of Code (HTTP {response.status_code}). Your session token may be invalid." 208 | ) 209 | 210 | if response.status_code != 200: 211 | raise SubmissionError( 212 | f"Unexpected HTTP status {response.status_code} from Advent of Code." 213 | ) 214 | 215 | if "To play, please identify yourself" in response.text: 216 | raise SubmissionError( 217 | "Session cookie invalid or expired. " 218 | "Update AOC_SESSION with a valid 'session' cookie from your browser." 219 | ) 220 | 221 | # Extract
content 222 | parser = AocResponseParser() 223 | parser.feed(response.text) 224 | content = parser.text() 225 | 226 | # Determine result type 227 | message_type = classify_message(content) 228 | match message_type: 229 | case AocMessageType.EMPTY: 230 | message = "Answer submitted, but no response message found." 231 | status = SubmissionStatus.UNKNOWN 232 | case AocMessageType.CORRECT: 233 | message = get_correct_answer_message(answer) 234 | status = SubmissionStatus.CORRECT 235 | case AocMessageType.TOO_HIGH: 236 | message = get_answer_too_high_message(answer) 237 | status = SubmissionStatus.TOO_HIGH 238 | case AocMessageType.TOO_LOW: 239 | message = get_answer_too_low_message(answer) 240 | status = SubmissionStatus.TOO_LOW 241 | case AocMessageType.RECENT_SUBMISSION: 242 | message = get_recent_submission_message() 243 | status = SubmissionStatus.WAIT 244 | case AocMessageType.ALREADY_COMPLETED: 245 | message = get_already_completed_message() 246 | status = SubmissionStatus.COMPLETED 247 | case AocMessageType.WRONG_LEVEL: 248 | message = get_wrong_level_message() 249 | status = SubmissionStatus.INCORRECT 250 | case AocMessageType.INCORRECT: 251 | message = get_incorrect_answer_message(answer) 252 | status = SubmissionStatus.INCORRECT 253 | case _: 254 | message = get_unexpected_response_message() 255 | status = SubmissionStatus.UNKNOWN 256 | 257 | # Cache the guess (including WAIT, with TTL handling on read) 258 | write_guess_cache(year, day, level, answer, status) 259 | 260 | return SubmissionResult( 261 | guess=answer, 262 | result=status, 263 | message=message, 264 | is_correct=status == SubmissionStatus.CORRECT, 265 | is_cached=False, 266 | ) 267 | 268 | 269 | def write_guess_cache( 270 | year: int, 271 | day: int, 272 | part: int, 273 | guess: int | str, 274 | status: SubmissionStatus, 275 | ) -> None: 276 | cache_file = get_cache_guess_file(year, day) 277 | 278 | timestamp = datetime.now(timezone.utc).isoformat() 279 | 280 | canonical_guess, _ = _normalize_answer(guess) 281 | row = { 282 | "timestamp": timestamp, 283 | "part": part, 284 | "guess": str(canonical_guess), 285 | "status": status.name, 286 | } 287 | 288 | try: 289 | cache_file.parent.mkdir(parents=True, exist_ok=True) 290 | file_exists = cache_file.exists() 291 | with cache_file.open("a", newline="", encoding="utf-8") as f: 292 | writer = csv.DictWriter(f, fieldnames=row.keys()) 293 | if not file_exists: 294 | writer.writeheader() 295 | writer.writerow(row) 296 | except Exception as exc: 297 | raise RuntimeError(f"Failed to write guess cache {cache_file}: {exc}") from exc 298 | 299 | 300 | def check_cached_guesses( 301 | year: int, 302 | day: int, 303 | level: int, 304 | answer: int | str, 305 | numeric_answer: int | None, 306 | ) -> CachedGuessCheck: 307 | guesses = read_guesses(year, day) 308 | now = datetime.now(timezone.utc) 309 | 310 | highest_low: Guess | None = None 311 | lowest_high: Guess | None = None 312 | completed_guess: Guess | None = None # NEW 313 | wait_guess: Guess | None = None 314 | 315 | for g in guesses: 316 | if g.part != level: 317 | continue 318 | 319 | # Honor recent WAIT responses for this part 320 | if g.status is SubmissionStatus.WAIT and _within_wait_ttl(g.timestamp, now): 321 | wait_guess = g 322 | break 323 | 324 | is_same_guess = g.guess == answer or ( 325 | numeric_answer is not None 326 | and isinstance(g.guess, int) 327 | and g.guess == numeric_answer 328 | ) 329 | 330 | # Prevent repeating guess unless guess response was WAIT 331 | if is_same_guess and g.status != SubmissionStatus.WAIT: 332 | return CachedGuessCheck( 333 | guess=answer, 334 | previous_guess=g.guess, 335 | previous_timestamp=g.timestamp, 336 | status=g.status, 337 | message=get_cached_duplicate_message(answer, g), 338 | ) 339 | 340 | match g: 341 | # Mark that this part is completed (any previous guess) 342 | case Guess(status=SubmissionStatus.COMPLETED): 343 | completed_guess = g 344 | 345 | # Bounds checking (int only) 346 | case Guess(guess=ans, status=SubmissionStatus.TOO_LOW) if ( 347 | numeric_answer is not None and isinstance(ans, int) 348 | ): 349 | if highest_low is None or ( 350 | isinstance(highest_low.guess, int) and ans > highest_low.guess 351 | ): 352 | highest_low = g 353 | case Guess(guess=ans, status=SubmissionStatus.TOO_HIGH) if ( 354 | numeric_answer is not None and isinstance(ans, int) 355 | ): 356 | if lowest_high is None or ( 357 | isinstance(lowest_high.guess, int) and ans < lowest_high.guess 358 | ): 359 | lowest_high = g 360 | 361 | # Short-circuit on active cooldown 362 | if wait_guess is not None: 363 | retry_at = _retry_at(wait_guess.timestamp) 364 | return CachedGuessCheck( 365 | guess=answer, 366 | previous_guess=None, 367 | previous_timestamp=wait_guess.timestamp, 368 | status=SubmissionStatus.WAIT, 369 | message=_wait_cache_message(retry_at), 370 | ) 371 | 372 | # If we know this part is completed, short-circuit before bounds logic 373 | if completed_guess is not None: 374 | return CachedGuessCheck( 375 | guess=answer, 376 | previous_guess=None, 377 | previous_timestamp=completed_guess.timestamp, 378 | status=SubmissionStatus.COMPLETED, 379 | message=get_already_completed_message(), 380 | ) 381 | 382 | # Infer bounds 383 | if numeric_answer is not None: 384 | match (highest_low, lowest_high): 385 | case (h_low, _) if ( 386 | h_low and isinstance(h_low.guess, int) and numeric_answer <= h_low.guess 387 | ): 388 | return CachedGuessCheck( 389 | guess=answer, 390 | previous_guess=h_low.guess, 391 | previous_timestamp=h_low.timestamp, 392 | status=SubmissionStatus.TOO_LOW, 393 | message=get_cached_low_message(answer, h_low), 394 | ) 395 | case (_, l_high) if ( 396 | l_high 397 | and isinstance(l_high.guess, int) 398 | and numeric_answer >= l_high.guess 399 | ): 400 | return CachedGuessCheck( 401 | guess=answer, 402 | previous_guess=l_high.guess, 403 | previous_timestamp=l_high.timestamp, 404 | status=SubmissionStatus.TOO_HIGH, 405 | message=get_cached_high_message(answer, l_high), 406 | ) 407 | 408 | # No bounds found 409 | return CachedGuessCheck( 410 | guess=answer, 411 | previous_guess=None, 412 | previous_timestamp=None, 413 | status=SubmissionStatus.UNKNOWN, 414 | message="This is a unique guess.", 415 | ) 416 | 417 | 418 | def _retry_at(ts: datetime) -> datetime: 419 | ts_aware = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc) 420 | return ts_aware + WAIT_CACHE_TTL 421 | 422 | 423 | def _within_wait_ttl(ts: datetime, now: datetime) -> bool: 424 | return _retry_at(ts) > now 425 | 426 | 427 | def _wait_cache_message(retry_at: datetime) -> str: 428 | retry_str = retry_at.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") 429 | return f"{get_recent_submission_message()} Cached locally; try again after {retry_str}." 430 | 431 | 432 | def _normalize_answer(answer: int | str) -> tuple[int | str, int | None]: 433 | """ 434 | Preserve the user's answer text but still provide a numeric variant for bounds/duplicate guardrails. 435 | Returns (submission_answer, numeric_answer). 436 | """ 437 | if isinstance(answer, str): 438 | stripped = answer.strip() 439 | if not stripped: 440 | raise ValueError("Answer cannot be empty.") 441 | 442 | numeric_value: int | None = None 443 | if stripped.lstrip("+-").isdigit(): 444 | try: 445 | numeric_value = int(stripped) 446 | except ValueError: 447 | numeric_value = None 448 | 449 | return stripped, numeric_value 450 | 451 | return answer, int(answer) 452 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.11" 4 | 5 | [[package]] 6 | name = "annotated-types" 7 | version = "0.7.0" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.12.0" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "idna" }, 20 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, 25 | ] 26 | 27 | [[package]] 28 | name = "beautifulsoup4" 29 | version = "4.14.3" 30 | source = { registry = "https://pypi.org/simple" } 31 | dependencies = [ 32 | { name = "soupsieve" }, 33 | { name = "typing-extensions" }, 34 | ] 35 | sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } 36 | wheels = [ 37 | { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, 38 | ] 39 | 40 | [[package]] 41 | name = "certifi" 42 | version = "2025.11.12" 43 | source = { registry = "https://pypi.org/simple" } 44 | sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } 45 | wheels = [ 46 | { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, 47 | ] 48 | 49 | [[package]] 50 | name = "click" 51 | version = "8.3.1" 52 | source = { registry = "https://pypi.org/simple" } 53 | dependencies = [ 54 | { name = "colorama", marker = "sys_platform == 'win32'" }, 55 | ] 56 | sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } 57 | wheels = [ 58 | { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, 59 | ] 60 | 61 | [[package]] 62 | name = "colorama" 63 | version = "0.4.6" 64 | source = { registry = "https://pypi.org/simple" } 65 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 66 | wheels = [ 67 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 68 | ] 69 | 70 | [[package]] 71 | name = "elf" 72 | version = "1.1.1" 73 | source = { editable = "." } 74 | dependencies = [ 75 | { name = "beautifulsoup4" }, 76 | { name = "httpx" }, 77 | { name = "pydantic" }, 78 | { name = "rich" }, 79 | { name = "typer" }, 80 | ] 81 | 82 | [package.dev-dependencies] 83 | dev = [ 84 | { name = "pytest" }, 85 | ] 86 | 87 | [package.metadata] 88 | requires-dist = [ 89 | { name = "beautifulsoup4", specifier = ">=4.14.2" }, 90 | { name = "httpx", specifier = ">=0.28.1" }, 91 | { name = "pydantic", specifier = ">=2.12.4" }, 92 | { name = "rich", specifier = ">=14.2.0" }, 93 | { name = "typer", specifier = ">=0.20.0" }, 94 | ] 95 | 96 | [package.metadata.requires-dev] 97 | dev = [{ name = "pytest", specifier = ">=9.0.1" }] 98 | 99 | [[package]] 100 | name = "h11" 101 | version = "0.16.0" 102 | source = { registry = "https://pypi.org/simple" } 103 | sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 104 | wheels = [ 105 | { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 106 | ] 107 | 108 | [[package]] 109 | name = "httpcore" 110 | version = "1.0.9" 111 | source = { registry = "https://pypi.org/simple" } 112 | dependencies = [ 113 | { name = "certifi" }, 114 | { name = "h11" }, 115 | ] 116 | sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 117 | wheels = [ 118 | { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 119 | ] 120 | 121 | [[package]] 122 | name = "httpx" 123 | version = "0.28.1" 124 | source = { registry = "https://pypi.org/simple" } 125 | dependencies = [ 126 | { name = "anyio" }, 127 | { name = "certifi" }, 128 | { name = "httpcore" }, 129 | { name = "idna" }, 130 | ] 131 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 132 | wheels = [ 133 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 134 | ] 135 | 136 | [[package]] 137 | name = "idna" 138 | version = "3.11" 139 | source = { registry = "https://pypi.org/simple" } 140 | sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } 141 | wheels = [ 142 | { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, 143 | ] 144 | 145 | [[package]] 146 | name = "iniconfig" 147 | version = "2.3.0" 148 | source = { registry = "https://pypi.org/simple" } 149 | sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 150 | wheels = [ 151 | { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 152 | ] 153 | 154 | [[package]] 155 | name = "markdown-it-py" 156 | version = "4.0.0" 157 | source = { registry = "https://pypi.org/simple" } 158 | dependencies = [ 159 | { name = "mdurl" }, 160 | ] 161 | sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 162 | wheels = [ 163 | { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, 164 | ] 165 | 166 | [[package]] 167 | name = "mdurl" 168 | version = "0.1.2" 169 | source = { registry = "https://pypi.org/simple" } 170 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 171 | wheels = [ 172 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 173 | ] 174 | 175 | [[package]] 176 | name = "packaging" 177 | version = "25.0" 178 | source = { registry = "https://pypi.org/simple" } 179 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 180 | wheels = [ 181 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 182 | ] 183 | 184 | [[package]] 185 | name = "pluggy" 186 | version = "1.6.0" 187 | source = { registry = "https://pypi.org/simple" } 188 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 189 | wheels = [ 190 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 191 | ] 192 | 193 | [[package]] 194 | name = "pydantic" 195 | version = "2.12.5" 196 | source = { registry = "https://pypi.org/simple" } 197 | dependencies = [ 198 | { name = "annotated-types" }, 199 | { name = "pydantic-core" }, 200 | { name = "typing-extensions" }, 201 | { name = "typing-inspection" }, 202 | ] 203 | sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } 204 | wheels = [ 205 | { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, 206 | ] 207 | 208 | [[package]] 209 | name = "pydantic-core" 210 | version = "2.41.5" 211 | source = { registry = "https://pypi.org/simple" } 212 | dependencies = [ 213 | { name = "typing-extensions" }, 214 | ] 215 | sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } 216 | wheels = [ 217 | { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, 218 | { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, 219 | { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, 220 | { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, 221 | { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, 222 | { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, 223 | { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, 224 | { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, 225 | { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, 226 | { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, 227 | { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, 228 | { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, 229 | { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, 230 | { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, 231 | { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, 232 | { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, 233 | { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, 234 | { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, 235 | { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, 236 | { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, 237 | { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, 238 | { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, 239 | { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, 240 | { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, 241 | { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, 242 | { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, 243 | { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, 244 | { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, 245 | { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, 246 | { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, 247 | { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, 248 | { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, 249 | { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, 250 | { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, 251 | { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, 252 | { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, 253 | { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, 254 | { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, 255 | { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, 256 | { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, 257 | { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, 258 | { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, 259 | { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, 260 | { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, 261 | { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, 262 | { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, 263 | { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, 264 | { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, 265 | { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, 266 | { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, 267 | { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, 268 | { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, 269 | { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, 270 | { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, 271 | { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, 272 | { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, 273 | { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, 274 | { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, 275 | { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, 276 | { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, 277 | { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, 278 | { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, 279 | { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, 280 | { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, 281 | { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, 282 | { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, 283 | { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, 284 | { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, 285 | { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, 286 | { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, 287 | { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, 288 | { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, 289 | { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, 290 | { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, 291 | { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, 292 | { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, 293 | { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, 294 | { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, 295 | { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, 296 | { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, 297 | { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, 298 | { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, 299 | { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, 300 | { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, 301 | { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, 302 | { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, 303 | ] 304 | 305 | [[package]] 306 | name = "pygments" 307 | version = "2.19.2" 308 | source = { registry = "https://pypi.org/simple" } 309 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 310 | wheels = [ 311 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 312 | ] 313 | 314 | [[package]] 315 | name = "pytest" 316 | version = "9.0.1" 317 | source = { registry = "https://pypi.org/simple" } 318 | dependencies = [ 319 | { name = "colorama", marker = "sys_platform == 'win32'" }, 320 | { name = "iniconfig" }, 321 | { name = "packaging" }, 322 | { name = "pluggy" }, 323 | { name = "pygments" }, 324 | ] 325 | sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } 326 | wheels = [ 327 | { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, 328 | ] 329 | 330 | [[package]] 331 | name = "rich" 332 | version = "14.2.0" 333 | source = { registry = "https://pypi.org/simple" } 334 | dependencies = [ 335 | { name = "markdown-it-py" }, 336 | { name = "pygments" }, 337 | ] 338 | sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } 339 | wheels = [ 340 | { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, 341 | ] 342 | 343 | [[package]] 344 | name = "shellingham" 345 | version = "1.5.4" 346 | source = { registry = "https://pypi.org/simple" } 347 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } 348 | wheels = [ 349 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, 350 | ] 351 | 352 | [[package]] 353 | name = "soupsieve" 354 | version = "2.8" 355 | source = { registry = "https://pypi.org/simple" } 356 | sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } 357 | wheels = [ 358 | { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, 359 | ] 360 | 361 | [[package]] 362 | name = "typer" 363 | version = "0.20.0" 364 | source = { registry = "https://pypi.org/simple" } 365 | dependencies = [ 366 | { name = "click" }, 367 | { name = "rich" }, 368 | { name = "shellingham" }, 369 | { name = "typing-extensions" }, 370 | ] 371 | sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } 372 | wheels = [ 373 | { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, 374 | ] 375 | 376 | [[package]] 377 | name = "typing-extensions" 378 | version = "4.15.0" 379 | source = { registry = "https://pypi.org/simple" } 380 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 381 | wheels = [ 382 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 383 | ] 384 | 385 | [[package]] 386 | name = "typing-inspection" 387 | version = "0.4.2" 388 | source = { registry = "https://pypi.org/simple" } 389 | dependencies = [ 390 | { name = "typing-extensions" }, 391 | ] 392 | sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 393 | wheels = [ 394 | { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 395 | ] 396 | --------------------------------------------------------------------------------