themeparks 3.0.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- themeparks-3.2.0/CHANGELOG.md +264 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/PKG-INFO +109 -2
- {themeparks-3.0.0 → themeparks-3.2.0}/README.md +108 -1
- {themeparks-3.0.0 → themeparks-3.2.0}/pyproject.toml +1 -1
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/__init__.py +6 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_client.py +79 -1
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/entity.py +3 -0
- themeparks-3.2.0/themeparks/_ergonomic/history.py +276 -0
- themeparks-3.2.0/themeparks/_generated/models.py +1121 -0
- themeparks-3.2.0/themeparks/_ratelimit.py +182 -0
- themeparks-3.2.0/themeparks/_raw.py +216 -0
- themeparks-3.2.0/themeparks/_transport.py +449 -0
- themeparks-3.0.0/CHANGELOG.md +0 -126
- themeparks-3.0.0/themeparks/_generated/models.py +0 -452
- themeparks-3.0.0/themeparks/_raw.py +0 -101
- themeparks-3.0.0/themeparks/_transport.py +0 -215
- {themeparks-3.0.0 → themeparks-3.2.0}/.gitignore +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/LICENSE +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/MIGRATION.md +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_cache.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/__init__.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/dates.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/destinations.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/live.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_errors.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_generated/__init__.py +0 -0
- {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/py.typed +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [3.2.0] - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The client reads the rate-limit headers, and acts on them.** Both meters,
|
|
8
|
+
the per-minute REST one and the separate hourly history budget, are exposed
|
|
9
|
+
on `client.rate_limit`:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
tp.rate_limit.rest.remaining # 299
|
|
13
|
+
tp.rate_limit.history.remaining # on a history call
|
|
14
|
+
tp.rate_limit.rest.seconds_until_reset()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every field is optional, and `None` means the server did not say rather than
|
|
18
|
+
"nothing left". Use `.exhausted`, which is true only when the server said
|
|
19
|
+
zero. The per-minute figures ride most responses; the hourly history ones are
|
|
20
|
+
withheld from anything a shared cache may store, because they are per-caller;
|
|
21
|
+
an unmetered plan advertises nothing. A response served from a cache is
|
|
22
|
+
ignored entirely, because its figures belong to whoever populated the entry. `reset` is a
|
|
23
|
+
relative countdown frozen when it was read, so `seconds_until_reset()` ages
|
|
24
|
+
it rather than returning a stale number.
|
|
25
|
+
|
|
26
|
+
When a response says the window is spent, the next request now waits for the
|
|
27
|
+
advertised reset instead of sending one that is certain to be refused, and to
|
|
28
|
+
spend a unit of budget being refused. `RetryConfig(respect_remaining=False)`
|
|
29
|
+
turns it off.
|
|
30
|
+
|
|
31
|
+
The hourly history budget is new on the wire; before it there was nothing to
|
|
32
|
+
read.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Calls may now block before sending.** When the server has said your window
|
|
37
|
+
is spent, or has issued a 429 that is still in force, the client waits rather
|
|
38
|
+
than sending a request that is certain to be refused. A call that used to
|
|
39
|
+
return in 200ms can now take up to `retry.max_retry_after` (120s) first. That
|
|
40
|
+
is a TOTAL across the call, not per wait: the shared 429 gate and the
|
|
41
|
+
spent-window wait stack, and before the budget existed a 429 carrying both a
|
|
42
|
+
`Retry-After` and a spent window blocked for 180 seconds under a 120 second
|
|
43
|
+
cap. Turn the two halves off with `RetryConfig(respect_remaining=False)` and
|
|
44
|
+
`RetryConfig(respect_429=False)`.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`respect_429=False` did not opt out.** It raised the error the caller asked
|
|
49
|
+
for and then held their NEXT call for the full `Retry-After` anyway, because
|
|
50
|
+
the shared gate was closed regardless of the setting.
|
|
51
|
+
|
|
52
|
+
- **A 429 was waited out once per in-flight request.** The wait belongs to the
|
|
53
|
+
caller, not to whichever request met it, so ten concurrent requests each
|
|
54
|
+
slept their own `Retry-After` and then retried at the same instant,
|
|
55
|
+
re-tripping the limit together. It is now taken once, on a gate shared by the
|
|
56
|
+
whole client, with a little jitter so the waiters do not wake in unison. A
|
|
57
|
+
shorter wait arriving while a longer one is in force no longer brings the
|
|
58
|
+
gate forward.
|
|
59
|
+
|
|
60
|
+
## [3.1.0] - 2026-09-23
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- **History.** `tp.entity(id).history` reads the archive, and pages for you:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
68
|
+
history = tp.entity(DISNEYLAND).history
|
|
69
|
+
span = history.span()
|
|
70
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `span()` returns `archive_from`, `recorded_to` and `retrievable_through`
|
|
75
|
+
in one shape. The underlying coverage documents do not: a park nests them
|
|
76
|
+
under `summary`, an entity carries them at the top level under different
|
|
77
|
+
names, so without this every caller writes that branch first.
|
|
78
|
+
`retrievable_through` is the end date to bound a backfill by, because it
|
|
79
|
+
is what the key may read rather than what the archive holds.
|
|
80
|
+
- `days(start, end)` yields `(entity id, row)` for one summary row per
|
|
81
|
+
park-local day; `changes(date)` yields every recorded observation.
|
|
82
|
+
Both follow the server's paging links to the end and yield as they go, so
|
|
83
|
+
a resort's five years never has to be in memory at once.
|
|
84
|
+
- Given a park id, both use the park-level call, which answers every entity
|
|
85
|
+
in the park in one request. The same data fetched ride by ride is around a
|
|
86
|
+
hundred times more calls against the same budget.
|
|
87
|
+
- `BudgetExhaustedError` (a `RateLimitError`) is raised when the history
|
|
88
|
+
budget is spent and the server asks for a longer wait than `max_wait`
|
|
89
|
+
(120s by default). It carries `retry_after`, so a backfill can checkpoint
|
|
90
|
+
and resume rather than hold a process open for most of an hour.
|
|
91
|
+
|
|
92
|
+
- **`examples/backfill.py`** — a complete backfill with resume and NDJSON or
|
|
93
|
+
CSV output. It pulls Disneyland Resort's whole daily archive, 98,452 rows,
|
|
94
|
+
in one run.
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
|
|
98
|
+
- **A 429 could park the client for hours.** The transport honoured any
|
|
99
|
+
`Retry-After` up to `max_retries` times. That is right for a REST 429, which
|
|
100
|
+
asks for seconds, and wrong for a history 429: that budget is hourly, so a
|
|
101
|
+
spent one can ask for most of an hour, and three of those is roughly two and
|
|
102
|
+
a half hours of a silent process. `RetryConfig` gains `max_retry_after`
|
|
103
|
+
(120s by default): past it the client does not sleep at all and raises
|
|
104
|
+
`RateLimitError` with `retry_after` set. Without this `BudgetExhaustedError`
|
|
105
|
+
was unreachable in practice, because the transport rode out the wait before
|
|
106
|
+
the history layer ever saw the 429.
|
|
107
|
+
|
|
108
|
+
- **The user agent announced the wrong version.** `PACKAGE_VERSION` was a
|
|
109
|
+
literal reading `2.0.0` in a package at `3.1.0`, so every request this SDK
|
|
110
|
+
has made since 3.0.0 named a version two majors old, and nothing anywhere
|
|
111
|
+
failed. It is now read from the installed package metadata, which cannot
|
|
112
|
+
drift, and a gate test pins it to `pyproject.toml` and to the `User-Agent`
|
|
113
|
+
the transport builds.
|
|
114
|
+
|
|
115
|
+
- **The client had no way to send an API key.** There was no `api_key`
|
|
116
|
+
parameter anywhere, and the transport sent only `user-agent` and `accept`,
|
|
117
|
+
so every request this SDK made was anonymous: the lowest rate limit and the
|
|
118
|
+
most recent seven days of history, whatever the caller had paid for. A
|
|
119
|
+
paying customer had to drop to raw `httpx` to use their own plan.
|
|
120
|
+
`ThemeParks(api_key=...)` and `AsyncThemeParks(api_key=...)` now send
|
|
121
|
+
`x-api-key`. An empty string is treated as no key, because an unset
|
|
122
|
+
environment variable arrives as `""` far more often than as `None`, and
|
|
123
|
+
sending an empty key is a 401 rather than an anonymous request.
|
|
124
|
+
|
|
125
|
+
- **The model generator was silently under-patching every documented class.**
|
|
126
|
+
`scripts/regenerate.py` restores nullability that `datamodel-code-generator`
|
|
127
|
+
drops, by matching the field line inside its class. The pattern could not
|
|
128
|
+
cross the blank line after a class docstring, so it matched only classes
|
|
129
|
+
without one and left every documented class unpatched, printing a warning
|
|
130
|
+
nobody read. That included `next` on all four history envelopes, which is
|
|
131
|
+
null on the last page of every paged response, so the SDK would have failed
|
|
132
|
+
to parse the page that ends a backfill. The pattern now spans blank lines,
|
|
133
|
+
an unmatched patch is a hard failure rather than a warning, and the 22
|
|
134
|
+
fields the spec marks both required and nullable are all listed.
|
|
135
|
+
|
|
136
|
+
## [3.0.0] - 2026-09-08
|
|
137
|
+
|
|
138
|
+
### Fixed
|
|
139
|
+
|
|
140
|
+
- **Schedule entries now carry `purchases`.** The upstream spec described a
|
|
141
|
+
park's schedule two different ways: precisely when nested under a
|
|
142
|
+
destination, loosely when fetched directly. This client uses the direct
|
|
143
|
+
path, so `purchases` was absent from the model entirely. Magic Kingdom
|
|
144
|
+
served 26 of 79 upcoming entries with purchases on the day this shipped.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
sched = client.entity(park_id).schedule.upcoming()
|
|
148
|
+
for day in sched.schedule or []:
|
|
149
|
+
for p in day.purchases or []:
|
|
150
|
+
print(day.date, p.name, p.price.amount, p.price.currency)
|
|
151
|
+
# 2026-09-08 Lightning Lane for Seven Dwarfs Mine Train 1100.0 USD
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Purchases are not limited to `TICKETED_EVENT` days — Lightning Lane entries
|
|
155
|
+
attach to ordinary `OPERATING` days, so do not filter on `type` to find
|
|
156
|
+
them.
|
|
157
|
+
|
|
158
|
+
- **A null price on a schedule purchase no longer rejects the response.**
|
|
159
|
+
2.0.1 made `PriceData.amount` nullable, but the schedule path used a
|
|
160
|
+
second, inline price model that kept `amount` non-nullable. Both now use
|
|
161
|
+
`PriceData`. Tokyo Disneyland serves six Premier Access rows with a null
|
|
162
|
+
amount, and this client raised `ValidationError` on every one of them.
|
|
163
|
+
|
|
164
|
+
- Schedule entries gained the `description` field the API has always sent.
|
|
165
|
+
|
|
166
|
+
### Changed
|
|
167
|
+
|
|
168
|
+
- **BREAKING — `ScheduleEntry.type` is an enum, not a `str`.** It was
|
|
169
|
+
`type: str`; it is now a `Type` enum. This breaks *silently*: the comparison
|
|
170
|
+
does not raise, it just stops being true.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
# before: True. now: False, with no error.
|
|
174
|
+
if day.type == "OPERATING":
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
# use one of these instead
|
|
178
|
+
if day.type.value == "OPERATING":
|
|
179
|
+
...
|
|
180
|
+
from themeparks._generated.models import Type
|
|
181
|
+
if day.type is Type.OPERATING:
|
|
182
|
+
...
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Audit any comparison of `.type` against a string literal before upgrading.
|
|
186
|
+
This is the one change here that will not announce itself.
|
|
187
|
+
|
|
188
|
+
- **BREAKING — the `PricedScheduleEntry` and `Price` models are gone.**
|
|
189
|
+
`PricedScheduleEntry` is now `ScheduleEntry`, and the inline `Price` model
|
|
190
|
+
is replaced by `PriceData`. Neither was exported from the package root, so
|
|
191
|
+
this only affects code importing from `themeparks._generated.models`
|
|
192
|
+
directly — a private module.
|
|
193
|
+
|
|
194
|
+
- The duplicate `EntityType1` and `EntityType2` enums collapse into
|
|
195
|
+
`EntityType`. Same members, same values.
|
|
196
|
+
|
|
197
|
+
## [2.0.1] - 2026-09-01
|
|
198
|
+
### Fixed
|
|
199
|
+
- `PriceData.amount` is now nullable. The API returns `null` when a paid queue
|
|
200
|
+
exists but the provider does not publish a price, and `0` only when the queue
|
|
201
|
+
is genuinely free. Both previously arrived as `0`, so an unknown price was
|
|
202
|
+
indistinguishable from a free one.
|
|
203
|
+
|
|
204
|
+
Before this, a single null amount on one attraction made pydantic reject the
|
|
205
|
+
**entire** live response for that park, not just the one field.
|
|
206
|
+
|
|
207
|
+
Code testing `if price.amount:` or `if not price.amount:` now conflates
|
|
208
|
+
"free" with "price unknown" — the exact confusion this change exists to
|
|
209
|
+
remove. Switch to an explicit check:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
if price.amount is None:
|
|
213
|
+
label = "price not published"
|
|
214
|
+
else:
|
|
215
|
+
label = f"{price.currency} {price.amount / 100:.2f}"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`price.formatted` is optional and may be absent when the amount is unknown,
|
|
219
|
+
so do not rely on it alone to detect the case.
|
|
220
|
+
|
|
221
|
+
## [2.0.0] - 2026-04-15
|
|
222
|
+
First stable v2 release. Identical surface to `2.0.0a1` after a brief alpha
|
|
223
|
+
soak; no code changes since `2.0.0a1`. Bumped `Development Status` classifier
|
|
224
|
+
to `Production/Stable`.
|
|
225
|
+
|
|
226
|
+
## [2.0.0a1] - 2026-04-15
|
|
227
|
+
### Added
|
|
228
|
+
- MkDocs Material documentation site with full API reference and a cookbook
|
|
229
|
+
(recipes for sorted wait times, 7-day schedules, geo-locations grouped by
|
|
230
|
+
entity type, every queue variant, and HTTP debugging).
|
|
231
|
+
- Top-level exports for `current_wait_time`, `iter_queues`, `parse_api_datetime`.
|
|
232
|
+
- Class-level docstrings on every public surface for readable API reference rendering.
|
|
233
|
+
- README sections explaining every queue variant (STANDBY, PAID_RETURN_TIME,
|
|
234
|
+
BOARDING_GROUP, etc.) and how to enable the httpx logger for HTTP debugging.
|
|
235
|
+
|
|
236
|
+
### Fixed
|
|
237
|
+
- `walk()` now makes a single API call instead of one per descendant — the
|
|
238
|
+
`/children` endpoint already returns the entire subtree recursively. Walking
|
|
239
|
+
Walt Disney World dropped from ~250 requests to 1.
|
|
240
|
+
- `get_entity_schedule_month` now zero-pads the month (`/schedule/2026/05`,
|
|
241
|
+
not `/schedule/2026/5`) per the API requirement.
|
|
242
|
+
- `RetryConfig` field renamed `max_attempts` → `max_retries` to match its
|
|
243
|
+
actual semantics (N retries beyond the first attempt = N+1 total calls).
|
|
244
|
+
- `APIError` now includes a server-body excerpt in the exception message;
|
|
245
|
+
`RateLimitError.__repr__` includes `retry_after`.
|
|
246
|
+
- `destinations.find()` now performs the loose, case-insensitive substring
|
|
247
|
+
match it had always promised in its docstring (was exact equality).
|
|
248
|
+
- Generated queue variant classes renamed (`STANDBY` → `StandbyQueue` etc.)
|
|
249
|
+
so users access `queue.STANDBY` instead of the awkward `queue.STANDBY_1`.
|
|
250
|
+
- `eval-type-backport` is now a conditional dep on Python 3.9 so pydantic
|
|
251
|
+
can evaluate PEP 604 union syntax in the generated models.
|
|
252
|
+
|
|
253
|
+
## [2.0.0a0] - 2026-04-14
|
|
254
|
+
### Added
|
|
255
|
+
- Full rewrite on pydantic v2 + httpx.
|
|
256
|
+
- Sync `ThemeParks` and `AsyncThemeParks` clients with shared core.
|
|
257
|
+
- Ergonomic `client.entity(id)` navigation, `walk()`, `schedule.range()`.
|
|
258
|
+
- Typed pydantic models, correctly handling nullable queue fields (fixes #1, #2).
|
|
259
|
+
- Default-on caching with per-endpoint TTLs and pluggable adapter.
|
|
260
|
+
- 429 `Retry-After` handling.
|
|
261
|
+
|
|
262
|
+
### Removed
|
|
263
|
+
- Legacy `openapi_client` top-level import and generated surface. See MIGRATION.md.
|
|
264
|
+
- `urllib3`-based transport.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: themeparks
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Official SDK for the ThemeParks.wiki API
|
|
5
5
|
Project-URL: Homepage, https://api.themeparks.wiki
|
|
6
6
|
Project-URL: Source, https://github.com/ThemeParks/ThemeParks_Python
|
|
@@ -129,23 +129,31 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
129
129
|
| Option | Type | Default | Purpose |
|
|
130
130
|
|--------------|------------------------------------------|--------------------------------------|---------|
|
|
131
131
|
| `base_url` | `str` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
|
|
132
|
+
| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. |
|
|
132
133
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
133
134
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
134
|
-
| `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). |
|
|
135
|
+
| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True, max_retry_after=120.0, respect_remaining=True)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). `max_retry_after` is the TOTAL the client will block for within one call, across both the shared 429 gate and any spent-window wait. Past a single `Retry-After` that long you get `RateLimitError` instead of a silent wait. |
|
|
135
136
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
136
137
|
|
|
137
138
|
Example:
|
|
138
139
|
|
|
139
140
|
```python
|
|
141
|
+
import os
|
|
142
|
+
|
|
140
143
|
from themeparks import ThemeParks, RetryConfig
|
|
141
144
|
|
|
142
145
|
tp = ThemeParks(
|
|
146
|
+
api_key=os.environ["THEMEPARKS_API_KEY"],
|
|
143
147
|
user_agent="my-app/1.2.3 (+https://example.com)",
|
|
144
148
|
timeout=15.0,
|
|
145
149
|
retry=RetryConfig(max_retries=5, respect_429=True),
|
|
146
150
|
)
|
|
147
151
|
```
|
|
148
152
|
|
|
153
|
+
Without a key you get the anonymous tier: the most recent seven days of
|
|
154
|
+
history and the lowest rate limit. Keys are issued from your account at
|
|
155
|
+
[api.themeparks.wiki](https://api.themeparks.wiki).
|
|
156
|
+
|
|
149
157
|
## Ergonomic helpers
|
|
150
158
|
|
|
151
159
|
```python
|
|
@@ -247,6 +255,105 @@ remaining keys are whatever fields that variant carries.
|
|
|
247
255
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
248
256
|
inputs.
|
|
249
257
|
|
|
258
|
+
## Rate limits
|
|
259
|
+
|
|
260
|
+
`client.rate_limit` is a read-only property, not a constructor option.
|
|
261
|
+
|
|
262
|
+
The API meters requests per minute, and history requests again per hour. Both
|
|
263
|
+
are advertised on every response that can carry them, and the client reads
|
|
264
|
+
them:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
268
|
+
tp.entity(park_id).live()
|
|
269
|
+
|
|
270
|
+
print(tp.rate_limit.rest.remaining) # 299
|
|
271
|
+
print(tp.rate_limit.rest.seconds_until_reset())
|
|
272
|
+
print(tp.rate_limit.history.remaining) # on a history call
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**`None` means the server did not say, never "nothing left".** Use
|
|
276
|
+
`.exhausted`, which is true only when the server actually said zero.
|
|
277
|
+
|
|
278
|
+
Which figures you get depends on the response:
|
|
279
|
+
|
|
280
|
+
- The **per-minute** figures ride most responses, anonymous ones included.
|
|
281
|
+
- The **hourly history** figures are withheld from anything a shared cache may
|
|
282
|
+
store, because they are per-caller and a cache would hand one caller's budget
|
|
283
|
+
to another. In practice you get them on calls made with a key.
|
|
284
|
+
- An **unmetered plan** advertises nothing at all.
|
|
285
|
+
|
|
286
|
+
A response served from a cache is ignored entirely. Its figures belong to
|
|
287
|
+
whoever populated the entry and its countdown is already wrong: a cached
|
|
288
|
+
`remaining: 0` would otherwise make the client sleep out someone else's
|
|
289
|
+
window.
|
|
290
|
+
|
|
291
|
+
The client also acts on what it reads. When a response says the window is
|
|
292
|
+
spent, the next request waits for the advertised reset rather than sending a
|
|
293
|
+
request that is certain to be refused, and to cost a unit of budget being
|
|
294
|
+
refused. Turn that off with `RetryConfig(respect_remaining=False)`.
|
|
295
|
+
|
|
296
|
+
**A 429 is held once for the whole client.** The wait belongs to the caller,
|
|
297
|
+
not to whichever request happened to meet it, so it goes on a shared gate with
|
|
298
|
+
a little jitter. Without that, ten concurrent requests each sleep their own
|
|
299
|
+
copy of `Retry-After` and then all retry at the same instant, re-tripping the
|
|
300
|
+
limit together.
|
|
301
|
+
|
|
302
|
+
## History
|
|
303
|
+
|
|
304
|
+
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
305
|
+
rows as they arrive, so a resort's five years never has to fit in memory.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from themeparks import ThemeParks
|
|
309
|
+
|
|
310
|
+
DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66"
|
|
311
|
+
|
|
312
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
313
|
+
history = tp.entity(DISNEYLAND).history
|
|
314
|
+
|
|
315
|
+
# What exists, and what your key may read. Same three fields whether the
|
|
316
|
+
# id is a park or a single ride.
|
|
317
|
+
span = history.span()
|
|
318
|
+
print(span.archive_from, span.recorded_to, span.retrievable_through)
|
|
319
|
+
|
|
320
|
+
# One summary row per park-local day, as (entity id, row).
|
|
321
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
322
|
+
print(row.date, entity_id, row.operatingMinutes, row.standby.p50 if row.standby else None)
|
|
323
|
+
|
|
324
|
+
# Every recorded change on one day.
|
|
325
|
+
for entity_id, row in history.changes("2026-09-20"):
|
|
326
|
+
print(row.time, entity_id, row.status)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Ask the park, not the rides.** Both history endpoints answer every entity in
|
|
330
|
+
a park in one request. Pulling the same data ride by ride is around a hundred
|
|
331
|
+
times more calls for a large resort, against the same budget. Pass a park id
|
|
332
|
+
and you are on the cheap path without having to know the expensive one exists.
|
|
333
|
+
|
|
334
|
+
**History has its own hourly budget**, separate from the per-minute rate limit.
|
|
335
|
+
A large backfill will hit it, and the wait can be most of an hour because that
|
|
336
|
+
is when the window rolls. The client will not sleep through that: past
|
|
337
|
+
`retry.max_retry_after` (120s) it stops retrying, and the history layer turns
|
|
338
|
+
the result into `BudgetExhaustedError` (a `RateLimitError`) carrying
|
|
339
|
+
`retry_after`, so you can checkpoint and come back:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from themeparks import BudgetExhaustedError
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
for entity_id, row in history.days(start, end):
|
|
346
|
+
write(entity_id, row)
|
|
347
|
+
last_day = row.date
|
|
348
|
+
except BudgetExhaustedError as exc:
|
|
349
|
+
checkpoint(last_day)
|
|
350
|
+
print(f"resume in {exc.retry_after:.0f}s")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
A complete backfill script with resume and CSV output is in
|
|
354
|
+
[`examples/backfill.py`](examples/backfill.py); it pulls Disneyland Resort's
|
|
355
|
+
whole daily archive, 98,452 rows, in one run.
|
|
356
|
+
|
|
250
357
|
## Low-level escape hatch
|
|
251
358
|
|
|
252
359
|
Every ergonomic helper is built on top of `tp.raw`, which is a thin, typed
|
|
@@ -90,23 +90,31 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
90
90
|
| Option | Type | Default | Purpose |
|
|
91
91
|
|--------------|------------------------------------------|--------------------------------------|---------|
|
|
92
92
|
| `base_url` | `str` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
|
|
93
|
+
| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. |
|
|
93
94
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
94
95
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
95
|
-
| `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). |
|
|
96
|
+
| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True, max_retry_after=120.0, respect_remaining=True)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). `max_retry_after` is the TOTAL the client will block for within one call, across both the shared 429 gate and any spent-window wait. Past a single `Retry-After` that long you get `RateLimitError` instead of a silent wait. |
|
|
96
97
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
97
98
|
|
|
98
99
|
Example:
|
|
99
100
|
|
|
100
101
|
```python
|
|
102
|
+
import os
|
|
103
|
+
|
|
101
104
|
from themeparks import ThemeParks, RetryConfig
|
|
102
105
|
|
|
103
106
|
tp = ThemeParks(
|
|
107
|
+
api_key=os.environ["THEMEPARKS_API_KEY"],
|
|
104
108
|
user_agent="my-app/1.2.3 (+https://example.com)",
|
|
105
109
|
timeout=15.0,
|
|
106
110
|
retry=RetryConfig(max_retries=5, respect_429=True),
|
|
107
111
|
)
|
|
108
112
|
```
|
|
109
113
|
|
|
114
|
+
Without a key you get the anonymous tier: the most recent seven days of
|
|
115
|
+
history and the lowest rate limit. Keys are issued from your account at
|
|
116
|
+
[api.themeparks.wiki](https://api.themeparks.wiki).
|
|
117
|
+
|
|
110
118
|
## Ergonomic helpers
|
|
111
119
|
|
|
112
120
|
```python
|
|
@@ -208,6 +216,105 @@ remaining keys are whatever fields that variant carries.
|
|
|
208
216
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
209
217
|
inputs.
|
|
210
218
|
|
|
219
|
+
## Rate limits
|
|
220
|
+
|
|
221
|
+
`client.rate_limit` is a read-only property, not a constructor option.
|
|
222
|
+
|
|
223
|
+
The API meters requests per minute, and history requests again per hour. Both
|
|
224
|
+
are advertised on every response that can carry them, and the client reads
|
|
225
|
+
them:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
229
|
+
tp.entity(park_id).live()
|
|
230
|
+
|
|
231
|
+
print(tp.rate_limit.rest.remaining) # 299
|
|
232
|
+
print(tp.rate_limit.rest.seconds_until_reset())
|
|
233
|
+
print(tp.rate_limit.history.remaining) # on a history call
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**`None` means the server did not say, never "nothing left".** Use
|
|
237
|
+
`.exhausted`, which is true only when the server actually said zero.
|
|
238
|
+
|
|
239
|
+
Which figures you get depends on the response:
|
|
240
|
+
|
|
241
|
+
- The **per-minute** figures ride most responses, anonymous ones included.
|
|
242
|
+
- The **hourly history** figures are withheld from anything a shared cache may
|
|
243
|
+
store, because they are per-caller and a cache would hand one caller's budget
|
|
244
|
+
to another. In practice you get them on calls made with a key.
|
|
245
|
+
- An **unmetered plan** advertises nothing at all.
|
|
246
|
+
|
|
247
|
+
A response served from a cache is ignored entirely. Its figures belong to
|
|
248
|
+
whoever populated the entry and its countdown is already wrong: a cached
|
|
249
|
+
`remaining: 0` would otherwise make the client sleep out someone else's
|
|
250
|
+
window.
|
|
251
|
+
|
|
252
|
+
The client also acts on what it reads. When a response says the window is
|
|
253
|
+
spent, the next request waits for the advertised reset rather than sending a
|
|
254
|
+
request that is certain to be refused, and to cost a unit of budget being
|
|
255
|
+
refused. Turn that off with `RetryConfig(respect_remaining=False)`.
|
|
256
|
+
|
|
257
|
+
**A 429 is held once for the whole client.** The wait belongs to the caller,
|
|
258
|
+
not to whichever request happened to meet it, so it goes on a shared gate with
|
|
259
|
+
a little jitter. Without that, ten concurrent requests each sleep their own
|
|
260
|
+
copy of `Retry-After` and then all retry at the same instant, re-tripping the
|
|
261
|
+
limit together.
|
|
262
|
+
|
|
263
|
+
## History
|
|
264
|
+
|
|
265
|
+
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
266
|
+
rows as they arrive, so a resort's five years never has to fit in memory.
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
from themeparks import ThemeParks
|
|
270
|
+
|
|
271
|
+
DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66"
|
|
272
|
+
|
|
273
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
274
|
+
history = tp.entity(DISNEYLAND).history
|
|
275
|
+
|
|
276
|
+
# What exists, and what your key may read. Same three fields whether the
|
|
277
|
+
# id is a park or a single ride.
|
|
278
|
+
span = history.span()
|
|
279
|
+
print(span.archive_from, span.recorded_to, span.retrievable_through)
|
|
280
|
+
|
|
281
|
+
# One summary row per park-local day, as (entity id, row).
|
|
282
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
283
|
+
print(row.date, entity_id, row.operatingMinutes, row.standby.p50 if row.standby else None)
|
|
284
|
+
|
|
285
|
+
# Every recorded change on one day.
|
|
286
|
+
for entity_id, row in history.changes("2026-09-20"):
|
|
287
|
+
print(row.time, entity_id, row.status)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Ask the park, not the rides.** Both history endpoints answer every entity in
|
|
291
|
+
a park in one request. Pulling the same data ride by ride is around a hundred
|
|
292
|
+
times more calls for a large resort, against the same budget. Pass a park id
|
|
293
|
+
and you are on the cheap path without having to know the expensive one exists.
|
|
294
|
+
|
|
295
|
+
**History has its own hourly budget**, separate from the per-minute rate limit.
|
|
296
|
+
A large backfill will hit it, and the wait can be most of an hour because that
|
|
297
|
+
is when the window rolls. The client will not sleep through that: past
|
|
298
|
+
`retry.max_retry_after` (120s) it stops retrying, and the history layer turns
|
|
299
|
+
the result into `BudgetExhaustedError` (a `RateLimitError`) carrying
|
|
300
|
+
`retry_after`, so you can checkpoint and come back:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from themeparks import BudgetExhaustedError
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
for entity_id, row in history.days(start, end):
|
|
307
|
+
write(entity_id, row)
|
|
308
|
+
last_day = row.date
|
|
309
|
+
except BudgetExhaustedError as exc:
|
|
310
|
+
checkpoint(last_day)
|
|
311
|
+
print(f"resume in {exc.retry_after:.0f}s")
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A complete backfill script with resume and CSV output is in
|
|
315
|
+
[`examples/backfill.py`](examples/backfill.py); it pulls Disneyland Resort's
|
|
316
|
+
whole daily archive, 98,452 rows, in one run.
|
|
317
|
+
|
|
211
318
|
## Low-level escape hatch
|
|
212
319
|
|
|
213
320
|
Every ergonomic helper is built on top of `tp.raw`, which is a thin, typed
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from themeparks._cache import Cache, CacheConfig, InMemoryLRUCache
|
|
2
2
|
from themeparks._client import AsyncThemeParks, ThemeParks
|
|
3
3
|
from themeparks._ergonomic.dates import parse_api_datetime
|
|
4
|
+
from themeparks._ergonomic.history import BudgetExhaustedError, HistorySpan
|
|
4
5
|
from themeparks._ergonomic.live import current_wait_time, iter_queues
|
|
5
6
|
from themeparks._errors import (
|
|
6
7
|
APIError,
|
|
@@ -9,11 +10,16 @@ from themeparks._errors import (
|
|
|
9
10
|
ThemeParksError,
|
|
10
11
|
TimeoutError,
|
|
11
12
|
)
|
|
13
|
+
from themeparks._ratelimit import RateLimit, RateLimits
|
|
12
14
|
from themeparks._transport import RetryConfig
|
|
13
15
|
|
|
14
16
|
__all__ = [
|
|
15
17
|
"APIError",
|
|
16
18
|
"AsyncThemeParks",
|
|
19
|
+
"BudgetExhaustedError",
|
|
20
|
+
"HistorySpan",
|
|
21
|
+
"RateLimit",
|
|
22
|
+
"RateLimits",
|
|
17
23
|
"Cache",
|
|
18
24
|
"CacheConfig",
|
|
19
25
|
"InMemoryLRUCache",
|