├── 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 |
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 "
")
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 |
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 |
--------------------------------------------------------------------------------