Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,81 @@
# 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

- **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 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
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
Expand Down
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,31 @@ 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/<version>` | 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:

```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
Expand Down Expand Up @@ -208,6 +216,61 @@ 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. 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

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
Expand Down
20 changes: 20 additions & 0 deletions docs/api/history.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions docs/api/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,3 @@ for the full set.
options:
heading_level: 3

::: themeparks._generated.models.TagData
options:
heading_level: 3
114 changes: 113 additions & 1 deletion docs/cookbook.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading