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..89acea3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# 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` | ✓ | raw asyncio | +| 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 | + +## 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` 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 + 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. 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 + 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: + - 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 + 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 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 + +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 | +|---|---|---| +| `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` 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). | + +## 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/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 new file mode 100644 index 0000000..e89d855 --- /dev/null +++ b/flake.nix @@ -0,0 +1,69 @@ +{ + 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", - "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", + "anyio>=4", + "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 + "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 +45,104 @@ 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: 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 + +# --- basedpyright: types --- +[tool.basedpyright] +include = ["src", "tests"] +pythonVersion = "3.12" +typeCheckingMode = "strict" +reportMissingTypeStubs = "warning" +reportImplicitOverride = "error" +reportUnusedCallResult = "none" +# 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] +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..5398d06 100644 --- a/src/flight_cli/_http.py +++ b/src/flight_cli/_http.py @@ -1,17 +1,35 @@ """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 -from typing import Any, cast +import hashlib +import json +import os +import pathlib +from http import HTTPStatus +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 +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 +47,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 +79,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, ) @@ -75,19 +92,18 @@ 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( - 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 +129,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: - log.warning("cache read failed for %s: %s", key, e) + return cast("dict[str, Any]", json.loads(p.read_text())) + except (OSError, json.JSONDecodeError) as 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 Exception as e: - log.warning("cache write failed for %s: %s", key, e) + except OSError as e: + log.warning("cache_write_failed", key=key, error=str(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, @@ -135,17 +152,25 @@ async def get_json(self, url: str, *, params: dict[str, Any] | None = None, 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: - @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 +179,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. @@ -168,19 +198,30 @@ async def post_json(self, url: str, body: dict[str, Any], *, 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: - @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..4672e7c 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -8,56 +8,97 @@ flight gflight — Google Flights handoff via fli flight airport — IATA autocomplete """ + from __future__ import annotations -import asyncio, json, logging, re, sys + +import json +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 anyio 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 +from .log import configure as configure_logging + +if TYPE_CHECKING: + from .models import CalendarResult, Location, SearchResult, Slice + +# Tuple-length sentinels for `--slice` parser (`ORIGIN-DEST:DATE[:r=...:e=...]`). +_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.") +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. - 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 ────────────────────────────── + 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 +119,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 +143,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 +161,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 +194,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()) + return anyio.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 +220,37 @@ 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}[/]") + 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 × 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 "?" @@ -174,14 +259,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 "—" - mark = "★" if c.min_price_in_grid else ( - "·" if c.min_price_in_row else "") + 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") @@ -191,36 +275,48 @@ 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) + st.add_row(str(i), _amount(it.price), 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") + 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]{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{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") @@ -228,10 +324,10 @@ def _render_calendar(res: CalendarResult, *, dmin: int, dmax: int, 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) @@ -246,33 +342,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 +410,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 +461,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 +524,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 +567,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 +608,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 +648,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 +738,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()) + + locs = anyio.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..6c74438 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 # pyright: ignore[reportMissingTypeStubs] + SearchFlights, + ) + 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/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/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_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 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..526cb3f --- /dev/null +++ b/uv.lock @@ -0,0 +1,870 @@ +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 = "anyio" }, + { name = "fast-flights" }, + { name = "flights" }, + { name = "httpx" }, + { name = "httpx-curl-cffi" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "stamina" }, + { name = "structlog" }, + { 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 = "anyio", specifier = ">=4" }, + { 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 = "structlog", specifier = ">=24" }, + { 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 = "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" +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" }, +]