From 002b5f503c7ddf859afc4a96f0e6d2db08ff517d Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 00:10:25 -0400 Subject: [PATCH 1/9] Adopt python-starter conventions (basedpyright, ruff strict, AGENTS.md, CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates flight-cli to the ak2k/python-starter template (Profile A — distributable CLI). Validates the template against a real 2K-LOC repo; the gaps to close were narrow because the runtime stack already matched (src/ + httpx + Pydantic + typer + stamina + aiolimiter + httpx-curl-cffi). Changes: * Type checker: pyright --strict → basedpyright strict; pyrightconfig.json folded into [tool.basedpyright]. 0 errors on src/ and tests/; the remaining reportAny warnings sit at typed-as-Any third-party boundaries (fli, fast_flights, raw JSON) and are expected per template. * Lint: full python-starter ruff ruleset (E/W/F/I/B/UP/SIM/RUF/ASYNC/PTH/ TC/RET/PIE/PL/ANN/S/TRY/BLE/N). N815 (camelCase) per-file-ignored for wire.py / domain.py / models.py — Matrix's JSON dictates field names. Profile A divergences (D*, PLR0913, fail_under=0) commented at site. * Inner-loop scaffolding: Makefile, AGENTS.md (Profile A flavored, with canonical-example pointers into the existing source instead of new example files), .claude/settings.json allowlist, py.typed, .editorconfig, .python-version. .gitignore tracks .claude/settings.json now (template convention — discoverable allowlist for new collaborators). * CI: ci.yml + gitleaks.yml ported from template. uv path runs for everyone; conditional nix path is a no-op without flake.nix. * Code adjustments to satisfy strict ruleset (all behavior-preserving): HTTPStatus enum in place of bare 200/403/404/408/500 magic numbers, StrEnum in place of (str, Enum), narrowed bare excepts, `from e` on re-raises, contextlib.suppress for ignored OSError, _ROUND_TRIP_LEGS / _IATA_CODE_LEN / _SLICE_*_PARTS sentinels for clarity, lazy fli imports kept with explicit noqa + reason. cli.py loses three isinstance asserts in favor of cast (still type-safe via discriminated dispatch in client._parse_response). * uv.lock now tracked (template step 1: commit uv.lock). `make check` is fully green; `flight fare JFK LHR --dep ... -n 2` smoke test against the live Alkali backend confirms no runtime regression. --- .claude/settings.json | 17 + .editorconfig | 18 + .github/workflows/ci.yml | 50 ++ .github/workflows/gitleaks.yml | 33 ++ .gitignore | 9 +- .python-version | 1 + AGENTS.md | 165 +++++++ Makefile | 31 ++ pyproject.toml | 133 ++++- pyrightconfig.json | 8 - src/flight_cli/__init__.py | 55 ++- src/flight_cli/_api_key.py | 57 +-- src/flight_cli/_http.py | 84 +++- src/flight_cli/cli.py | 571 ++++++++++++++-------- src/flight_cli/client.py | 72 ++- src/flight_cli/domain.py | 115 +++-- src/flight_cli/fli_bridge.py | 64 ++- src/flight_cli/links.py | 147 ++++-- src/flight_cli/models.py | 24 +- src/flight_cli/py.typed | 0 src/flight_cli/wire.py | 96 ++-- tests/test_wire_round_trip.py | 92 ++-- uv.lock | 857 +++++++++++++++++++++++++++++++++ 23 files changed, 2211 insertions(+), 488 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/gitleaks.yml create mode 100644 .python-version create mode 100644 AGENTS.md create mode 100644 Makefile delete mode 100644 pyrightconfig.json create mode 100644 src/flight_cli/py.typed create mode 100644 uv.lock diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..6221530 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "enabledPlugins": { + "pr-review-toolkit@claude-plugins-official": true + }, + "permissions": { + "allow": [ + "Bash(uv:*)", + "Bash(uvx:*)", + "Bash(ruff:*)", + "Bash(basedpyright:*)", + "Bash(pytest:*)", + "Bash(make:*)", + "Bash(nix develop:*)", + "Bash(nix flake:*)" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0708c2a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.{toml,yaml,yml,json,jsonc}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85e0018 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + # uv path — runs for everyone + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install + + - name: Install deps + run: uv sync --frozen + + - name: Inner loop (uv) + run: make check + + # nix path — only if the user kept flake.nix + - name: Install Nix + if: ${{ hashFiles('flake.nix') != '' }} + uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Inner loop (nix develop) + if: ${{ hashFiles('flake.nix') != '' }} + run: nix develop -c make check diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..3ef3a8d --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,33 @@ +name: gitleaks + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: gitleaks-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # gitleaks needs full history to scan commit-by-commit + + - name: Install gitleaks + run: | + set -euo pipefail + version=8.21.2 + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + + - name: Scan + run: gitleaks detect --source . --redact --no-banner -v diff --git a/.gitignore b/.gitignore index 58e0f45..4190e42 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,12 @@ dist/ venv/ env/ -# Pytest / mypy / etc. +# Pytest / basedpyright / hypothesis / coverage caches .pytest_cache/ .mypy_cache/ .ruff_cache/ +.basedpyright/ +.hypothesis/ .coverage htmlcov/ @@ -32,7 +34,6 @@ htmlcov/ # Tracked test fixtures live in tests/fixtures/ instead. research/ -# Claude Code per-session agent state — settings only. Project-shared -# skills under .claude/skills/ ARE checked in. -.claude/settings.json +# Claude Code per-session agent state. Project-shared `.claude/settings.json` +# and `.claude/skills/` ARE checked in; per-machine override stays local. .claude/settings.local.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ccf5f05 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# Agent instructions + +Contract for agents working in this repo. Read first; overrides defaults. + +This project follows the [`ak2k/python-starter`](https://github.com/ak2k/python-starter) +template (Profile A — distributable CLI). See also `CLAUDE.md` for +flight-cli-specific load-bearing quirks (Matrix wire-format gotchas, SPA +capture workflow, two-phase calendar flow) — read both before non-trivial +work. + +## Stack (one per concern; substitutes are bans) + +| Concern | Use | flight-cli uses | Not | +|---|---|---|---| +| Package manager | `uv` | ✓ | pip, poetry, pipenv, pyenv | +| Lint / format | `ruff` + `ruff format` | ✓ | black, isort, flake8, pylint | +| Type checker | `basedpyright` strict | ✓ (was `pyright`) | mypy, pyright | +| Boundary validation | Pydantic v2 | ✓ (see `wire.py`, `models.py`, `domain.py`) | dataclasses, attrs, TypedDict | +| HTTP | `httpx` | ✓ | requests, aiohttp, urllib | +| Async runtime | `anyio` | asyncio (DIVERGE — see below) | raw asyncio | +| Logging | `structlog` | stdlib `logging` (DIVERGE — see below) | `print()` | +| Paths | `pathlib.Path` | ✓ | `os.path` | +| Tests | `pytest` + `hypothesis` | `pytest` (no hypothesis yet) | unittest | +| Errors | subclass project-local error hierarchy | `MatrixApiError`, `ApiKeyResolutionError` | bare `Exception`, string errors | + +## When you need it, use + +Not every project hits these concerns. When yours does, this is the +choice that fits the rest of the stack — don't substitute. + +| Concern | Use | flight-cli uses | +|---|---|---| +| Retry / backoff | `stamina` | ✓ (see `_http.py`) | +| CLI framework | `typer` (paired with `rich` for human-facing output) | ✓ (see `cli.py`) | +| Time injection in tests | `time-machine` | — | +| HTTP rate limiting (outbound) | `aiolimiter` | ✓ (see `_http.py`) | +| HTTP fingerprint evasion | `httpx-curl-cffi` transport (keep httpx API + `MockTransport` tests) | ✓ (see `_http.py`) | +| SQL | `sqlalchemy 2.0` Core | — | +| Async file I/O | `anyio.Path` | — | + +## Inner loop + +``` +make fix # autofix + full check +make check # full check (CI runs this) +``` + +Both must be green. `filterwarnings = ["error"]` and `xfail_strict = true` +are load-bearing — deprecation warnings and unexpected passes are real +failures, not noise. + +Smoke test against the live backend: + +``` +flight fare JFK LHR --dep 2026-08-15 --return 2026-08-22 -n 2 +``` + +If `make check` is green but the smoke test fails, suspect the API key +cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see +`CLAUDE.md` quirk #7). + +## Principles + +1. **Boundaries fail loudly.** Pydantic at every external edge. Wire + models in `wire.py` use `extra="ignore"` for forward-compat (Matrix + adds fields unannounced); domain models in `domain.py` and response + models in `models.py` validate strictly. Domain errors + (`MatrixApiError`, `ApiKeyResolutionError`) wrap third-party + exceptions — `httpx.HTTPStatusError` never leaks to a caller. Shape + drift is an alarm, not a silent fallthrough. + +2. **Suppress with cost.** Every `# pyright: ignore[code]` and `# noqa: code` + names the specific rule and a one-line reason. Suppression is annotated + debt — visible, greppable, justified — not a workaround. + +3. **Copy the canonical example.** Inventing a new shape for any of these + is a deliberate choice. The defaults are: + - **Wire boundary (request)**: `src/flight_cli/wire.py` — pydantic + models with `populate_by_name`, discriminated-union dispatch via + `match` + `assert_never`. + - **Wire boundary (response)**: `src/flight_cli/models.py` — `_Loose` + base with `extra="ignore"`, `from_api()` classmethod constructors. + - **Domain types (intent)**: `src/flight_cli/domain.py` — frozen + pydantic models, discriminated by `kind` Literal, validators with + domain-meaningful error messages. + - **HTTP client**: `src/flight_cli/client.py` — single `execute()` + entry point; wraps `httpx.HTTPStatusError` in `MatrixApiError` at + the boundary. + - **HTTP transport**: `src/flight_cli/_http.py` — `stamina.retry` + decorator, `aiolimiter.AsyncLimiter`, `httpx-curl-cffi` for + fingerprinting, on-disk JSON cache. + - **Env + disk-cache config**: `src/flight_cli/_api_key.py` — env-var + override → disk cache (TTL) → live resolution → cache write. + - **Test pattern**: `tests/test_wire_round_trip.py` — captured SPA + bodies as golden-file fixtures; reconstruct via `to_wire()` and + compare. + +4. **Diverge with a comment.** When tuning a default below (or deviating + from the stack table), leave `# DIVERGE: ` so future readers + don't "fix" it back. Existing divergences from the template default: + - `asyncio` instead of `anyio` — flight-cli is single-runtime; no + library-author concern. Cost of switching is real, value is small. + - stdlib `logging` instead of `structlog` — CLI tool with rich-handler + output; structured logging would target a sink we don't have. + - Coverage gate at 0% — golden-file regression tests; fixture parity + is the signal, not line coverage. + +5. **Ask when guessing.** Unknown Matrix wire shape, new SPA capture + needed, irresolvable type error → ask. Don't invent the shape; capture + a real body via `research/record_user_session.py` first. + +6. **Tests assert behavior, not implementation.** Golden-file fixtures + for wire-format round-trips (`tests/fixtures/`). `httpx.MockTransport` + is the substitution point for hermetic HTTP tests. `monkeypatch.setenv` + for env-driven settings. Assert on domain errors (`MatrixApiError`), + not on HTTP status codes leaking through. + +7. **Imports declare intent.** `from __future__ import annotations` at + the top of every module; runtime-only third-party types inside + `if TYPE_CHECKING:`. + +8. **Async runtime is asyncio (DIVERGE).** Not anyio. See divergence note + in principle 4. Stick with asyncio unless we add a library-author + concern. + +## Appropriate divergence + +This project is **Profile A — distributable CLI**. Tunings already +applied versus the strict-service default: + +| Knob | Default | flight-cli | +|---|---|---| +| `requires-python` | `>=3.13` | `>=3.12` | +| Ruff `D*` (docstrings) | enabled | omitted | +| `pythonVersion` (basedpyright) | `"3.13"` | `"3.12"` | +| Coverage `fail_under` | 80 | 0 (golden-file suite) | +| `PLR0913` (too many args) | strict | ignored (CLI verbs are wide) | +| `N815` (camelCase) | strict | per-file-ignored in `wire.py`, `domain.py`, `models.py` — Matrix wire JSON dictates field names | + +## flight-cli load-bearing quirks (read before touching wire.py) + +Full detail in [`CLAUDE.md`](./CLAUDE.md) and +[`docs/memories/MEMORY.md`](./docs/memories/MEMORY.md). One-line summaries: + +- `routeLanguage` ≠ `commandLine` (two distinct wire fields). +- Per-mode field rules: specific-date emits `dateModifier` + `isArrivalDate`; calendar omits. +- `maxLegsRelativeToMin` defaults to 1, not 10. +- Calendar brownouts are real; not our bug. +- Two-phase calendar flow: `calendar` returns grid; `calendarFollowup` returns itineraries. +- Multiple AIza keys in the SPA bundle — only the bare `matrix` label is the prod key. + +When in doubt: capture a real SPA body via `research/record_user_session.py` +and write a reconstruction test in `tests/test_wire_round_trip.py`. + +## Known gotchas (non-obvious from the toolchain) + +- `extra="forbid"` Pydantic models raise on any unknown upstream field. + Wire models use `extra="ignore"` instead — Matrix adds fields without + notice and we don't want every shape addition to be a P0. +- `http.HTTPStatus.NOT_FOUND` — stdlib, well-typed. Not + `httpx.codes.NOT_FOUND` (mis-typed as tuple) and not bare `404` + (PLR2004). +- Domain `InputValidationError`-style errors should never shadow + `pydantic.ValidationError` — keep our errors named distinctly + (`MatrixApiError`, `ApiKeyResolutionError`). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..76fc3ef --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: help install check lint format typecheck test fix clean + +help: ## show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}' + +install: ## uv sync (install all deps including dev) + uv sync + +check: lint typecheck test ## full inner loop (CI runs this) + +fix: ## autofix lint + format, then run full check + uv run ruff check --fix src tests + uv run ruff format src tests + $(MAKE) check + +lint: ## ruff check (no fix) + format --check + uv run ruff check src tests + uv run ruff format --check src tests + +format: ## ruff format (writes) + uv run ruff format src tests + +typecheck: ## basedpyright strict + uv run basedpyright src tests + +test: ## pytest + uv run pytest + +clean: ## remove caches + rm -rf .pytest_cache .ruff_cache .basedpyright .hypothesis .coverage htmlcov dist build + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/pyproject.toml b/pyproject.toml index d1e63c4..fc68f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,17 +2,36 @@ name = "flight-cli" version = "0.1.0" description = "CLI for ITA Matrix's undocumented Alkali backend (flight search + lowest-fare calendar)" -requires-python = ">=3.11" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.12" +authors = [{ name = "ak2k" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", +] dependencies = [ - "httpx>=0.27", - "httpx-curl-cffi>=0.1.5", - "aiolimiter>=1.1", - "stamina>=24.3", - "pydantic>=2.7", - "typer>=0.12", - "rich>=13", - "flights>=0.8", # fli — drives Google Flights for booking handoff - "fast-flights>=2.2", # tfs= URL encoder (Google Flights deep-link) + "httpx>=0.27", + "httpx-curl-cffi>=0.1.5", + "aiolimiter>=1.1", + "stamina>=24.3", + "pydantic>=2.7", + "typer>=0.12", + "rich>=13", + "flights>=0.8", # fli — drives Google Flights for booking handoff + "fast-flights>=2.2", # tfs= URL encoder (Google Flights deep-link) +] + +[dependency-groups] +dev = [ + "basedpyright>=1.20", + "ruff>=0.6", + "pytest>=8.3", + "pytest-cov>=5", + "hypothesis>=6.100", ] [project.scripts] @@ -24,3 +43,97 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/flight_cli"] + +# --- ruff: lint + format --- +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", "F", # pycodestyle + pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade — keeps agents from regressing to old idioms + "SIM", # simplify + "RUF", # ruff-specific + "ASYNC", # async correctness — high signal + "PTH", # pathlib over os.path + "TC", # TYPE_CHECKING import discipline + "RET", # return-statement hygiene + "PIE", # flake8-pie: misc anti-patterns + "PL", # pylint subset + "ANN", # require annotations — agents drop them otherwise + "S", # bandit (security) + "TRY", # try/except hygiene + "BLE", # no bare except + "N", # naming +] +ignore = [ + "ANN401", # tactically allow Any — basedpyright reports it separately + "TRY003", # exception message length — too prescriptive + "PLR0913", # DIVERGE Profile A: CLI verb signatures are wide + "COM812", # conflicts with ruff format (trailing-comma rules) +] +# DIVERGE Profile A: D* (docstrings) intentionally omitted — small internal CLI surface. + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "S101", # asserts are pytest's primary verb + "ANN", # test signatures don't need annotations + "PLR2004", # magic numbers are fine in test data + "TRY", # try/except discipline doesn't apply + "BLE", # bare excepts are fine in tests + "N", # test function names may mirror wire-field names (camelCase) + "PLC0415", # internal lazy imports are fine for accessing private helpers +] +# DIVERGE: Matrix's wire JSON forces camelCase field names; N815 cannot apply here. +"src/flight_cli/wire.py" = ["N815"] +"src/flight_cli/domain.py" = ["N815"] +"src/flight_cli/models.py" = ["N815"] + +[tool.ruff.format] +docstring-code-format = true + +# --- basedpyright: types --- +[tool.basedpyright] +include = ["src", "tests"] +pythonVersion = "3.12" +typeCheckingMode = "strict" +reportMissingTypeStubs = "warning" +reportImplicitOverride = "error" +reportUnusedCallResult = "none" +reportAny = "warning" + +# --- pytest --- +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" +xfail_strict = true +filterwarnings = [ + "error", # deprecations become test failures — catch early + "ignore::hypothesis.errors.NonInteractiveExampleWarning", +] +markers = [ + # Pre-declare custom markers here; `--strict-markers` rejects undeclared ones. + # "slow: marks tests as slow (deselect with -m 'not slow')", + # "integration: requires external services", +] + +# --- coverage --- +# DIVERGE Profile A: golden-file regression suite, not coverage-driven. +# Coverage machinery left in for ad-hoc inspection (`uv run pytest --cov=src`) +# but no gate — fixture parity is the real signal. +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_also = [ + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] +show_missing = true +fail_under = 0 diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index db96714..0000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "include": ["src/flight_cli"], - "venvPath": ".", - "venv": ".venv", - "pythonVersion": "3.11", - "strict": ["src/flight_cli"], - "reportMissingTypeStubs": false -} diff --git a/src/flight_cli/__init__.py b/src/flight_cli/__init__.py index d3d7dce..a9e3f53 100644 --- a/src/flight_cli/__init__.py +++ b/src/flight_cli/__init__.py @@ -1,23 +1,52 @@ """flight-cli: ITA Matrix Alkali backend wrapper.""" -from .client import MatrixClient, MatrixApiError, ApiKeyResolutionError, resolve_api_key + +from .client import ApiKeyResolutionError, MatrixApiError, MatrixClient, resolve_api_key from .domain import ( - Cabin, Pax, TimeOfDay, Leg, SearchOptions, CalendarWindow, - SpecificDateSearch, CalendarSearch, CalendarFollowup, Search, + Cabin, + CalendarFollowup, + CalendarSearch, + CalendarWindow, + Leg, + Pax, + Search, + SearchOptions, + SpecificDateSearch, + TimeOfDay, ) +from .links import google_flights_url, matrix_deep_link from .models import ( - SearchResult, Itinerary, CalendarResult, CalendarDay, DurationOption, + CalendarDay, + CalendarResult, + DurationOption, + Itinerary, Location, + SearchResult, ) -from .wire import to_wire, WireBody -from .links import matrix_deep_link, google_flights_url +from .wire import WireBody, to_wire __all__ = [ - "MatrixClient", "MatrixApiError", "ApiKeyResolutionError", "resolve_api_key", - "Cabin", "Pax", "TimeOfDay", "Leg", "SearchOptions", "CalendarWindow", - "SpecificDateSearch", "CalendarSearch", "CalendarFollowup", "Search", - "SearchResult", "Itinerary", - "CalendarResult", "CalendarDay", "DurationOption", + "ApiKeyResolutionError", + "Cabin", + "CalendarDay", + "CalendarFollowup", + "CalendarResult", + "CalendarSearch", + "CalendarWindow", + "DurationOption", + "Itinerary", + "Leg", "Location", - "to_wire", "WireBody", - "matrix_deep_link", "google_flights_url", + "MatrixApiError", + "MatrixClient", + "Pax", + "Search", + "SearchOptions", + "SearchResult", + "SpecificDateSearch", + "TimeOfDay", + "WireBody", + "google_flights_url", + "matrix_deep_link", + "resolve_api_key", + "to_wire", ] diff --git a/src/flight_cli/_api_key.py b/src/flight_cli/_api_key.py index 9668430..8517f23 100644 --- a/src/flight_cli/_api_key.py +++ b/src/flight_cli/_api_key.py @@ -11,7 +11,10 @@ 2. ~/.cache/flight-cli/.matrix-key (auto-cached after first bootstrap; 30-day TTL) 3. Bootstrap: scrape Matrix's SPA bundle live """ + from __future__ import annotations + +import contextlib import os import re import time @@ -27,9 +30,7 @@ _HOMEPAGE = "https://matrix.itasoftware.com/search" # Matrix's homepage references the SPA bundle via a protocol-relative URL # (`//www.gstatic.com/alkali/...`), not an absolute one. Accept either. -_BUNDLE_PATTERN = re.compile( - r'src="((?:https:)?//www\.gstatic\.com/alkali/[^"]+\.js)"' -) +_BUNDLE_PATTERN = re.compile(r'src="((?:https:)?//www\.gstatic\.com/alkali/[^"]+\.js)"') # The bundle defines MULTIPLE API keys: DEFAULT, matrix, matrix-nightly, # matrix-uat, People API, WAA, etc. We want specifically the production # Matrix-search key — tagged in the bundle as `.matrix="AIza..."` or @@ -38,17 +39,19 @@ # Must NOT match "matrix-nightly", "matrix-uat", "matrix-dev" etc. _KEY_PATTERN_MATRIX_PROD = re.compile( r'(?:[.])matrix\s*[=:]\s*["\'](AIzaSy[A-Za-z0-9_-]{33})["\']' - r'|' + r"|" r'["\']matrix["\']\s*:\s*["\'](AIzaSy[A-Za-z0-9_-]{33})["\']' ) # Looser fallback (any AIzaSy key in the bundle) — used only if the # targeted pattern fails. Likely returns the wrong key, but better than # nothing; the error message guides the user to fix manually. -_KEY_PATTERN_ANY = re.compile(r'AIzaSy[A-Za-z0-9_-]{33}') +_KEY_PATTERN_ANY = re.compile(r"AIzaSy[A-Za-z0-9_-]{33}") # Browser-y User-Agent so the homepage / static bundle requests look ordinary. -_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") +_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) class ApiKeyResolutionError(RuntimeError): @@ -76,26 +79,22 @@ def resolve_api_key(*, force_bootstrap: bool = False) -> str: def invalidate_cache() -> None: """Delete the cached API key. Call after a 403 from Matrix to force re-bootstrap on the next resolve_api_key() call.""" - try: + with contextlib.suppress(OSError): _CACHE_PATH.unlink(missing_ok=True) - except OSError: - pass # ──────────────────────────────── helpers ────────────────────────────────── + def _cache_fresh() -> bool: - return (_CACHE_PATH.exists() and - (time.time() - _CACHE_PATH.stat().st_mtime) < _CACHE_TTL_SECS) + return _CACHE_PATH.exists() and (time.time() - _CACHE_PATH.stat().st_mtime) < _CACHE_TTL_SECS def _write_cache(key: str) -> None: - try: + # Cache write failure is non-fatal; we'll just re-bootstrap next time. + with contextlib.suppress(OSError): _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) _CACHE_PATH.write_text(key + "\n") - except OSError: - # Cache write failure is non-fatal; we'll just re-bootstrap next time. - pass def _bootstrap_from_spa() -> str: @@ -109,10 +108,12 @@ def _bootstrap_from_spa() -> str: home = c.get(_HOMEPAGE).text bundle_match = _BUNDLE_PATTERN.search(home) if not bundle_match: - raise ApiKeyResolutionError(_help_text( - "Couldn't locate Matrix's SPA bundle URL in the homepage HTML " - "(the SPA may have been restructured)." - )) + raise ApiKeyResolutionError( + _help_text( + "Couldn't locate Matrix's SPA bundle URL in the homepage HTML " + "(the SPA may have been restructured)." + ) + ) bundle_url = bundle_match.group(1) if bundle_url.startswith("//"): bundle_url = "https:" + bundle_url @@ -121,14 +122,16 @@ def _bootstrap_from_spa() -> str: if m := _KEY_PATTERN_MATRIX_PROD.search(js): # group(1) or group(2) — only one alternative matches return m.group(1) or m.group(2) - raise ApiKeyResolutionError(_help_text( - "Found AIzaSy* keys in Matrix's SPA bundle but none tagged " - "as the prod 'matrix' key. Bundle structure may have changed." - )) + raise ApiKeyResolutionError( + _help_text( + "Found AIzaSy* keys in Matrix's SPA bundle but none tagged " + "as the prod 'matrix' key. Bundle structure may have changed." + ) + ) except httpx.HTTPError as e: - raise ApiKeyResolutionError(_help_text( - f"Network error contacting matrix.itasoftware.com: {e}" - )) from e + raise ApiKeyResolutionError( + _help_text(f"Network error contacting matrix.itasoftware.com: {e}") + ) from e def _help_text(reason: str) -> str: diff --git a/src/flight_cli/_http.py b/src/flight_cli/_http.py index 41c1be0..5862bdf 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -1,7 +1,15 @@ """Low-level HTTP transport: httpx + curl_cffi TLS fingerprint, rate-limit, retry, and an optional on-disk response cache for offline development.""" + from __future__ import annotations -import asyncio, hashlib, json, logging, os, pathlib + +import asyncio +import hashlib +import json +import logging +import os +import pathlib +from http import HTTPStatus from typing import Any, cast import httpx @@ -11,7 +19,12 @@ log = logging.getLogger(__name__) -DEFAULT_IMPERSONATE = "chrome" # alias → latest curl_cffi knows about +DEFAULT_IMPERSONATE = "chrome" # alias → latest curl_cffi knows about + +# 5xx + 408 are retryable per AGENTS.md (`http.HTTPStatus.*` is well-typed; +# `httpx.codes.*` is mis-typed as tuple — see AGENTS.md gotchas). +_SERVER_ERROR_FLOOR = HTTPStatus.INTERNAL_SERVER_ERROR.value # 500 +_REQUEST_TIMEOUT = HTTPStatus.REQUEST_TIMEOUT.value # 408 # Headers required by the Alkali backend. Values copied from real SPA captures. ALKALI_HEADERS = { @@ -29,12 +42,11 @@ def _is_retryable(exc: Exception) -> bool: """Retry on transient network errors and 5xx. Surface 4xx immediately.""" - if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, - httpx.RemoteProtocolError)): + if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)): return True if isinstance(exc, httpx.HTTPStatusError): s = exc.response.status_code - return s >= 500 or s == 408 + return s >= _SERVER_ERROR_FLOOR or s == _REQUEST_TIMEOUT return False @@ -62,7 +74,7 @@ def __init__( self._transport = AsyncCurlTransport( # `impersonate` is a curl_cffi BrowserTypeLiteral string at runtime; # accept any str from callers and let curl_cffi validate. - impersonate=cast(Any, impersonate), + impersonate=cast("Any", impersonate), curl_options={CurlOpt.FRESH_CONNECT: True}, default_headers=True, ) @@ -79,15 +91,14 @@ def __init__( if cache_dir is None: cache_dir = pathlib.Path( - os.environ.get("MATRIX_CACHE_DIR") - or pathlib.Path.home() / ".cache" / "flight-cli" + os.environ.get("MATRIX_CACHE_DIR") or pathlib.Path.home() / ".cache" / "flight-cli" ) self._cache_dir = pathlib.Path(cache_dir) self._cache_dir.mkdir(parents=True, exist_ok=True) self._cache_read = cache_read self._cache_write = cache_write - async def __aenter__(self) -> "HttpTransport": + async def __aenter__(self) -> HttpTransport: return self async def __aexit__(self, *_: object) -> None: @@ -113,21 +124,22 @@ def _cache_get(self, key: str) -> dict[str, Any] | None: if not p.exists(): return None try: - return json.loads(p.read_text()) - except Exception as e: + return cast("dict[str, Any]", json.loads(p.read_text())) + except (OSError, json.JSONDecodeError) as e: log.warning("cache read failed for %s: %s", key, e) return None def _cache_put(self, key: str, value: dict[str, Any]) -> None: try: self._cache_path(key).write_text(json.dumps(value, indent=2)) - except Exception as e: + except OSError as e: log.warning("cache write failed for %s: %s", key, e) # ───────────────────────────── public API ────────────────────────────── - async def get_json(self, url: str, *, params: dict[str, Any] | None = None, - cache: bool = True) -> dict[str, Any]: + async def get_json( + self, url: str, *, params: dict[str, Any] | None = None, cache: bool = True + ) -> dict[str, Any]: cache_key = self._cache_key( url + "?" + "&".join(f"{k}={v}" for k, v in (params or {}).items()), None, @@ -139,13 +151,21 @@ async def get_json(self, url: str, *, params: dict[str, Any] | None = None, return hit async with self._limiter, self._sem: - @stamina.retry(on=_is_retryable, attempts=3, wait_initial=2.0, - wait_jitter=1.0, wait_max=15.0, timeout=120.0) + + @stamina.retry( + on=_is_retryable, + attempts=3, + wait_initial=2.0, + wait_jitter=1.0, + wait_max=15.0, + timeout=120.0, + ) async def _send() -> httpx.Response: r = await self._client.get(url, params=params, headers=ALKALI_HEADERS) - if r.status_code >= 500: + if r.status_code >= _SERVER_ERROR_FLOOR: r.raise_for_status() return r + r = await _send() r.raise_for_status() @@ -154,9 +174,14 @@ async def _send() -> httpx.Response: self._cache_put(cache_key, data) return data - async def post_json(self, url: str, body: dict[str, Any], *, - params: dict[str, Any] | None = None, cache: bool = True, - ) -> dict[str, Any]: + async def post_json( + self, + url: str, + body: dict[str, Any], + *, + params: dict[str, Any] | None = None, + cache: bool = True, + ) -> dict[str, Any]: # NB: payload includes a unique-ish bgProgramResponse on captured # bodies; we strip that field before hashing so two semantically equal # requests share a cache entry. @@ -172,15 +197,26 @@ async def post_json(self, url: str, body: dict[str, Any], *, return hit async with self._limiter, self._sem: - @stamina.retry(on=_is_retryable, attempts=3, wait_initial=2.0, - wait_jitter=1.0, wait_max=15.0, timeout=240.0) + + @stamina.retry( + on=_is_retryable, + attempts=3, + wait_initial=2.0, + wait_jitter=1.0, + wait_max=15.0, + timeout=240.0, + ) async def _send() -> httpx.Response: r = await self._client.post( - url, params=params, json=body, headers=ALKALI_HEADERS, + url, + params=params, + json=body, + headers=ALKALI_HEADERS, ) - if r.status_code >= 500: + if r.status_code >= _SERVER_ERROR_FLOOR: r.raise_for_status() return r + r = await _send() r.raise_for_status() diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 39b66d2..e8f49da 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -8,36 +8,62 @@ flight gflight — Google Flights handoff via fli flight airport — IATA autocomplete """ + from __future__ import annotations -import asyncio, json, logging, re, sys + +import asyncio +import json +import logging +import re +import sys from datetime import date, datetime, timedelta -from typing import Annotated, Any, Optional +from typing import TYPE_CHECKING, Annotated, Any, cast import typer from rich.console import Console from rich.logging import RichHandler from rich.table import Table -from .client import MatrixClient, MatrixApiError +from .client import MatrixApiError, MatrixClient from .domain import ( - Cabin, Pax, Leg, SearchOptions, TimeOfDay, - SpecificDateSearch, CalendarSearch, CalendarFollowup, Search, + Cabin, + CalendarFollowup, + CalendarSearch, + CalendarWindow, + Leg, + Pax, + Search, + SearchOptions, + SpecificDateSearch, + TimeOfDay, ) -from .links import matrix_deep_link, google_flights_url -from .models import SearchResult, CalendarResult, Slice +from .links import google_flights_url, matrix_deep_link + +if TYPE_CHECKING: + from .models import CalendarResult, Location, SearchResult, Slice -app = typer.Typer(add_completion=False, rich_markup_mode="rich", - help="CLI for ITA Matrix's Alkali backend.") +# Tuple-length sentinels for `--slice` parser (`ORIGIN-DEST:DATE[:r=...:e=...]`). +_SLICE_MIN_PARTS = 2 +_SLICE_MAX_PARTS = 3 + +app = typer.Typer( + add_completion=False, rich_markup_mode="rich", help="CLI for ITA Matrix's Alkali backend." +) console = Console() err = Console(stderr=True) @app.callback() def main( - verbose: Annotated[int, typer.Option( - "--verbose", "-v", count=True, - help="Increase log verbosity (-v=INFO, -vv=DEBUG). Logs go to stderr.", - )] = 0, + verbose: Annotated[ + int, + typer.Option( + "--verbose", + "-v", + count=True, + help="Increase log verbosity (-v=INFO, -vv=DEBUG). Logs go to stderr.", + ), + ] = 0, ) -> None: # _http.py emits log.warning/debug for cache hits/misses, retry attempts, # and rate-limit pauses. Without a handler those go nowhere. @@ -52,12 +78,13 @@ def main( # ─────────────────────────── argument parsers ────────────────────────────── + def _parse_date(s: str) -> date: try: return datetime.strptime(s, "%Y-%m-%d").date() - except ValueError: + except ValueError as e: err.print(f"[red]bad date {s!r}; use YYYY-MM-DD[/]") - raise typer.Exit(2) + raise typer.Exit(2) from e def _parse_duration(s: str) -> tuple[int, int]: @@ -78,16 +105,22 @@ def _parse_times(s: str | None) -> tuple[TimeOfDay, ...]: return () out: list[TimeOfDay] = [] aliases = { - "early": TimeOfDay.EARLY_MORNING, "early_morning": TimeOfDay.EARLY_MORNING, - "morning": TimeOfDay.MORNING, "midday": TimeOfDay.MIDDAY, - "noon": TimeOfDay.MIDDAY, "afternoon": TimeOfDay.AFTERNOON, - "evening": TimeOfDay.EVENING, "night": TimeOfDay.NIGHT, + "early": TimeOfDay.EARLY_MORNING, + "early_morning": TimeOfDay.EARLY_MORNING, + "morning": TimeOfDay.MORNING, + "midday": TimeOfDay.MIDDAY, + "noon": TimeOfDay.MIDDAY, + "afternoon": TimeOfDay.AFTERNOON, + "evening": TimeOfDay.EVENING, + "night": TimeOfDay.NIGHT, } for raw in s.split(","): key = raw.strip().lower().replace("-", "_") if key not in aliases: - err.print(f"[red]bad time-of-day {raw!r}; choose: " - f"early,morning,midday,afternoon,evening,night[/]") + err.print( + f"[red]bad time-of-day {raw!r}; choose: " + f"early,morning,midday,afternoon,evening,night[/]" + ) raise typer.Exit(2) out.append(aliases[key]) return tuple(out) @@ -96,11 +129,17 @@ def _parse_times(s: str | None) -> tuple[TimeOfDay, ...]: def _resolve_cabin(name: str) -> Cabin: norm = name.lower().replace("_", "").replace("-", "").replace(" ", "") aliases = { - "coach": Cabin.COACH, "economy": Cabin.COACH, "y": Cabin.COACH, - "premiumcoach": Cabin.PREMIUM_COACH, "premiumeconomy": Cabin.PREMIUM_COACH, - "premium": Cabin.PREMIUM_COACH, "w": Cabin.PREMIUM_COACH, - "business": Cabin.BUSINESS, "j": Cabin.BUSINESS, - "first": Cabin.FIRST, "f": Cabin.FIRST, + "coach": Cabin.COACH, + "economy": Cabin.COACH, + "y": Cabin.COACH, + "premiumcoach": Cabin.PREMIUM_COACH, + "premiumeconomy": Cabin.PREMIUM_COACH, + "premium": Cabin.PREMIUM_COACH, + "w": Cabin.PREMIUM_COACH, + "business": Cabin.BUSINESS, + "j": Cabin.BUSINESS, + "first": Cabin.FIRST, + "f": Cabin.FIRST, } if norm in aliases: return aliases[norm] @@ -108,15 +147,30 @@ def _resolve_cabin(name: str) -> Cabin: raise typer.Exit(2) -def _build_options(*, cabin: str, adults: int, children: int, seniors: int, - youth: int, infants_in_seat: int, infants_in_lap: int, - stops: int | None, allow_airport_changes: bool, - show_only_available: bool, - page_size: int = 25) -> SearchOptions: +def _build_options( + *, + cabin: str, + adults: int, + children: int, + seniors: int, + youth: int, + infants_in_seat: int, + infants_in_lap: int, + stops: int | None, + allow_airport_changes: bool, + show_only_available: bool, + page_size: int = 25, +) -> SearchOptions: return SearchOptions( cabin=_resolve_cabin(cabin), - pax=Pax(adults=adults, children=children, seniors=seniors, youth=youth, - infants_in_seat=infants_in_seat, infants_in_lap=infants_in_lap), + pax=Pax( + adults=adults, + children=children, + seniors=seniors, + youth=youth, + infants_in_seat=infants_in_seat, + infants_in_lap=infants_in_lap, + ), max_extra_stops=stops, allow_airport_changes=allow_airport_changes, show_only_available=show_only_available, @@ -126,17 +180,24 @@ def _build_options(*, cabin: str, adults: int, children: int, seniors: int, # ─────────────────────────── shared execution ────────────────────────────── -def _run(search: Search, rps: float, impersonate: str, no_cache: bool): - async def go(): + +def _run( + search: Search, + rps: float, + impersonate: str, + no_cache: bool, +) -> SearchResult | CalendarResult: + async def go() -> SearchResult | CalendarResult: async with MatrixClient(rps=rps, impersonate=impersonate) as c: return await c.execute(search, cache=not no_cache) + try: return asyncio.run(go()) except MatrixApiError as e: err.print(f"[red]Matrix returned an error ({e.kind}):[/] {e.message}") if e.request_id: err.print(f"[dim]request_id: {e.request_id}[/]") - raise typer.Exit(1) + raise typer.Exit(1) from e def _emit_urls(search: Search, *, matrix_url: bool, google_url: bool) -> None: @@ -145,27 +206,29 @@ def _emit_urls(search: Search, *, matrix_url: bool, google_url: bool) -> None: console.print("[dim]Matrix deep-link:[/]") console.print(f" [link]{matrix_deep_link(search)}[/]") if google_url: + # `google_flights_url` builds protobuf-encoded tfs= URLs via fast_flights. + # That library has no documented exception surface — catch broadly so a + # missing IATA or unsupported variant degrades the URL line, not the run. try: console.print("[dim]Google Flights (tfs= structured):[/]") console.print(f" [link]{google_flights_url(search)}[/]") - except Exception as e: + except Exception as e: # noqa: BLE001 - third-party undocumented errors; non-fatal fallback console.print(f"[dim]Google Flights link: {e}[/]") # ─────────────────────────── result renderers ────────────────────────────── + def _render_search(res: SearchResult) -> None: if res.solution_count == 0: console.print("[yellow]No solutions returned.[/]") return cheapest = res.cheapest_price or "—" - console.print(f"[bold]{res.solution_count} solutions[/] · " - f"cheapest: [bold cyan]{cheapest}[/]") + console.print(f"[bold]{res.solution_count} solutions[/] · cheapest: [bold cyan]{cheapest}[/]") cm = res.carrier_stop_matrix if cm and cm.columns and cm.rows: - t = Table(title="Carrier × stops grid", show_header=True, - header_style="bold magenta") + t = Table(title="Carrier x stops grid", show_header=True, header_style="bold magenta") t.add_column("stops") for col in cm.columns: code = col.label.code if col.label else "?" @@ -175,8 +238,7 @@ def _render_search(res: SearchResult) -> None: cells = [str(row.label) if row.label is not None else "?"] for c in row.cells: p = c.min_price or "—" - mark = "★" if c.min_price_in_grid else ( - "·" if c.min_price_in_row else "") + mark = "★" if c.min_price_in_grid else ("·" if c.min_price_in_row else "") cells.append(f"{p} {mark}") t.add_row(*cells) console.print(t) @@ -191,36 +253,46 @@ def _render_search(res: SearchResult) -> None: itn = it.itinerary slcs: list[Slice] = itn.slices if itn else [] it_carriers = ",".join((c.code or "?") for c in (itn.carriers if itn else [])) + def _fmt(s: Slice) -> str: dep = s.departure or "" arr = s.arrival or "" dur_min = s.duration or 0 - dur = f"{dur_min//60}h{dur_min%60:02d}m" if dur_min else "" + dur = f"{dur_min // 60}h{dur_min % 60:02d}m" if dur_min else "" o = (s.origin.code if s.origin else None) or "?" d = (s.destination.code if s.destination else None) or "?" - return (f"{o}→{d} " - f"{'/'.join(s.flights) or '?'} " - f"{dep[:16]}→{arr[:16]} {dur}") + return f"{o}→{d} {'/'.join(s.flights) or '?'} {dep[:16]}→{arr[:16]} {dur}" + out = _fmt(slcs[0]) if slcs else "—" ret = _fmt(slcs[1]) if len(slcs) > 1 else "—" st.add_row(str(i), it.price or "—", it_carriers or "?", out, ret) console.print(st) -def _render_calendar(res: CalendarResult, *, dmin: int, dmax: int, - origin: tuple[str, ...], destination: tuple[str, ...], - sd: date, ed: date) -> None: +def _render_calendar( + res: CalendarResult, + *, + dmin: int, + dmax: int, + origin: tuple[str, ...], + destination: tuple[str, ...], + sd: date, + ed: date, +) -> None: if res.solution_count == 0 or not res.priced_days: - console.print("[yellow]Calendar empty.[/] Matrix's calendar mode " - "brownouts regularly; retry, or use [bold]flight fare[/] " - "for a single date.") + console.print( + "[yellow]Calendar empty.[/] Matrix's calendar mode " + "brownouts regularly; retry, or use [bold]flight fare[/] " + "for a single date." + ) return - console.print(f"[bold]{res.solution_count} solutions[/] · " - f"overall cheapest: [bold cyan]{res.cheapest_price}[/] · " - f"window {sd.isoformat()} → {ed.isoformat()} · " - f"duration {dmin}-{dmax} nights") - title = (f"{','.join(origin)} → {','.join(destination)}: " - "lowest fare per departure day") + console.print( + f"[bold]{res.solution_count} solutions[/] · " + f"overall cheapest: [bold cyan]{res.cheapest_price}[/] · " + f"window {sd.isoformat()} → {ed.isoformat()} · " + f"duration {dmin}-{dmax} nights" + ) + title = f"{','.join(origin)} → {','.join(destination)}: lowest fare per departure day" t = Table(title=title, show_header=True, header_style="bold green") t.add_column("departure", justify="right") t.add_column("min", justify="right") @@ -246,33 +318,63 @@ def _render_calendar(res: CalendarResult, *, dmin: int, dmax: int, @app.command() def fare( - origin: Annotated[Optional[str], typer.Argument(help="Origin IATA (comma-list ok)")] = None, - destination: Annotated[Optional[str], typer.Argument(help="Destination IATA (comma-list ok)")] = None, - dep: Annotated[Optional[str], typer.Option("--dep", help="YYYY-MM-DD")] = None, - ret: Annotated[Optional[str], typer.Option("--return", "-r", help="YYYY-MM-DD; omit for one-way")] = None, - slice_specs: Annotated[Optional[list[str]], typer.Option( - "--slice", "-s", - help="Multi-city: 'ORIG-DEST:DATE[:r=ROUTING:e=EXT]'. Repeat.")] = None, + origin: Annotated[ + str | None, + typer.Argument(help="Origin IATA (comma-list ok)"), + ] = None, + destination: Annotated[ + str | None, + typer.Argument(help="Destination IATA (comma-list ok)"), + ] = None, + dep: Annotated[str | None, typer.Option("--dep", help="YYYY-MM-DD")] = None, + ret: Annotated[ + str | None, + typer.Option("--return", "-r", help="YYYY-MM-DD; omit for one-way"), + ] = None, + slice_specs: Annotated[ + list[str] | None, + typer.Option( + "--slice", "-s", help="Multi-city: 'ORIG-DEST:DATE[:r=ROUTING:e=EXT]'. Repeat." + ), + ] = None, cabin: str = "economy", - adults: int = 1, children: int = 0, seniors: int = 0, youth: int = 0, + adults: int = 1, + children: int = 0, + seniors: int = 0, + youth: int = 0, inf_seat: Annotated[int, typer.Option("--inf-seat")] = 0, inf_lap: Annotated[int, typer.Option("--inf-lap")] = 0, - routing: Annotated[Optional[str], typer.Option("--routing", - help="Routing language ('LH+', 'BA AA', '[F* X F*]')")] = None, - extension: Annotated[Optional[str], typer.Option("--extension", "--ext", - help="Extension codes ('MAXCONNECT 2:00', 'MAXSTOPS 1')")] = None, - depart_times: Annotated[Optional[str], typer.Option("--depart-times", - help="Preferred outbound times-of-day (comma list: morning,evening)")] = None, - return_times: Annotated[Optional[str], typer.Option("--return-times", - help="Preferred return times-of-day")] = None, - stops: Annotated[Optional[int], typer.Option("--stops", - help="Max extra stops beyond nonstop (0=nonstop only, 1=up to 1 stop, ...)")] = None, + routing: Annotated[ + str | None, typer.Option("--routing", help="Routing language ('LH+', 'BA AA', '[F* X F*]')") + ] = None, + extension: Annotated[ + str | None, + typer.Option( + "--extension", "--ext", help="Extension codes ('MAXCONNECT 2:00', 'MAXSTOPS 1')" + ), + ] = None, + depart_times: Annotated[ + str | None, + typer.Option( + "--depart-times", help="Preferred outbound times-of-day (comma list: morning,evening)" + ), + ] = None, + return_times: Annotated[ + str | None, typer.Option("--return-times", help="Preferred return times-of-day") + ] = None, + stops: Annotated[ + int | None, + typer.Option( + "--stops", help="Max extra stops beyond nonstop (0=nonstop only, 1=up to 1 stop, ...)" + ), + ] = None, allow_airport_changes: bool = typer.Option( - True, "--allow-airport-changes/--no-airport-changes"), - only_available: bool = typer.Option( - True, "--only-available/--include-unavailable"), + True, "--allow-airport-changes/--no-airport-changes" + ), + only_available: bool = typer.Option(True, "--only-available/--include-unavailable"), page_size: int = typer.Option(10, "--n", "-n"), - rps: float = _RPS_OPT, impersonate: str = _IMPERSONATE_OPT, + rps: float = _RPS_OPT, + impersonate: str = _IMPERSONATE_OPT, json_out: bool = typer.Option(False, "--json"), matrix_url: bool = typer.Option(True, "--matrix-url/--no-matrix-url"), google_url: bool = typer.Option(True, "--google-url/--no-google-url"), @@ -284,30 +386,50 @@ def fare( elif origin and destination and dep: out_times = _parse_times(depart_times) ret_times = _parse_times(return_times) - legs = (Leg.of(_parse_iata_list(origin), _parse_iata_list(destination), - _parse_date(dep), - route_language=routing, extension=extension, - time_ranges=out_times),) + legs = ( + Leg.of( + _parse_iata_list(origin), + _parse_iata_list(destination), + _parse_date(dep), + route_language=routing, + extension=extension, + time_ranges=out_times, + ), + ) if ret: - legs += (Leg.of(_parse_iata_list(destination), _parse_iata_list(origin), - _parse_date(ret), - route_language=routing, extension=extension, - time_ranges=ret_times),) + legs += ( + Leg.of( + _parse_iata_list(destination), + _parse_iata_list(origin), + _parse_date(ret), + route_language=routing, + extension=extension, + time_ranges=ret_times, + ), + ) else: err.print("[red]Specify --slice ... or origin destination --dep[/]") raise typer.Exit(2) opts = _build_options( - cabin=cabin, adults=adults, children=children, seniors=seniors, - youth=youth, infants_in_seat=inf_seat, infants_in_lap=inf_lap, - stops=stops, allow_airport_changes=allow_airport_changes, - show_only_available=only_available, page_size=page_size, + cabin=cabin, + adults=adults, + children=children, + seniors=seniors, + youth=youth, + infants_in_seat=inf_seat, + infants_in_lap=inf_lap, + stops=stops, + allow_airport_changes=allow_airport_changes, + show_only_available=only_available, + page_size=page_size, ) search = SpecificDateSearch(legs=legs, options=opts) - res = _run(search, rps, impersonate, no_cache) - assert isinstance(res, SearchResult) # SpecificDateSearch → SearchResult + # SpecificDateSearch → SearchResult by client._parse_response dispatch. + res = cast("SearchResult", _run(search, rps, impersonate, no_cache)) if json_out: - sys.stdout.write(json.dumps(res.raw, indent=2)); return + sys.stdout.write(json.dumps(res.raw, indent=2)) + return _render_search(res) _emit_urls(search, matrix_url=matrix_url, google_url=google_url) @@ -315,42 +437,55 @@ def fare( def _parse_slice_spec(s: str) -> Leg: """Parse 'JFK-LHR:2026-08-15[:r=LH+:e=MAXCONNECT 2:00]'.""" parts = s.split(":", 2) - if len(parts) < 2: - raise typer.BadParameter( - f"slice {s!r} should be ORIGIN-DEST:DATE[:r=...:e=...]") + if len(parts) < _SLICE_MIN_PARTS: + raise typer.BadParameter(f"slice {s!r} should be ORIGIN-DEST:DATE[:r=...:e=...]") od, dt = parts[0], parts[1] o, d = od.split("-", 1) routing = extension = None - if len(parts) == 3: + if len(parts) == _SLICE_MAX_PARTS: for chunk in re.split(r":(?=[re]=)", parts[2]): - if chunk.startswith("r="): routing = chunk[2:] - elif chunk.startswith("e="): extension = chunk[2:] - return Leg.of(o, d, _parse_date(dt), - route_language=routing, extension=extension) + if chunk.startswith("r="): + routing = chunk[2:] + elif chunk.startswith("e="): + extension = chunk[2:] + return Leg.of(o, d, _parse_date(dt), route_language=routing, extension=extension) @app.command() def calendar( - origin: Annotated[str, typer.Argument(help="Origin IATA (comma-list for multi-airport)")], + origin: Annotated[ + str, + typer.Argument(help="Origin IATA (comma-list for multi-airport)"), + ], destination: Annotated[str, typer.Argument(help="Destination IATA (comma-list)")], start: Annotated[str, typer.Option("--start", help="Window start YYYY-MM-DD")], - end: Annotated[Optional[str], typer.Option("--end", help="Window end (default: start+30d)")] = None, - duration: Annotated[str, typer.Option("--duration", "-d", help="Nights, '5' or '5-7'")] = "5-7", + end: Annotated[ + str | None, + typer.Option("--end", help="Window end (default: start+30d)"), + ] = None, + duration: Annotated[ + str, + typer.Option("--duration", "-d", help="Nights, '5' or '5-7'"), + ] = "5-7", one_way: bool = typer.Option(False, "--one-way"), cabin: str = "economy", - adults: int = 1, children: int = 0, seniors: int = 0, youth: int = 0, - routing: Optional[str] = typer.Option(None, "--routing"), - extension: Optional[str] = typer.Option(None, "--extension", "--ext"), - routing_return: Optional[str] = typer.Option(None, "--routing-ret"), - extension_return: Optional[str] = typer.Option(None, "--ext-ret"), - depart_times: Optional[str] = typer.Option(None, "--depart-times"), - return_times: Optional[str] = typer.Option(None, "--return-times"), - stops: Optional[int] = typer.Option(None, "--stops"), + adults: int = 1, + children: int = 0, + seniors: int = 0, + youth: int = 0, + routing: str | None = typer.Option(None, "--routing"), + extension: str | None = typer.Option(None, "--extension", "--ext"), + routing_return: str | None = typer.Option(None, "--routing-ret"), + extension_return: str | None = typer.Option(None, "--ext-ret"), + depart_times: str | None = typer.Option(None, "--depart-times"), + return_times: str | None = typer.Option(None, "--return-times"), + stops: int | None = typer.Option(None, "--stops"), allow_airport_changes: bool = typer.Option( - True, "--allow-airport-changes/--no-airport-changes"), - only_available: bool = typer.Option( - True, "--only-available/--include-unavailable"), - rps: float = _RPS_OPT, impersonate: str = _IMPERSONATE_OPT, + True, "--allow-airport-changes/--no-airport-changes" + ), + only_available: bool = typer.Option(True, "--only-available/--include-unavailable"), + rps: float = _RPS_OPT, + impersonate: str = _IMPERSONATE_OPT, json_out: bool = typer.Option(False, "--json"), matrix_url: bool = typer.Option(True, "--matrix-url/--no-matrix-url"), google_url: bool = typer.Option(False, "--google-url/--no-google-url"), @@ -365,31 +500,41 @@ def calendar( out_times = _parse_times(depart_times) ret_times = _parse_times(return_times) - out_leg = Leg.of(origins, dests, route_language=routing, - extension=extension, time_ranges=out_times) + out_leg = Leg.of( + origins, dests, route_language=routing, extension=extension, time_ranges=out_times + ) legs = (out_leg,) if not one_way: - legs += (Leg.of(dests, origins, - route_language=routing_return or routing, - extension=extension_return or extension, - time_ranges=ret_times),) + legs += ( + Leg.of( + dests, + origins, + route_language=routing_return or routing, + extension=extension_return or extension, + time_ranges=ret_times, + ), + ) - from .domain import CalendarWindow opts = _build_options( - cabin=cabin, adults=adults, children=children, seniors=seniors, - youth=youth, infants_in_seat=0, infants_in_lap=0, - stops=stops, allow_airport_changes=allow_airport_changes, + cabin=cabin, + adults=adults, + children=children, + seniors=seniors, + youth=youth, + infants_in_seat=0, + infants_in_lap=0, + stops=stops, + allow_airport_changes=allow_airport_changes, show_only_available=only_available, ) - window = CalendarWindow(start=sd, end=ed, - duration_min=dmin, duration_max=dmax) + window = CalendarWindow(start=sd, end=ed, duration_min=dmin, duration_max=dmax) search = CalendarSearch(legs=legs, options=opts, window=window) - res = _run(search, rps, impersonate, no_cache) - assert isinstance(res, CalendarResult) # CalendarSearch → CalendarResult + # CalendarSearch → CalendarResult by client._parse_response dispatch. + res = cast("CalendarResult", _run(search, rps, impersonate, no_cache)) if json_out: - sys.stdout.write(json.dumps(res.raw, indent=2)); return - _render_calendar(res, dmin=dmin, dmax=dmax, - origin=origins, destination=dests, sd=sd, ed=ed) + sys.stdout.write(json.dumps(res.raw, indent=2)) + return + _render_calendar(res, dmin=dmin, dmax=dmax, origin=origins, destination=dests, sd=sd, ed=ed) _emit_urls(search, matrix_url=matrix_url, google_url=google_url) @@ -398,30 +543,39 @@ def detail( origin: Annotated[str, typer.Argument()], destination: Annotated[str, typer.Argument()], dep: Annotated[str, typer.Option("--dep", help="Departure YYYY-MM-DD")], - ret: Annotated[Optional[str], typer.Option("--return", "-r")] = None, - start: Annotated[Optional[str], typer.Option("--start", - help="Original calendar window start (defaults to --dep)")] = None, - end: Annotated[Optional[str], typer.Option("--end", - help="Original calendar window end (defaults to start+30d)")] = None, - duration: Annotated[str, typer.Option("--duration", "-d", - help="Original duration range")] = "5-7", + ret: Annotated[str | None, typer.Option("--return", "-r")] = None, + start: Annotated[ + str | None, + typer.Option("--start", help="Original calendar window start (defaults to --dep)"), + ] = None, + end: Annotated[ + str | None, + typer.Option("--end", help="Original calendar window end (defaults to start+30d)"), + ] = None, + duration: Annotated[ + str, typer.Option("--duration", "-d", help="Original duration range") + ] = "5-7", cabin: str = "economy", - adults: int = 1, children: int = 0, seniors: int = 0, youth: int = 0, - routing: Optional[str] = typer.Option(None, "--routing"), - extension: Optional[str] = typer.Option(None, "--extension", "--ext"), - routing_return: Optional[str] = typer.Option(None, "--routing-ret"), - extension_return: Optional[str] = typer.Option(None, "--ext-ret"), - stops: Optional[int] = typer.Option(None, "--stops"), + adults: int = 1, + children: int = 0, + seniors: int = 0, + youth: int = 0, + routing: str | None = typer.Option(None, "--routing"), + extension: str | None = typer.Option(None, "--extension", "--ext"), + routing_return: str | None = typer.Option(None, "--routing-ret"), + extension_return: str | None = typer.Option(None, "--ext-ret"), + stops: int | None = typer.Option(None, "--stops"), allow_airport_changes: bool = typer.Option( - True, "--allow-airport-changes/--no-airport-changes"), - rps: float = _RPS_OPT, impersonate: str = _IMPERSONATE_OPT, + True, "--allow-airport-changes/--no-airport-changes" + ), + rps: float = _RPS_OPT, + impersonate: str = _IMPERSONATE_OPT, json_out: bool = typer.Option(False, "--json"), matrix_url: bool = typer.Option(True, "--matrix-url/--no-matrix-url"), google_url: bool = typer.Option(True, "--google-url/--no-google-url"), no_cache: bool = typer.Option(False, "--no-cache"), ) -> None: """Phase-2 of the calendar flow: full itineraries for a picked date.""" - from .domain import CalendarWindow origins = _parse_iata_list(origin) dests = _parse_iata_list(destination) dep_d = _parse_date(dep) @@ -430,26 +584,37 @@ def detail( ed = _parse_date(end) if end else sd + timedelta(days=30) dmin, dmax = _parse_duration(duration) - legs = (Leg.of(origins, dests, dep_d, - route_language=routing, extension=extension),) + legs = (Leg.of(origins, dests, dep_d, route_language=routing, extension=extension),) if ret_d: - legs += (Leg.of(dests, origins, ret_d, - route_language=routing_return or routing, - extension=extension_return or extension),) + legs += ( + Leg.of( + dests, + origins, + ret_d, + route_language=routing_return or routing, + extension=extension_return or extension, + ), + ) opts = _build_options( - cabin=cabin, adults=adults, children=children, seniors=seniors, - youth=youth, infants_in_seat=0, infants_in_lap=0, - stops=stops, allow_airport_changes=allow_airport_changes, + cabin=cabin, + adults=adults, + children=children, + seniors=seniors, + youth=youth, + infants_in_seat=0, + infants_in_lap=0, + stops=stops, + allow_airport_changes=allow_airport_changes, show_only_available=True, ) - window = CalendarWindow(start=sd, end=ed, - duration_min=dmin, duration_max=dmax) + window = CalendarWindow(start=sd, end=ed, duration_min=dmin, duration_max=dmax) search = CalendarFollowup(legs=legs, options=opts, window=window) - res = _run(search, rps, impersonate, no_cache) - assert isinstance(res, SearchResult) # CalendarFollowup → SearchResult + # CalendarFollowup → SearchResult by client._parse_response dispatch. + res = cast("SearchResult", _run(search, rps, impersonate, no_cache)) if json_out: - sys.stdout.write(json.dumps(res.raw, indent=2)); return + sys.stdout.write(json.dumps(res.raw, indent=2)) + return _render_search(res) _emit_urls(search, matrix_url=matrix_url, google_url=google_url) @@ -459,65 +624,87 @@ def gflight( origin: Annotated[str, typer.Argument()], destination: Annotated[str, typer.Argument()], dep: Annotated[str, typer.Option("--dep")], - ret: Annotated[Optional[str], typer.Option("--return", "-r")] = None, + ret: Annotated[str | None, typer.Option("--return", "-r")] = None, cabin: str = "economy", - adults: int = 1, children: int = 0, + adults: int = 1, + children: int = 0, top_n: Annotated[int, typer.Option("--n", "-n")] = 5, json_out: bool = typer.Option(False, "--json"), ) -> None: """Query Google Flights for a city-pair you discovered with flight calendar.""" - from .fli_bridge import run_gflight_search + + # the CLI shouldn't pay that startup cost. + from .fli_bridge import run_gflight_search # noqa: PLC0415 + legs = (Leg.of(origin, destination, _parse_date(dep)),) if ret: legs += (Leg.of(destination, origin, _parse_date(ret)),) opts = _build_options( - cabin=cabin, adults=adults, children=children, seniors=0, youth=0, - infants_in_seat=0, infants_in_lap=0, - stops=None, allow_airport_changes=True, show_only_available=True, + cabin=cabin, + adults=adults, + children=children, + seniors=0, + youth=0, + infants_in_seat=0, + infants_in_lap=0, + stops=None, + allow_airport_changes=True, + show_only_available=True, ) search = SpecificDateSearch(legs=legs, options=opts) + # fli wraps selenium/selectolax and has no documented exception surface; + # catching broadly here translates any underlying failure into a clean CLI + # exit instead of an opaque traceback. try: - results = run_gflight_search(search, top_n=top_n) + # fli has no type stubs; treat its return as opaque at this boundary + # and use duck-typed attribute access below. + results: list[Any] = run_gflight_search(search, top_n=top_n) except Exception as e: err.print(f"[red]Google Flights query failed:[/] {e}") - raise typer.Exit(1) + raise typer.Exit(1) from e if not results: console.print("[yellow]Google Flights returned no results.[/]") return + # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, + # reportUnknownArgumentType, reportUnknownParameterType] + # fli/fast_flights have no type stubs; results are duck-typed pydantic + # models. Suppressing the noisy unknown-type chatter for this rendering + # block keeps the boundary localized. if json_out: out: list[Any] = [] for r in results: if isinstance(r, tuple): - out.append([fr.model_dump(mode="json") for fr in r]) + out.append([fr.model_dump(mode="json") for fr in r]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] else: out.append(r.model_dump(mode="json")) sys.stdout.write(json.dumps(out, indent=2, default=str)) return - t = Table(title=f"Google Flights · {origin.upper()}→{destination.upper()}" - + (f" + return" if ret else ""), - show_header=True, header_style="bold green") + t = Table( + title=f"Google Flights · {origin.upper()}→{destination.upper()}" + + (" + return" if ret else ""), + show_header=True, + header_style="bold green", + ) t.add_column("#", justify="right") t.add_column("price", justify="right") t.add_column("stops", justify="right") t.add_column("duration") t.add_column("legs") for i, r in enumerate(results[:top_n], 1): - items = list(r) if isinstance(r, tuple) else [r] + items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType] for j, fr in enumerate(items): label = f"{i}{'a' if j == 0 else 'b'}" if len(items) > 1 else str(i) legs_str = " → ".join( - f"{getattr(leg.airline,'name',leg.airline)} " - f"{getattr(leg,'flight_number','?')}" + f"{getattr(leg.airline, 'name', leg.airline)} {getattr(leg, 'flight_number', '?')}" for leg in fr.legs ) mins = fr.duration - dur = f"{mins//60}h{mins%60:02d}m" - t.add_row(label, f"{fr.currency or 'USD'}{fr.price:.2f}", - str(fr.stops), dur, legs_str) + dur = f"{mins // 60}h{mins % 60:02d}m" + t.add_row(label, f"{fr.currency or 'USD'}{fr.price:.2f}", str(fr.stops), dur, legs_str) console.print(t) @@ -527,18 +714,22 @@ def airport( impersonate: str = _IMPERSONATE_OPT, ) -> None: """Look up airports by partial name or IATA code.""" - async def go(): + + async def go() -> list[Location]: async with MatrixClient(impersonate=impersonate) as c: return await c.airports(query) + locs = asyncio.run(go()) if not locs: - console.print("[yellow]No matches.[/]"); return - t = Table(title=f"Airport lookup: {query!r}", show_header=True, - header_style="bold blue") - t.add_column("code"); t.add_column("name"); t.add_column("city"); t.add_column("tz") + console.print("[yellow]No matches.[/]") + return + t = Table(title=f"Airport lookup: {query!r}", show_header=True, header_style="bold blue") + t.add_column("code") + t.add_column("name") + t.add_column("city") + t.add_column("tz") for loc in locs: - t.add_row(loc.code, loc.display_name or "", loc.city_name or "", - loc.timezone or "") + t.add_row(loc.code, loc.display_name or "", loc.city_name or "", loc.timezone or "") console.print(t) diff --git a/src/flight_cli/client.py b/src/flight_cli/client.py index 7c0cacd..41fbf08 100644 --- a/src/flight_cli/client.py +++ b/src/flight_cli/client.py @@ -2,19 +2,26 @@ the domain Search into a wire body, hits the endpoint, returns a parsed response. All routing/filtering/mode-dispatch lives in `wire.to_wire()`. """ + from __future__ import annotations + +from http import HTTPStatus from typing import Any, assert_never, cast import httpx -from .domain import Search, SpecificDateSearch, CalendarSearch, CalendarFollowup -from .models import SearchResult, CalendarResult, Location -from .wire import to_wire +from ._api_key import ApiKeyResolutionError, invalidate_cache, resolve_api_key from ._http import HttpTransport -from ._api_key import ( - resolve_api_key, invalidate_cache, - ApiKeyResolutionError as ApiKeyResolutionError, -) +from .domain import CalendarFollowup, CalendarSearch, Search, SpecificDateSearch +from .models import CalendarResult, Location, SearchResult +from .wire import to_wire + +# Re-export so callers can `from flight_cli.client import ApiKeyResolutionError`. +__all__ = [ + "ApiKeyResolutionError", + "MatrixApiError", + "MatrixClient", +] BASE = "https://content-alkalimatrix-pa.googleapis.com" SEARCH_URL = f"{BASE}/v1/search" @@ -22,9 +29,14 @@ class MatrixApiError(Exception): """Matrix's validation errors come back as HTTP 200 + `{"error": ...}`.""" - def __init__(self, message: str, kind: str = "input", - request_id: str | None = None, - raw: dict[str, Any] | None = None) -> None: + + def __init__( + self, + message: str, + kind: str = "input", + request_id: str | None = None, + raw: dict[str, Any] | None = None, + ) -> None: super().__init__(message) self.message = message self.kind = kind @@ -35,7 +47,7 @@ def __init__(self, message: str, kind: str = "input", def _raise_if_api_error(data: dict[str, Any]) -> None: err_raw = data.get("error") if isinstance(err_raw, dict) and ("message" in err_raw or "code" in err_raw): - err = cast(dict[str, Any], err_raw) + err = cast("dict[str, Any]", err_raw) raise MatrixApiError( message=err.get("message", "unknown error"), kind=err.get("type") or err.get("status") or "unknown", @@ -76,12 +88,16 @@ def __init__( # Never hardcoded in the source. self._api_key = api_key or resolve_api_key() self._http = HttpTransport( - impersonate=impersonate, rps=rps, concurrency=concurrency, - timeout=timeout, cache_dir=cache_dir, - cache_read=cache_read, cache_write=cache_write, + impersonate=impersonate, + rps=rps, + concurrency=concurrency, + timeout=timeout, + cache_dir=cache_dir, + cache_read=cache_read, + cache_write=cache_write, ) - async def __aenter__(self) -> "MatrixClient": + async def __aenter__(self) -> MatrixClient: return self async def __aexit__(self, *_: object) -> None: @@ -104,21 +120,25 @@ async def execute(self, search: Search, *, cache: bool = True) -> SearchResult | body = to_wire(search).as_json() try: data = await self._http.post_json( - SEARCH_URL, body, params={"key": self._api_key, "alt": "json"}, + SEARCH_URL, + body, + params={"key": self._api_key, "alt": "json"}, cache=cache, ) except httpx.HTTPStatusError as e: - if e.response.status_code != 403: + if e.response.status_code != HTTPStatus.FORBIDDEN: raise invalidate_cache() self._api_key = resolve_api_key(force_bootstrap=True) try: data = await self._http.post_json( - SEARCH_URL, body, params={"key": self._api_key, "alt": "json"}, + SEARCH_URL, + body, + params={"key": self._api_key, "alt": "json"}, cache=cache, ) except httpx.HTTPStatusError as e2: - if e2.response.status_code == 403: + if e2.response.status_code == HTTPStatus.FORBIDDEN: raise ApiKeyResolutionError( "Matrix rejected the API key with HTTP 403 even after " "re-bootstrapping. The bootstrap regex may be picking up " @@ -132,10 +152,10 @@ async def execute(self, search: Search, *, cache: bool = True) -> SearchResult | # ───────────────────────── ancillary helpers ─────────────────────────── async def airports(self, partial: str, page_size: int = 10) -> list[Location]: - url = (f"{BASE}/v1/locationTypes/CITIES_AND_AIRPORTS/" - f"partialNames/{partial}/locations") + url = f"{BASE}/v1/locationTypes/CITIES_AND_AIRPORTS/partialNames/{partial}/locations" data = await self._http.get_json( - url, params={"pageSize": page_size, "key": self._api_key}, + url, + params={"pageSize": page_size, "key": self._api_key}, ) return [Location.model_validate(loc) for loc in data.get("locations", [])] @@ -143,18 +163,18 @@ async def airport(self, code: str) -> Location | None: """Look up a single airport by IATA code. Returns None only on 404 ('no such airport'); network errors and auth failures propagate so callers can distinguish 'doesn't exist' from 'lookup is broken'.""" - url = (f"{BASE}/v1/locationTypes/airportOrMultiAirportCity/" - f"locationCodes/{code.upper()}") + url = f"{BASE}/v1/locationTypes/airportOrMultiAirportCity/locationCodes/{code.upper()}" try: data = await self._http.get_json(url, params={"key": self._api_key}) except httpx.HTTPStatusError as e: - if e.response.status_code == 404: + if e.response.status_code == HTTPStatus.NOT_FOUND: return None raise return Location.model_validate(data) async def currencies(self) -> list[dict[str, str]]: data = await self._http.get_json( - f"{BASE}/v1/currencies", params={"key": self._api_key}, + f"{BASE}/v1/currencies", + params={"key": self._api_key}, ) return data.get("currencies", []) diff --git a/src/flight_cli/domain.py b/src/flight_cli/domain.py index 59b35db..478dfb2 100644 --- a/src/flight_cli/domain.py +++ b/src/flight_cli/domain.py @@ -8,42 +8,49 @@ block. Type checker (pyright/mypy) flags every adapter that forgot the new case via `typing.assert_never`. """ + from __future__ import annotations -from datetime import date as _date -from enum import Enum -from typing import Annotated, Literal, Union + +# resolves type hints at validation time and needs the symbol present in the +# module's runtime globals, even with `from __future__ import annotations`. +from datetime import date as _date # noqa: TC003 +from enum import StrEnum +from typing import Annotated, Literal + from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator # ──────────────────────────────── enums ──────────────────────────────────── -class Cabin(str, Enum): + +class Cabin(StrEnum): COACH = "COACH" PREMIUM_COACH = "PREMIUM_COACH" BUSINESS = "BUSINESS" FIRST = "FIRST" -class TimeOfDay(str, Enum): +class TimeOfDay(StrEnum): """Preferred time-of-day filter. Captured from Matrix's wire format — do NOT change the (min, max) tuples without re-verifying via capture. Quirk: Early Morning uses zero-padded '00:00', everything else uses single-digit hour ('8:00' not '08:00').""" - EARLY_MORNING = "early_morning" # before 8:00 - MORNING = "morning" # 8:00-11:00 - MIDDAY = "midday" # 11:00-14:00 - AFTERNOON = "afternoon" # 14:00-17:00 - EVENING = "evening" # 17:00-21:00 - NIGHT = "night" # after 21:00 + + EARLY_MORNING = "early_morning" # before 8:00 + MORNING = "morning" # 8:00-11:00 + MIDDAY = "midday" # 11:00-14:00 + AFTERNOON = "afternoon" # 14:00-17:00 + EVENING = "evening" # 17:00-21:00 + NIGHT = "night" # after 21:00 _TIME_RANGE_FOR: dict[TimeOfDay, tuple[str, str]] = { TimeOfDay.EARLY_MORNING: ("00:00", "8:00"), - TimeOfDay.MORNING: ("8:00", "11:00"), - TimeOfDay.MIDDAY: ("11:00", "14:00"), - TimeOfDay.AFTERNOON: ("14:00", "17:00"), - TimeOfDay.EVENING: ("17:00", "21:00"), - TimeOfDay.NIGHT: ("21:00", "23:59"), + TimeOfDay.MORNING: ("8:00", "11:00"), + TimeOfDay.MIDDAY: ("11:00", "14:00"), + TimeOfDay.AFTERNOON: ("14:00", "17:00"), + TimeOfDay.EVENING: ("17:00", "21:00"), + TimeOfDay.NIGHT: ("21:00", "23:59"), } @@ -55,8 +62,10 @@ def time_range_for(t: TimeOfDay) -> dict[str, str]: # ──────────────────────────────── shared ─────────────────────────────────── + class Pax(BaseModel): """Passenger counts. Adults default to 1.""" + model_config = ConfigDict(frozen=True, extra="forbid") adults: int = Field(default=1, ge=0, le=9) children: int = Field(default=0, ge=0, le=9) @@ -67,12 +76,19 @@ class Pax(BaseModel): @property def total(self) -> int: - return (self.adults + self.children + self.seniors + self.youth + - self.infants_in_seat + self.infants_in_lap) + return ( + self.adults + + self.children + + self.seniors + + self.youth + + self.infants_in_seat + + self.infants_in_lap + ) class SearchOptions(BaseModel): """Shared search constraints across all modes.""" + model_config = ConfigDict(frozen=True, extra="forbid") cabin: Cabin = Cabin.COACH pax: Pax = Pax() @@ -90,9 +106,13 @@ class SearchOptions(BaseModel): page_size: int = 25 +_IATA_CODE_LEN = 3 +_MAX_CAL_LEGS = 2 # calendar/followup support one-way (1) or round-trip (2) + + def _iata(v: str) -> str: v = v.upper().strip() - if not (len(v) == 3 and v.isalpha()): + if not (len(v) == _IATA_CODE_LEN and v.isalpha()): raise ValueError(f"Not a 3-letter IATA code: {v!r}") return v @@ -102,6 +122,7 @@ class Leg(BaseModel): Calendar-mode legs leave `date` unset; the calendar window owns dates at the search level. Specific-date and followup legs require date.""" + model_config = ConfigDict(frozen=True, extra="forbid") origins: tuple[str, ...] destinations: tuple[str, ...] @@ -109,9 +130,9 @@ class Leg(BaseModel): is_arrival_date: bool = False date_minus: int = Field(default=0, ge=0, le=3) date_plus: int = Field(default=0, ge=0, le=3) - route_language: str | None = None # 'LH+', 'BA AA', '[F* X F*]' - extension: str | None = None # 'MAXCONNECT 5:00', etc. - time_ranges: tuple[TimeOfDay, ...] = () # empty = no preference + route_language: str | None = None # 'LH+', 'BA AA', '[F* X F*]' + extension: str | None = None # 'MAXCONNECT 5:00', etc. + time_ranges: tuple[TimeOfDay, ...] = () # empty = no preference @field_validator("origins", "destinations") @classmethod @@ -119,24 +140,32 @@ def _validate_airports(cls, v: tuple[str, ...]) -> tuple[str, ...]: return tuple(_iata(c) for c in v) @classmethod - def of(cls, origin: str | list[str] | tuple[str, ...], - destination: str | list[str] | tuple[str, ...], - dt: _date | None = None, - *, - route_language: str | None = None, - extension: str | None = None, - time_ranges: tuple[TimeOfDay, ...] = (), - ) -> "Leg": + def of( + cls, + origin: str | list[str] | tuple[str, ...], + destination: str | list[str] | tuple[str, ...], + dt: _date | None = None, + *, + route_language: str | None = None, + extension: str | None = None, + time_ranges: tuple[TimeOfDay, ...] = (), + ) -> Leg: """Convenience constructor — accepts a single IATA or list/tuple.""" os = (origin,) if isinstance(origin, str) else tuple(origin) ds = (destination,) if isinstance(destination, str) else tuple(destination) - return cls(origins=os, destinations=ds, date=dt, - route_language=route_language, extension=extension, - time_ranges=time_ranges) + return cls( + origins=os, + destinations=ds, + date=dt, + route_language=route_language, + extension=extension, + time_ranges=time_ranges, + ) # ───────────────────────────── search variants ───────────────────────────── + class _SearchBase(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") legs: tuple[Leg, ...] @@ -145,6 +174,7 @@ class _SearchBase(BaseModel): class SpecificDateSearch(_SearchBase): """1 leg = one-way. 2 = round-trip. 3+ = multi-city. Each leg has date.""" + kind: Literal["specific"] = "specific" @field_validator("legs") @@ -154,13 +184,13 @@ def _legs_have_dates(cls, legs: tuple[Leg, ...]) -> tuple[Leg, ...]: raise ValueError("at least one leg required") for i, leg in enumerate(legs): if leg.date is None: - raise ValueError( - f"SpecificDateSearch.legs[{i}] requires a date") + raise ValueError(f"SpecificDateSearch.legs[{i}] requires a date") return legs class CalendarWindow(BaseModel): """Shared between CalendarSearch and CalendarFollowup.""" + model_config = ConfigDict(frozen=True, extra="forbid") start: _date end: _date @@ -180,42 +210,43 @@ class CalendarSearch(_SearchBase): """Lowest-fare grid across a date window. 1 leg = one-way calendar, 2 legs = round-trip calendar. Legs are templates — no per-leg date; the calendar window owns dates.""" + kind: Literal["calendar"] = "calendar" window: CalendarWindow @field_validator("legs") @classmethod def _legs_no_dates(cls, legs: tuple[Leg, ...]) -> tuple[Leg, ...]: - if not (1 <= len(legs) <= 2): + if not (1 <= len(legs) <= _MAX_CAL_LEGS): raise ValueError("calendar search supports 1 or 2 legs") for i, leg in enumerate(legs): if leg.date is not None: raise ValueError( - f"CalendarSearch.legs[{i}].date must be None " - "(window owns the dates)") + f"CalendarSearch.legs[{i}].date must be None (window owns the dates)" + ) return legs class CalendarFollowup(_SearchBase): """Phase-2: itineraries for a date picked from a calendar grid. Legs have dates; preserves window context for the API.""" + kind: Literal["followup"] = "followup" window: CalendarWindow @field_validator("legs") @classmethod def _legs_have_dates(cls, legs: tuple[Leg, ...]) -> tuple[Leg, ...]: - if not (1 <= len(legs) <= 2): + if not (1 <= len(legs) <= _MAX_CAL_LEGS): raise ValueError("followup search supports 1 or 2 legs") for i, leg in enumerate(legs): if leg.date is None: - raise ValueError( - f"CalendarFollowup.legs[{i}] requires a date") + raise ValueError(f"CalendarFollowup.legs[{i}] requires a date") return legs Search = Annotated[ - Union[SpecificDateSearch, CalendarSearch, CalendarFollowup], + SpecificDateSearch | CalendarSearch | CalendarFollowup, Field(discriminator="kind"), ] """Tagged union of every search variant. Adapters dispatch on `kind` (or via diff --git a/src/flight_cli/fli_bridge.py b/src/flight_cli/fli_bridge.py index a7b36dc..b76b3f2 100644 --- a/src/flight_cli/fli_bridge.py +++ b/src/flight_cli/fli_bridge.py @@ -4,26 +4,39 @@ fli has no notion of multi-airport per slice or calendar mode — so we flatten to the first IATA per leg, and for calendar searches we use the window start as departure + mean(duration) as return.""" + from __future__ import annotations + from datetime import timedelta from typing import Any, assert_never from .domain import ( - Cabin, Search, SpecificDateSearch, CalendarSearch, CalendarFollowup, + Cabin, + CalendarFollowup, + CalendarSearch, + Search, + SpecificDateSearch, ) +_ROUND_TRIP_LEGS = 2 # 2 legs = round-trip; 1 = one-way + -def to_fli_filter(s: Search): +def to_fli_filter(s: Search) -> Any: """Translate domain → fli FlightSearchFilters. Lazy imports so the rest of flight_cli doesn't pay the fli import cost when not used.""" - from fli.models.google_flights.flights import ( # pyright: ignore[reportMissingTypeStubs] - FlightSearchFilters, FlightSegment, PassengerInfo, + + # selectolax, etc.); the rest of the CLI shouldn't pay that startup cost. + from fli.models.airport import ( # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs] + Airport as FliAirport, ) - from fli.models.google_flights.base import ( # pyright: ignore[reportMissingTypeStubs] - SeatType, TripType, + from fli.models.google_flights.base import ( # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs] + SeatType, + TripType, ) - from fli.models.airport import ( # pyright: ignore[reportMissingTypeStubs] - Airport as FliAirport, + from fli.models.google_flights.flights import ( # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs] + FlightSearchFilters, + FlightSegment, + PassengerInfo, ) cab_map = { @@ -33,7 +46,7 @@ def to_fli_filter(s: Search): Cabin.FIRST: SeatType.FIRST, } - def _seg(origin: str, dest: str, dt: str) -> "FlightSegment": + def _seg(origin: str, dest: str, dt: str) -> FlightSegment: o = getattr(FliAirport, origin) d = getattr(FliAirport, dest) return FlightSegment( @@ -46,19 +59,26 @@ def _seg(origin: str, dest: str, dt: str) -> "FlightSegment": match s: case SpecificDateSearch() | CalendarFollowup(): for leg in s.legs: - # SpecificDate/Followup validators guarantee leg.date is set - assert leg.date is not None - segs.append(_seg(leg.origins[0], leg.destinations[0], - leg.date.isoformat())) + # SpecificDate/Followup validators guarantee leg.date is set; + # surface a clear error if invariants were bypassed. + if leg.date is None: + raise AssertionError( + f"{type(s).__name__}.leg.date should be set after validation", + ) + segs.append(_seg(leg.origins[0], leg.destinations[0], leg.date.isoformat())) case CalendarSearch(): mean_dur = (s.window.duration_min + s.window.duration_max) // 2 out = s.legs[0] - ret = s.legs[1] if len(s.legs) == 2 else None - segs.append(_seg(out.origins[0], out.destinations[0], - s.window.start.isoformat())) + ret = s.legs[1] if len(s.legs) == _ROUND_TRIP_LEGS else None + segs.append(_seg(out.origins[0], out.destinations[0], s.window.start.isoformat())) if ret: - segs.append(_seg(ret.origins[0], ret.destinations[0], - (s.window.start + timedelta(days=mean_dur)).isoformat())) + segs.append( + _seg( + ret.origins[0], + ret.destinations[0], + (s.window.start + timedelta(days=mean_dur)).isoformat(), + ) + ) case _: assert_never(s) @@ -75,7 +95,11 @@ def _seg(origin: str, dest: str, dt: str) -> "FlightSegment": ) -def run_gflight_search(s: Search, *, top_n: int = 5): +def run_gflight_search(s: Search, *, top_n: int = 5) -> Any: """Build a fli filter from a Search and run the Google Flights query.""" - from fli.search.flights import SearchFlights # pyright: ignore[reportMissingTypeStubs] + + from fli.search.flights import ( # noqa: PLC0415 + SearchFlights, # pyright: ignore[reportMissingTypeStubs] + ) + return SearchFlights().search(to_fli_filter(s), top_n=top_n) diff --git a/src/flight_cli/links.py b/src/flight_cli/links.py index 910722f..6b22d1d 100644 --- a/src/flight_cli/links.py +++ b/src/flight_cli/links.py @@ -7,20 +7,34 @@ Both dispatch on the Search variant via `match` (with `assert_never` for exhaustiveness checking).""" + from __future__ import annotations -import base64, json, urllib.parse + +import base64 +import json +import urllib.parse from datetime import date, timedelta from typing import Any, Literal, assert_never from .domain import ( - Cabin, Leg, Pax, Search, SearchOptions, - SpecificDateSearch, CalendarSearch, CalendarFollowup, + Cabin, + CalendarFollowup, + CalendarSearch, + Leg, + Pax, + Search, + SearchOptions, + SpecificDateSearch, ) - # ───────────────────────── Matrix deep-link URL ──────────────────────────── -def _spa_options_block(opts: SearchOptions, *, extra_stops_override: int | None = None) -> dict[str, str]: + +def _spa_options_block( + opts: SearchOptions, + *, + extra_stops_override: int | None = None, +) -> dict[str, str]: """SPA URL-state `options` dict. All values are strings.""" if extra_stops_override is not None: es = extra_stops_override @@ -31,8 +45,11 @@ def _spa_options_block(opts: SearchOptions, *, extra_stops_override: int | None es = -1 if opts.max_extra_stops is not None else 1 return { "cabin": opts.cabin.value, - "stops": ("-1" if opts.max_extra_stops is None or opts.max_extra_stops < 0 - else str(opts.max_extra_stops)), + "stops": ( + "-1" + if opts.max_extra_stops is None or opts.max_extra_stops < 0 + else str(opts.max_extra_stops) + ), "extraStops": str(es), "allowAirportChanges": "true" if opts.allow_airport_changes else "false", "showOnlyAvailable": "true" if opts.show_only_available else "false", @@ -41,10 +58,13 @@ def _spa_options_block(opts: SearchOptions, *, extra_stops_override: int | None def _pax_strs(pax: Pax) -> dict[str, str]: d = {"adults": str(pax.adults)} - for k, v in (("children", pax.children), ("seniors", pax.seniors), - ("youth", pax.youth), - ("infantsInSeat", pax.infants_in_seat), - ("infantsInLap", pax.infants_in_lap)): + for k, v in ( + ("children", pax.children), + ("seniors", pax.seniors), + ("youth", pax.youth), + ("infantsInSeat", pax.infants_in_seat), + ("infantsInLap", pax.infants_in_lap), + ): if v: d[k] = str(v) return d @@ -69,9 +89,9 @@ def _spa_specific_leg(leg: Leg) -> dict[str, Any]: } -def _spa_calendar_leg(out: Leg, ret: Leg | None, - start: date, end: date, - duration_min: int, duration_max: int) -> dict[str, Any]: +def _spa_calendar_leg( + out: Leg, ret: Leg | None, start: date, end: date, duration_min: int, duration_max: int +) -> dict[str, Any]: """SPA URL-state slice for calendar mode. Round-trip is folded into ONE slice with `routingRet`/`extRet` carrying return-direction routing.""" d: dict[str, Any] = { @@ -81,8 +101,9 @@ def _spa_calendar_leg(out: Leg, ret: Leg | None, if out.route_language or out.extension: d["routing"] = out.route_language or "" d["ext"] = out.extension or "" - if ret is None or (ret.route_language == out.route_language and - ret.extension == out.extension): + if ret is None or ( + ret.route_language == out.route_language and ret.extension == out.extension + ): d["routingRet"] = "" d["extRet"] = "" else: @@ -94,17 +115,19 @@ def _spa_calendar_leg(out: Leg, ret: Leg | None, "departureDateType": "depart", "departureDateModifier": "0", "departureDatePreferredTimes": [t.value for t in out.time_ranges], - "duration": (f"{duration_min}-{duration_max}" - if duration_min != duration_max - else str(duration_min)), + "duration": ( + f"{duration_min}-{duration_max}" if duration_min != duration_max else str(duration_min) + ), "returnDateType": "depart", "returnDateModifier": "0", - "returnDatePreferredTimes": ([t.value for t in ret.time_ranges] - if ret else []), + "returnDatePreferredTimes": ([t.value for t in ret.time_ranges] if ret else []), } return d +_ROUND_TRIP_LEGS = 2 # 2 legs = round-trip; 1 = one-way; >2 = multi-city + + def _encode_payload(payload: dict[str, Any], path: str) -> str: b = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() return f"https://matrix.itasoftware.com/{path}?search={urllib.parse.quote(b)}" @@ -114,8 +137,11 @@ def matrix_deep_link(s: Search) -> str: """Build the matrix.itasoftware.com deep-link URL for any search variant.""" match s: case SpecificDateSearch(): - trip = ("round-trip" if len(s.legs) == 2 - else ("one-way" if len(s.legs) == 1 else "multi-city")) + trip = ( + "round-trip" + if len(s.legs) == _ROUND_TRIP_LEGS + else ("one-way" if len(s.legs) == 1 else "multi-city") + ) payload = { "type": trip, "slices": [_spa_specific_leg(leg) for leg in s.legs], @@ -126,12 +152,19 @@ def matrix_deep_link(s: Search) -> str: case CalendarSearch(): out = s.legs[0] - ret = s.legs[1] if len(s.legs) == 2 else None + ret = s.legs[1] if len(s.legs) == _ROUND_TRIP_LEGS else None payload = { "type": "round-trip" if ret else "one-way", - "slices": [_spa_calendar_leg(out, ret, s.window.start, s.window.end, - s.window.duration_min, - s.window.duration_max)], + "slices": [ + _spa_calendar_leg( + out, + ret, + s.window.start, + s.window.end, + s.window.duration_min, + s.window.duration_max, + ) + ], "options": _spa_options_block(s.options), "pax": _pax_strs(s.options.pax), } @@ -141,7 +174,7 @@ def matrix_deep_link(s: Search) -> str: # The SPA URL for a followup is essentially a specific-date URL # for the picked dates — that's how you'd share "the itineraries # I'm looking at" with someone else. - trip = "round-trip" if len(s.legs) == 2 else "one-way" + trip = "round-trip" if len(s.legs) == _ROUND_TRIP_LEGS else "one-way" payload = { "type": trip, "slices": [_spa_specific_leg(leg) for leg in s.legs], @@ -164,8 +197,7 @@ def matrix_deep_link(s: Search) -> str: } -def google_flights_url(s: Search, *, currency: str = "USD", - language: str = "en") -> str: +def google_flights_url(s: Search, *, currency: str = "USD", language: str = "en") -> str: """Build a Google Flights `tfs=` URL that opens directly into a populated search result. Multi-airport is flattened to first IATA per leg (Google Flights URL grammar doesn't support airport sets per slice). @@ -174,38 +206,53 @@ def google_flights_url(s: Search, *, currency: str = "USD", and start + mean(duration) as return — gives the user a representative URL to land on Google Flights with, even though Google doesn't have a calendar-grid concept.""" - from fast_flights import TFSData, FlightData, Passengers + + # we only need on this code path. + from fast_flights import FlightData, Passengers, TFSData # noqa: PLC0415 match s: case SpecificDateSearch() | CalendarFollowup(): flight_data: list[Any] = [] for leg in s.legs: - # SpecificDate/Followup validators guarantee leg.date is set - assert leg.date is not None - flight_data.append(FlightData( - date=leg.date.isoformat(), - from_airport=leg.origins[0], - to_airport=leg.destinations[0])) + # SpecificDate/Followup validators guarantee leg.date is set; + # surface a clear error if invariants were bypassed. + if leg.date is None: + raise AssertionError( + f"{type(s).__name__}.leg.date should be set after validation", + ) + flight_data.append( + FlightData( + date=leg.date.isoformat(), + from_airport=leg.origins[0], + to_airport=leg.destinations[0], + ) + ) case CalendarSearch(): mean_dur = (s.window.duration_min + s.window.duration_max) // 2 ret_date = s.window.start + timedelta(days=mean_dur) out = s.legs[0] - ret = s.legs[1] if len(s.legs) == 2 else None - flight_data = [FlightData( - date=s.window.start.isoformat(), - from_airport=out.origins[0], - to_airport=out.destinations[0])] + ret = s.legs[1] if len(s.legs) == _ROUND_TRIP_LEGS else None + flight_data = [ + FlightData( + date=s.window.start.isoformat(), + from_airport=out.origins[0], + to_airport=out.destinations[0], + ) + ] if ret: - flight_data.append(FlightData( - date=ret_date.isoformat(), - from_airport=ret.origins[0], - to_airport=ret.destinations[0])) + flight_data.append( + FlightData( + date=ret_date.isoformat(), + from_airport=ret.origins[0], + to_airport=ret.destinations[0], + ) + ) case _: assert_never(s) if len(flight_data) == 1: trip = "one-way" - elif len(flight_data) == 2: + elif len(flight_data) == _ROUND_TRIP_LEGS: trip = "round-trip" else: trip = "multi-city" @@ -223,5 +270,7 @@ def google_flights_url(s: Search, *, currency: str = "USD", ), ) b64 = td.as_b64().decode() - return (f"https://www.google.com/travel/flights/search?" - f"tfs={urllib.parse.quote(b64)}&hl={language}&curr={currency}") + return ( + f"https://www.google.com/travel/flights/search?" + f"tfs={urllib.parse.quote(b64)}&hl={language}&curr={currency}" + ) diff --git a/src/flight_cli/models.py b/src/flight_cli/models.py index 8e4aa6f..716170e 100644 --- a/src/flight_cli/models.py +++ b/src/flight_cli/models.py @@ -3,9 +3,15 @@ Lenient (`extra='ignore'`) because the Matrix Alkali response is undocumented and prone to occasional field additions. """ + from __future__ import annotations -from datetime import datetime + +# `datetime` is used in pydantic-model annotations; pydantic v2 resolves +# type hints at validation time and needs the symbol in the module's +# runtime globals, even with `from __future__ import annotations`. +from datetime import datetime # noqa: TC003 from typing import Any + from pydantic import BaseModel, ConfigDict, Field @@ -31,6 +37,7 @@ class BookingInfo(_Loose): class Leg(_Loose): """One physical flight segment within a solution.itinerary.slices[]. Distinct from the domain `Leg` (which represents user intent).""" + origin: Airport destination: Airport departure: datetime @@ -41,6 +48,7 @@ class Leg(_Loose): class Flight(_Loose): """Flight-number metadata attached to a Segment.""" + number: str | None = None @@ -52,7 +60,10 @@ class Segment(_Loose): duration: int | None = None carrier: Carrier flight: Flight | None = None - booking_infos: list[BookingInfo] = Field(default_factory=list[BookingInfo], alias="bookingInfos") + booking_infos: list[BookingInfo] = Field( + default_factory=list[BookingInfo], + alias="bookingInfos", + ) legs: list[Leg] = Field(default_factory=list[Leg]) @property @@ -63,6 +74,7 @@ def flight_number(self) -> str | None: class ItineraryExt(_Loose): """`ext` blob shared by Itinerary and CurrencyNotice. Holds the user-facing price string.""" + price: str | None = None @@ -85,12 +97,14 @@ class SliceCarrier(_Loose): class ItineraryDetails(_Loose): """The deep itinerary payload — slices + carriers per solution.""" + slices: list[Slice] = Field(default_factory=list[Slice]) carriers: list[SliceCarrier] = Field(default_factory=list[SliceCarrier]) class Itinerary(_Loose): """One bookable itinerary returned by /v1/search.""" + display_total: str | None = Field(None, alias="displayTotal") ext: ItineraryExt | None = None itinerary: ItineraryDetails | None = None @@ -135,6 +149,7 @@ class CurrencyNotice(_Loose): class SearchResult(_Loose): """/v1/search (specific-date or followup) response.""" + solution_count: int = Field(0, alias="solutionCount") solutions: list[Itinerary] = Field(default_factory=list[Itinerary]) carrier_stop_matrix: CarrierStopMatrix | None = Field(None, alias="carrierStopMatrix") @@ -171,7 +186,7 @@ class DurationOption(_Loose): @property def price_value(self) -> float: s = self.min_price - i = next((j for j, c in enumerate(s) if c.isdigit() or c == '.'), len(s)) + i = next((j for j, c in enumerate(s) if c.isdigit() or c == "."), len(s)) return float(s[i:]) @@ -196,7 +211,7 @@ def price_value(self) -> float | None: if not self.min_price: return None s = self.min_price - i = next((j for j, c in enumerate(s) if c.isdigit() or c == '.'), len(s)) + i = next((j for j, c in enumerate(s) if c.isdigit() or c == "."), len(s)) return float(s[i:]) @@ -249,6 +264,7 @@ def priced_days(self) -> list[CalendarDay]: class Location(_Loose): """Result of an airport autocomplete or single-code lookup.""" + code: str display_name: str | None = Field(None, alias="displayName") city_code: str | None = Field(None, alias="cityCode") diff --git a/src/flight_cli/py.typed b/src/flight_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/flight_cli/wire.py b/src/flight_cli/wire.py index 59ee9eb..e17ea29 100644 --- a/src/flight_cli/wire.py +++ b/src/flight_cli/wire.py @@ -9,21 +9,32 @@ produces a typed body. Exhaustiveness is enforced by `typing.assert_never` — add a new variant, every adapter that doesn't handle it lights red. """ + from __future__ import annotations + from typing import Any, Literal, assert_never + from pydantic import BaseModel, ConfigDict, Field from .domain import ( - Leg, Pax, SearchOptions, Search, SpecificDateSearch, - CalendarSearch, CalendarFollowup, time_range_for, + CalendarFollowup, + CalendarSearch, + Leg, + Pax, + Search, + SearchOptions, + SpecificDateSearch, + time_range_for, ) +_ROUND_TRIP_LEGS = 2 # 2 legs = round-trip; 1 = one-way (calendar variants) # ─────────────────────────────── wire shapes ─────────────────────────────── # Pydantic config: camelCase field names match Matrix's JSON exactly. # `extra="ignore"` so we tolerate fields we don't know about (forward-compat). # `exclude_none=True` at dump time omits unset optional fields. + class _Wire(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -46,16 +57,17 @@ class WireSlice(_Wire): """One slice in the API request. Field order matches captured SPA bodies. Optional fields use `None` default + `exclude_none=True` at dump time so unset fields disappear (matching SPA omission).""" + origins: list[str] destinations: list[str] date: str | None = None - routeLanguage: str | None = None # routing language ('LH+', 'BA AA') - commandLine: str | None = None # extension codes ('MAXCONNECT 2:00') + routeLanguage: str | None = None # routing language ('LH+', 'BA AA') + commandLine: str | None = None # extension codes ('MAXCONNECT 2:00') dateModifier: WireDateModifier | None = None isArrivalDate: bool | None = None timeRanges: list[WireTimeRange] | None = None filter: WireSliceFilter = Field(default_factory=WireSliceFilter) - selected: bool = False # always emitted (matches SPA) + selected: bool = False # always emitted (matches SPA) class WirePage(_Wire): @@ -108,25 +120,38 @@ def as_json(self) -> dict[str, Any]: # Summarizer sets per mode — order matters for golden-file regression # tests; ordering verified from real SPA captures. _SUMMARIZERS_SPECIFIC = [ - "carrierStopMatrix", "currencyNotice", "solutionList", - "itineraryPriceSlider", "itineraryCarrierList", - "itineraryDepartureTimeRanges", "itineraryArrivalTimeRanges", - "durationSliderItinerary", "itineraryOrigins", - "itineraryDestinations", "itineraryStopCountList", + "carrierStopMatrix", + "currencyNotice", + "solutionList", + "itineraryPriceSlider", + "itineraryCarrierList", + "itineraryDepartureTimeRanges", + "itineraryArrivalTimeRanges", + "durationSliderItinerary", + "itineraryOrigins", + "itineraryDestinations", + "itineraryStopCountList", "warningsItinerary", ] _SUMMARIZERS_CALENDAR = [ - "calendar", "overnightFlightsCalendar", - "itineraryStopCountList", "itineraryCarrierList", "currencyNotice", + "calendar", + "overnightFlightsCalendar", + "itineraryStopCountList", + "itineraryCarrierList", + "currencyNotice", ] _SUMMARIZERS_FOLLOWUP = _SUMMARIZERS_SPECIFIC def _pax_dict(p: Pax) -> dict[str, int]: d = {"adults": p.adults} - for k, v in (("children", p.children), ("seniors", p.seniors), - ("youth", p.youth), ("infantsInSeat", p.infants_in_seat), - ("infantsInLap", p.infants_in_lap)): + for k, v in ( + ("children", p.children), + ("seniors", p.seniors), + ("youth", p.youth), + ("infantsInSeat", p.infants_in_seat), + ("infantsInLap", p.infants_in_lap), + ): if v: d[k] = v return d @@ -134,23 +159,29 @@ def _pax_dict(p: Pax) -> dict[str, int]: def _leg_to_wire(leg: Leg, *, mode: Literal["specific", "calendar", "followup"]) -> WireSlice: """Convert domain Leg to wire slice. Captured behaviour per mode: - specific: date + dateModifier + isArrivalDate always present - calendar: no date, no dateModifier, no isArrivalDate - followup: date present; dateModifier / isArrivalDate omitted + specific: date + dateModifier + isArrivalDate always present + calendar: no date, no dateModifier, no isArrivalDate + followup: date present; dateModifier / isArrivalDate omitted """ include_date = mode in ("specific", "followup") - include_modifier_fields = (mode == "specific") + include_modifier_fields = mode == "specific" return WireSlice( origins=list(leg.origins), destinations=list(leg.destinations), date=leg.date.isoformat() if (include_date and leg.date) else None, routeLanguage=leg.route_language, commandLine=leg.extension, - dateModifier=(WireDateModifier(minus=leg.date_minus, plus=leg.date_plus) - if include_modifier_fields else None), + dateModifier=( + WireDateModifier(minus=leg.date_minus, plus=leg.date_plus) + if include_modifier_fields + else None + ), isArrivalDate=(leg.is_arrival_date if include_modifier_fields else None), - timeRanges=([WireTimeRange(**time_range_for(t)) for t in leg.time_ranges] - if leg.time_ranges else None), + timeRanges=( + [WireTimeRange(**time_range_for(t)) for t in leg.time_ranges] + if leg.time_ranges + else None + ), ) @@ -163,8 +194,9 @@ def _base_inputs(opts: SearchOptions, slices: list[WireSlice]) -> WireInputs: checkAvailability=opts.show_only_available, # Matches the SPA's "No limit" / "Up to 1 extra stop" default = 1. # User can override by passing options.max_extra_stops explicitly. - maxLegsRelativeToMin=(1 if opts.max_extra_stops is None or opts.max_extra_stops < 0 - else opts.max_extra_stops), + maxLegsRelativeToMin=( + 1 if opts.max_extra_stops is None or opts.max_extra_stops < 0 else opts.max_extra_stops + ), slices=slices, ) @@ -174,7 +206,7 @@ def to_wire(s: Search) -> WireBody: adding a new Search variant breaks type-check until handled here.""" match s: case SpecificDateSearch(): - slices = [_leg_to_wire(l, mode="specific") for l in s.legs] + slices = [_leg_to_wire(leg, mode="specific") for leg in s.legs] inputs = _base_inputs(s.options, slices) inputs.filter = {} inputs.page = WirePage(current=1, size=s.options.page_size) @@ -186,14 +218,13 @@ def to_wire(s: Search) -> WireBody: ) case CalendarSearch(): - slices = [_leg_to_wire(l, mode="calendar") for l in s.legs] + slices = [_leg_to_wire(leg, mode="calendar") for leg in s.legs] inputs = _base_inputs(s.options, slices) inputs.filter = {} inputs.startDate = s.window.start.isoformat() inputs.endDate = s.window.end.isoformat() - inputs.layover = WireLayover(min=s.window.duration_min, - max=s.window.duration_max) - rt = len(s.legs) == 2 + inputs.layover = WireLayover(min=s.window.duration_min, max=s.window.duration_max) + rt = len(s.legs) == _ROUND_TRIP_LEGS return WireBody( summarizers=_SUMMARIZERS_CALENDAR, summarizerSet="calendarRoundTrip" if rt else "calendarOneWay", @@ -202,7 +233,7 @@ def to_wire(s: Search) -> WireBody: ) case CalendarFollowup(): - slices = [_leg_to_wire(l, mode="followup") for l in s.legs] + slices = [_leg_to_wire(leg, mode="followup") for leg in s.legs] inputs = _base_inputs(s.options, slices) # Followup omits inputs.filter but DOES include page.current=1 # (per SPA capture). @@ -210,8 +241,7 @@ def to_wire(s: Search) -> WireBody: inputs.page = WirePage(current=1, size=s.options.page_size) inputs.startDate = s.window.start.isoformat() inputs.endDate = s.window.end.isoformat() - inputs.layover = WireLayover(min=s.window.duration_min, - max=s.window.duration_max) + inputs.layover = WireLayover(min=s.window.duration_min, max=s.window.duration_max) return WireBody( summarizers=_SUMMARIZERS_FOLLOWUP, summarizerSet="wholeTrip", diff --git a/tests/test_wire_round_trip.py b/tests/test_wire_round_trip.py index a76fc4d..7f895ce 100644 --- a/tests/test_wire_round_trip.py +++ b/tests/test_wire_round_trip.py @@ -8,33 +8,45 @@ To capture more fixtures: drive a search in the recording harness (research/record_user_session.py) and drop the resulting req_*.json into tests/fixtures/.""" + from __future__ import annotations + import json import pathlib from datetime import date - -import pytest +from typing import Any, cast from flight_cli.domain import ( - Cabin, Pax, Leg, SearchOptions, TimeOfDay, - SpecificDateSearch, CalendarSearch, CalendarFollowup, + Cabin, + CalendarFollowup, + CalendarSearch, + CalendarWindow, + Leg, + Pax, + SearchOptions, + SpecificDateSearch, + TimeOfDay, ) -from flight_cli.domain import CalendarWindow from flight_cli.wire import to_wire FIXTURES = pathlib.Path(__file__).parent / "fixtures" +Json = dict[str, Any] + -def _load(name: str) -> dict: - return json.loads((FIXTURES / name).read_text()) +def _load(name: str) -> Json: + return cast("Json", json.loads((FIXTURES / name).read_text())) -def _strip(d: dict, *keys: str) -> dict: +def _strip(d: Json, *keys: str) -> Json: """Drop top-level keys we don't reproduce (bgProgramResponse: the anti-abuse token; the SPA sends it but the server doesn't validate. session/id: server context, not part of the user-facing request).""" - return {k: v for k, v in d.items() - if k not in keys and k not in ("bgProgramResponse", "session", "id")} + return { + k: v + for k, v in d.items() + if k not in keys and k not in ("bgProgramResponse", "session", "id") + } # ─────────────────────────────── fixtures ────────────────────────────────── @@ -45,13 +57,11 @@ def _strip(d: dict, *keys: str) -> dict: def test_calendar_round_trip_multi_airport(): """NYC → [MUC, FRA] round-trip, 5-7 nights, MAXCONNECT 2:00 outbound.""" - captured = _strip(_load("calendar_nyc_munich_frankfurt.json"), - "bgProgramResponse") + captured = _strip(_load("calendar_nyc_munich_frankfurt.json"), "bgProgramResponse") search = CalendarSearch( legs=( - Leg.of("NYC", ["MUC", "FRA"], - route_language="LH+", extension="MAXCONNECT 2:00"), + Leg.of("NYC", ["MUC", "FRA"], route_language="LH+", extension="MAXCONNECT 2:00"), Leg.of(["MUC", "FRA"], "NYC"), ), options=SearchOptions(cabin=Cabin.COACH, pax=Pax(adults=1)), @@ -68,16 +78,18 @@ def test_calendar_round_trip_multi_airport(): def test_calendarFollowup_after_pick(): """Same NYC → [MUC,FRA] search but with picked dates 6/7 → 6/11.""" - captured = _strip(_load("followup_nyc_munich_frankfurt.json"), - "bgProgramResponse") + captured = _strip(_load("followup_nyc_munich_frankfurt.json"), "bgProgramResponse") search = CalendarFollowup( legs=( - Leg.of("NYC", ["MUC", "FRA"], - date.fromisoformat("2026-06-07"), - route_language="LH+", extension="MAXCONNECT 2:00"), - Leg.of(["MUC", "FRA"], "NYC", - date.fromisoformat("2026-06-11")), + Leg.of( + "NYC", + ["MUC", "FRA"], + date.fromisoformat("2026-06-07"), + route_language="LH+", + extension="MAXCONNECT 2:00", + ), + Leg.of(["MUC", "FRA"], "NYC", date.fromisoformat("2026-06-11")), ), options=SearchOptions(cabin=Cabin.COACH, pax=Pax(adults=1)), window=CalendarWindow( @@ -94,26 +106,27 @@ def test_calendarFollowup_after_pick(): def test_specific_with_time_ranges(): """JFK → LHR round-trip with Early Morning + Evening time filters selected on the outbound.""" - captured = _strip(_load("specific_jfk_lhr_timeofday.json"), - "bgProgramResponse") - out_slice = captured["inputs"]["slices"][0] + captured = _strip(_load("specific_jfk_lhr_timeofday.json"), "bgProgramResponse") + out_slice: Json = captured["inputs"]["slices"][0] # Reconstruct: from captured timeRanges, identify which TimeOfDay # values were selected. - times = [] + times: list[TimeOfDay] = [] + from flight_cli.domain import ( + _TIME_RANGE_FOR, # pyright: ignore[reportPrivateUsage] + ) + for tr in out_slice.get("timeRanges", []): key = (tr["min"], tr["max"]) for t in TimeOfDay: - from flight_cli.domain import _TIME_RANGE_FOR if _TIME_RANGE_FOR[t] == key: times.append(t) break + return_date: str = captured["inputs"]["slices"][1]["date"] search = SpecificDateSearch( legs=( - Leg.of("JFK", "LHR", date.fromisoformat(out_slice["date"]), - time_ranges=tuple(times)), - Leg.of("LHR", "JFK", - date.fromisoformat(captured["inputs"]["slices"][1]["date"])), + Leg.of("JFK", "LHR", date.fromisoformat(out_slice["date"]), time_ranges=tuple(times)), + Leg.of("LHR", "JFK", date.fromisoformat(return_date)), ), options=SearchOptions(cabin=Cabin.COACH, pax=Pax(adults=1)), ) @@ -123,18 +136,21 @@ def test_specific_with_time_ranges(): # ─────────────────────────────── helpers ─────────────────────────────────── -def _diff(expected: dict, actual: dict, path: str = "") -> str: + +def _diff(expected: Any, actual: Any, path: str = "") -> str: """Return a human-readable diff between two dicts, recursively.""" lines: list[str] = [] if isinstance(expected, dict) and isinstance(actual, dict): - for k in sorted(set(expected) | set(actual)): + exp = cast("Json", expected) + act = cast("Json", actual) + for k in sorted(set(exp.keys()) | set(act.keys())): sub = f"{path}.{k}" if path else k - if k not in expected: - lines.append(f" + {sub}: {actual[k]!r}") - elif k not in actual: - lines.append(f" - {sub}: {expected[k]!r}") - elif expected[k] != actual[k]: - lines.extend(_diff(expected[k], actual[k], sub).splitlines()) + if k not in exp: + lines.append(f" + {sub}: {act[k]!r}") + elif k not in act: + lines.append(f" - {sub}: {exp[k]!r}") + elif exp[k] != act[k]: + lines.extend(_diff(exp[k], act[k], sub).splitlines()) elif expected != actual: lines.append(f" ≠ {path}: expected={expected!r} actual={actual!r}") return "Differences:\n" + "\n".join(lines) if lines else "" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c700ab9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,857 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.39.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/b0/ab36a25bb4637c6083e88721351f96b30742b84debdea42892b0f0d08cc9/basedpyright-1.39.4.tar.gz", hash = "sha256:e604088a0a808377a91d8ea0c4ef427c1af6fb335529bbe65a055263163d9252", size = 25507140, upload-time = "2026-05-11T11:27:04.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/39/9c256405e8d2a974b9fb819bd9c8a44ca4ba0f335c8ebb8d6961cee5f0c0/basedpyright-1.39.4-py3-none-any.whl", hash = "sha256:3cefd334f101fa4125c4ced4db6ca620267b57536ba0c3f8a7ae8b13c4c11f7a", size = 12420117, upload-time = "2026-05-11T11:26:59.829Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + +[[package]] +name = "fast-flights" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "primp" }, + { name = "protobuf" }, + { name = "selectolax" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/11/207d9a9300466e6f2f95cc7fcb150ffa35416aa8de15850aa35cf39c5e30/fast_flights-2.2.tar.gz", hash = "sha256:d496630793357e16a03f1bba94f52d81504cd8772350918d91ac8b2589b20c7d", size = 52692, upload-time = "2025-03-08T23:41:40.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ba/06b2d76e22cd0f1d467830bd05e191ef80db07c5016d7cc6b1adb4097145/fast_flights-2.2-py3-none-any.whl", hash = "sha256:ad8393f29c57797b4d7f03df1c8c66771302ece3ab268a13be0b3ae7a93291ad", size = 52610, upload-time = "2025-03-08T23:41:39.353Z" }, +] + +[[package]] +name = "flight-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiolimiter" }, + { name = "fast-flights" }, + { name = "flights" }, + { name = "httpx" }, + { name = "httpx-curl-cffi" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "stamina" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiolimiter", specifier = ">=1.1" }, + { name = "fast-flights", specifier = ">=2.2" }, + { name = "flights", specifier = ">=0.8" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx-curl-cffi", specifier = ">=0.1.5" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "rich", specifier = ">=13" }, + { name = "stamina", specifier = ">=24.3" }, + { name = "typer", specifier = ">=0.12" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.20" }, + { name = "hypothesis", specifier = ">=6.100" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-cov", specifier = ">=5" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "flights" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "curl-cffi" }, + { name = "httpx" }, + { name = "plotext" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "ratelimit" }, + { name = "tenacity" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/a4/781bf3eb1cd51a01fe5c69104570cc888e645009522733267ac98fec7f39/flights-0.8.5.tar.gz", hash = "sha256:fed27974af8320b64529fd5a2fc87f809c42e820e538a1b1ed55e4afd0b73528", size = 11032932, upload-time = "2026-05-12T17:22:42.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/71/d71f8b72497806b1481e50dda8e6fc1258da05effca52fd20b471b66625e/flights-0.8.5-py3-none-any.whl", hash = "sha256:4b459a8e6abccb6e4456f1979f9daf74bbec7bb8cabda0fb271b0c9af7e9e5a1", size = 148963, upload-time = "2026-05-12T17:22:41.025Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "httpx-curl-cffi" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curl-cffi" }, + { name = "httpx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/1f/158975d2541effa30f0d7634542dce50e8280ab283e7efc8d221ebf8a949/httpx_curl_cffi-0.1.5.tar.gz", hash = "sha256:177ee9968e9da142407017816cc3fb08ab281b134f773a9359b6a4650a6c81f3", size = 7937, upload-time = "2025-12-02T08:59:13.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/13/82039e3df58e0d52a6f82cc73d958400a2777d78c6cd6378c937a707afd0/httpx_curl_cffi-0.1.5-py3-none-any.whl", hash = "sha256:be414a97ac1f627693f4c8a8631f2852bb1c09456e61ff8ad996ad050a11fb53", size = 8933, upload-time = "2025-12-02T08:59:12.447Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.152.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/dd/19d273652eb20dac15f32bbc484f2f6d51ccd8fe51fdb27da3f85f9017e8/hypothesis-6.152.7.tar.gz", hash = "sha256:741dedcede2ae0f32c32929a5992804b61f2b0400403b6a51a881a2b58482782", size = 468147, upload-time = "2026-05-13T04:19:34.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/1e/8222edaee03c37350eaa726213614e343a62f1e56396dd000ad9277bfa3d/hypothesis-6.152.7-py3-none-any.whl", hash = "sha256:c0b17dd428fcb6e962f60315f6f4a77816c72fbb281ce9ba73699dabead5ec82", size = 533802, upload-time = "2026-05-13T04:19:30.635Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "primp" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/ca/383bdf0df3dc87b60bf73c55da526ac743d42c5155a84a9014775b895e96/primp-1.2.3.tar.gz", hash = "sha256:a531b01f57cb59e3e7a3a2b526bb151b61dc7b2e15d2f6961615a553632e2889", size = 1342866, upload-time = "2026-04-20T08:34:09.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b1/05bb7e00bd17a439aae7ed49b0c0834508e3ce50624dfa43c6dcb8b71bd0/primp-1.2.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e96d6ab40fba41039947dad0fcc42b0b56b67180883e526715720bb2d90f3bfc", size = 4360352, upload-time = "2026-04-20T08:33:52.785Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/8bb1e4b6bb715f606b53793831e7910aebeb40169e439138e9124686820a/primp-1.2.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:42f28679916ce080e643e7464786abeb659c8062c0f74bb411918c7f07e5b806", size = 4035926, upload-time = "2026-04-20T08:34:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/52b5e6d840be4d5a8f689d9ba82dd616c67e29e3e1d13baf6a4c9be3f4b3/primp-1.2.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:643d47cf24962331ad2b049d6bb4329dce6b18a0914490dbf09541cb38596d39", size = 4309649, upload-time = "2026-04-20T08:34:01.369Z" }, + { url = "https://files.pythonhosted.org/packages/40/85/d98e20104429bb8393939396c2317ec6163a83d856b1fe555bf5f021e97a/primp-1.2.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:898a12b44af9aed20c10fd4b497314731e9f6dcab20f4aa64cf118f79df17fa0", size = 3910331, upload-time = "2026-04-20T08:33:37.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c7/53f11e2e2fe758830f6f208cef5bd3d0ecc6f538c0a2ca8e15a1fa502bd1/primp-1.2.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4bdf2b164c2908f7bdc845215dd21ebded1f2f43e9e9ae2d9a961ea56e5cb87", size = 4152054, upload-time = "2026-04-20T08:33:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5a/48e8f985daeeb58c7f1789bb6748d9aba250ea3541d787bcbcb1243abfd3/primp-1.2.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:036884d4c6c866c93a88e591e49f67ed160f6a9d98c779fc652cce63de62996a", size = 4443286, upload-time = "2026-04-20T08:34:07.782Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/42b32337bd8c7b7b8184a1fcd79fd51d4773427553a8d2036eb0a93a137d/primp-1.2.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc695c20f5c6e345abc3262bef3c246a571986db5cea73bdc41db6b166066c8", size = 4323757, upload-time = "2026-04-20T08:33:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/37/43/9ee78c283cbca7fcd635c2a9bb5633aa6568b1925958017e1a979fffe56c/primp-1.2.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13255b0826c60681478c787fbe29cfc773caf6242390fee047dac0f23f6e8c11", size = 4525772, upload-time = "2026-04-20T08:33:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/f10164485d84145a1ea18c7966d1ee098d1790b2088b7f122570463b7d5c/primp-1.2.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9bc40f94c8e58444befaf7e78b8aadd96e94e32789dadbe4a03785db772aa4dc", size = 4471705, upload-time = "2026-04-20T08:33:54.317Z" }, + { url = "https://files.pythonhosted.org/packages/18/a2/5328d66dd8f63bacfc9ef0e1e5fde66b2bb43103108fe8f01dcb2d2f0e50/primp-1.2.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2e0e8e245113ab3c9fd4b36014b3a04869cf08f72e8e7f36c4f5ef46d26da090", size = 4148309, upload-time = "2026-04-20T08:33:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c9/86/4d000d3ee341e5f1e73d81fb2c84e985f23b343a1aa4234c40987ec6eae7/primp-1.2.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3bb50934b5e209e7da4876d3419ebc23d1425fe6f089c0cd95382d21bd8a39bd", size = 4276881, upload-time = "2026-04-20T08:33:34.414Z" }, + { url = "https://files.pythonhosted.org/packages/22/f5/50acc3e349d22044abf4ed7c2abaaba11a01b9ccb6a7d66a3fbc2275c590/primp-1.2.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b293f13078afee9d41b74c77d05a5eaeb57dfab5110648dd24bc3fb3c750a05c", size = 4775229, upload-time = "2026-04-20T08:34:10.241Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d6/2c482973211666fdf2c4365babf343a88ea30f753ef650121cebd4ad7ece/primp-1.2.3-cp310-abi3-win32.whl", hash = "sha256:c600dd83e9c978bf494aab072cd5bbfdd59b131f3afc353850c53373a86992e5", size = 3504032, upload-time = "2026-04-20T08:33:28.344Z" }, + { url = "https://files.pythonhosted.org/packages/70/89/3d2032a8f5e986fcc03bc86ac98705ce2bd35ed0e182d4949c159214da6a/primp-1.2.3-cp310-abi3-win_amd64.whl", hash = "sha256:3cbbe52a6eb51a4831d3dd35055f13b28ff5b9be2487c14ffe66922bf8028b49", size = 3872596, upload-time = "2026-04-20T08:33:46.927Z" }, + { url = "https://files.pythonhosted.org/packages/79/00/f726d54ff00213641069a66ee2fadf17f01f77a2f1ba92de229830056419/primp-1.2.3-cp310-abi3-win_arm64.whl", hash = "sha256:03c668481b2cf34552880f4b6ebabfa913fdaeb2921ce982e42c428f451b630c", size = 3875239, upload-time = "2026-04-20T08:33:32.981Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e6/1fe1bcaf7566d7e6c9b39748f0d6ded857c80cf3252acf6aa3c6b7b8dbb0/primp-1.2.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:0148fd5c0502bb88a26217b606ebb254de44a666dac52657ff97f727577bc7ce", size = 4348055, upload-time = "2026-04-20T08:33:48.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/46/cae79466969a31d885409ce716eb5a4af66f66d1121e63ee3ddd7d666b9e/primp-1.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13bfc39172a083cc5520d9045a99988d7f8502458be139d6b1926de4d57e30e9", size = 4034114, upload-time = "2026-04-20T08:33:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c3/e8b24ff42ef6bf71f8b4277fd6d83a139d9b9ad14b0ffb4c76a8e9225a15/primp-1.2.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:283e751671e78cbac9f1ff72e89225d74bcd9ea08886910160f485cd78a55f2b", size = 4303794, upload-time = "2026-04-20T08:34:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9c59197fee68abdc53683b65b6b0e5ea6e0f098cc2527af29edd05b22ed4/primp-1.2.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec5d94244f24c61b350791112c1687c97d144f400a7ea5d8476dbd2fad6de9af", size = 3908577, upload-time = "2026-04-20T08:33:59.567Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/25c7cfcfe33f2fa82ab8cc72f4082fc12c8f803ceb2624c787fa72c73e12/primp-1.2.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48d459dda86bfddce3a14b514694e0c8028f20cc461dd52e105aba400c622513", size = 4153501, upload-time = "2026-04-20T08:34:03.22Z" }, + { url = "https://files.pythonhosted.org/packages/04/9b/83e61b32b9049478f63c1956ff3244619691c3edf6e96b19254047756b36/primp-1.2.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0d8441f07c948a54ff21bfc4094cd2967b5fb6f8a9ef0c9562a1eb35da0127e", size = 4440289, upload-time = "2026-04-20T08:33:24.734Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/48c351f601f204c52d5a477d7a19efcd8b7deb6478c57195fbe8bf9c3632/primp-1.2.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:728b6f63552f1bc729e0490ee69a3a9fbd39698c1578b0e6313f3779de469a8c", size = 4306848, upload-time = "2026-04-20T08:33:40.482Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9a/683b2fbb8c2df3b3d2b351e86eec0a873479b95bb804e5d882939057366b/primp-1.2.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9952509dcd8aa8750f4cdfc86b24cdcec96f9342a6525003bc3446ba466d5512", size = 4521679, upload-time = "2026-04-20T08:33:38.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/eb/bbbbc79cf4132f2beeb0a0f99f41837dd66c158d0439f59df75a2d592d0a/primp-1.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e74a6aa83aa65d02665ad5d0e53d7877ea8abce6f3d50ed92495f9c4c9d10e2e", size = 4469041, upload-time = "2026-04-20T08:33:51.161Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/208fe1c6e9d3a28c6eae530378ac9c60fe8ae389f68a4707ef7986d5b133/primp-1.2.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1dfc4dd5bf7e4f9a45f9c7ae1256f957cc7ca8d5344f82d335129a13e1d7ffea", size = 4144925, upload-time = "2026-04-20T08:33:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/da/06/ce175b54edecf22b750da67bc095bea53f2d87d191c4461b30122c7ddd1e/primp-1.2.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:97c37df6fc7eb989f324b326e0547b23757a82f9d8b334075d4b0bd29e310723", size = 4276929, upload-time = "2026-04-20T08:33:49.521Z" }, + { url = "https://files.pythonhosted.org/packages/2e/12/a913ab53ad61fe51d44ef0f8fee6b63a897d2b9c8a4b43299d7f5decc003/primp-1.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ead9c8b660fa0ebb193317b85d61dd1bd840a3e97e882b33b896dc9e37ed6d63", size = 4772684, upload-time = "2026-04-20T08:33:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/2c3aafb2814703979bf5a216575f4478bcf6070bc6ec143056cf3b56c134/primp-1.2.3-cp314-cp314t-win32.whl", hash = "sha256:cb52db4d54105097773c0df4e2ef0ce7b42461d9d4d3ec28a0622ca2d22945bb", size = 3499126, upload-time = "2026-04-20T08:33:29.953Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/bad9c739a35f4cf69e2f39c01f664c9a5969e753c4cfcdc33e113cc984af/primp-1.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d85a44585df27038e18298f7e35335c4961926e7401edd1a1bf4f7f967b3f107", size = 3870129, upload-time = "2026-04-20T08:34:11.666Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/882563dc554c5f710b4e47c58978586c2528703435800a7a84e81b339d90/primp-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:6513dcb55e6b511c1ae383a3a0e887929b09d8d6b2c70e2b188b425c51471a02", size = 3874087, upload-time = "2026-04-20T08:33:58.109Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "ratelimit" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" } + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "selectolax" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/1a/ce7768c1bff5cf07e3e12f904d54a63c3f85f78e3b299c0b561839d0238e/selectolax-0.4.8.tar.gz", hash = "sha256:cd703165b9a346be255e2ca5b4219e01009911977ac8a474d8ccb7e32e9a4fae", size = 4875521, upload-time = "2026-05-04T15:10:44.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/3c/9cf255f11d04cf203b61f5a85fb273bc85778c3ab5d2ad98ce892f7df3b4/selectolax-0.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbcd4cb837dec4b16a3ab6a8b2ec4388ea20c050092b68536dc9a60ce8f19b56", size = 2236812, upload-time = "2026-05-04T15:09:20.193Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/4c343f767fa61131fc03b3de278dfcff024485996d7d6c917b55b0f9ce16/selectolax-0.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1cdcf946dd46e11640ca0a0361945c3ed65627ba4bd219ccf84a53fab28c072", size = 2287194, upload-time = "2026-05-04T15:09:22.15Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7d/5a47a0d102709297b6b09473a58c7e9727955a0d6e0c8eba9b27574e3680/selectolax-0.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:074254670a8a00b36202ab0cd99ce8fb2a25b3b2f10072891a35a6a85c53f679", size = 2369192, upload-time = "2026-05-04T15:09:23.768Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/48b5cfa5b2803379855050310c876a6f68b7e53598f9252e5bb3fb911ff0/selectolax-0.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bbd33d348a555b37d5f922c421338db690500e5b579353ffa24e3bd23a04704", size = 2415401, upload-time = "2026-05-04T15:09:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/cf0ffe4668af87f2071253ad43cccf73ac6bc6324c13fefe38fdb198d54b/selectolax-0.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c0cf863f652ed6de58c9f3599173be70064827807f46ddd38ad82b185fead322", size = 2373885, upload-time = "2026-05-04T15:09:26.889Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/4de417c1bbc44c31453ac0582f578ddfdf4f4e29d55d451e4435cef7f4eb/selectolax-0.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca2e1a9a06d1b1a7549d2828240f1888a172b60720baf8a2cb90936482d8b6b4", size = 2437684, upload-time = "2026-05-04T15:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e4/a465e3d7b641b7693c36a20401c1cd924bd9038dfbee195c11087de08aae/selectolax-0.4.8-cp312-cp312-win32.whl", hash = "sha256:b812691bbdf7c958b29956138917bd46cd78b96f2d4f51f56063f584f0d6c476", size = 1759388, upload-time = "2026-05-04T15:09:30.679Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/b7e681cf829f58be0927b10d606ec96126e3d863b11c4ba6cabb701a1b32/selectolax-0.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:877df05c9fb306ed6b4d094cbd655ad8e89ea4744d3597eb1e5217c7f71d4ea4", size = 1855778, upload-time = "2026-05-04T15:09:32.284Z" }, + { url = "https://files.pythonhosted.org/packages/2d/85/9b64306821eb38fb84510e43bd5e8685fbf179ce5717496522fdf208573c/selectolax-0.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:52db6be936a0f0648d9d8acbfdf16d4c5a16600cfc31db1b557b0effd8b164ce", size = 1805553, upload-time = "2026-05-04T15:09:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/678ea1250811a4b42686c506df43d3c752ce2158dc66fd4f60c39d4ae1d6/selectolax-0.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2c4bed19e0c3e34b877b6df607150725d84a38dda32bccb030d8744078eda6", size = 2236220, upload-time = "2026-05-04T15:09:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fe/1624eb5024e897bf4074bfc31f9e5e823160aed1ac14e7720e849a3d1109/selectolax-0.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a2d83e04bf0a9f0a8c9a6a7989df1844acc2242025f26293c1e12ce82b4a3f9", size = 2286820, upload-time = "2026-05-04T15:09:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/f0d4e4061de679f5f3d1858f50d31b09935fa97c05f8a2b2e46c344c74a8/selectolax-0.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8513c179ec9758d9bbc43a9e7e58fecfcfc1ec88e9100ed592cce2703c316b63", size = 2368949, upload-time = "2026-05-04T15:09:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2d/2ba76651c93907b2c3c0005d4745df94493e29bd15cfb9edd7c0e6a7a4d9/selectolax-0.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b6225fd6b5bec6be59bc07aeee17f50448c9b4769531afaa079c74650ebd2c5", size = 2415032, upload-time = "2026-05-04T15:09:40.93Z" }, + { url = "https://files.pythonhosted.org/packages/b8/08/a20f9ef0213b8f33c5164781e8cc18eeb059779ed8b4051df39cbfde3d50/selectolax-0.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:899b62117147fd804b43822e7af0c422730c6743b27b20b233c4896363b1e655", size = 2373656, upload-time = "2026-05-04T15:09:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/ba692cc70aa45feb55d8241e05f8b8bde97ed4c7d5b4d83b148d1f6ebc43/selectolax-0.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:573b67610d2876b67707eaaad71d28da5a0d4ce72ab4880a68abeacdbdd825b1", size = 2437386, upload-time = "2026-05-04T15:09:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/771e3e7ad7ef51ddafecf7b80e92421113cc4026fb951740009633d34d1d/selectolax-0.4.8-cp313-cp313-win32.whl", hash = "sha256:69cf8b1edbc2f6e401a1be0a2fc6eb1ea05679446053724a3ac399abea1e98d3", size = 1759310, upload-time = "2026-05-04T15:09:47.103Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4d/6635f0ba2d0f456f5f5ded29691fab431da94ae8403b88379da730337971/selectolax-0.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:e57a2c314b339ffd6da899bf7d37a3d850aa03b8bcbcbb25d183fdae4525ad6d", size = 1856663, upload-time = "2026-05-04T15:09:49.247Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2d/a9adae174d3b6e83eda10854e3921f298a2764f604bb6b8bea68461cb003/selectolax-0.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:9a593eaf1a6686b559e3f65f20d21c592933cf6c18a0a042a9f46d96a88f54d3", size = 1805441, upload-time = "2026-05-04T15:09:51.3Z" }, + { url = "https://files.pythonhosted.org/packages/25/60/d1a14de44725277d91111931d785edc8387de1e95fe0987766b1e73a899e/selectolax-0.4.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:412d8c3203d294a36bed5b01942d8f81724a6731458e257b0153f2f4425e3da9", size = 2252331, upload-time = "2026-05-04T15:09:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/4e/79/2455aab4dc66a7f6c49af5dda759eca3bdbd20f2fa4193eb1daf32abc93e/selectolax-0.4.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:45e02ed68d9888828edae568c17ebd055f265221c2784329ed63078283c4e0ea", size = 2304020, upload-time = "2026-05-04T15:09:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/04/81/ae320006cb5f327926d7888fabdec3181c1a4dbb6218ebe05f10f204fdd0/selectolax-0.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f7aa77dec4a04b8b63aeaa505c168ac2597df2be426783ede8d74d56e925b48", size = 2368262, upload-time = "2026-05-04T15:09:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b4/cf8c860906aeeb074a0babe070d399416b9ec80c1029e1ed187aed443936/selectolax-0.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fab78f7cab55c06a1a994f522adb5b4c5302aaacac60bdca7de1f8eaacaa9f9", size = 2414392, upload-time = "2026-05-04T15:09:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/ab5f27398bfa65d49e92b5846e0ed4bb92c45c5cfe759d3c14ba47845085/selectolax-0.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7923f90a5d51325cde8b47e41aa71a0f81429afb85d26966369389c5eaf99c6e", size = 2390375, upload-time = "2026-05-04T15:10:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3d/0045463e7b8e9229a422507aac28fa71fa5cf548c1410f47f1337fe54bdc/selectolax-0.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7af017e28bcdcea1d7aaeab329f78515fab3becf30c4c9408e0c9b8303be1393", size = 2454560, upload-time = "2026-05-04T15:10:03.135Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9c/628723b10aece08f963b8d38473433df66986b419c9789d130cae9ff61dd/selectolax-0.4.8-cp314-cp314-win32.whl", hash = "sha256:40f6de3a820983c1f3feacb60351677b69d0c829b63aa11c7bd4811bcbcb8f42", size = 1870694, upload-time = "2026-05-04T15:10:04.912Z" }, + { url = "https://files.pythonhosted.org/packages/34/e4/2d111f68cbd2ed1270d37b2fe23f9dbd2059cfa80f492ee19b3ad3b641b4/selectolax-0.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ce6668c260ada8880106f6d37cd073b53ac3342f04b8a771dacb6ea68d953285", size = 1965854, upload-time = "2026-05-04T15:10:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/f0042a20e4ca914e66d2f513491bfc92d907ead59d3a63c6e56e6130b006/selectolax-0.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:5125e171b16bbbdb76ea36140c165f183b1e99fe4170eb9b79a7141090bba51c", size = 1914520, upload-time = "2026-05-04T15:10:08.223Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3b/5d0087a277802a0054073d67919e560de3fada54497762f065ea5e54b0cb/selectolax-0.4.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:bc041e2554c4cce221401bbde42177a99670fdba8a60448f0ae65731fc08a0b5", size = 2266985, upload-time = "2026-05-04T15:10:10.405Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/1c398f0040f21897945d6a750dbc49bf1e0190f57490ba631bda4a421e86/selectolax-0.4.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:957fd757b0491e29f96235cb5ce0a46b8cdc8cce41f5032805387311d2504677", size = 2314767, upload-time = "2026-05-04T15:10:12.239Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/4ef2186699d1097f7275eade50cc27d41a1ecb8311b8d46c185e5266dd9d/selectolax-0.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d001ceff303937e0d05ecba4c1a8d39ea04840a653ccaecf59446667c16f65bc", size = 2374250, upload-time = "2026-05-04T15:10:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/9f/01/bb5fe5a69997282764174d4fdd0246d6ceb269c03806f07ad454d3fdc07c/selectolax-0.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f293e28183b329bed738918d18cd35966add6600bad2f3431470ffa18c0639e", size = 2425296, upload-time = "2026-05-04T15:10:16.493Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/5114524c7480c6f55791ef7ab82a4a923b0367553bc9f39541ed8b392117/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3972b884caa78abf3710a0e7723dc705974dbdaef46d98a05e47c68493d86922", size = 2401425, upload-time = "2026-05-04T15:10:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/59/8d/e51946644e5abbe53d08634ac2251f73132d290116a9d349ec5d381c8ba0/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a6aacac9d106a2c992b817b5c1d4a1aa2e825403a5c1354386657968fa4b9b15", size = 2462447, upload-time = "2026-05-04T15:10:20.026Z" }, + { url = "https://files.pythonhosted.org/packages/9b/54/2250ce95c2b4f47f43b5fb9a156db823faffe1623c95e42137c738234573/selectolax-0.4.8-cp314-cp314t-win32.whl", hash = "sha256:ccfb2d172db96401a8d4ebc2dc12f4f4f20208597f5e6510d3f6bdc5a49c46b5", size = 1919388, upload-time = "2026-05-04T15:10:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/4e576a67fdd3a44d76418a196b7850890af8ef254efbca3eabef1b70fad5/selectolax-0.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:5c34fd9a29a430c1f8800d94e7a2c4fd0b1aef10485b0871fbae914cde232c41", size = 2036030, upload-time = "2026-05-04T15:10:23.775Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/1e080cf00d7c4712e6411cd9cd3c927ec6ad4adf25d280a73ef38aeeb68a/selectolax-0.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:136dfad919728023fcd13fc773a1c488204fb0efd9c1f9baf7bd815fbb35eec3", size = 1939289, upload-time = "2026-05-04T15:10:25.481Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stamina" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/bd/b2f71ae14368a066f103d182f25bbc6c3bf4aa695889f3ed3cba026d6f36/stamina-26.1.0.tar.gz", hash = "sha256:0214d05fdf5102c518194a4aac7520ce53cf660550ae3b940701aad88cf50c17", size = 568171, upload-time = "2026-04-13T17:44:31.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/f0/1ff90a1d1dd02de23feafdf9dffaecef3958348be5c192df56670ccb4f86/stamina-26.1.0-py3-none-any.whl", hash = "sha256:62e06829bec87c06d4cafde520b32a6097d1017c378a9eb63253c5bf5ebbbb88", size = 18508, upload-time = "2026-04-13T17:44:29.545Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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" } +wheels = [ + { 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" }, +] From 3688b147e324121a560c3816e1b62c2b8709004d Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 00:31:01 -0400 Subject: [PATCH 2/9] Silence reportAny: declare Profile-B edge for untyped boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flight-cli is a CLI veneer over RE/scraping work — fli/fast_flights ship no type stubs, Matrix's JSON response is dict[str, Any] by design, and pydantic's ValidationInfo.data + Field overloads are Any internally. All 62 reportAny warnings sat at these unfixable boundaries. Keep all reportUnknown* rules at their strict defaults (those are the higher-signal "can't type this" warnings, and they already caught real gaps during the strict-mode migration). Silence reportAny only. `make check`: 0 errors, 0 warnings. Also: hoist the reportMissingTypeStubs ignore to the from-import line in fli_bridge.run_gflight_search (the per-symbol placement only suppressed the symbol-level miss, not the module-level one). --- AGENTS.md | 8 ++++++-- pyproject.toml | 8 +++++++- src/flight_cli/fli_bridge.py | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ccf5f05..044c0e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,8 +125,11 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see ## Appropriate divergence -This project is **Profile A — distributable CLI**. Tunings already -applied versus the strict-service default: +This project is **Profile A — distributable CLI** with a Profile-B edge +(reverse-engineering / scraping) — the CLI surface is typed strictly, +but the integration boundaries with `fli`, `fast_flights`, Matrix's +undocumented JSON, and pydantic internals are inherently `Any`-typed. +Tunings applied versus the strict-service default: | Knob | Default | flight-cli | |---|---|---| @@ -136,6 +139,7 @@ applied versus the strict-service default: | Coverage `fail_under` | 80 | 0 (golden-file suite) | | `PLR0913` (too many args) | strict | ignored (CLI verbs are wide) | | `N815` (camelCase) | strict | per-file-ignored in `wire.py`, `domain.py`, `models.py` — Matrix wire JSON dictates field names | +| `reportAny` | `"warning"` | `"none"` — Profile-B edge: fli/fast_flights ship no stubs; Matrix response is `dict[str, Any]` by design; `ValidationInfo.data` and `Field` overloads are Any internally. `reportUnknown*` stays on (higher-signal "can't type this"). | ## flight-cli load-bearing quirks (read before touching wire.py) diff --git a/pyproject.toml b/pyproject.toml index fc68f6a..8ebe4a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,13 @@ typeCheckingMode = "strict" reportMissingTypeStubs = "warning" reportImplicitOverride = "error" reportUnusedCallResult = "none" -reportAny = "warning" +# DIVERGE Profile A → A/B edge: flight-cli is a CLI veneer over RE/scraping +# work. fli/fast_flights ship no stubs; Matrix's JSON response is +# dict[str, Any] by design (pydantic validates downstream); pydantic's +# ValidationInfo.data and Field overloads are Any internally. All +# reportUnknown* rules stay on (the higher-signal "can't type this" +# warnings); silencing reportAny only. +reportAny = "none" # --- pytest --- [tool.pytest.ini_options] diff --git a/src/flight_cli/fli_bridge.py b/src/flight_cli/fli_bridge.py index b76b3f2..6c74438 100644 --- a/src/flight_cli/fli_bridge.py +++ b/src/flight_cli/fli_bridge.py @@ -98,8 +98,8 @@ def _seg(origin: str, dest: str, dt: str) -> FlightSegment: def run_gflight_search(s: Search, *, top_n: int = 5) -> Any: """Build a fli filter from a Search and run the Google Flights query.""" - from fli.search.flights import ( # noqa: PLC0415 - SearchFlights, # pyright: ignore[reportMissingTypeStubs] + from fli.search.flights import ( # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs] + SearchFlights, ) return SearchFlights().search(to_fli_filter(s), top_n=top_n) From d6d750a1bbb480cd6b8482c5f8272e1a767c6e50 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 00:48:29 -0400 Subject: [PATCH 3/9] Adopt anyio + add flake.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops two divergences that weren't actually justified: * asyncio → anyio. Surface was tiny (asyncio.Semaphore at _http.py:90, asyncio.run at cli.py:195,722). httpx is anyio-internal under the hood, so we were bypassing the runtime our HTTP client already speaks. anyio's CancelScope semantics also clean up Ctrl-C behavior during long Matrix searches. ~5 lines of code change. * Add flake.nix. Template-provided, derives Python interpreter from pyproject.toml's `requires-python` via pyproject-nix.lib.project. loadPyproject — bumping the floor auto-swaps the dev shell. Also activates the conditional nix path in CI (.github/workflows/ci.yml's `hashFiles('flake.nix') != ''` block), restoring reproducibility verification on every PR. AGENTS.md updated: stack table flips async row to ✓, principle 8 now documents the anyio idiom, asyncio divergence note removed from principle 4. `make check`: 0/0. Live `flight fare JFK LHR --dep ... -n 2` smoke test under the anyio runtime returns identical output (64 solutions, cheapest USD877 VS154). --- AGENTS.md | 11 ++++---- flake.nix | 61 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + src/flight_cli/_http.py | 4 +-- src/flight_cli/cli.py | 6 ++-- uv.lock | 2 ++ 6 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 flake.nix diff --git a/AGENTS.md b/AGENTS.md index 044c0e5..8c0f7b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ work. | Type checker | `basedpyright` strict | ✓ (was `pyright`) | mypy, pyright | | Boundary validation | Pydantic v2 | ✓ (see `wire.py`, `models.py`, `domain.py`) | dataclasses, attrs, TypedDict | | HTTP | `httpx` | ✓ | requests, aiohttp, urllib | -| Async runtime | `anyio` | asyncio (DIVERGE — see below) | raw asyncio | +| Async runtime | `anyio` | ✓ | raw asyncio | | Logging | `structlog` | stdlib `logging` (DIVERGE — see below) | `print()` | | Paths | `pathlib.Path` | ✓ | `os.path` | | Tests | `pytest` + `hypothesis` | `pytest` (no hypothesis yet) | unittest | @@ -98,8 +98,6 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see 4. **Diverge with a comment.** When tuning a default below (or deviating from the stack table), leave `# DIVERGE: ` so future readers don't "fix" it back. Existing divergences from the template default: - - `asyncio` instead of `anyio` — flight-cli is single-runtime; no - library-author concern. Cost of switching is real, value is small. - stdlib `logging` instead of `structlog` — CLI tool with rich-handler output; structured logging would target a sink we don't have. - Coverage gate at 0% — golden-file regression tests; fixture parity @@ -119,9 +117,10 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see the top of every module; runtime-only third-party types inside `if TYPE_CHECKING:`. -8. **Async runtime is asyncio (DIVERGE).** Not anyio. See divergence note - in principle 4. Stick with asyncio unless we add a library-author - concern. +8. **Async runtime is anyio.** Not raw asyncio. Compose with sync at the + edges via `anyio.from_thread` / `anyio.to_thread`. CLI entrypoints use + `anyio.run(coro_fn)` (not `asyncio.run(coro_fn())`). Concurrency + primitives: `anyio.Semaphore`, `anyio.create_task_group()`. ## Appropriate divergence diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7d60092 --- /dev/null +++ b/flake.nix @@ -0,0 +1,61 @@ +{ + description = "flight-cli: ITA Matrix Alkali backend wrapper (uv-managed; Nix dev shell optional)."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, pyproject-nix }: + let + inherit (nixpkgs) lib; + systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]; + forAllSystems = lib.genAttrs systems; + pkgsFor = system: nixpkgs.legacyPackages.${system}; + + # Python interpreter follows pyproject.toml's `requires-python`: the + # first PEP 440 spec is taken as the floor (handles `>=X.Y` and + # `>=X.Y,=0.27", "httpx-curl-cffi>=0.1.5", + "anyio>=4", "aiolimiter>=1.1", "stamina>=24.3", "pydantic>=2.7", diff --git a/src/flight_cli/_http.py b/src/flight_cli/_http.py index 5862bdf..f29449d 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -3,7 +3,6 @@ from __future__ import annotations -import asyncio import hashlib import json import logging @@ -12,6 +11,7 @@ from http import HTTPStatus from typing import Any, cast +import anyio import httpx import stamina from aiolimiter import AsyncLimiter @@ -87,7 +87,7 @@ def __init__( self._limiter = AsyncLimiter(max_rate=1, time_period=1.0 / rps) else: self._limiter = AsyncLimiter(max_rate=rps, time_period=1.0) - self._sem = asyncio.Semaphore(concurrency) + self._sem = anyio.Semaphore(concurrency) if cache_dir is None: cache_dir = pathlib.Path( diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index e8f49da..6a626f3 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -11,7 +11,6 @@ from __future__ import annotations -import asyncio import json import logging import re @@ -19,6 +18,7 @@ from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Annotated, Any, cast +import anyio import typer from rich.console import Console from rich.logging import RichHandler @@ -192,7 +192,7 @@ async def go() -> SearchResult | CalendarResult: return await c.execute(search, cache=not no_cache) try: - return asyncio.run(go()) + return anyio.run(go) except MatrixApiError as e: err.print(f"[red]Matrix returned an error ({e.kind}):[/] {e.message}") if e.request_id: @@ -719,7 +719,7 @@ async def go() -> list[Location]: async with MatrixClient(impersonate=impersonate) as c: return await c.airports(query) - locs = asyncio.run(go()) + locs = anyio.run(go) if not locs: console.print("[yellow]No matches.[/]") return diff --git a/uv.lock b/uv.lock index c700ab9..9359c4d 100644 --- a/uv.lock +++ b/uv.lock @@ -287,6 +287,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiolimiter" }, + { name = "anyio" }, { name = "fast-flights" }, { name = "flights" }, { name = "httpx" }, @@ -309,6 +310,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiolimiter", specifier = ">=1.1" }, + { name = "anyio", specifier = ">=4" }, { name = "fast-flights", specifier = ">=2.2" }, { name = "flights", specifier = ">=0.8" }, { name = "httpx", specifier = ">=0.27" }, From 382e41e2d0a003bf12568cb6f19234bf3b399057 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 01:42:53 -0400 Subject: [PATCH 4/9] AGENTS.md: response models are extra="ignore", not strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Principle 1 claimed response models in models.py "validate strictly". They don't — _Loose base uses extra="ignore", same as wire models, for the same Profile-B reason (Matrix is reverse-engineered and adds fields unannounced in directions that aren't load-bearing). Adds a row to the Appropriate-divergence table documenting the explicit carveout the template anticipates: "Pydantic at every external edge with extra='forbid' unless an explicit comment justifies otherwise." This is the textbook justified case. Domain models in domain.py keep extra="forbid" — that's where we control the contract. --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c0f7b8..5391777 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,12 +62,14 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see ## Principles 1. **Boundaries fail loudly.** Pydantic at every external edge. Wire - models in `wire.py` use `extra="ignore"` for forward-compat (Matrix - adds fields unannounced); domain models in `domain.py` and response - models in `models.py` validate strictly. Domain errors - (`MatrixApiError`, `ApiKeyResolutionError`) wrap third-party - exceptions — `httpx.HTTPStatusError` never leaks to a caller. Shape - drift is an alarm, not a silent fallthrough. + models in `wire.py` AND response models in `models.py` both use + `extra="ignore"` — Matrix is reverse-engineered and adds fields + unannounced in directions that aren't load-bearing for us + (Profile-B-edge divergence; see "Appropriate divergence" below). + Domain models in `domain.py` use `extra="forbid"` — that's where + *we* control the contract. Domain errors (`MatrixApiError`, + `ApiKeyResolutionError`) wrap third-party exceptions — + `httpx.HTTPStatusError` never leaks to a caller. 2. **Suppress with cost.** Every `# pyright: ignore[code]` and `# noqa: code` names the specific rule and a one-line reason. Suppression is annotated @@ -139,6 +141,7 @@ Tunings applied versus the strict-service default: | `PLR0913` (too many args) | strict | ignored (CLI verbs are wide) | | `N815` (camelCase) | strict | per-file-ignored in `wire.py`, `domain.py`, `models.py` — Matrix wire JSON dictates field names | | `reportAny` | `"warning"` | `"none"` — Profile-B edge: fli/fast_flights ship no stubs; Matrix response is `dict[str, Any]` by design; `ValidationInfo.data` and `Field` overloads are Any internally. `reportUnknown*` stays on (higher-signal "can't type this"). | +| Pydantic `extra` on boundary models | `"forbid"` | `"ignore"` on wire + response (`_Wire`, `_Loose`) — Profile-B edge: Matrix is reverse-engineered and adds fields unannounced in directions that aren't load-bearing. Domain models stay `extra="forbid"` (we control that contract). | ## flight-cli load-bearing quirks (read before touching wire.py) From 4c032a5d3b4cf2c71ea08d73d31ebb02568567af Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 01:44:42 -0400 Subject: [PATCH 5/9] Render currency once in table titles, not on every cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix returns prices as 'USD877.00' (ISO-4217 prefix + decimal). Within a single query the currency is uniform, so showing 'USD' on every grid cell, itinerary row, and calendar day is noise. Now the currency renders once in the table title ('Itineraries (USD)', 'Carrier x stops grid (USD)', 'lowest fare per departure day (USD)') and the header line ('cheapest: 877.00 (USD)'). Cells show just the amount. Helpers: _split_price(s) -> (currency, amount), _amount(s) -> str. Both pass through placeholders like '—' unchanged. --- src/flight_cli/cli.py | 49 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 6a626f3..7d15460 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -46,6 +46,25 @@ _SLICE_MIN_PARTS = 2 _SLICE_MAX_PARTS = 3 +# Matrix returns prices as 'USD877.00' (ISO-4217 prefix + decimal). We split +# the prefix off for rendering so tables can show the currency once in the +# title and keep cells uncluttered. +_PRICE_RE = re.compile(r"^([A-Z]{3})(.+)$") + + +def _split_price(s: str | None) -> tuple[str, str]: + """Return (currency, amount). ('', s) if no recognizable prefix.""" + if not s: + return "", s or "" + m = _PRICE_RE.match(s) + return (m.group(1), m.group(2)) if m else ("", s) + + +def _amount(s: str | None) -> str: + """Strip the currency prefix; pass-through for placeholders like '—'.""" + return _split_price(s)[1] if s else "—" + + app = typer.Typer( add_completion=False, rich_markup_mode="rich", help="CLI for ITA Matrix's Alkali backend." ) @@ -223,12 +242,20 @@ def _render_search(res: SearchResult) -> None: if res.solution_count == 0: console.print("[yellow]No solutions returned.[/]") return - cheapest = res.cheapest_price or "—" - console.print(f"[bold]{res.solution_count} solutions[/] · cheapest: [bold cyan]{cheapest}[/]") + ccy, cheapest = _split_price(res.cheapest_price) + ccy_tag = f" ({ccy})" if ccy else "" + console.print( + f"[bold]{res.solution_count} solutions[/] · " + f"cheapest: [bold cyan]{cheapest or '—'}{ccy_tag}[/]" + ) cm = res.carrier_stop_matrix if cm and cm.columns and cm.rows: - t = Table(title="Carrier x stops grid", show_header=True, header_style="bold magenta") + t = Table( + title=f"Carrier x stops grid{ccy_tag}", + show_header=True, + header_style="bold magenta", + ) t.add_column("stops") for col in cm.columns: code = col.label.code if col.label else "?" @@ -237,13 +264,13 @@ def _render_search(res: SearchResult) -> None: for row in cm.rows: cells = [str(row.label) if row.label is not None else "?"] for c in row.cells: - p = c.min_price or "—" + p = _amount(c.min_price) mark = "★" if c.min_price_in_grid else ("·" if c.min_price_in_row else "") cells.append(f"{p} {mark}") t.add_row(*cells) console.print(t) - st = Table(title="Itineraries", show_header=True, header_style="bold green") + st = Table(title=f"Itineraries{ccy_tag}", show_header=True, header_style="bold green") st.add_column("#", justify="right") st.add_column("price", justify="right") st.add_column("carriers") @@ -265,7 +292,7 @@ def _fmt(s: Slice) -> str: out = _fmt(slcs[0]) if slcs else "—" ret = _fmt(slcs[1]) if len(slcs) > 1 else "—" - st.add_row(str(i), it.price or "—", it_carriers or "?", out, ret) + st.add_row(str(i), _amount(it.price), it_carriers or "?", out, ret) console.print(st) @@ -286,13 +313,15 @@ def _render_calendar( "for a single date." ) return + ccy, cheapest = _split_price(res.cheapest_price) + ccy_tag = f" ({ccy})" if ccy else "" console.print( f"[bold]{res.solution_count} solutions[/] · " - f"overall cheapest: [bold cyan]{res.cheapest_price}[/] · " + f"overall cheapest: [bold cyan]{cheapest or '—'}{ccy_tag}[/] · " f"window {sd.isoformat()} → {ed.isoformat()} · " f"duration {dmin}-{dmax} nights" ) - title = f"{','.join(origin)} → {','.join(destination)}: lowest fare per departure day" + title = f"{','.join(origin)} → {','.join(destination)}: lowest fare per departure day{ccy_tag}" t = Table(title=title, show_header=True, header_style="bold green") t.add_column("departure", justify="right") t.add_column("min", justify="right") @@ -300,10 +329,10 @@ def _render_calendar( t.add_column(f"{dur}n", justify="right") t.add_column("sols", justify="right") for d in sorted(res.priced_days, key=lambda x: x.price_value or 9e9): - row = [str(d.date), d.min_price or "—"] + row = [str(d.date), _amount(d.min_price)] opts = {o.trip_length: o.min_price for o in d.options} for dur in range(dmin, dmax + 1): - row.append(opts.get(dur, "—")) + row.append(_amount(opts.get(dur))) row.append(str(d.solution_count)) t.add_row(*row) console.print(t) From 010ddb1bfece533624f25fbc2f610683b3aa5e7b Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 01:51:31 -0400 Subject: [PATCH 6/9] Adopt structlog: drop stdlib logging + RichHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template parity. The four log sites in _http.py (cache_read_failed, cache_write_failed, cache_hit GET/POST) now emit key-value events through structlog's ConsoleRenderer; CLI `-v`/`-vv` selects info/debug. * New src/flight_cli/log.py — idempotent configure(level) with TimeStamper + ConsoleRenderer; colors auto-disable on non-TTY. * _http.py: structlog.get_logger(__name__) typed as BoundLogger via if TYPE_CHECKING (per AGENTS.md gotcha #1); printf-style calls rewritten as kv events. * cli.py main(): logging.basicConfig + RichHandler removed; replaced with configure_logging("warning"/"info"/"debug"). * structlog added as explicit dep. * AGENTS.md: Stack table flips Logging to ✓; canonical-examples list gains a Logging-config entry; divergence note for stdlib-logging removed from principle 4. `-vv` output: `01:47:31 [debug ] cache_hit method=POST url=...` Cleaner than the old `[01:47:31] DEBUG cache hit POST https://...`, and now bindable with contextvars for future request-correlation. --- AGENTS.md | 11 ++++++---- pyproject.toml | 1 + src/flight_cli/_http.py | 19 ++++++++++------- src/flight_cli/cli.py | 17 ++++++---------- src/flight_cli/log.py | 45 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 11 ++++++++++ 6 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 src/flight_cli/log.py diff --git a/AGENTS.md b/AGENTS.md index 5391777..f852563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ work. | Boundary validation | Pydantic v2 | ✓ (see `wire.py`, `models.py`, `domain.py`) | dataclasses, attrs, TypedDict | | HTTP | `httpx` | ✓ | requests, aiohttp, urllib | | Async runtime | `anyio` | ✓ | raw asyncio | -| Logging | `structlog` | stdlib `logging` (DIVERGE — see below) | `print()` | +| Logging | `structlog` | ✓ (see `log.py`) | `print()` | | Paths | `pathlib.Path` | ✓ | `os.path` | | Tests | `pytest` + `hypothesis` | `pytest` (no hypothesis yet) | unittest | | Errors | subclass project-local error hierarchy | `MatrixApiError`, `ApiKeyResolutionError` | bare `Exception`, string errors | @@ -90,7 +90,10 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see the boundary. - **HTTP transport**: `src/flight_cli/_http.py` — `stamina.retry` decorator, `aiolimiter.AsyncLimiter`, `httpx-curl-cffi` for - fingerprinting, on-disk JSON cache. + fingerprinting, on-disk JSON cache. structlog `BoundLogger` for + cache-hit / retry / rate-limit diagnostics. + - **Logging config**: `src/flight_cli/log.py` — idempotent + `configure(level)` with ConsoleRenderer; stderr-bound for CLI use. - **Env + disk-cache config**: `src/flight_cli/_api_key.py` — env-var override → disk cache (TTL) → live resolution → cache write. - **Test pattern**: `tests/test_wire_round_trip.py` — captured SPA @@ -100,10 +103,10 @@ cache (`~/.cache/flight-cli/.matrix-key`) or a Matrix brownout (see 4. **Diverge with a comment.** When tuning a default below (or deviating from the stack table), leave `# DIVERGE: ` so future readers don't "fix" it back. Existing divergences from the template default: - - stdlib `logging` instead of `structlog` — CLI tool with rich-handler - output; structured logging would target a sink we don't have. - Coverage gate at 0% — golden-file regression tests; fixture parity is the signal, not line coverage. + - See "Appropriate divergence" table for the Profile-A→A/B-edge + tunings (reportAny, Pydantic `extra` on boundary models). 5. **Ask when guessing.** Unknown Matrix wire shape, new SPA capture needed, irresolvable type error → ask. Don't invent the shape; capture diff --git a/pyproject.toml b/pyproject.toml index 11affe8..e931ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "aiolimiter>=1.1", "stamina>=24.3", "pydantic>=2.7", + "structlog>=24", "typer>=0.12", "rich>=13", "flights>=0.8", # fli — drives Google Flights for booking handoff diff --git a/src/flight_cli/_http.py b/src/flight_cli/_http.py index f29449d..5398d06 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -5,19 +5,24 @@ import hashlib import json -import logging import os import pathlib from http import HTTPStatus -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import anyio import httpx import stamina +import structlog from aiolimiter import AsyncLimiter from httpx_curl_cffi import AsyncCurlTransport, CurlOpt -log = logging.getLogger(__name__) +if TYPE_CHECKING: + from structlog.stdlib import BoundLogger + +# structlog.get_logger() is typed Any; pin to BoundLogger so downstream +# call sites are typed without spreading reportAny across the module. +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] DEFAULT_IMPERSONATE = "chrome" # alias → latest curl_cffi knows about @@ -126,14 +131,14 @@ def _cache_get(self, key: str) -> dict[str, Any] | None: try: return cast("dict[str, Any]", json.loads(p.read_text())) except (OSError, json.JSONDecodeError) as e: - log.warning("cache read failed for %s: %s", key, e) + log.warning("cache_read_failed", key=key, error=str(e)) return None def _cache_put(self, key: str, value: dict[str, Any]) -> None: try: self._cache_path(key).write_text(json.dumps(value, indent=2)) except OSError as e: - log.warning("cache write failed for %s: %s", key, e) + log.warning("cache_write_failed", key=key, error=str(e)) # ───────────────────────────── public API ────────────────────────────── @@ -147,7 +152,7 @@ async def get_json( if cache and self._cache_read: hit = self._cache_get(cache_key) if hit is not None: - log.debug("cache hit %s", url) + log.debug("cache_hit", method="GET", url=url) return hit async with self._limiter, self._sem: @@ -193,7 +198,7 @@ async def post_json( if cache and self._cache_read: hit = self._cache_get(cache_key) if hit is not None: - log.debug("cache hit POST %s", url) + log.debug("cache_hit", method="POST", url=url) return hit async with self._limiter, self._sem: diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 7d15460..4672e7c 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -import logging import re import sys from datetime import date, datetime, timedelta @@ -21,7 +20,6 @@ import anyio import typer from rich.console import Console -from rich.logging import RichHandler from rich.table import Table from .client import MatrixApiError, MatrixClient @@ -38,6 +36,7 @@ TimeOfDay, ) from .links import google_flights_url, matrix_deep_link +from .log import configure as configure_logging if TYPE_CHECKING: from .models import CalendarResult, Location, SearchResult, Slice @@ -84,15 +83,11 @@ def main( ), ] = 0, ) -> None: - # _http.py emits log.warning/debug for cache hits/misses, retry attempts, - # and rate-limit pauses. Without a handler those go nowhere. - level = logging.WARNING - 10 * min(verbose, 2) - logging.basicConfig( - level=level, - format="%(message)s", - datefmt="[%X]", - handlers=[RichHandler(console=err, rich_tracebacks=False, show_path=False)], - ) + # _http.py emits structlog warning/debug for cache hits/misses, retry + # attempts, and rate-limit pauses. Configure the renderer to taste: + # `-v` shows info-level diagnostics, `-vv` includes debug. + level = ("warning", "info", "debug")[min(verbose, 2)] + configure_logging(level) # ─────────────────────────── argument parsers ────────────────────────────── diff --git a/src/flight_cli/log.py b/src/flight_cli/log.py new file mode 100644 index 0000000..1dd06ac --- /dev/null +++ b/src/flight_cli/log.py @@ -0,0 +1,45 @@ +"""Idempotent structlog setup. Call `configure(level)` once at CLI startup. + +Output goes through structlog.dev.ConsoleRenderer + stderr — readable for +a human running the CLI, structured under the hood so future debugging +glue (binding request IDs, piping to a file) is one line not a rewrite. +""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from structlog.typing import Processor + +LEVELS: dict[str, int] = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, +} + + +def configure(level: str = "warning") -> None: + """Configure structlog for human-readable stderr output. + + Idempotent: safe to call multiple times (tests + app entry). + """ + lvl = LEVELS.get(level.lower(), logging.WARNING) + processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="%H:%M:%S", utc=False), + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()), + ] + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(lvl), + cache_logger_on_first_use=True, + ) diff --git a/uv.lock b/uv.lock index 9359c4d..526cb3f 100644 --- a/uv.lock +++ b/uv.lock @@ -295,6 +295,7 @@ dependencies = [ { name = "pydantic" }, { name = "rich" }, { name = "stamina" }, + { name = "structlog" }, { name = "typer" }, ] @@ -318,6 +319,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.7" }, { name = "rich", specifier = ">=13" }, { name = "stamina", specifier = ">=24.3" }, + { name = "structlog", specifier = ">=24" }, { name = "typer", specifier = ">=0.12" }, ] @@ -813,6 +815,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/f0/1ff90a1d1dd02de23feafdf9dffaecef3958348be5c192df56670ccb4f86/stamina-26.1.0-py3-none-any.whl", hash = "sha256:62e06829bec87c06d4cafde520b32a6097d1017c378a9eb63253c5bf5ebbbb88", size = 18508, upload-time = "2026-04-13T17:44:29.545Z" }, ] +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" From c56ef2e28539320abca0d03909d592102566dc33 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 01:53:14 -0400 Subject: [PATCH 7/9] Add hypothesis property tests for domain validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the wire-fidelity golden-file tests with input-space exploration of domain.py invariants. Catches typos in alias mappings, off-by-one errors in length checks, regressions on field_validator ordering — bugs the regression suite fundamentally can't see because it tests one captured fixture at a time. Targets: * _iata accept/reject — 3-letter alpha after .upper().strip() * Leg.of — multi-airport lists round-trip * CalendarWindow — duration_max >= duration_min always enforced * Pax.total — math holds across all six pax fields * time_range_for — every TimeOfDay enum value yields valid {min, max} * SpecificDateSearch.legs — dated/dateless legs validate as expected Found during authorship: hypothesis surfaced that my test filter for "non-IATA garbage" was wrong — _iata() .upper().strip()s before validating, so "AAa" → "AAA" is accepted. Fixed the strategy filter (not the validator) to match actual behavior. Exactly the class of mental-model bug property tests catch. 12 tests pass in 0.31s. hypothesis was already in dev-deps. --- tests/test_domain_properties.py | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/test_domain_properties.py diff --git a/tests/test_domain_properties.py b/tests/test_domain_properties.py new file mode 100644 index 0000000..ad2345a --- /dev/null +++ b/tests/test_domain_properties.py @@ -0,0 +1,138 @@ +"""Property tests for domain.py validators. + +The golden-file tests in test_wire_round_trip.py cover wire fidelity: +"do we serialize this captured search the same way the SPA did?" These +tests cover the *other* axis: "do the domain validators enforce their +invariants across the full input space?" + +Catches: typos in alias mappings, off-by-one in length checks, regression +on `field_validator` ordering, accidental Mutable-default-on-frozen-model. +""" + +from __future__ import annotations + +from datetime import date, timedelta + +import pytest +from hypothesis import given +from hypothesis import strategies as st +from pydantic import ValidationError + +from flight_cli.domain import ( + _TIME_RANGE_FOR, # pyright: ignore[reportPrivateUsage] + CalendarWindow, + Leg, + Pax, + SpecificDateSearch, + TimeOfDay, + time_range_for, +) + +# ───────────────────────────── strategies ───────────────────────────────── + +iata_codes = st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ", min_size=3, max_size=3) +# _iata() uppercases + strips first, then checks 3-letter alpha. The negative +# strategy must filter on the *post-normalized* form so we don't generate +# inputs the validator legitimately accepts (e.g. "AAa" → "AAA"). +non_iata_text = st.text().filter( + lambda s: not (len(s.upper().strip()) == 3 and s.upper().strip().isalpha()) +) +small_int = st.integers(min_value=0, max_value=9) + + +# ───────────────────────────── _iata / Leg.of ───────────────────────────── + + +@given(iata_codes) +def test_iata_accepts_3_letter_uppercase(code: str) -> None: + """Any 3-letter ASCII-uppercase string round-trips through Leg.of().""" + leg = Leg.of(code, code, date(2026, 6, 1)) + assert leg.origins == (code,) + assert leg.destinations == (code,) + + +@given(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", min_size=3, max_size=3)) +def test_iata_uppercases_lowercase_input(code: str) -> None: + """Lowercase is upcased, not rejected.""" + leg = Leg.of(code, code, date(2026, 6, 1)) + assert leg.origins == (code.upper(),) + + +@given(non_iata_text) +def test_iata_rejects_non_3_letter(garbage: str) -> None: + """Anything that isn't 3 ASCII letters (post-upper) should raise.""" + with pytest.raises(ValidationError): + Leg.of(garbage, "JFK", date(2026, 6, 1)) + + +@given(st.lists(iata_codes, min_size=1, max_size=5).map(tuple)) +def test_leg_of_accepts_iata_list(origins: tuple[str, ...]) -> None: + """Multi-airport per slice round-trips.""" + leg = Leg.of(origins, ("LHR",), date(2026, 6, 1)) + assert leg.origins == origins + + +# ───────────────────────────── CalendarWindow ───────────────────────────── + + +@given(small_int, small_int) +def test_calendar_window_duration_invariant(a: int, b: int) -> None: + """duration_max < duration_min must always fail; >= must always succeed.""" + lo, hi = sorted((a, b)) + # valid: max >= min + w = CalendarWindow( + start=date(2026, 6, 1), end=date(2026, 7, 1), duration_min=lo, duration_max=hi + ) + assert w.duration_min == lo and w.duration_max == hi + + # invalid: swapped (only if they differ — equal is fine) + if lo != hi: + with pytest.raises(ValidationError): + CalendarWindow( + start=date(2026, 6, 1), end=date(2026, 7, 1), duration_min=hi, duration_max=lo + ) + + +# ───────────────────────────── Pax totals ───────────────────────────────── + + +@given(small_int, small_int, small_int, small_int, small_int, small_int) +def test_pax_total_sums(a: int, c: int, s: int, y: int, ins: int, inl: int) -> None: + """Pax.total is the sum of all six pax fields.""" + pax = Pax(adults=a, children=c, seniors=s, youth=y, infants_in_seat=ins, infants_in_lap=inl) + assert pax.total == a + c + s + y + ins + inl + + +# ───────────────────────────── time_range_for ───────────────────────────── + + +@given(st.sampled_from(list(TimeOfDay))) +def test_time_range_well_formed(t: TimeOfDay) -> None: + """Every TimeOfDay produces a {min, max} dict with min < max lexically.""" + r = time_range_for(t) + assert set(r.keys()) == {"min", "max"} + # Lexical comparison works because the times are zero-padded compatibly + # within each pair (see TimeOfDay docstring quirk re: '00:00' vs '8:00'). + raw = _TIME_RANGE_FOR[t] + assert r["min"] == raw[0] + assert r["max"] == raw[1] + + +# ───────────────────────────── SpecificDateSearch ───────────────────────── + + +@given(st.lists(iata_codes, min_size=1, max_size=3)) +def test_specific_search_requires_dates(origins: list[str]) -> None: + """A leg without a date must fail SpecificDateSearch validation.""" + leg_no_date = Leg.of(origins[0], "LHR") # date defaults to None + with pytest.raises(ValidationError): + SpecificDateSearch(legs=(leg_no_date,)) + + +@given(st.integers(min_value=1, max_value=30)) +def test_specific_search_accepts_dated_legs(days_out: int) -> None: + """Dated single-leg searches always validate.""" + dt = date(2026, 6, 1) + timedelta(days=days_out) + leg = Leg.of("JFK", "LHR", dt) + s = SpecificDateSearch(legs=(leg,)) + assert s.legs[0].date == dt From 38d0e351802cd3a16106afcff0f5798aac5c26f5 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 01:57:16 -0400 Subject: [PATCH 8/9] Drop dead N815 per-file-ignores from domain.py and models.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit: only wire.py actually triggers N815 (14 hits) — all on camelCase pydantic fields that mirror Matrix's JSON shape directly. domain.py is snake_case throughout (our contract, not Matrix's). models.py uses snake_case Python attrs + `Field(alias="bookingCode")` strings — the alias is a string, not an attribute name, so N815 doesn't fire. Tightened pyproject.toml + AGENTS.md divergence-table row to reflect the actual single-file scope. --- AGENTS.md | 2 +- pyproject.toml | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f852563..89acea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ Tunings applied versus the strict-service default: | `pythonVersion` (basedpyright) | `"3.13"` | `"3.12"` | | Coverage `fail_under` | 80 | 0 (golden-file suite) | | `PLR0913` (too many args) | strict | ignored (CLI verbs are wide) | -| `N815` (camelCase) | strict | per-file-ignored in `wire.py`, `domain.py`, `models.py` — Matrix wire JSON dictates field names | +| `N815` (camelCase) | strict | per-file-ignored in `wire.py` only — direct camelCase attrs mirror Matrix's JSON without per-field `alias=`. `models.py` uses snake_case + `Field(alias="…")` so N815 doesn't fire there; `domain.py` is snake_case throughout. | | `reportAny` | `"warning"` | `"none"` — Profile-B edge: fli/fast_flights ship no stubs; Matrix response is `dict[str, Any]` by design; `ValidationInfo.data` and `Field` overloads are Any internally. `reportUnknown*` stays on (higher-signal "can't type this"). | | Pydantic `extra` on boundary models | `"forbid"` | `"ignore"` on wire + response (`_Wire`, `_Loose`) — Profile-B edge: Matrix is reverse-engineered and adds fields unannounced in directions that aren't load-bearing. Domain models stay `extra="forbid"` (we control that contract). | diff --git a/pyproject.toml b/pyproject.toml index e931ed4..a494926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,10 +90,11 @@ ignore = [ "N", # test function names may mirror wire-field names (camelCase) "PLC0415", # internal lazy imports are fine for accessing private helpers ] -# DIVERGE: Matrix's wire JSON forces camelCase field names; N815 cannot apply here. -"src/flight_cli/wire.py" = ["N815"] -"src/flight_cli/domain.py" = ["N815"] -"src/flight_cli/models.py" = ["N815"] +# DIVERGE: wire.py mirrors Matrix's JSON shape directly with camelCase attrs +# (instead of snake_case + Field(alias=...) on every field). N815 doesn't +# apply to models.py either — its alias= strings aren't attr names — or to +# domain.py, which is snake_case throughout. +"src/flight_cli/wire.py" = ["N815"] [tool.ruff.format] docstring-code-format = true From 9a7a1991a9efcb4034999592abb1bb69dcf87c50 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Fri, 15 May 2026 02:33:13 -0400 Subject: [PATCH 9/9] flake: expose libstdc++ on Linux for curl_cffi at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's nix-path job failed during pytest collection: ImportError: libstdc++.so.6: cannot open shared object file curl_cffi (via httpx-curl-cffi, used for TLS fingerprinting against Matrix) ships a compiled C++ extension that dlopens libstdc++.so.6 at runtime. The Nix devShell doesn't expose the host's library paths, and python's CFFI binding can't find the symbol. Fix: add pkgs.stdenv.cc.cc.lib to LD_LIBRARY_PATH inside the shellHook, scoped to Linux only (Darwin uses libc++ via @rpath and needs no help). Verified locally — Darwin shell unchanged (empty LD_LIBRARY_PATH), `nix develop -c make check` runs all 12 tests. --- flake.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 8 ++++++++ 2 files changed, 56 insertions(+) create mode 100644 flake.lock diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1afec1b --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1778443072, + "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778824905, + "narHash": "sha256-nzpb7npmpVQGDihItjtu5aVhmnGbh+X2qThRfZ+yknA=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "e0de53826b89b6cee1930abb544dcf4c4d753050", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "pyproject-nix": "pyproject-nix" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 7d60092..e89d855 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,14 @@ shellHook = '' export UV_PYTHON=${python}/bin/python3 export UV_PYTHON_PREFERENCE=only-system + ${lib.optionalString pkgs.stdenv.isLinux '' + # Binary Python wheels with compiled C++ extensions + # (curl_cffi via httpx-curl-cffi, used for TLS + # fingerprinting) link against libstdc++.so.6 at + # runtime. The Nix devShell doesn't expose the host's + # library paths; surface it from the Nix store. + export LD_LIBRARY_PATH="${lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + ''} echo "🐍 python: $(python3 --version)" echo "📦 uv: $(uv --version)" '';