From b190c32133a868b642988d3b8dbd69afbddc3f98 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Wed, 23 Sep 2026 20:11:19 +0100 Subject: [PATCH 1/3] feat: history, and an API key to read it with `tp.entity(id).history` reads the archive and pages for you. `span()` gives the three dates a backfill needs in one shape for parks and rides, `days()` yields one summary row per park-local day, `changes()` yields every recorded observation. Both iterate to the end of the server's paging links and yield as they go, so a resort's five years never has to be in memory at once. Given a park id, both use the park-level call, which answers every entity in the park in one request. Fetching the same data ride by ride is around a hundred times more calls against the same budget, and that is the difference between a backfill that takes an afternoon and one that takes a week. The history budget is hourly, so a spent one can be most of an hour from resetting. Sleeping through that is indistinguishable from a hung process, so past `max_wait` (120s) we raise `BudgetExhaustedError` carrying `retry_after` and let the caller checkpoint. `examples/backfill.py` does exactly that, with NDJSON or CSV output; it pulled Disneyland Resort's whole daily archive, 98,452 rows, in one run. Two defects found on the way, both pre-existing: - The client could not send an API key at all. No `api_key` parameter existed and the transport sent only `user-agent` and `accept`, so every request this SDK made was anonymous regardless of what the caller had paid for. Both clients now take `api_key` and send `x-api-key`; an empty string is treated as no key, since an unset environment variable arrives as "" more often than as None and an empty key is a 401 rather than an anonymous request. - The generator's nullable patcher matched only classes with no docstring. Its pattern could not cross the blank line that follows one, so every documented class went unpatched behind a warning nobody read. That included `next` on all four history envelopes, which is null on the last page of every paged response, so the SDK would have failed to parse the page that ends a backfill. The pattern now spans blank lines, an unmatched patch is fatal rather than a warning, and all 22 fields the spec marks required and nullable are listed. 172 unit tests pass; ruff, mypy and `mkdocs build --strict` are clean. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 59 +++ README.md | 62 +++ docs/api/history.md | 20 + docs/api/models.md | 3 - docs/cookbook.md | 114 ++++- docs/index.md | 3 +- examples/backfill.py | 176 ++++++++ mkdocs.yml | 1 + pyproject.toml | 2 +- scripts/regenerate.py | 54 ++- tests/unit/test_history.py | 401 +++++++++++++++++ themeparks/__init__.py | 3 + themeparks/_client.py | 4 + themeparks/_ergonomic/entity.py | 3 + themeparks/_ergonomic/history.py | 276 ++++++++++++ themeparks/_generated/models.py | 719 +++++++++++++++++++++++++++++-- themeparks/_raw.py | 119 ++++- themeparks/_transport.py | 29 +- uv.lock | 655 +++++++++++++++++++++++++++- 19 files changed, 2659 insertions(+), 44 deletions(-) create mode 100644 docs/api/history.md create mode 100644 examples/backfill.py create mode 100644 tests/unit/test_history.py create mode 100644 themeparks/_ergonomic/history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 084caba..1cdaeb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## [3.1.0] - 2026-09-23 + +### Added + +- **History.** `tp.entity(id).history` reads the archive, and pages for you: + + ```python + with ThemeParks(api_key=KEY) as tp: + history = tp.entity(DISNEYLAND).history + span = history.span() + for entity_id, row in history.days(span.archive_from, span.retrievable_through): + ... + ``` + + - `span()` returns `archive_from`, `recorded_to` and `retrievable_through` + in one shape. The underlying coverage documents do not: a park nests them + under `summary`, an entity carries them at the top level under different + names, so without this every caller writes that branch first. + `retrievable_through` is the end date to bound a backfill by, because it + is what the key may read rather than what the archive holds. + - `days(start, end)` yields `(entity id, row)` for one summary row per + park-local day; `changes(date)` yields every recorded observation. + Both follow the server's paging links to the end and yield as they go, so + a resort's five years never has to be in memory at once. + - Given a park id, both use the park-level call, which answers every entity + in the park in one request. The same data fetched ride by ride is around a + hundred times more calls against the same budget. + - `BudgetExhaustedError` (a `RateLimitError`) is raised when the history + budget is spent and the server asks for a longer wait than `max_wait` + (120s by default). It carries `retry_after`, so a backfill can checkpoint + and resume rather than hold a process open for most of an hour. + +- **`examples/backfill.py`** — a complete backfill with resume and NDJSON or + CSV output. It pulls Disneyland Resort's whole daily archive, 98,452 rows, + in one run. + +### Fixed + +- **The client had no way to send an API key.** There was no `api_key` + parameter anywhere, and the transport sent only `user-agent` and `accept`, + so every request this SDK made was anonymous: the lowest rate limit and the + most recent seven days of history, whatever the caller had paid for. A + paying customer had to drop to raw `httpx` to use their own plan. + `ThemeParks(api_key=...)` and `AsyncThemeParks(api_key=...)` now send + `x-api-key`. An empty string is treated as no key, because an unset + environment variable arrives as `""` far more often than as `None`, and + sending an empty key is a 401 rather than an anonymous request. + +- **The model generator was silently under-patching every documented class.** + `scripts/regenerate.py` restores nullability that `datamodel-code-generator` + drops, by matching the field line inside its class. The pattern could not + cross the blank line after a class docstring, so it matched only classes + without one and left every documented class unpatched, printing a warning + nobody read. That included `next` on all four history envelopes, which is + null on the last page of every paged response, so the SDK would have failed + to parse the page that ends a backfill. The pattern now spans blank lines, + an unmatched patch is a hard failure rather than a warning, and the 22 + fields the spec marks both required and nullable are all listed. + ## [3.0.0] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index f73a505..5ec96c1 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options: | Option | Type | Default | Purpose | |--------------|------------------------------------------|--------------------------------------|---------| | `base_url` | `str` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). | +| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. | | `user_agent` | `str \| None` | `themeparks-sdk-py/` | Sent as the `User-Agent` header. Set this to identify your app. | | `timeout` | `float` (seconds) | `10.0` | Per-request timeout. | | `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). | @@ -98,15 +99,22 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options: Example: ```python +import os + from themeparks import ThemeParks, RetryConfig tp = ThemeParks( + api_key=os.environ["THEMEPARKS_API_KEY"], user_agent="my-app/1.2.3 (+https://example.com)", timeout=15.0, retry=RetryConfig(max_retries=5, respect_429=True), ) ``` +Without a key you get the anonymous tier: the most recent seven days of +history and the lowest rate limit. Keys are issued from your account at +[api.themeparks.wiki](https://api.themeparks.wiki). + ## Ergonomic helpers ```python @@ -208,6 +216,60 @@ remaining keys are whatever fields that variant carries. timezone-aware `datetime`, honoring the entity's IANA timezone for naive inputs. +## History + +`tp.entity(id).history` reads the archive. Both methods page for you and yield +rows as they arrive, so a resort's five years never has to fit in memory. + +```python +from themeparks import ThemeParks + +DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66" + +with ThemeParks(api_key=KEY) as tp: + history = tp.entity(DISNEYLAND).history + + # What exists, and what your key may read. Same three fields whether the + # id is a park or a single ride. + span = history.span() + print(span.archive_from, span.recorded_to, span.retrievable_through) + + # One summary row per park-local day, as (entity id, row). + for entity_id, row in history.days(span.archive_from, span.retrievable_through): + print(row.date, entity_id, row.operatingMinutes, row.standby.p50 if row.standby else None) + + # Every recorded change on one day. + for entity_id, row in history.changes("2026-09-20"): + print(row.time, entity_id, row.status) +``` + +**Ask the park, not the rides.** Both history endpoints answer every entity in +a park in one request. Pulling the same data ride by ride is around a hundred +times more calls for a large resort, against the same budget. Pass a park id +and you are on the cheap path without having to know the expensive one exists. + +**History has its own hourly budget**, separate from the per-minute rate limit. +A large backfill will hit it, and the wait can be most of an hour because that +is when the window rolls. Rather than block a process for that long, the SDK +raises `BudgetExhaustedError` (a `RateLimitError`) once the server asks for +more than `max_wait` seconds, carrying `retry_after` so you can checkpoint: + +```python +from themeparks import BudgetExhaustedError + +try: + for entity_id, row in history.days(start, end): + write(entity_id, row) + last_day = row.date +except BudgetExhaustedError as exc: + checkpoint(last_day) + print(f"resume in {exc.retry_after:.0f}s") +``` + +A complete backfill script with resume and CSV output is in +[`examples/backfill.py`](examples/backfill.py); it pulls Disneyland Resort's +whole daily archive, 98,452 rows, in one run. + ## Low-level escape hatch Every ergonomic helper is built on top of `tp.raw`, which is a thin, typed diff --git a/docs/api/history.md b/docs/api/history.md new file mode 100644 index 0000000..8dd9689 --- /dev/null +++ b/docs/api/history.md @@ -0,0 +1,20 @@ +# History + +Reached as `tp.entity(id).history`. Both methods page for you and yield rows +as they arrive, so a resort's five years never has to fit in memory. + +Ask a **park** id wherever you can. Both history endpoints answer a whole park +in one request, and a park-by-park backfill of a large resort costs around a +hundred times fewer calls than the same data fetched ride by ride. + +::: themeparks._ergonomic.history.HistoryApi + options: + heading_level: 2 + +::: themeparks._ergonomic.history.AsyncHistoryApi + options: + heading_level: 2 + +::: themeparks.BudgetExhaustedError + options: + heading_level: 2 diff --git a/docs/api/models.md b/docs/api/models.md index 72165f6..c0998f2 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -113,6 +113,3 @@ for the full set. options: heading_level: 3 -::: themeparks._generated.models.TagData - options: - heading_level: 3 diff --git a/docs/cookbook.md b/docs/cookbook.md index 2d0dcbe..b5e54d8 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,6 +1,6 @@ # Cookbook -Three complete recipes you can copy, paste, and run. Each one uses real +Complete recipes you can copy, paste, and run. Each one uses real entity IDs from the live ThemeParks.wiki API. ## Recipe 1 — Wait times in order (longest → shortest) @@ -287,3 +287,115 @@ with ThemeParks() as tp: # or {"type": "PAID_RETURN_TIME", "state": "AVAILABLE", "price": {...}, ...} print(entry.name, q["type"], q) ``` + +## Recipe 6 — Back fill a park's history to NDJSON + +This is the job most people buy history for: get everything that already +exists into your own store once, then follow the live feed from there. + +Two things make the difference between a backfill that takes an afternoon and +one that takes a week. + +**Ask the park, not the rides.** `tp.entity(park_id).history` answers every +entity in that park in one request. The same data fetched ride by ride is +around a hundred times more calls for a large park, and it counts against the +same budget. + +**Start with `span()`.** It tells you the first day the archive holds and the +last day your key may retrieve, so you ask for days that exist instead of +discovering the ends by trial. Bound the backfill by `retrievable_through`, +not by `recorded_to`: the archive holds more than a free or Pro key is +entitled to read, and asking past the entitlement is how a backfill walks into +a wall of 403s at the end of a long run. + +```python +import json +from themeparks import ThemeParks + +DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66" + +with ThemeParks(api_key="YOUR_KEY") as tp: + history = tp.entity(DISNEYLAND).history + + span = history.span() + print(f"archive from {span.archive_from}, yours through {span.retrievable_through}") + + with open("disneyland-daily.ndjson", "w") as out: + for entity_id, row in history.days(span.archive_from, span.retrievable_through): + out.write(json.dumps({"entityId": entity_id, **row.model_dump(mode="json")}) + "\n") +``` + +`days()` follows the server's paging links until there are no more, and yields +`(entity id, row)` pairs as they arrive. Nothing accumulates in memory, so the +file is the only thing that grows. + +`span()` returns the same three fields whether you asked about a park or a +single ride, which the underlying coverage documents do not: a park nests them +under `summary`, an entity carries them at the top level under different +names. + +## Recipe 7 — Resume a backfill when the budget runs out + +History has an hourly call budget separate from the per-minute rate limit. A +big backfill will hit it, and when it does the server asks you to wait — up to +most of an hour, because that is when the window rolls. + +The SDK will not silently sleep that long. Past `max_wait` (120 seconds by +default) it raises `BudgetExhaustedError`, carrying the `retry_after` the +server sent, so you can write down where you got to and come back: + +```python +import json +from pathlib import Path +from themeparks import ThemeParks, BudgetExhaustedError + +DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66" +CHECKPOINT = Path("disneyland.checkpoint") +OUT = Path("disneyland-daily.ndjson") + +with ThemeParks(api_key="YOUR_KEY") as tp: + history = tp.entity(DISNEYLAND).history + span = history.span() + + start = CHECKPOINT.read_text().strip() if CHECKPOINT.exists() else span.archive_from + last_day = None + + try: + with OUT.open("a") as out: + for entity_id, row in history.days(start, span.retrievable_through): + out.write(json.dumps({"entityId": entity_id, **row.model_dump(mode="json")}) + "\n") + last_day = row.date + except BudgetExhaustedError as exc: + if last_day is not None: + CHECKPOINT.write_text(str(last_day)) + print(f"budget spent at {last_day}; run again in {exc.retry_after:.0f}s") + else: + CHECKPOINT.unlink(missing_ok=True) + print("done") +``` + +Running the same script again picks up from the checkpoint. Re-reading the +last day is deliberate: a page can end mid-day, and one duplicate day is +cheaper to de-duplicate on your side than a missing one is to notice. + +A complete version of this, with `--csv` output and a park list, is in +[`examples/backfill.py`](https://github.com/ThemeParks/ThemeParks_Python/blob/main/examples/backfill.py). + +## Recipe 8 — Every recorded change for one day + +`days()` gives one summary row per park-local day. When you want the +underlying observations — every change we recorded, at the time we recorded +it — use `changes()`: + +```python +from themeparks import ThemeParks + +with ThemeParks(api_key="YOUR_KEY") as tp: + for entity_id, row in tp.entity(DISNEYLAND).history.changes("2026-09-20"): + print(row.time, entity_id, row.status, row.queue) +``` + +A park answers one day per call. A single entity answers up to 31 days, so +pass `start=` and `end=` there instead of `date=`. You do not have to +remember which cap applies: ask for the range you want, and the API either +answers or tells you it is too long. diff --git a/docs/index.md b/docs/index.md index 66898e8..c47ba3f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ pip install themeparks ## Where to go next - [Quickstart](quickstart.md) — install, print wait times sync & async. -- [Cookbook](cookbook.md) — three complete recipes you can copy and run. +- [Cookbook](cookbook.md) — complete recipes you can copy and run, including + backfilling a park's whole history. - [API reference](api/client.md) — every public class and helper, generated from docstrings. diff --git a/examples/backfill.py b/examples/backfill.py new file mode 100644 index 0000000..5cd4cce --- /dev/null +++ b/examples/backfill.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Pull a park's whole daily history into a file, and survive the budget. + + python examples/backfill.py 7340550b-c14d-4def-80bb-acdb51d49a66 + python examples/backfill.py --format csv PARK_ID_A PARK_ID_B + +The key comes from --api-key or the THEMEPARKS_API_KEY environment variable. + +Three things this demonstrates that are easy to get wrong by hand: + +1. It asks the PARK, not the rides. Both history endpoints answer every entity + in a park in one request, so a park-level backfill of a large resort is + around a hundred times fewer calls than the same data pulled ride by ride. + +2. It bounds the range with `span().retrievable_through`, not with what the + archive holds. Those are different dates on every plan below Business, and + asking past the entitlement is how a long backfill ends in 403s. + +3. It checkpoints. The history budget is hourly, so a spent one can be most of + an hour from resetting. The SDK raises BudgetExhaustedError rather than + sleeping through that; this writes down the last day it wrote and exits 75 + (EX_TEMPFAIL), which is the exit code to make a cron or a systemd timer + retry rather than alert. + +Re-running picks up from the checkpoint. It re-reads the last day on purpose: +a page can end mid-day, and one duplicate day is cheaper to de-duplicate than +a missing one is to notice. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +from pathlib import Path +from typing import Any, TextIO + +from themeparks import BudgetExhaustedError, ThemeParks + +EX_TEMPFAIL = 75 + +CSV_COLUMNS = [ + "entityId", + "date", + "firstOperatingAt", + "lastClosedAt", + "operatingMinutes", + "downMinutes", + "showCount", + "changes", + "standbyMin", + "standbyP50", + "standbyMean", + "standbyP90", + "standbyMax", + "singleRiderP50", + "singleRiderMax", +] + + +def _csv_row(entity_id: str, row: Any) -> dict[str, Any]: + """Flatten the nested standby/singleRider stats into one wide row.""" + standby = row.standby + single = row.singleRider + return { + "entityId": entity_id, + "date": row.date.isoformat(), + "firstOperatingAt": row.firstOperatingAt.isoformat() if row.firstOperatingAt else "", + "lastClosedAt": row.lastClosedAt.isoformat() if row.lastClosedAt else "", + "operatingMinutes": row.operatingMinutes, + "downMinutes": row.downMinutes, + "showCount": row.showCount if row.showCount is not None else "", + "changes": row.changes, + "standbyMin": standby.min if standby else "", + "standbyP50": standby.p50 if standby else "", + "standbyMean": standby.mean if standby else "", + "standbyP90": standby.p90 if standby else "", + "standbyMax": standby.max if standby else "", + "singleRiderP50": single.p50 if single else "", + "singleRiderMax": single.max if single else "", + } + + +class Writer: + """NDJSON or CSV behind one `write(entity_id, row)`.""" + + def __init__(self, handle: TextIO, fmt: str, write_header: bool) -> None: + self._handle = handle + self._fmt = fmt + self._csv = None + if fmt == "csv": + self._csv = csv.DictWriter(handle, fieldnames=CSV_COLUMNS) + if write_header: + self._csv.writeheader() + + def write(self, entity_id: str, row: Any) -> None: + if self._csv is not None: + self._csv.writerow(_csv_row(entity_id, row)) + return + payload = {"entityId": entity_id, **row.model_dump(mode="json")} + self._handle.write(json.dumps(payload) + "\n") + + +def backfill_park(tp: ThemeParks, park_id: str, out_dir: Path, fmt: str) -> int: + """Write one park's daily history. Returns 0, or EX_TEMPFAIL if the budget ran out.""" + history = tp.entity(park_id).history + span = history.span() + + out_path = out_dir / f"{park_id}.{'csv' if fmt == 'csv' else 'ndjson'}" + checkpoint = out_dir / f"{park_id}.checkpoint" + + resuming = checkpoint.exists() + has_rows = out_path.exists() and out_path.stat().st_size > 0 + start = checkpoint.read_text().strip() if resuming else span.archive_from + end = span.retrievable_through + + print( + f"{park_id}: {start} .. {end}{' (resumed)' if resuming else ''} -> {out_path}", + file=sys.stderr, + ) + + written = 0 + last_day = None + try: + with out_path.open("a", newline="") as handle: + writer = Writer(handle, fmt, write_header=not has_rows) + for entity_id, row in history.days(start, end): + writer.write(entity_id, row) + written += 1 + last_day = row.date + if written % 5000 == 0: + print(f" {written} rows, at {last_day}", file=sys.stderr) + except BudgetExhaustedError as exc: + if last_day is not None: + checkpoint.write_text(last_day.isoformat()) + wait = exc.retry_after or 0 + print( + f" budget spent after {written} rows at {last_day}; rerun in {wait:.0f}s to continue", + file=sys.stderr, + ) + return EX_TEMPFAIL + + checkpoint.unlink(missing_ok=True) + print(f" done: {written} rows", file=sys.stderr) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("park_ids", nargs="+", help="park entity ids") + parser.add_argument("--api-key", default=os.environ.get("THEMEPARKS_API_KEY")) + parser.add_argument("--format", choices=["ndjson", "csv"], default="ndjson") + parser.add_argument("--out", type=Path, default=Path("."), help="output directory") + args = parser.parse_args(argv) + + if not args.api_key: + parser.error("no key: pass --api-key or set THEMEPARKS_API_KEY") + + args.out.mkdir(parents=True, exist_ok=True) + + # One client for every park: the connection pool and the cache are worth + # reusing, and the budget is per account either way. + with ThemeParks(api_key=args.api_key, user_agent="themeparks-backfill-example/1") as tp: + for park_id in args.park_ids: + status = backfill_park(tp, park_id, args.out, args.format) + if status != 0: + # Stop at the first exhausted budget. Carrying on to the next + # park only spends the retry-after on 429s. + return status + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mkdocs.yml b/mkdocs.yml index cfa901a..bb3006b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,7 @@ nav: - API reference: - Client: api/client.md - Entity: api/entity.md + - History: api/history.md - Destinations: api/destinations.md - Raw client: api/raw.md - Helpers: api/helpers.md diff --git a/pyproject.toml b/pyproject.toml index 05c07aa..297b2e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "themeparks" -version = "3.0.0" +version = "3.1.0" description = "Official SDK for the ThemeParks.wiki API" readme = "README.md" requires-python = ">=3.9" diff --git a/scripts/regenerate.py b/scripts/regenerate.py index 43f9b62..5d921b2 100644 --- a/scripts/regenerate.py +++ b/scripts/regenerate.py @@ -55,6 +55,36 @@ ("BoardingGroupQueue", "nextAllocationTime"), ("BoardingGroupQueue", "estimatedWait"), ("PriceData", "amount"), + # History, added 2026-09-23 when the endpoints were first wrapped. Derived + # from the spec rather than found one failing test at a time: every + # History* schema field that is both `required` and `nullable: true` is + # listed here, because datamodel-code-generator honours neither together. + # + # `next` is the one that matters most. It is null on the LAST page of every + # paged response, so without this the SDK parses every page of a backfill + # except the one that ends it. + ("HistoryCoverage", "firstRecordedAt"), + ("HistoryCoverageDocument", "parentId"), + ("HistoryCoverageDocument", "destinationId"), + ("HistoryCoverageDocument", "firstRecordedAt"), + ("HistoryCoverageDocument", "lastRecordedAt"), + ("HistoryCoverageDocument", "retrievableThrough"), + ("HistoryDailyEnvelope", "parentId"), + ("HistoryDailyEnvelope", "destinationId"), + ("HistoryDailyEnvelope", "next"), + ("HistoryDailyRow", "firstOperatingAt"), + ("HistoryDailyRow", "lastClosedAt"), + ("HistoryEnvelope", "parentId"), + ("HistoryEnvelope", "destinationId"), + ("HistoryEnvelope", "next"), + ("HistoryParkCoverageDocument", "parentId"), + ("HistoryParkCoverageDocument", "destinationId"), + ("HistoryParkDailyEnvelope", "parentId"), + ("HistoryParkDailyEnvelope", "destinationId"), + ("HistoryParkDailyEnvelope", "next"), + ("HistoryParkRawEnvelope", "parentId"), + ("HistoryParkRawEnvelope", "destinationId"), + ("HistoryParkRawEnvelope", "next"), ] @@ -97,9 +127,17 @@ def apply_nullable_patches(text: str) -> str: for class_name, field_name in NULLABLE_PATCHES: # Match: "class (BaseModel):" then any indented lines until we # hit the target field line. Rewrite just that field line. + # The skip pattern has to cross BLANK lines as well as indented ones. + # datamodel-code-generator puts a docstring on every schema that has a + # description, followed by an empty line, and an empty line is "\n" + # with no leading spaces. The original `(?: [^\n]*\n)*?` stopped + # dead at it, so every DOCUMENTED class went unpatched while the + # undocumented queue variants worked. It warned and carried on, and the + # result was a model that cannot parse the last page of any paged + # response, where `next` is null. pattern = re.compile( rf"(?Pclass {class_name}\(BaseModel\):\n" - rf"(?: [^\n]*\n)*?" + rf"(?:(?: [^\n]*)?\n)*?" rf" {field_name}:\s*)" rf"(?P[^\n]+)" rf"(?P\n)", @@ -114,9 +152,17 @@ def repl(m: re.Match[str]) -> str: new_text, count = pattern.subn(repl, text, count=1) if count == 0: - print( - f"warning: nullable patch did not match {class_name}.{field_name}", - file=sys.stderr, + # FATAL, not a warning. An unapplied nullable patch produces a + # model that rejects a response the API really sends, and the + # consequence is invisible until a user hits it: `next` is null on + # the last page of every paged response, so the SDK would parse + # every page of a backfill except the one that ends it. This was a + # warning until 2026-09-23, and it had been printing one for every + # documented class without anyone noticing. + raise SystemExit( + f"nullable patch did not match {class_name}.{field_name}. " + "Either the field left the spec, or the generator's output " + "shape moved. Do not ship the models until this matches." ) text = new_text return text diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py new file mode 100644 index 0000000..ff6faba --- /dev/null +++ b/tests/unit/test_history.py @@ -0,0 +1,401 @@ +"""History helpers: paging, park flattening, and the hourly budget. + +The point of this layer is that a caller does not write the loop. These tests +are about the loop being right, because every customer who buys history would +otherwise write it themselves and most would write it the way our own monitor +did: treating a 429 as no data. +""" + +from datetime import date as _d + +import httpx +import pytest + +from themeparks import BudgetExhaustedError, RetryConfig, ThemeParks +from themeparks._errors import RateLimitError + + +def client(handler): + return ThemeParks(transport=httpx.MockTransport(handler), cache=False) + + +# Shaped from the generated models' required fields rather than trimmed by +# hand: a thin fixture passes for the wrong reason, and the published contract +# is the thing these helpers have to survive. +def day(d): + return { + "date": d, + "firstOperatingAt": f"{d}T09:00:00Z", + "lastClosedAt": f"{d}T22:00:00Z", + "operatingMinutes": 780, + "downMinutes": 0, + "changes": 42, + } + + +def daily_entity(rows, nxt=None): + return { + "id": "ride-1", + "name": "Space Mountain", + "entityType": "ATTRACTION", + "parentId": "park-1", + "destinationId": "dest-1", + "timezone": "America/New_York", + "range": {"from": "2026-09-01", "to": "2026-09-02"}, + "coverage": {"firstRecordedAt": "2021-07-03"}, + "days": rows, + "next": nxt, + } + + +def park_envelope(entities, key, nxt=None): + return { + "id": "park-1", + "name": "Magic Kingdom Park", + "entityType": "PARK", + "parentId": "dest-1", + "destinationId": "dest-1", + "timezone": "America/New_York", + "range": {"from": "2026-09-01", "to": "2026-09-01"}, + "entities": entities, + "next": nxt, + } + + +class TestPaging: + def test_follows_next_until_the_server_stops_offering_one(self): + pages = [ + daily_entity( + [day("2026-09-01")], + nxt="https://api.themeparks.wiki/v1/entity/ride-1/history/daily?from=2026-09-02", + ), + daily_entity([day("2026-09-02")], nxt=None), + ] + seen = [] + + def handler(req): + seen.append(str(req.url)) + return httpx.Response(200, json=pages[len(seen) - 1]) + + rows = list(client(handler).entity("ride-1").history.days("2026-09-01", "2026-09-02")) + # Real date objects, not strings: the generated models parse the + # park-local day for you, which is the whole reason to use the library + # over raw JSON. + assert [r.date for _, r in rows] == [_d(2026, 9, 1), _d(2026, 9, 2)] + assert len(seen) == 2 + + def test_follows_the_url_the_server_gave_rather_than_rebuilding_it(self): + # Re-deriving the next page from its parts is how a client drifts from + # the server's own idea of where the next page starts. + nxt = "https://api.themeparks.wiki/v1/entity/ride-1/history/daily?from=2026-09-02&to=2026-09-30" + pages = [daily_entity([day("2026-09-01")], nxt=nxt), daily_entity([day("2026-09-02")])] + seen = [] + + def handler(req): + seen.append(str(req.url)) + return httpx.Response(200, json=pages[len(seen) - 1]) + + list(client(handler).entity("ride-1").history.days()) + assert seen[1] == nxt + + def test_one_page_makes_one_request(self): + calls = {"n": 0} + + def handler(req): + calls["n"] += 1 + return httpx.Response(200, json=daily_entity([day("2026-09-01")])) + + list(client(handler).entity("ride-1").history.days()) + assert calls["n"] == 1 + + +class TestParkShape: + def test_a_park_envelope_flattens_to_the_same_stream_as_an_entity(self): + # Both history endpoints answer a whole park in one request, and the + # response carries `entities` instead of `days`. A caller should not + # have to branch on that: asking a park is the CHEAP path (124 requests + # against 15,128 for one resort's five years) and the shape difference + # must not be the reason someone avoids it. + park = park_envelope( + [ + { + "id": "ride-a", + "name": "A", + "entityType": "ATTRACTION", + "coverage": {"firstRecordedAt": "2021-07-03"}, + "days": [day("2026-09-01")], + }, + { + "id": "ride-b", + "name": "B", + "entityType": "ATTRACTION", + "coverage": {"firstRecordedAt": "2021-07-03"}, + "days": [day("2026-09-01")], + }, + ], + "days", + ) + rows = list( + client(lambda r: httpx.Response(200, json=park)).entity("park-1").history.days() + ) + assert [entity_id for entity_id, _ in rows] == ["ride-a", "ride-b"] + + def test_changes_flattens_a_park_the_same_way(self): + park = park_envelope( + [ + { + "id": "ride-a", + "name": "A", + "entityType": "ATTRACTION", + "coverage": {"firstRecordedAt": "2021-07-03"}, + "opening": {"time": "2026-09-01T00:00:00Z", "status": "CLOSED"}, + "history": [ + { + "time": "2026-09-01T12:00:00Z", + "changed": ["status"], + "status": "OPERATING", + } + ], + }, + ], + "history", + ) + rows = list( + client(lambda r: httpx.Response(200, json=park)) + .entity("park-1") + .history.changes(date="2026-09-01") + ) + assert rows[0][0] == "ride-a" + assert rows[0][1].status == "OPERATING" + + +class TestBudget: + def _limited(self, retry_after): + def handler(req): + return httpx.Response( + 429, + headers={"retry-after": str(retry_after)}, + json={ + "error": { + "type": "HISTORY_RATE_LIMITED", + "message": "Requests without a key get 60 history requests an hour.", + "retryAfter": retry_after, + } + }, + ) + + return handler + + def test_a_long_wait_raises_instead_of_blocking(self): + # The history budget is hourly, so a spent one can be 50 minutes from + # resetting. Sleeping through that is indistinguishable from a hung + # process, and a backfill would rather checkpoint and come back. + tp = ThemeParks( + transport=httpx.MockTransport(self._limited(2900)), + cache=False, + retry=RetryConfig(max_retries=0), + ) + with pytest.raises(BudgetExhaustedError) as exc: + list(tp.entity("ride-1").history.days()) + assert exc.value.retry_after == 2900 + assert "Checkpoint and resume" in str(exc.value) + + def test_the_error_is_still_a_rate_limit_error(self): + # Anyone already catching RateLimitError keeps working. + tp = ThemeParks( + transport=httpx.MockTransport(self._limited(2900)), + cache=False, + retry=RetryConfig(max_retries=0), + ) + with pytest.raises(RateLimitError): + list(tp.entity("ride-1").history.days()) + + def test_a_short_wait_is_left_to_the_transport(self): + # A brief limit is the transport's job; it already honours Retry-After. + tp = ThemeParks( + transport=httpx.MockTransport(self._limited(5)), + cache=False, + retry=RetryConfig(max_retries=0), + ) + with pytest.raises(RateLimitError) as exc: + list(tp.entity("ride-1").history.days()) + assert not isinstance(exc.value, BudgetExhaustedError) + + +class TestArguments: + def test_date_and_range_together_is_refused_before_the_request(self): + # The API answers 400 INVALID_DATE for this pair, so there is no reason + # to spend a request finding out. + tp = client(lambda r: httpx.Response(200, json=daily_entity([]))) + with pytest.raises(ValueError, match="either date, or from/to"): + list(tp.entity("ride-1").history.changes("2026-09-01", start="2026-09-01")) + + def test_accepts_date_objects_as_well_as_strings(self): + seen = [] + + def handler(req): + seen.append(str(req.url)) + return httpx.Response(200, json=daily_entity([])) + + list(client(handler).entity("ride-1").history.days(_d(2026, 9, 1), _d(2026, 9, 30))) + assert "from=2026-09-01" in seen[0] + assert "to=2026-09-30" in seen[0] + + +class TestCoverage: + def test_coverage_returns_what_exists(self): + body = { + "id": "ride-1", + "name": "A", + "entityType": "ATTRACTION", + "parentId": "park-1", + "destinationId": "dest-1", + "timezone": "UTC", + "firstRecordedAt": "2021-07-03", + "lastRecordedAt": "2026-09-20", + "retrievableThrough": "2026-09-20", + "kinds": {}, + } + doc = client(lambda r: httpx.Response(200, json=body)).entity("ride-1").history.coverage() + assert doc.firstRecordedAt == _d(2021, 7, 3) + + +class TestApiKey: + """The library could not send a key at all until 2026-09-23. + + That capped the official SDK at the anonymous window, seven days of + history and the unauthenticated rate limit, so a paying customer had to + drop to raw HTTP to use what they had bought. These pin it shut. + """ + + def _capture(self, seen): + def handler(req): + seen.append(dict(req.headers)) + return httpx.Response(200, json=daily_entity([])) + + return handler + + def test_sends_the_key_as_x_api_key(self): + seen = [] + tp = ThemeParks( + transport=httpx.MockTransport(self._capture(seen)), cache=False, api_key="tpw_example" + ) + list(tp.entity("ride-1").history.days()) + assert seen[0]["x-api-key"] == "tpw_example" + + def test_sends_no_key_header_when_none_was_given(self): + seen = [] + tp = ThemeParks(transport=httpx.MockTransport(self._capture(seen)), cache=False) + list(tp.entity("ride-1").history.days()) + assert "x-api-key" not in seen[0] + + def test_an_empty_key_is_treated_as_no_key(self): + # An unset environment variable arrives as "" far more often than as + # None, and sending an empty key is a 401 rather than an anonymous + # request. + seen = [] + tp = ThemeParks(transport=httpx.MockTransport(self._capture(seen)), cache=False, api_key="") + list(tp.entity("ride-1").history.days()) + assert "x-api-key" not in seen[0] + + def test_the_key_reaches_every_endpoint_not_just_history(self): + seen = [] + + def handler(req): + seen.append(dict(req.headers)) + return httpx.Response(200, json={"destinations": []}) + + tp = ThemeParks(transport=httpx.MockTransport(handler), cache=False, api_key="tpw_example") + tp.destinations.list() + assert seen[0]["x-api-key"] == "tpw_example" + + +PARK_COVERAGE = { + "id": "park-1", + "name": "Magic Kingdom Park", + "entityType": "PARK", + "parentId": "dest-1", + "destinationId": "dest-1", + "timezone": "America/New_York", + "summary": { + "entitiesWithData": 87, + "archiveFrom": "2021-07-03", + "recordedTo": "2026-09-22", + "retrievableThrough": "2026-08-23", + "measuredOn": "2026-09-23", + }, + "fields": {}, + "entities": [], +} + +ENTITY_COVERAGE = { + "id": "ride-1", + "name": "Space Mountain", + "entityType": "ATTRACTION", + "parentId": "park-1", + "destinationId": "dest-1", + "timezone": "America/New_York", + "firstRecordedAt": "2021-07-03", + "lastRecordedAt": "2026-09-22", + "retrievableThrough": "2026-08-23", + "kinds": {}, +} + + +class TestSpan: + """A park and a ride describe their coverage with different field names. + + Without span() every caller writes that branch before they can ask their + first question, and the two ends they want are the two they are most + likely to confuse: what the archive holds, versus what their key may + actually retrieve. + """ + + def _span_for(self, payload): + tp = ThemeParks( + transport=httpx.MockTransport(lambda req: httpx.Response(200, json=payload)), + cache=False, + ) + return tp.entity("any").history.span() + + def test_park_coverage_reads_through_summary(self): + span = self._span_for(PARK_COVERAGE) + assert span.archive_from == _d(2021, 7, 3) + assert span.recorded_to == _d(2026, 9, 22) + assert span.retrievable_through == _d(2026, 8, 23) + + def test_entity_coverage_reads_the_top_level_fields(self): + span = self._span_for(ENTITY_COVERAGE) + assert span.archive_from == _d(2021, 7, 3) + assert span.recorded_to == _d(2026, 9, 22) + assert span.retrievable_through == _d(2026, 8, 23) + + def test_both_shapes_produce_the_same_span(self): + # The whole point: one shape out, whatever went in. + assert self._span_for(PARK_COVERAGE) == self._span_for(ENTITY_COVERAGE) + + def test_retrievable_through_is_not_the_same_as_recorded_to(self): + # A backfill bounded by recorded_to walks past the entitlement and + # into 403s. The fixture keeps them a month apart so a helper that + # returned the wrong one could not pass. + span = self._span_for(PARK_COVERAGE) + assert span.retrievable_through < span.recorded_to + + def test_span_feeds_days_directly(self): + calls = [] + + def handler(req): + calls.append(req.url) + if "coverage" in req.url.path: + return httpx.Response(200, json=PARK_COVERAGE) + return httpx.Response(200, json=daily_entity([day("2021-07-03")])) + + tp = ThemeParks(transport=httpx.MockTransport(handler), cache=False) + history = tp.entity("park-1").history + span = history.span() + rows = list(history.days(span.archive_from, span.retrievable_through)) + assert [r.date for _, r in rows] == [_d(2021, 7, 3)] + # `from` is a Python keyword, so the methods say start/end while the + # wire says from/to. This pins that translation. + assert "from=2021-07-03" in str(calls[-1]) + assert "to=2026-08-23" in str(calls[-1]) diff --git a/themeparks/__init__.py b/themeparks/__init__.py index a60fd8e..48d148c 100644 --- a/themeparks/__init__.py +++ b/themeparks/__init__.py @@ -1,6 +1,7 @@ from themeparks._cache import Cache, CacheConfig, InMemoryLRUCache from themeparks._client import AsyncThemeParks, ThemeParks from themeparks._ergonomic.dates import parse_api_datetime +from themeparks._ergonomic.history import BudgetExhaustedError, HistorySpan from themeparks._ergonomic.live import current_wait_time, iter_queues from themeparks._errors import ( APIError, @@ -14,6 +15,8 @@ __all__ = [ "APIError", "AsyncThemeParks", + "BudgetExhaustedError", + "HistorySpan", "Cache", "CacheConfig", "InMemoryLRUCache", diff --git a/themeparks/_client.py b/themeparks/_client.py index ac27613..a75f505 100644 --- a/themeparks/_client.py +++ b/themeparks/_client.py @@ -89,6 +89,7 @@ def __init__( # noqa: PLR0913 retry: RetryConfig | None = None, cache: Cache | bool | CacheConfig | None = None, transport: httpx.BaseTransport | None = None, + api_key: str | None = None, ) -> None: self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport) sync_t = SyncTransport( @@ -96,6 +97,7 @@ def __init__( # noqa: PLR0913 base_url=base_url, user_agent=user_agent or _default_user_agent(), retry=retry or RetryConfig(), + api_key=api_key, ) cache_impl = _build_cache(cache) inner: Any = _CachingSyncTransport(sync_t, cache_impl) if cache_impl else sync_t @@ -146,6 +148,7 @@ def __init__( # noqa: PLR0913 retry: RetryConfig | None = None, cache: Cache | bool | CacheConfig | None = None, transport: httpx.AsyncBaseTransport | None = None, + api_key: str | None = None, ) -> None: self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport) async_t = AsyncTransport( @@ -153,6 +156,7 @@ def __init__( # noqa: PLR0913 base_url=base_url, user_agent=user_agent or _default_user_agent(), retry=retry or RetryConfig(), + api_key=api_key, ) cache_impl = _build_cache(cache) inner: Any = _CachingAsyncTransport(async_t, cache_impl) if cache_impl else async_t diff --git a/themeparks/_ergonomic/entity.py b/themeparks/_ergonomic/entity.py index adcba05..dacad7e 100644 --- a/themeparks/_ergonomic/entity.py +++ b/themeparks/_ergonomic/entity.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Iterator from datetime import date +from themeparks._ergonomic.history import AsyncHistoryApi, HistoryApi from themeparks._generated.models import ( EntityChild, EntityChildrenResponse, @@ -76,6 +77,7 @@ def __init__(self, *, raw: RawClient, entity_id: str) -> None: self._raw = raw self.entity_id = entity_id self.schedule = _ScheduleApi(raw, entity_id) + self.history = HistoryApi(raw, entity_id) def get(self) -> EntityData: return self._raw.get_entity(self.entity_id) @@ -143,6 +145,7 @@ def __init__(self, *, raw: AsyncRawClient, entity_id: str) -> None: self._raw = raw self.entity_id = entity_id self.schedule = _AsyncScheduleApi(raw, entity_id) + self.history = AsyncHistoryApi(raw, entity_id) async def get(self) -> EntityData: return await self._raw.get_entity(self.entity_id) diff --git a/themeparks/_ergonomic/history.py b/themeparks/_ergonomic/history.py new file mode 100644 index 0000000..bbbff21 --- /dev/null +++ b/themeparks/_ergonomic/history.py @@ -0,0 +1,276 @@ +"""Historical wait time data, without the paging loop. + +WHY THIS LAYER EXISTS. The raw endpoints are honest but they hand the caller +four jobs: walk the `next` links, respect an hourly budget that is separate +from the per-minute one, know that asking a PARK returns a different shape +from asking a ride, and keep five years of rows out of memory. Every customer +who buys history writes the same loop, and most of them write it wrong: the +first version of our own monitor treated a 429 as "no data" and reported +all-clear for eight days. + +So this module does the walking. `days()` and `changes()` are iterators that +page until the server stops offering a `next`, and they yield rows rather than +returning a list, because a resort's five years is not a list. + +THE ONE THING THAT MATTERS MOST is that asking about a park uses the PARK +call. Both history endpoints answer a whole park in one request; asking ride by +ride costs 122 times more for the same data. A caller who passes a park id gets +the cheap path here without having to know the expensive one exists. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from datetime import date as _date +from typing import Any, NamedTuple, Union + +from themeparks._errors import RateLimitError +from themeparks._generated.models import ( + HistoryCoverageDocument, + HistoryDailyEnvelope, + HistoryDailyRow, + HistoryEnvelope, + HistoryParkCoverageDocument, + HistoryParkDailyEnvelope, + HistoryParkRawEnvelope, + HistoryRow, +) +from themeparks._raw import AsyncRawClient, RawClient, _parse_daily_history + +CoverageDocument = Union[HistoryCoverageDocument, HistoryParkCoverageDocument] +DailyEnvelope = Union[HistoryDailyEnvelope, HistoryParkDailyEnvelope] +RawEnvelope = Union[HistoryEnvelope, HistoryParkRawEnvelope] + +# A history 429 can ask for a very long wait: the budget is hourly, so a spent +# one is up to an hour away from resetting. The transport will honour a +# Retry-After by sleeping, which is right for a 30-second REST limit and wrong +# for a 50-minute history one - a backfill that blocks for most of an hour +# looks identical to a hung process. Past this, we raise instead, so the caller +# can checkpoint and come back. +DEFAULT_MAX_WAIT_SECONDS = 120.0 + + +class HistorySpan(NamedTuple): + """The three dates a backfill needs, in one shape for parks and rides. + + A park's coverage document nests these under `summary` and an entity's + carries them at the top level under different names, so without this + every caller writes the same branch before they can ask their first + question. + + `retrievable_through` is the end date to use. It is what YOUR key may + retrieve, not what the archive holds, so a backfill bounded by it stops + at the edge of the plan instead of walking into 403s. + """ + + archive_from: _date | None + recorded_to: _date | None + retrievable_through: _date | None + + +def _span(document: CoverageDocument) -> HistorySpan: + summary = getattr(document, "summary", None) + if summary is not None: + return HistorySpan(summary.archiveFrom, summary.recordedTo, summary.retrievableThrough) + return HistorySpan( + document.firstRecordedAt, # type: ignore[union-attr] + document.lastRecordedAt, # type: ignore[union-attr] + document.retrievableThrough, # type: ignore[union-attr] + ) + + +def _as_day(value: str | _date | None) -> str | None: + """Accept a date object or an ISO day string; days here are park-local.""" + if value is None: + return None + if isinstance(value, _date): + return value.isoformat() + return value + + +class BudgetExhaustedError(RateLimitError): + """The hourly history budget is spent and the wait is longer than allowed. + + Carries `retry_after` from the underlying 429, so a backfill can record + where it got to and resume after that long rather than holding a process + open waiting for a budget window to roll. + """ + + +def _reraise_if_too_long(exc: RateLimitError, max_wait: float) -> None: + wait = exc.retry_after + if wait is not None and wait > max_wait: + raise BudgetExhaustedError( + f"history budget exhausted; retry in {wait:.0f}s " + f"(longer than max_wait={max_wait:.0f}s). Checkpoint and resume.", + status=exc.status, + body=exc.body, + url=exc.url, + retry_after=wait, + ) from exc + raise exc + + +def _daily_rows(envelope: DailyEnvelope) -> Iterator[tuple[str, HistoryDailyRow]]: + """Yield (entity id, row). A park envelope carries many entities; an entity + envelope carries its own rows, so both flatten to the same stream.""" + entities = getattr(envelope, "entities", None) + if entities is not None: + for entity in entities: + for row in entity.days or []: + yield (entity.id, row) + return + for row in getattr(envelope, "days", None) or []: + yield (envelope.id, row) + + +def _raw_rows(envelope: RawEnvelope) -> Iterator[tuple[str, HistoryRow]]: + entities = getattr(envelope, "entities", None) + if entities is not None: + for entity in entities: + for row in entity.history or []: + yield (entity.id, row) + return + for row in getattr(envelope, "history", None) or []: + yield (envelope.id, row) + + +class HistoryApi: + """History for one entity id, reached as ``tp.entity(id).history``.""" + + def __init__(self, raw: RawClient, entity_id: str) -> None: + self._raw = raw + self._id = entity_id + + def coverage(self) -> CoverageDocument: + """What history exists here, field by field. Ask this before a backfill.""" + return self._raw.get_entity_history_coverage(self._id) + + def span(self) -> HistorySpan: + """The dates a backfill should run between, as a :class:`HistorySpan`. + + One call, and the same three fields whether this id is a park or a + single ride. + """ + return _span(self.coverage()) + + def days( + self, + start: str | _date | None = None, + end: str | _date | None = None, + *, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + ) -> Iterator[tuple[str, HistoryDailyRow]]: + """One summary row per park-local day, as (entity id, row). + + Pages automatically. Given a park id this uses the park call, which + answers every entity in the park in one request. + """ + envelope: DailyEnvelope | None = self._first_daily(start, end, max_wait) + while envelope is not None: + yield from _daily_rows(envelope) + envelope = self._next_daily(envelope, max_wait) + + def _first_daily( + self, start: str | _date | None, end: str | _date | None, max_wait: float + ) -> DailyEnvelope: + try: + return self._raw.get_entity_history_daily( + self._id, start=_as_day(start), end=_as_day(end) + ) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + + def _next_daily(self, envelope: DailyEnvelope, max_wait: float) -> DailyEnvelope | None: + nxt = getattr(envelope, "next", None) + if not nxt: + return None + try: + payload: Any = self._raw.get_path(nxt) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + return _parse_daily_history(payload) + + def changes( + self, + date: str | _date | None = None, + *, + start: str | _date | None = None, + end: str | _date | None = None, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + ) -> Iterator[tuple[str, HistoryRow]]: + """Every recorded change, as (entity id, row). + + A single day for a park, or up to 31 days for one entity. The caller + does not have to know which cap applies: ask for what you want and the + API answers or tells you the range is too long. + """ + try: + envelope = self._raw.get_entity_history( + self._id, date=_as_day(date), start=_as_day(start), end=_as_day(end) + ) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + yield from _raw_rows(envelope) + + +class AsyncHistoryApi: + """Asynchronous mirror of :class:`HistoryApi`.""" + + def __init__(self, raw: AsyncRawClient, entity_id: str) -> None: + self._raw = raw + self._id = entity_id + + async def coverage(self) -> CoverageDocument: + return await self._raw.get_entity_history_coverage(self._id) + + async def span(self) -> HistorySpan: + """Asynchronous mirror of :meth:`HistoryApi.span`.""" + return _span(await self.coverage()) + + async def days( + self, + start: str | _date | None = None, + end: str | _date | None = None, + *, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + ) -> AsyncIterator[tuple[str, HistoryDailyRow]]: + try: + envelope: DailyEnvelope | None = await self._raw.get_entity_history_daily( + self._id, start=_as_day(start), end=_as_day(end) + ) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + while envelope is not None: + for pair in _daily_rows(envelope): + yield pair + nxt = getattr(envelope, "next", None) + if not nxt: + return + try: + envelope = _parse_daily_history(await self._raw.get_path(nxt)) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + + async def changes( + self, + date: str | _date | None = None, + *, + start: str | _date | None = None, + end: str | _date | None = None, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + ) -> AsyncIterator[tuple[str, HistoryRow]]: + try: + envelope = await self._raw.get_entity_history( + self._id, date=_as_day(date), start=_as_day(start), end=_as_day(end) + ) + except RateLimitError as exc: + _reraise_if_too_long(exc, max_wait) + raise + for pair in _raw_rows(envelope): + yield pair diff --git a/themeparks/_generated/models.py b/themeparks/_generated/models.py index e452f21..868d3b0 100644 --- a/themeparks/_generated/models.py +++ b/themeparks/_generated/models.py @@ -3,10 +3,11 @@ from __future__ import annotations -from enum import Enum -from typing import Annotated, Any +from datetime import date as date_aliased +from enum import Enum, IntEnum +from typing import Annotated -from pydantic import AwareDatetime, BaseModel, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field class BoardingGroupState(Enum): @@ -30,6 +31,64 @@ class DiningAvailability(BaseModel): """ +class AttractionType(Enum): + """ + Kind of attraction. Present on ATTRACTION entities. + """ + + UNKNOWN = "UNKNOWN" + RIDE = "RIDE" + SHOW = "SHOW" + TRANSPORT = "TRANSPORT" + PARADE = "PARADE" + MEET_AND_GREET = "MEET_AND_GREET" + OTHER = "OTHER" + + +class Success(Enum): + """ + Always false. Branch on this rather than on the status alone. + """ + + boolean_False = False + + +class Type(Enum): + Bad_request = "Bad request" + + +class Code(IntEnum): + """ + Repeats the HTTP status. + """ + + integer_400 = 400 + + +class Error(BaseModel): + type: Type + message: str + """ + Says which parameter was rejected and what it must look like, e.g. "Month must be a two-digit number (01-12)". + """ + code: Code | None = None + """ + Repeats the HTTP status. + """ + + +class EntityInvalidParameter(BaseModel): + """ + 400: a path parameter is the wrong shape. Checked before the entity is looked up, so a bad `year` or `month` answers 400 whether or not the id exists. + """ + + success: Success + """ + Always false. Branch on this rather than on the status alone. + """ + error: Error + + class EntityLocation(BaseModel): latitude: float | None = None """ @@ -41,6 +100,42 @@ class EntityLocation(BaseModel): """ +class Type1(Enum): + Not_found = "Not found" + + +class Code1(IntEnum): + """ + Repeats the HTTP status. + """ + + integer_404 = 404 + + +class Error1(BaseModel): + type: Type1 + message: str + """ + Names the id that did not resolve, e.g. "Entity 00000000-0000-0000-0000-000000000000 not found". + """ + code: Code1 | None = None + """ + Repeats the HTTP status. + """ + + +class EntityNotFound(BaseModel): + """ + 404: nothing resolved from `id`. An id is a UUID, a slug, or — for a destination — its upstream external id; an id malformed enough that the lookup itself rejects it answers 404 as well, rather than 400 or 500. The body is the same whether the entity never existed or has been removed, so it cannot be used to tell those apart. + """ + + success: Success + """ + Always false. Branch on this rather than on the status alone. + """ + error: Error1 + + class EntityType(Enum): """ Type of entity in the theme park system @@ -54,6 +149,300 @@ class EntityType(Enum): SHOW = "SHOW" +class HistoryCoverage(BaseModel): + firstRecordedAt: date_aliased | None = None + """ + First park-local day with recorded history for this entity, or null when nothing has been archived yet. Per-kind detail and gaps: GET /v1/entity/{id}/history/coverage. + """ + + +class HistoryCoverageKindSpan(BaseModel): + """ + One live-data field's recorded span for an entity, both ends inclusive. + """ + + first: date_aliased + """ + First park-local day this field was reported. + """ + last: date_aliased + """ + Newest park-local day this field appears in the ARCHIVE. This is not the same as the last day the field was reported, and it does NOT mean the field has stopped: the archive is written two to three days behind live data, so a field being published right now still has a `last` a few days in the past. Every active field looks the same as a withdrawn one here. Use this to know how far back the archive goes and how current it is, not to decide whether a field is still live — GET /v1/entity/{id}/live answers that directly. + """ + + +class HistoryDailyStats(BaseModel): + """ + Wait statistics for one park-local day. The percentiles and the mean are weighted by the MINUTES the wait was posted rather than by the number of readings, so a wait that stood for three hours counts three hours and a brief flap does not drag the median; they are sampled at minute resolution and the percentiles are nearest-rank, never interpolated. `min` and `max` are TRUE extremes over every value posted, so a spike too short to be sampled still shows there. Only periods where the entity was OPERATING and published a numeric wait count at all. The block is absent when it never did. + """ + + min: int + """ + Lowest wait, in minutes, the entity published while OPERATING that day. A true extreme over every value posted, including one that stood for less than a minute — so unlike the percentiles below it is not minute-weighted. + """ + p50: int + """ + Median wait, nearest-rank over the minute weights (a value actually posted, never interpolated). + """ + mean: int + """ + Minute-weighted average wait, rounded to the nearest whole minute. + """ + p90: int + """ + 90th-percentile wait, nearest-rank over the minute weights. + """ + max: int + """ + Highest wait, in minutes, the entity published while OPERATING that day. A true extreme, as min is: a spike that lasted forty seconds counts here and is invisible to the percentiles, which is the intended difference between the two halves of this block — extremes answer "what did it ever reach", percentiles answer "what was it usually like". + """ + + +class Type2(Enum): + HISTORY_BACKEND_UNAVAILABLE = "HISTORY_BACKEND_UNAVAILABLE" + + +class Error2(BaseModel): + type: Type2 + message: str + + +class HistoryErrorBackendUnavailable(BaseModel): + """ + 502: the range needs archived history and that backend is temporarily unavailable. The request is retryable. + """ + + error: Error2 + + +class Type3(Enum): + INVALID_DATE = "INVALID_DATE" + + +class Error3(BaseModel): + type: Type3 + message: str + """ + e.g. "from must be a calendar day (YYYY-MM-DD) or an RFC 3339 instant with an offset (e.g. 2026-09-13T14:00:00Z)." + """ + + +class HistoryErrorInvalidDate(BaseModel): + """ + 400: a date parameter is not a calendar day or an RFC 3339 instant with an explicit offset, or date was combined with from/to, or from and to mix the two forms, or to was given without from. + """ + + error: Error3 + + +class Type4(Enum): + INVALID_RANGE = "INVALID_RANGE" + + +class Error4(BaseModel): + type: Type4 + message: str + """ + e.g. "to must not be before from." + """ + + +class HistoryErrorInvalidRange(BaseModel): + """ + 400: both ends parsed, but the range runs backwards (days: to before from; instants: to not after from). + """ + + error: Error4 + + +class Type5(Enum): + NOT_FOUND = "NOT_FOUND" + + +class Error5(BaseModel): + type: Type5 + message: str + + +class HistoryErrorNotFound(BaseModel): + """ + 404: no entity with that id. + """ + + error: Error5 + + +class Type6(Enum): + RANGE_TOO_LONG = "RANGE_TOO_LONG" + + +class Error6(BaseModel): + type: Type6 + message: str + """ + Names the cap that was exceeded and the span that was asked for, e.g. "A history call covers at most 31 park-local days (2026-01-01 to 2026-03-01 is 60). Ask for a shorter range." The number is the cap for the path that answered, not a constant: read it from the message rather than hard-coding 31. + """ + + +class HistoryErrorRangeTooLong(BaseModel): + """ + 400: the range is longer than the path allows. The cap is NOT the same on every path, and this one error type is returned by all of them: GET /v1/entity/{id}/history allows 31 park-local days for a single entity and 1 for a PARK; GET /v1/entity/{id}/history/daily allows 3660 (ten years) for a single entity, and for a PARK serves 31 days a page and gives you `next` for the rest. The message names the cap that applied. Split the ask into consecutive calls. + """ + + error: Error6 + + +class Type7(Enum): + HISTORY_RATE_LIMITED = "HISTORY_RATE_LIMITED" + + +class Error7(BaseModel): + type: Type7 + message: str + """ + e.g. "This key can make 600 history requests an hour." + """ + retryAfter: int + """ + Seconds until the hourly history budget admits another request. Also sent as the Retry-After header. + """ + + +class HistoryErrorRateLimited(BaseModel): + """ + 429: the caller's hourly history request budget is spent. The budget is separate from the per-minute REST limit and is published per tier in GET /tiers as limits.historyRequestsPerHour (anonymous.historyRequestsPerHour for keyless calls). + """ + + error: Error7 + + +class Type8(Enum): + HISTORY_WINDOW_EXCEEDED = "HISTORY_WINDOW_EXCEEDED" + + +class Error8(BaseModel): + type: Type8 + message: str + """ + A fact and a date, naming no plan and selling nothing. e.g. "This key can see history back to 2026-09-08 (7 days).", or "Requests without an API key can see history back to 2026-09-08 (7 days)." when you sent no key. + """ + earliestAllowedDate: date_aliased + """ + First park-local day this credential may query. + """ + + +class HistoryErrorWindowExceeded(BaseModel): + error: Error8 + + +class HistoryParkCoverageDepth(BaseModel): + """ + How far back each entity's record of one field goes, as counts. Calendar years, measured from the day the document was built (`summary.measuredOn`). Counts, not a percentage or an average: a single figure for a park would hide that most of one park's standby entities have two to four years while its oldest reach back to the start of the archive. + """ + + fourYearsPlus: int + """ + Entities whose record of this field goes back four calendar years or more. + """ + twoToFourYears: int + """ + Entities with at least two and under four calendar years. + """ + oneToTwoYears: int + """ + Entities with at least one and under two calendar years. + """ + underOneYear: int + """ + Entities with under a calendar year, typically something that opened recently rather than a gap. + """ + + +class HistoryParkCoverageEntity(BaseModel): + """ + One entity of the park that history is held for. An entity nothing is held for is ABSENT rather than listed as empty: it is usually a parade, a show or a land, which never had a queue to record, and listing it would read as a gap. + """ + + id: str + name: str + entityType: str + from_: Annotated[date_aliased, Field(alias="from")] + """ + The earliest park-local day any field of this entity was recorded. + """ + newest: date_aliased + """ + The newest park-local day any field of this entity was recorded. + """ + fields: list[str] + """ + The live-data field paths held for this entity, named exactly as the single-entity coverage document names them, so one client type reads both. + """ + stillListed: bool + """ + False when the park no longer lists this entity. Its history is still held and still retrievable, and every count in this document includes it; this says where the entity is now, not what the archive has. + """ + + +class HistoryParkCoverageField(BaseModel): + """ + What one live-data field looks like across the whole park. `entities` counts what is HELD and is never a fraction: entities that have never reported this field are simply absent from the count, because a denominator drawn from entityType would publish parades, shows and lands as missing wait times. + """ + + entities: int + """ + How many of the park's entities this field is held for. + """ + from_: Annotated[date_aliased, Field(alias="from")] + """ + The earliest park-local day any entity in the park reported this field. + """ + newest: date_aliased + """ + The newest park-local day any entity in the park reported it. A day in the past is not staleness: when a park stops publishing a field the ending is recorded, so the span genuinely stops there. + """ + depth: HistoryParkCoverageDepth + + +class HistoryParkCoverageSummary(BaseModel): + """ + The park in four numbers. Every one describes what is held; none is a fraction of a total, and nothing here asserts that anything is missing. + """ + + entitiesWithData: int + """ + Entities of this park any live-data history is held for - attractions, restaurants, shows and anything else that has ever reported. Larger than the number with wait times: `fields` breaks it down. Counts entities the park no longer lists as well, since their history is still held; those carry `stillListed: false` in `entities`. + """ + archiveFrom: date_aliased + """ + The earliest park-local day anything in this park was recorded, or null when nothing has been. + """ + recordedTo: date_aliased + """ + The newest park-local day anything in this park was recorded, or null. + """ + retrievableThrough: date_aliased + """ + The newest park-local day a caller can actually retrieve. Runs ahead of `recordedTo` by a day or two: the most recent days are served from live data before they are sealed into the archive. Null when nothing is recorded. + """ + measuredOn: date_aliased + """ + The park-local day these figures were computed. They move as the archive grows, so a reader comparing two copies of this document needs to know which day each was built. + """ + + +class HistoryRange(BaseModel): + from_: Annotated[str, Field(alias="from")] + """ + The requested start. A park-local day comes back verbatim (YYYY-MM-DD); an instant comes back NORMALISED to UTC whole seconds (2026-09-13T14:00:00Z), so an offset or sub-second precision you sent is not echoed back. On a day-granular endpoint such as /history/daily this is ALWAYS a park-local day, even when you asked with an instant: that endpoint's rows are whole days and cannot be sliced finer, so echoing your instant back would claim a precision the data does not have. + """ + to: str + """ + The requested end, in the same form as from, and normalised the same way. Omitted instants default to now; omitted days default to today, park-local. The same day-granular rule as from applies on /history/daily. + """ + + class StandbyQueue(BaseModel): waitTime: float | None = None """ @@ -157,7 +546,7 @@ class ReturnTimeState(Enum): FINISHED = "FINISHED" -class Type(Enum): +class Type9(Enum): """ Type of schedule entry """ @@ -179,25 +568,6 @@ class SchedulePriceType(Enum): ATTRACTION = "ATTRACTION" -class TagData(BaseModel): - tag: str - """ - Tag identifier - """ - tagName: str - """ - Human readable tag name - """ - id: str | None = None - """ - Unique identifier - """ - value: Any | None = None - """ - Tag value - can be string, number or object - """ - - class DestinationEntry(BaseModel): id: str """ @@ -247,6 +617,10 @@ class EntityChild(BaseModel): Parent entity identifier """ location: EntityLocation | None = None + slug: str | None = None + """ + URL-friendly slug + """ class EntityChildrenResponse(BaseModel): @@ -267,6 +641,13 @@ class EntityChildrenResponse(BaseModel): class EntityData(BaseModel): + """ + A single entity. Beyond the properties listed here, an entity may carry additional tag-derived properties named after the tag's slug, for example `minimumHeight` (integer, centimetres) or `mayGetWet` (boolean). The set is open-ended and driven by data rather than fixed by this contract, so clients should read them defensively rather than assume any particular tag is present. + """ + + model_config = ConfigDict( + extra="allow", + ) id: str """ Unique entity identifier @@ -276,6 +657,10 @@ class EntityData(BaseModel): Entity name """ entityType: EntityType + attractionType: AttractionType | None = None + """ + Kind of attraction. Present on ATTRACTION entities. + """ parentId: str | None = None """ Parent entity identifier @@ -284,12 +669,132 @@ class EntityData(BaseModel): """ DestinationEntry identifier """ + parkId: str | None = None + """ + Identifier of the park this entity belongs to. Absent on destinations and on parks themselves. + """ timezone: str """ Entity timezone """ location: EntityLocation | None = None - tags: list[TagData] | None = None + externalId: str | None = None + """ + Identifier used by the source data provider. + """ + slug: str | None = None + """ + URL-friendly slug. Served for destinations. + """ + + +class HistoryCoverageDocument(BaseModel): + """ + What history is actually held for one entity, broken down per live-data field. Two different questions, answered separately: `firstRecordedAt`/`lastRecordedAt` and the per-field spans describe the ARCHIVE, while `retrievableThrough` is the newest day a history call could return for this entity — which is normally today, and normally two to three days AHEAD of `lastRecordedAt`. An entity with nothing recorded is a 200 with kinds: {} and every day null, never a 404: "we hold nothing for this entity" is a real, actionable answer and a different claim from "this entity does not exist". + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local days are resolved in. + """ + firstRecordedAt: date_aliased | None = None + """ + First park-local day with any recorded history for this entity, or null when nothing has been archived yet. May legitimately be earlier than any individual kind's first: the two are recorded independently and answer slightly different questions. + """ + lastRecordedAt: date_aliased | None = None + """ + The newest `last` across `kinds` — the most recent park-local day we hold anything at all for this entity — or null when nothing is recorded. Like the per-field `last`, this tracks the ARCHIVE and lags live data by two to three days, so it sits in the past for an entity reporting normally. + """ + retrievableThrough: date_aliased | None = None + """ + The newest park-local day GET /v1/entity/{id}/history and .../history/daily could return data for this entity — what you can ASK FOR, as opposed to what has been filed. Normally TODAY for an entity still reporting, because those endpoints answer from recent readings as well as the archive, and it therefore sits AHEAD of `lastRecordedAt` by two to three days for a healthy entity. That gap is the whole point of this field: `lastRecordedAt` and every per-field `last` describe the ARCHIVE only, and reading them as capability is what makes coverage look as though it has stopped a couple of days short. It is the LATER of `lastRecordedAt` and the newest day recent readings can still answer for — so an entity that stopped reporting long ago reports its archive day here, NOT today, and the field never promises data that is not there. Recent readings do not go back indefinitely, so a day is reported here only if one of those endpoints can actually return it: an entity whose last reading is old enough falls back to its archive day rather than naming the day that reading was taken. Null only when we hold nothing for this entity in either place. A request for a range up to this day can still be narrowed by your tier's history window, which bounds how far BACK you may ask, never how recent. + """ + kinds: dict[str, HistoryCoverageKindSpan] + """ + Keyed by LIVE-DATA PATH (status, queue.StandbyQueue, showtimes, ...), not by the internal kind name, so a key matches straight against what GET /v1/entity/{id}/live and GET /v1/entity/{id}/history return. A kind the entity never reported is ABSENT here and absent from every /history row — there is no zero-span entry for it. See `last` for why a span ending in the past does NOT mean the field stopped being reported: the archive lags live data by two to three days, so an actively published field ends in the past too. Every span here is archive-only; `retrievableThrough` is the entity-wide answer to how recent a day you can actually ask for. + """ + + +class HistoryDailyRow(BaseModel): + """ + One park-local day of an entity's history reduced to the numbers a crowd calendar needs. standby, singleRider and showCount are ABSENT rather than null when there is nothing to report: an absent standby means no numeric standby wait was in force while OPERATING for a whole sampled minute that day — the statistics are sampled at minute resolution, so a wait published only inside a sub-minute window produces no block at all, even though /history records it and the day's operatingMinutes count it. The same applies to singleRider. An absent showCount means the entity published no showtimes. showCount counts distinct performance start times in the local day. + """ + + date: date_aliased + """ + The park-local calendar day this row summarises, YYYY-MM-DD, in the entity's timezone. + """ + firstOperatingAt: AwareDatetime | None = None + """ + UTC instant (whole seconds) the entity first became OPERATING on this park-local day, or null if it never did. A day that opened already OPERATING reports the start of the local day. + """ + lastClosedAt: AwareDatetime | None = None + """ + UTC instant (whole seconds) of the last transition out of OPERATING or DOWN into CLOSED or REFURBISHMENT after firstOperatingAt, or null if there was none. For a park closing after local midnight this instant falls on the following UTC day. + """ + operatingMinutes: int + """ + Minutes of this park-local day the entity was OPERATING. Minutes with no observed state count as neither operating nor down, so the two counters need not add up to the length of the day. For TODAY the count covers only the minutes that have already elapsed, so it grows through the day and is final once the day ends: a caller polling today's row sees it rise, which is the day filling in rather than the answer changing. + """ + downMinutes: int + """ + Minutes of this park-local day the entity was DOWN. As with operatingMinutes, today's count covers only the elapsed part of the day. + """ + standby: HistoryDailyStats | None = None + singleRider: HistoryDailyStats | None = None + showCount: int | None = None + """ + Distinct performance start times whose park-local day is this day. Present only for entities that published showtimes on the day. + """ + changes: int + """ + Number of history rows recorded on this day, i.e. instants at which any kind changed. Short flaps are counted as they happened; this is not a cleaned figure. + """ + + +class HistoryParkCoverageDocument(BaseModel): + """ + What history is held across a whole PARK. GET /v1/entity/{id}/history/coverage returns this shape when the entity is a PARK; every other entityType, a DESTINATION included, returns HistoryCoverageDocument for that entity alone. A park records nothing itself, so without this rollup the honest-looking answer for a park would be "nothing". It reports depth and breadth only: it does not detect missing days and does not judge whether a recorded value was correct. + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local days are resolved in. The park's children inherit it. + """ + summary: HistoryParkCoverageSummary + fields: dict[str, HistoryParkCoverageField] + """ + Keyed by live-data field path, in live-data order. + """ + entities: list[HistoryParkCoverageEntity] + """ + Every entity of the park history is held for. + """ + + +class HistoryParkEntityDaily(BaseModel): + """ + One entity of a park in a park DAILY response: its identity, the first day it has any history, and its day rows. The park itself appears as an entry too when it has history of its own (match it by id against the envelope's id). An entity with no history at all is ABSENT from entities[]. + """ + + id: str + name: str + entityType: str + coverage: HistoryCoverage + days: list[HistoryDailyRow] + """ + One row per park-local day, ascending by date, exactly as GET /v1/entity/{id}/history/daily returns for this entity on its own. A day with no data is ABSENT, and an entity with history but nothing in the requested days has an empty array rather than vanishing from entities[] — so the entity list keeps its shape from one page to the next. + """ class ReturnTimeQueue(BaseModel): @@ -381,6 +886,102 @@ class EntityLiveDataResponse(BaseModel): liveData: list[EntityLiveData] | None = None +class HistoryDailyEnvelope(BaseModel): + """ + A day-by-day summary of one entity's history. GET /v1/entity/{id}/history/daily returns this shape for every entityType EXCEPT PARK; for a PARK the same path returns HistoryParkDailyEnvelope, which carries an entities[] array instead of this envelope's coverage/days block. range.from and range.to ALWAYS echo park-local calendar days (YYYY-MM-DD), even when the call supplied RFC 3339 instants, because this endpoint summarises whole park-local days and an instant echo would claim a precision the rows do not have. TODAY's row is the day so far — its counters cover only elapsed minutes and grow as the day does — and a response to a request without an API key may be up to an hour old, so a poller can see a value that far behind the live feed. If you need the current state of an entity rather than its day so far, GET /v1/entity/{id}/live is not cached that way. + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local days are resolved in. + """ + range: HistoryRange + coverage: HistoryCoverage + days: list[HistoryDailyRow] + """ + One row per park-local day, ascending by date. A day with no data is ABSENT: there is no row of zeroes, because "we have nothing for this day" is a different claim from "observed, closed all day". + """ + next: str | None = None + """ + URL of the next page, or null. Always null for a single entity: a daily call is unpaged. Park calls page; see HistoryParkDailyEnvelope. + """ + + +class HistoryOpening(BaseModel): + """ + The full live-data state effective at the start of the range, in the same shape as a row. A key is present only when the entity has that kind. When the value is unknown at that instant (typically an older range, answered from the archive rather than from recent readings) the kind carries its EMPTY live value rather than a null container — an unknown standby is {"waitTime": null}, an unknown showtimes list is [] — and status, which has no empty value, is null. + """ + + time: AwareDatetime + """ + Start of the range (UTC, whole seconds). The state below is effective from this instant. + """ + observedAt: AwareDatetime | None = None + """ + The UTC instant (whole seconds) at which this state was actually OBSERVED, as opposed to `time`, which is the start of the range you asked for. The two are different questions and only this one tells you whether to trust the state. + + A state carried forward from before the range has an `observedAt` BEFORE `time` — sometimes long before, because the lookup is deliberately unbounded and returns the last reading of each kind at any age. So a day we hold nothing for still reports the newest state we ever saw, which is usually right and occasionally very wrong: a ride whose feed simply stopped mid-operation carries its last wait time forward indefinitely. + + Compare it against `range.from` to decide. Equal to or after the range start means the state was seen inside the range. Before it means carried forward, and how far back you tolerate is yours to choose — a reading an hour before the day began is ordinary, one from three weeks earlier is not evidence about this day. Absent means nothing survives to carry: we can say nothing at all about the state at the start of this range. + + It is the NEWEST instant any kind in this opening was seen. Kinds can be observed at different moments, so an older kind may be staler than this field suggests; treat it as the most generous reading of the opening's age, not a guarantee about every key. Days that genuinely have data are better read from the rows, which carry their own `time`. + """ + status: str | None = None + """ + Live status at the start of the range; null when unknown. + """ + queue: LiveQueue | None = None + showtimes: list[LiveShowTime] | None = None + + +class HistoryParkDailyEnvelope(BaseModel): + """ + A day-by-day summary of a whole PARK: every entity of the park that has history, in one call. GET /v1/entity/{id}/history/daily returns THIS shape when the entity is a PARK (entityType: "PARK") and HistoryDailyEnvelope for every other entityType, so a client should branch on the presence of entities[] or on the entity's type. range.from and range.to are always park-local calendar days, and range.to is THIS PAGE's last day rather than the whole range you asked for: a call serves at most 31 park-local days and next carries the rest. + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local days are resolved in. The park is the authority on where its day boundaries fall, so every entity below is summarised in THIS zone. + """ + range: HistoryRange + entities: list[HistoryParkEntityDaily] + """ + One entry per entity of the park that has history, ascending by name. An entity with no history is ABSENT — never an entry of nulls or an empty days[] — because "we hold nothing for this entity" is a different claim from "we hold nothing for these days". The park itself is included when it has history of its own. + """ + next: str | None = None + """ + Absolute URL of the next page, or null on the last one. A park daily call serves at most 31 park-local days and pages by DAY: the next URL repeats your other parameters with from advanced past this page's last day. Entity order never affects paging. + """ + + +class HistoryRow(BaseModel): + """ + One row per instant at which any kind changed. Every present kind is carried forward, so a row is the complete live-data object at that instant (same keys, nesting and enum values as GET /v1/entity/{id}/live). + """ + + time: AwareDatetime + """ + UTC instant (whole seconds) from which this state is effective, until the next row's time. + """ + changed: list[str] + """ + Leaf paths that differ from the previous row (or from opening for the first row), e.g. queue.StandbyQueue.waitTime, status, showtimes. + """ + status: str | None = None + queue: LiveQueue | None = None + showtimes: list[LiveShowTime] | None = None + + class ScheduleEntry(BaseModel): date: str """ @@ -394,7 +995,7 @@ class ScheduleEntry(BaseModel): """ Closing time """ - type: Type + type: Type9 """ Type of schedule entry """ @@ -408,6 +1009,74 @@ class ScheduleEntry(BaseModel): """ +class HistoryEnvelope(BaseModel): + """ + One entity's history as full-state change rows. GET /v1/entity/{id}/history returns this shape for every entityType EXCEPT PARK; for a PARK the same path returns HistoryParkRawEnvelope, which carries an entities[] array instead of this envelope's coverage/opening/history block. + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local days are resolved in. + """ + range: HistoryRange + coverage: HistoryCoverage + opening: HistoryOpening + history: list[HistoryRow] + """ + Ascending by time. + """ + next: str | None = None + """ + URL of the next page, or null. Always null for a single entity (a call covers up to 31 days). Park calls page; see HistoryParkRawEnvelope. + """ + + +class HistoryParkEntityRaw(BaseModel): + """ + One entity of a park in a park RAW response: the same coverage, opening and history block GET /v1/entity/{id}/history returns for that entity on its own, so one client type reads both. The park itself appears as an entry too when it has history of its own (match it by id against the envelope's id). + """ + + id: str + name: str + entityType: str + coverage: HistoryCoverage + opening: HistoryOpening + history: list[HistoryRow] + """ + Ascending by time. Empty when this entity recorded no change in the requested range, which is a different claim from having no history at all — an entity with no history is absent from entities[]. + """ + + +class HistoryParkRawEnvelope(BaseModel): + """ + Full-state change rows for a whole PARK: every entity of the park that has history, in one call, for exactly 1 park-local day. GET /v1/entity/{id}/history returns THIS shape when the entity is a PARK (entityType: "PARK") and HistoryEnvelope for every other entityType, so a client should branch on the presence of entities[] or on the entity's type. A range spanning more than one day is 400 RANGE_TOO_LONG: a day of a park is every recorded change for every entity in it, and the one-day limit is what bounds that. Ask day by day. + """ + + id: str + name: str + entityType: str + parentId: str | None = None + destinationId: str | None = None + timezone: str + """ + IANA timezone the park-local day is resolved in. The park is the authority on where its day boundaries fall, so every entity below is resolved in THIS zone. + """ + range: HistoryRange + entities: list[HistoryParkEntityRaw] + """ + One entry per entity of the park that has history, ascending by name. An entity with no history is ABSENT — never an entry of nulls — because "we hold nothing for this entity" is a different claim from "this entity recorded no change today". The park itself is included when it has history of its own. + """ + next: str | None = None + """ + Always null: a park history call covers one park-local day, so there is never a next page. + """ + + class ParkSchedule(BaseModel): id: str | None = None """ diff --git a/themeparks/_raw.py b/themeparks/_raw.py index 8258c46..f7ca304 100644 --- a/themeparks/_raw.py +++ b/themeparks/_raw.py @@ -2,8 +2,8 @@ from __future__ import annotations -from typing import Union -from urllib.parse import quote +from typing import Any, Union +from urllib.parse import quote, urlencode from themeparks._generated.models import ( DestinationsResponse, @@ -11,6 +11,12 @@ EntityData, EntityLiveDataResponse, EntityScheduleResponse, + HistoryCoverageDocument, + HistoryDailyEnvelope, + HistoryEnvelope, + HistoryParkCoverageDocument, + HistoryParkDailyEnvelope, + HistoryParkRawEnvelope, ) from themeparks._transport import AsyncTransport, SyncTransport @@ -21,6 +27,44 @@ def _path_entity(entity_id: str) -> str: return f"/entity/{quote(entity_id, safe='')}" +def _history_query( + date: str | None = None, + start: str | None = None, + end: str | None = None, +) -> str: + """Build the query string for a history request. + + ``date`` cannot be combined with ``start``/``end``: the API answers 400 + INVALID_DATE for that pair, so the SDK does not let a caller construct it. + """ + if date is not None and (start is not None or end is not None): + raise ValueError("pass either date, or from/to, not both") + params = [(k, v) for k, v in (("date", date), ("from", start), ("to", end)) if v is not None] + return f"?{urlencode(params)}" if params else "" + + +# The same path answers a single entity and a whole PARK with two different +# shapes, and the response tells you which by carrying `entities`. Sniffing the +# payload keeps the raw layer 1:1 with the operation rather than making the +# caller pick a method based on something they may not know yet. +def _parse_raw_history(payload: Any) -> HistoryEnvelope | HistoryParkRawEnvelope: + if isinstance(payload, dict) and "entities" in payload: + return HistoryParkRawEnvelope.model_validate(payload) + return HistoryEnvelope.model_validate(payload) + + +def _parse_daily_history(payload: Any) -> HistoryDailyEnvelope | HistoryParkDailyEnvelope: + if isinstance(payload, dict) and "entities" in payload: + return HistoryParkDailyEnvelope.model_validate(payload) + return HistoryDailyEnvelope.model_validate(payload) + + +def _parse_coverage(payload: Any) -> HistoryCoverageDocument | HistoryParkCoverageDocument: + if isinstance(payload, dict) and "entities" in payload: + return HistoryParkCoverageDocument.model_validate(payload) + return HistoryCoverageDocument.model_validate(payload) + + class RawClient: """Thin, 1:1 synchronous wrapper over the ThemeParks OpenAPI operations. @@ -60,6 +104,45 @@ def get_entity_schedule_month( self._t.get(_path_entity(entity_id) + f"/schedule/{year}/{month:02d}") ) + def get_entity_history( + self, + entity_id: str, + *, + date: str | None = None, + start: str | None = None, + end: str | None = None, + ) -> HistoryEnvelope | HistoryParkRawEnvelope: + """Every recorded change for an entity, or for a whole park.""" + query = _history_query(date, start, end) + return _parse_raw_history(self._t.get(_path_entity(entity_id) + "/history" + query)) + + def get_entity_history_daily( + self, + entity_id: str, + *, + date: str | None = None, + start: str | None = None, + end: str | None = None, + ) -> HistoryDailyEnvelope | HistoryParkDailyEnvelope: + """One summary row per park-local day, for an entity or a whole park.""" + query = _history_query(date, start, end) + return _parse_daily_history(self._t.get(_path_entity(entity_id) + "/history/daily" + query)) + + def get_entity_history_coverage( + self, entity_id: str + ) -> HistoryCoverageDocument | HistoryParkCoverageDocument: + """What history exists for an entity, field by field.""" + return _parse_coverage(self._t.get(_path_entity(entity_id) + "/history/coverage")) + + def get_path(self, path: str) -> Any: + """Fetch a path the API itself handed us. + + Only used to follow a `next` page link. The history endpoints page by + returning an absolute URL, and re-deriving it from its parts is how a + client drifts from the server's own idea of where the next page is. + """ + return self._t.get(path) + class AsyncRawClient: """Asynchronous mirror of :class:`RawClient`. @@ -99,3 +182,35 @@ async def get_entity_schedule_month( return EntityScheduleResponse.model_validate( await self._t.get(_path_entity(entity_id) + f"/schedule/{year}/{month:02d}") ) + + async def get_entity_history( + self, + entity_id: str, + *, + date: str | None = None, + start: str | None = None, + end: str | None = None, + ) -> HistoryEnvelope | HistoryParkRawEnvelope: + query = _history_query(date, start, end) + return _parse_raw_history(await self._t.get(_path_entity(entity_id) + "/history" + query)) + + async def get_entity_history_daily( + self, + entity_id: str, + *, + date: str | None = None, + start: str | None = None, + end: str | None = None, + ) -> HistoryDailyEnvelope | HistoryParkDailyEnvelope: + query = _history_query(date, start, end) + return _parse_daily_history( + await self._t.get(_path_entity(entity_id) + "/history/daily" + query) + ) + + async def get_entity_history_coverage( + self, entity_id: str + ) -> HistoryCoverageDocument | HistoryParkCoverageDocument: + return _parse_coverage(await self._t.get(_path_entity(entity_id) + "/history/coverage")) + + async def get_path(self, path: str) -> Any: + return await self._t.get(path) diff --git a/themeparks/_transport.py b/themeparks/_transport.py index 61a3742..155e03b 100644 --- a/themeparks/_transport.py +++ b/themeparks/_transport.py @@ -77,20 +77,39 @@ def _parse_body(response: httpx.Response) -> Any: return None +def _headers(user_agent: str, api_key: str | None) -> dict[str, str]: + """Request headers, with the API key when one was supplied. + + The SDK could not send a key at all until 2026-09-23, which meant the + official library could reach only the anonymous window: seven days of + history and the unauthenticated rate limit. A paying customer had to drop + to raw HTTP to use what they had bought. + + `x-api-key` is the header the API documents. Nothing here logs or repeats + the value. + """ + headers = {"user-agent": user_agent, "accept": "application/json"} + if api_key: + headers["x-api-key"] = api_key + return headers + + class SyncTransport: - def __init__( + def __init__( # noqa: PLR0913 self, *, client: httpx.Client, base_url: str, user_agent: str, retry: RetryConfig, + api_key: str | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: self._client = client self._base_url = base_url.rstrip("/") self._user_agent = user_agent self._retry = retry + self._headers = _headers(user_agent, api_key) self._sleep = sleep def get(self, path: str) -> Any: @@ -100,7 +119,7 @@ def get(self, path: str) -> Any: try: response = self._client.get( path, - headers={"user-agent": self._user_agent, "accept": "application/json"}, + headers=self._headers, ) except httpx.TimeoutException as exc: raise TimeoutError(f"request to {url} timed out") from exc @@ -147,19 +166,21 @@ def get(self, path: str) -> Any: class AsyncTransport: - def __init__( + def __init__( # noqa: PLR0913 self, *, client: httpx.AsyncClient, base_url: str, user_agent: str, retry: RetryConfig, + api_key: str | None = None, sleep: Callable[..., Awaitable[None]] | None = None, ) -> None: self._client = client self._base_url = base_url.rstrip("/") self._user_agent = user_agent self._retry = retry + self._headers = _headers(user_agent, api_key) self._sleep: Callable[..., Awaitable[None]] = sleep if sleep is not None else asyncio.sleep async def get(self, path: str) -> Any: @@ -169,7 +190,7 @@ async def get(self, path: str) -> Any: try: response = await self._client.get( path, - headers={"user-agent": self._user_agent, "accept": "application/json"}, + headers=self._headers, ) except httpx.TimeoutException as exc: raise TimeoutError(f"request to {url} timed out") from exc diff --git a/uv.lock b/uv.lock index 75c48da..2fd649b 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[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 = "backports-asyncio-runner" version = "1.2.0" @@ -71,6 +80,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + [[package]] name = "black" version = "25.11.0" @@ -175,6 +219,185 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/d0/91/bc145e42f93d6601b9a26f5421af2d7c3093ae6e6d03b8e583c9cebbf530/charset_normalizer-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f", size = 368830, upload-time = "2026-08-15T08:20:17.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/62df6a907162461f372e95cbbc1bc64c7457e86abcc851feb84409a11eff/charset_normalizer-3.5.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b", size = 251725, upload-time = "2026-08-15T08:20:18.942Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2d/64a13610fd28c80f97aff0ea5cf31cf255d220a8243ac0c78c66fd3d874d/charset_normalizer-3.5.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f", size = 241254, upload-time = "2026-08-15T08:20:20.641Z" }, + { url = "https://files.pythonhosted.org/packages/84/79/a88c181e7f4a7579696fedb34fa63844ede2ff7caf44c5f321cec57d92fb/charset_normalizer-3.5.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795", size = 281944, upload-time = "2026-08-15T08:20:22.219Z" }, + { url = "https://files.pythonhosted.org/packages/64/60/7c5469f455f4fa65d39da9f088dffc1a586560bfb9e3279441eed78bd469/charset_normalizer-3.5.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2", size = 278350, upload-time = "2026-08-15T08:20:23.744Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cf/7568d8c1c9100b7c8bab9035215a6b36b32b39bb50cabaee9389c4606887/charset_normalizer-3.5.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f", size = 262670, upload-time = "2026-08-15T08:20:25.387Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ec/3a616c3806ec3f957337e6bf874ae7d64693185039edfbbf87103b8c8631/charset_normalizer-3.5.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d", size = 260445, upload-time = "2026-08-15T08:20:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e0/489aa2a33b944077d4c2c705c245d833dc12cd571a52fc67eaf273f5373a/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a", size = 253263, upload-time = "2026-08-15T08:20:28.6Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bc/f528dfb78d3bfdc8ee6aeea81eb22e6918d03e4442d373a79717f17de45e/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18", size = 242879, upload-time = "2026-08-15T08:20:30.186Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/58efc6393e405a8d52b241d31dd9118352c247e4017110c3edfdb4618f0d/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf", size = 282086, upload-time = "2026-08-15T08:20:31.826Z" }, + { url = "https://files.pythonhosted.org/packages/02/fc/0d9ab98fa7a61394353e8acd0f5f60fc6e94a4615f574af8be0eca14a7ef/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d", size = 259212, upload-time = "2026-08-15T08:20:33.442Z" }, + { url = "https://files.pythonhosted.org/packages/79/71/6ee3a48a21e844e5079d8e9b2e91c641da5a7912a748e9e94c9e3ab9ce1c/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838", size = 278949, upload-time = "2026-08-15T08:20:35.133Z" }, + { url = "https://files.pythonhosted.org/packages/64/77/9ae101cb33bd9f681551e82a2b9e08eec99ff715458340931370f4228de9/charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17", size = 264513, upload-time = "2026-08-15T08:20:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/249943372195935ff7393eae5842c7dae6fd04401e512bbd69dab1aae40b/charset_normalizer-3.5.1-cp39-cp39-win32.whl", hash = "sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420", size = 182431, upload-time = "2026-08-15T08:20:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/31/7f79c671d827080d6eecd697fbbeb4f0f6f8507bf4c5625b5f6398ec5876/charset_normalizer-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d", size = 206385, upload-time = "2026-08-15T08:20:40.242Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/c9295c61e3f826ba7d874f0fd1c5e335dbec928d7b9146b33b48d14a25f1/charset_normalizer-3.5.1-cp39-cp39-win_arm64.whl", hash = "sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8", size = 185470, upload-time = "2026-08-15T08:20:41.765Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + [[package]] name = "click" version = "8.1.8" @@ -275,6 +498,15 @@ http = [ { name = "httpx", marker = "python_full_version >= '3.10'" }, ] +[[package]] +name = "eval-type-backport" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/15/273a4baf8248d6d76220723c3caf039d283774b31a7c46ba686120145b76/eval_type_backport-0.4.0.tar.gz", hash = "sha256:8397d25e6524c2e67b9576bb0636be27dea2192017711220c534ec2de921e9b0", size = 10260, upload-time = "2026-06-02T13:22:06.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432, upload-time = "2026-06-02T13:22:04.827Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -296,6 +528,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -533,6 +798,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/1a/63732c1062b744669ff1aff62e788fd66a3f6635ae0e66061291ddc63b5c/librt-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:54d412e47c21b85865676ed0724e37a89e9593c2eee1e7367adf85bfad56ffb1", size = 62726, upload-time = "2026-04-09T16:06:24.995Z" }, ] +[[package]] +name = "markdown" +version = "3.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -629,6 +923,199 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "mergedeep" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.9.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "backrefs", version = "8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions", version = "10.21.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pymdown-extensions", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markupsafe", marker = "python_full_version < '3.10'" }, + { name = "mkdocs", marker = "python_full_version < '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version < '3.10'" }, + { name = "pymdown-extensions", version = "10.21.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python", version = "1.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +dependencies = [ + { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markupsafe", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version >= '3.10'" }, + { name = "pymdown-extensions", version = "12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python", version = "2.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "griffe", marker = "python_full_version < '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version < '3.10'" }, + { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +dependencies = [ + { name = "griffelib", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version >= '3.10'" }, + { name = "mkdocstrings", version = "1.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -947,6 +1434,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -1274,6 +1770,40 @@ 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 = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1369,6 +1899,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1486,6 +2028,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.10' and python_full_version < '3.14'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.10" @@ -1511,11 +2103,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "themeparks" -version = "2.0.0a0" +version = "3.1.0" source = { editable = "." } dependencies = [ + { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, { name = "httpx" }, { name = "pydantic" }, { name = "typing-extensions" }, @@ -1534,20 +2136,30 @@ dev = [ { name = "pytest-recording" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version < '3.10'" }, + { name = "mkdocstrings", version = "1.0.6", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version >= '3.10'" }, +] [package.metadata] requires-dist = [ { name = "datamodel-code-generator", extras = ["http"], marker = "extra == 'dev'", specifier = ">=0.25" }, + { name = "eval-type-backport", marker = "python_full_version < '3.10'", specifier = ">=0.2" }, { name = "httpx", specifier = ">=0.27" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "pydantic", specifier = ">=2.6" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-recording", marker = "extra == 'dev'", specifier = ">=0.13" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4,<0.17" }, { name = "typing-extensions", specifier = ">=4.10" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "docs"] [[package]] name = "tomli" @@ -1682,6 +2294,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/d7/f79b05a5d728f8786876a7d75dfb0c5cae27e428081b2d60152fb52f155f/vcrpy-8.1.1-py3-none-any.whl", hash = "sha256:2d16f31ad56493efb6165182dd99767207031b0da3f68b18f975545ede8ac4b9", size = 42445, upload-time = "2026-01-04T19:22:02.532Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" }, + { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wrapt" version = "2.1.2" From 64bcf9b95a78e6da49c2e8654879497f9d406348 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Wed, 23 Sep 2026 20:17:33 +0100 Subject: [PATCH 2/3] fix: cap how long a 429 can park the client The transport honoured any Retry-After up to max_retries times. For a REST 429, which asks for seconds, that is right. For a history 429 it is not: that budget is hourly, so a spent one can ask for most of an hour, and three of those is about two and a half hours of a process producing no output and no error. Indistinguishable from a hang. It also made BudgetExhaustedError unreachable. The whole point of that error is to let a backfill checkpoint instead of blocking, and the transport rode the wait out before the history layer ever saw the 429 - so the error only arrived after the delay it exists to avoid. RetryConfig gains max_retry_after, 120 seconds by default. Past it the client does not sleep at all: it raises RateLimitError with retry_after set, and the history layer turns that into BudgetExhaustedError. A REST 429 asking for a few seconds is still ridden out exactly as before. Nine tests, in both transports and end to end through the history layer with the shipped retry config rather than a test-only one, since building the client with retries off is precisely how this hid. Mutation-checked: reverting the condition fails three of them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++ README.md | 9 ++--- tests/unit/test_history.py | 39 ++++++++++++++++++++ tests/unit/test_transport.py | 58 ++++++++++++++++++++++++++++++ tests/unit/test_transport_async.py | 46 ++++++++++++++++++++++++ themeparks/_transport.py | 28 +++++++++++---- 6 files changed, 180 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cdaeb2..9db7ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ ### Fixed +- **A 429 could park the client for hours.** The transport honoured any + `Retry-After` up to `max_retries` times. That is right for a REST 429, which + asks for seconds, and wrong for a history 429: that budget is hourly, so a + spent one can ask for most of an hour, and three of those is roughly two and + a half hours of a silent process. `RetryConfig` gains `max_retry_after` + (120s by default): past it the client does not sleep at all and raises + `RateLimitError` with `retry_after` set. Without this `BudgetExhaustedError` + was unreachable in practice, because the transport rode out the wait before + the history layer ever saw the 429. + - **The client had no way to send an API key.** There was no `api_key` parameter anywhere, and the transport sent only `user-agent` and `accept`, so every request this SDK made was anonymous: the lowest rate limit and the diff --git a/README.md b/README.md index 5ec96c1..416da33 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options: | `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. | | `user_agent` | `str \| None` | `themeparks-sdk-py/` | Sent as the `User-Agent` header. Set this to identify your app. | | `timeout` | `float` (seconds) | `10.0` | Per-request timeout. | -| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). | +| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True, max_retry_after=120.0)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). `max_retry_after` is the longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. | | `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. | Example: @@ -250,9 +250,10 @@ and you are on the cheap path without having to know the expensive one exists. **History has its own hourly budget**, separate from the per-minute rate limit. A large backfill will hit it, and the wait can be most of an hour because that -is when the window rolls. Rather than block a process for that long, the SDK -raises `BudgetExhaustedError` (a `RateLimitError`) once the server asks for -more than `max_wait` seconds, carrying `retry_after` so you can checkpoint: +is when the window rolls. The client will not sleep through that: past +`retry.max_retry_after` (120s) it stops retrying, and the history layer turns +the result into `BudgetExhaustedError` (a `RateLimitError`) carrying +`retry_after`, so you can checkpoint and come back: ```python from themeparks import BudgetExhaustedError diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py index ff6faba..9b31a88 100644 --- a/tests/unit/test_history.py +++ b/tests/unit/test_history.py @@ -399,3 +399,42 @@ def handler(req): # wire says from/to. This pins that translation. assert "from=2021-07-03" in str(calls[-1]) assert "to=2026-08-23" in str(calls[-1]) + + +class TestBudgetErrorIsReachableWithDefaults: + """The one that matters: the shipped retry config, not a test-only one. + + Every other budget test in this file builds a client with retries turned + off, which is exactly how the original defect hid. With the defaults the + transport slept through the server's Retry-After three times before the + history layer ever saw the 429, so a spent budget cost about two and a + half hours of silence and BudgetExhaustedError was unreachable in + practice. Only `sleep` is substituted here - the retry policy under test + is the real one. + """ + + def _client(self, retry_after, slept): + def handler(request): + return httpx.Response(429, headers={"retry-after": retry_after}, json={}) + + tp = ThemeParks(transport=httpx.MockTransport(handler), cache=False) + tp.raw._t._sleep = slept.append + return tp + + def test_a_spent_history_budget_raises_instead_of_sleeping(self): + slept: list[float] = [] + tp = self._client("2700", slept) + with pytest.raises(BudgetExhaustedError) as caught: + list(tp.entity("park-1").history.days("2021-07-03", "2026-08-23")) + assert slept == [], "the SDK slept through a 45 minute budget wait" + assert caught.value.retry_after == 2700.0 + + def test_an_ordinary_rest_429_is_still_ridden_out(self): + # The cap must not turn every 429 into an error. A REST limit asks for + # seconds, and retrying is the right answer. + slept: list[float] = [] + tp = self._client("2", slept) + with pytest.raises(RateLimitError) as caught: + list(tp.entity("park-1").history.days("2026-09-01", "2026-09-02")) + assert not isinstance(caught.value, BudgetExhaustedError) + assert slept == [2.0, 2.0, 2.0] diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py index fadd773..9f0d5ac 100644 --- a/tests/unit/test_transport.py +++ b/tests/unit/test_transport.py @@ -168,3 +168,61 @@ def test_parse_body_malformed_json_returns_none(): r = httpx.Response(200, content=b"{not json", headers={"content-type": "application/json"}) assert _parse_body(r) is None + + +class TestRetryAfterCap: + """A history 429 asks for most of an hour. We do not sleep through it. + + Before this cap the transport honoured any Retry-After up to max_retries + times, so a spent history budget parked the process for roughly two and a + half hours with no output. That is indistinguishable from a hang, and it + made BudgetExhaustedError - the whole point of which is to let a backfill + checkpoint instead of blocking - effectively unreachable. + """ + + def _transport(self, slept, calls, retry): + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url) + headers = {} if self.retry_after is None else {"retry-after": self.retry_after} + return httpx.Response(429, headers=headers, json={}) + + client = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://x/v1") + return SyncTransport( + client=client, + base_url="https://x/v1", + user_agent="test/1", + retry=retry, + sleep=slept.append, + ) + + def _run(self, retry_after, retry=None): + self.retry_after = retry_after + slept: list[float] = [] + calls: list[object] = [] + transport = self._transport(slept, calls, retry or RetryConfig()) + with pytest.raises(RateLimitError) as caught: + transport.get("/anything") + return caught.value, slept, calls + + def test_a_long_wait_is_not_slept_through(self): + error, slept, calls = self._run("3000") + assert slept == [] + assert len(calls) == 1 + # The caller still gets the number, so it can schedule its own return. + assert error.retry_after == 3000.0 + + def test_a_short_wait_is_still_honoured(self): + _, slept, calls = self._run("5") + assert slept == [5.0, 5.0, 5.0] + assert len(calls) == 4 + + def test_the_cap_is_configurable(self): + _, slept, _ = self._run("3000", RetryConfig(max_retry_after=3600.0)) + assert slept == [3000.0, 3000.0, 3000.0] + + def test_no_retry_after_header_still_backs_off(self): + # The cap is about the server's stated wait. With no header we fall + # back to our own backoff, which was never the problem. + _, slept, _ = self._run(None) + assert len(slept) == 3 + assert all(s > 0 for s in slept) diff --git a/tests/unit/test_transport_async.py b/tests/unit/test_transport_async.py index 2936653..98888ba 100644 --- a/tests/unit/test_transport_async.py +++ b/tests/unit/test_transport_async.py @@ -109,3 +109,49 @@ def handler(request: httpx.Request) -> httpx.Response: t = make_transport(handler, retry_max=3) assert await t.get("/x") == {"ok": True} assert calls["n"] == 2 + + +class TestRetryAfterCapAsync: + """The async transport must cap the same way. See the sync sibling. + + An async backfill that sleeps for most of an hour inside a single await is + worse than the sync one, not better: it holds the task and gives the event + loop nothing to say about it. + """ + + def _run_setup(self, retry_after, retry): + slept: list[float] = [] + calls: list[object] = [] + + async def sleep(seconds): + slept.append(seconds) + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url) + headers = {} if retry_after is None else {"retry-after": retry_after} + return httpx.Response(429, headers=headers, json={}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://x/v1") + transport = AsyncTransport( + client=client, + base_url="https://x/v1", + user_agent="test/1", + retry=retry, + sleep=sleep, + ) + return transport, slept, calls + + async def test_a_long_wait_is_not_slept_through(self): + transport, slept, calls = self._run_setup("3000", RetryConfig()) + with pytest.raises(RateLimitError) as caught: + await transport.get("/anything") + assert slept == [] + assert len(calls) == 1 + assert caught.value.retry_after == 3000.0 + + async def test_a_short_wait_is_still_honoured(self): + transport, slept, calls = self._run_setup("5", RetryConfig()) + with pytest.raises(RateLimitError): + await transport.get("/anything") + assert slept == [5.0, 5.0, 5.0] + assert len(calls) == 4 diff --git a/themeparks/_transport.py b/themeparks/_transport.py index 155e03b..4023e50 100644 --- a/themeparks/_transport.py +++ b/themeparks/_transport.py @@ -24,6 +24,15 @@ class RetryConfig: max_retries: int = 3 respect_429: bool = True + #: Longest `Retry-After` this client will sleep through, in seconds. + #: + #: A REST 429 asks for seconds and is worth waiting out. A HISTORY 429 is + #: a different animal: that budget is hourly, so a spent one can ask for + #: most of an hour, and honouring it up to `max_retries` times means a + #: process that sits silent for hours and looks hung. Past this cap we do + #: not sleep at all, and raise `RateLimitError` carrying `retry_after` so + #: the caller can checkpoint and come back. + max_retry_after: float = 120.0 def _parse_retry_after(raw: str | None) -> float | None: @@ -40,6 +49,11 @@ def _parse_retry_after(raw: str | None) -> float | None: return None +def _wait_too_long(retry_after: float | None, retry: RetryConfig) -> bool: + """True when the server's wait is longer than this client will sleep for.""" + return retry_after is not None and retry_after > retry.max_retry_after + + def _backoff(attempt: int) -> float: base = 0.25 * (2**attempt) jittered: float = base + random.random() * base * 0.25 @@ -136,13 +150,14 @@ def get(self, path: str) -> Any: body = _parse_body(response) status = response.status_code + retry_after = _parse_retry_after(response.headers.get("retry-after")) if ( status == _STATUS_TOO_MANY_REQUESTS and self._retry.respect_429 and attempt < self._retry.max_retries + and not _wait_too_long(retry_after, self._retry) ): - ra = _parse_retry_after(response.headers.get("retry-after")) - self._sleep(ra if ra is not None else _backoff(attempt)) + self._sleep(retry_after if retry_after is not None else _backoff(attempt)) attempt += 1 continue if status == _STATUS_TOO_MANY_REQUESTS: @@ -151,7 +166,7 @@ def get(self, path: str) -> Any: status=status, body=body, url=url, - retry_after=_parse_retry_after(response.headers.get("retry-after")), + retry_after=retry_after, ) if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries: self._sleep(_backoff(attempt)) @@ -207,13 +222,14 @@ async def get(self, path: str) -> Any: body = _parse_body(response) status = response.status_code + retry_after = _parse_retry_after(response.headers.get("retry-after")) if ( status == _STATUS_TOO_MANY_REQUESTS and self._retry.respect_429 and attempt < self._retry.max_retries + and not _wait_too_long(retry_after, self._retry) ): - ra = _parse_retry_after(response.headers.get("retry-after")) - await self._sleep(ra if ra is not None else _backoff(attempt)) + await self._sleep(retry_after if retry_after is not None else _backoff(attempt)) attempt += 1 continue if status == _STATUS_TOO_MANY_REQUESTS: @@ -222,7 +238,7 @@ async def get(self, path: str) -> Any: status=status, body=body, url=url, - retry_after=_parse_retry_after(response.headers.get("retry-after")), + retry_after=retry_after, ) if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries: await self._sleep(_backoff(attempt)) From 0743b2e2b97ffcac404e6fe9e9ecc2905857988b Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Wed, 23 Sep 2026 21:00:43 +0100 Subject: [PATCH 3/3] fix: read the announced version instead of restating it PACKAGE_VERSION was a literal saying 2.0.0 in a package at 3.1.0. Every request this SDK has made since 3.0.0 announced a version two majors old, and nothing anywhere failed, because a literal only stays right while someone remembers to change it and across two releases nobody did. It now comes from importlib.metadata, which cannot drift, with a "0+unknown" fallback for a source tree with nothing installed. A gate test pins it to pyproject.toml, to the installed metadata and to the User-Agent the transport builds, and refuses the fallback value outright so it can never reach a released artifact. Restoring the old literal fails three of the five. The JavaScript sibling had the same bug, a major out rather than two. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++++++ tests/unit/test_version.py | 48 ++++++++++++++++++++++++++++++++++++++ themeparks/_client.py | 19 ++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db7ac8..ebec53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,13 @@ was unreachable in practice, because the transport rode out the wait before the history layer ever saw the 429. +- **The user agent announced the wrong version.** `PACKAGE_VERSION` was a + literal reading `2.0.0` in a package at `3.1.0`, so every request this SDK + has made since 3.0.0 named a version two majors old, and nothing anywhere + failed. It is now read from the installed package metadata, which cannot + drift, and a gate test pins it to `pyproject.toml` and to the `User-Agent` + the transport builds. + - **The client had no way to send an API key.** There was no `api_key` parameter anywhere, and the transport sent only `user-agent` and `accept`, so every request this SDK made was anonymous: the lowest rate limit and the diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..f7d4cbb --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,48 @@ +"""The version the SDK announces must be the version it is. + +This was a literal in _client.py. It said 2.0.0 while the package was at +3.1.0, so every request made for two releases announced a version two majors +old, and nothing anywhere failed. The constant is now read from the installed +metadata, which cannot drift; these hold that shut and pin the value to +pyproject.toml so a release that forgets one of the two files is a red test +rather than a quiet lie in a header. +""" + +import re +from importlib import metadata +from pathlib import Path + +from themeparks._client import PACKAGE_VERSION, _default_user_agent + +PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" + + +def declared_version() -> str: + for line in PYPROJECT.read_text().splitlines(): + match = re.fullmatch(r'version = "([^"]+)"', line.strip()) + if match: + return match.group(1) + raise AssertionError(f"no version line in {PYPROJECT}") + + +def test_pyproject_declares_a_version(): + # Guard the helper: a parser that silently found nothing would make every + # other test here pass for the wrong reason. + assert re.fullmatch(r"\d+\.\d+\.\d+", declared_version()) + + +def test_announced_version_matches_pyproject(): + assert declared_version() == PACKAGE_VERSION + + +def test_announced_version_matches_installed_metadata(): + assert metadata.version("themeparks") == PACKAGE_VERSION + + +def test_user_agent_carries_it(): + assert _default_user_agent() == f"themeparks-sdk-py/{declared_version()}" + + +def test_the_version_is_not_a_placeholder(): + # The source-tree fallback must never reach a released artifact. + assert PACKAGE_VERSION != "0+unknown" diff --git a/themeparks/_client.py b/themeparks/_client.py index a75f505..4b2a136 100644 --- a/themeparks/_client.py +++ b/themeparks/_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from importlib import metadata from typing import Any, Callable import httpx @@ -13,7 +14,23 @@ from themeparks._transport import AsyncTransport, RetryConfig, SyncTransport DEFAULT_BASE_URL = "https://api.themeparks.wiki/v1" -PACKAGE_VERSION = "2.0.0" + + +def _package_version() -> str: + """Read the installed version rather than restating it. + + This was a literal, and it said 2.0.0 in a package at 3.1.0: every request + the SDK made announced a version two majors old, and nothing failed. A + literal only stays right while someone remembers to change it, and nobody + did across two releases. + """ + try: + return metadata.version("themeparks") + except metadata.PackageNotFoundError: # running from a source tree + return "0+unknown" + + +PACKAGE_VERSION = _package_version() def _default_user_agent() -> str: