themeparks 2.0.1__tar.gz → 3.1.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.1.0/CHANGELOG.md +207 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/PKG-INFO +65 -2
- {themeparks-2.0.1 → themeparks-3.1.0}/README.md +64 -1
- {themeparks-2.0.1 → themeparks-3.1.0}/pyproject.toml +1 -1
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/__init__.py +3 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_client.py +22 -1
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_ergonomic/entity.py +3 -0
- themeparks-3.1.0/themeparks/_ergonomic/history.py +276 -0
- themeparks-3.1.0/themeparks/_generated/models.py +1121 -0
- themeparks-3.1.0/themeparks/_raw.py +216 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_transport.py +47 -10
- themeparks-2.0.1/CHANGELOG.md +0 -70
- themeparks-2.0.1/themeparks/_generated/models.py +0 -516
- themeparks-2.0.1/themeparks/_raw.py +0 -101
- {themeparks-2.0.1 → themeparks-3.1.0}/.gitignore +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/LICENSE +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/MIGRATION.md +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_cache.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_ergonomic/__init__.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_ergonomic/dates.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_ergonomic/destinations.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_ergonomic/live.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_errors.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/_generated/__init__.py +0 -0
- {themeparks-2.0.1 → themeparks-3.1.0}/themeparks/py.typed +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [3.1.0] - 2026-09-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **History.** `tp.entity(id).history` reads the archive, and pages for you:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
11
|
+
history = tp.entity(DISNEYLAND).history
|
|
12
|
+
span = history.span()
|
|
13
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
14
|
+
...
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `span()` returns `archive_from`, `recorded_to` and `retrievable_through`
|
|
18
|
+
in one shape. The underlying coverage documents do not: a park nests them
|
|
19
|
+
under `summary`, an entity carries them at the top level under different
|
|
20
|
+
names, so without this every caller writes that branch first.
|
|
21
|
+
`retrievable_through` is the end date to bound a backfill by, because it
|
|
22
|
+
is what the key may read rather than what the archive holds.
|
|
23
|
+
- `days(start, end)` yields `(entity id, row)` for one summary row per
|
|
24
|
+
park-local day; `changes(date)` yields every recorded observation.
|
|
25
|
+
Both follow the server's paging links to the end and yield as they go, so
|
|
26
|
+
a resort's five years never has to be in memory at once.
|
|
27
|
+
- Given a park id, both use the park-level call, which answers every entity
|
|
28
|
+
in the park in one request. The same data fetched ride by ride is around a
|
|
29
|
+
hundred times more calls against the same budget.
|
|
30
|
+
- `BudgetExhaustedError` (a `RateLimitError`) is raised when the history
|
|
31
|
+
budget is spent and the server asks for a longer wait than `max_wait`
|
|
32
|
+
(120s by default). It carries `retry_after`, so a backfill can checkpoint
|
|
33
|
+
and resume rather than hold a process open for most of an hour.
|
|
34
|
+
|
|
35
|
+
- **`examples/backfill.py`** — a complete backfill with resume and NDJSON or
|
|
36
|
+
CSV output. It pulls Disneyland Resort's whole daily archive, 98,452 rows,
|
|
37
|
+
in one run.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **A 429 could park the client for hours.** The transport honoured any
|
|
42
|
+
`Retry-After` up to `max_retries` times. That is right for a REST 429, which
|
|
43
|
+
asks for seconds, and wrong for a history 429: that budget is hourly, so a
|
|
44
|
+
spent one can ask for most of an hour, and three of those is roughly two and
|
|
45
|
+
a half hours of a silent process. `RetryConfig` gains `max_retry_after`
|
|
46
|
+
(120s by default): past it the client does not sleep at all and raises
|
|
47
|
+
`RateLimitError` with `retry_after` set. Without this `BudgetExhaustedError`
|
|
48
|
+
was unreachable in practice, because the transport rode out the wait before
|
|
49
|
+
the history layer ever saw the 429.
|
|
50
|
+
|
|
51
|
+
- **The user agent announced the wrong version.** `PACKAGE_VERSION` was a
|
|
52
|
+
literal reading `2.0.0` in a package at `3.1.0`, so every request this SDK
|
|
53
|
+
has made since 3.0.0 named a version two majors old, and nothing anywhere
|
|
54
|
+
failed. It is now read from the installed package metadata, which cannot
|
|
55
|
+
drift, and a gate test pins it to `pyproject.toml` and to the `User-Agent`
|
|
56
|
+
the transport builds.
|
|
57
|
+
|
|
58
|
+
- **The client had no way to send an API key.** There was no `api_key`
|
|
59
|
+
parameter anywhere, and the transport sent only `user-agent` and `accept`,
|
|
60
|
+
so every request this SDK made was anonymous: the lowest rate limit and the
|
|
61
|
+
most recent seven days of history, whatever the caller had paid for. A
|
|
62
|
+
paying customer had to drop to raw `httpx` to use their own plan.
|
|
63
|
+
`ThemeParks(api_key=...)` and `AsyncThemeParks(api_key=...)` now send
|
|
64
|
+
`x-api-key`. An empty string is treated as no key, because an unset
|
|
65
|
+
environment variable arrives as `""` far more often than as `None`, and
|
|
66
|
+
sending an empty key is a 401 rather than an anonymous request.
|
|
67
|
+
|
|
68
|
+
- **The model generator was silently under-patching every documented class.**
|
|
69
|
+
`scripts/regenerate.py` restores nullability that `datamodel-code-generator`
|
|
70
|
+
drops, by matching the field line inside its class. The pattern could not
|
|
71
|
+
cross the blank line after a class docstring, so it matched only classes
|
|
72
|
+
without one and left every documented class unpatched, printing a warning
|
|
73
|
+
nobody read. That included `next` on all four history envelopes, which is
|
|
74
|
+
null on the last page of every paged response, so the SDK would have failed
|
|
75
|
+
to parse the page that ends a backfill. The pattern now spans blank lines,
|
|
76
|
+
an unmatched patch is a hard failure rather than a warning, and the 22
|
|
77
|
+
fields the spec marks both required and nullable are all listed.
|
|
78
|
+
|
|
79
|
+
## [3.0.0] - 2026-09-08
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **Schedule entries now carry `purchases`.** The upstream spec described a
|
|
84
|
+
park's schedule two different ways: precisely when nested under a
|
|
85
|
+
destination, loosely when fetched directly. This client uses the direct
|
|
86
|
+
path, so `purchases` was absent from the model entirely. Magic Kingdom
|
|
87
|
+
served 26 of 79 upcoming entries with purchases on the day this shipped.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
sched = client.entity(park_id).schedule.upcoming()
|
|
91
|
+
for day in sched.schedule or []:
|
|
92
|
+
for p in day.purchases or []:
|
|
93
|
+
print(day.date, p.name, p.price.amount, p.price.currency)
|
|
94
|
+
# 2026-09-08 Lightning Lane for Seven Dwarfs Mine Train 1100.0 USD
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Purchases are not limited to `TICKETED_EVENT` days — Lightning Lane entries
|
|
98
|
+
attach to ordinary `OPERATING` days, so do not filter on `type` to find
|
|
99
|
+
them.
|
|
100
|
+
|
|
101
|
+
- **A null price on a schedule purchase no longer rejects the response.**
|
|
102
|
+
2.0.1 made `PriceData.amount` nullable, but the schedule path used a
|
|
103
|
+
second, inline price model that kept `amount` non-nullable. Both now use
|
|
104
|
+
`PriceData`. Tokyo Disneyland serves six Premier Access rows with a null
|
|
105
|
+
amount, and this client raised `ValidationError` on every one of them.
|
|
106
|
+
|
|
107
|
+
- Schedule entries gained the `description` field the API has always sent.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- **BREAKING — `ScheduleEntry.type` is an enum, not a `str`.** It was
|
|
112
|
+
`type: str`; it is now a `Type` enum. This breaks *silently*: the comparison
|
|
113
|
+
does not raise, it just stops being true.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# before: True. now: False, with no error.
|
|
117
|
+
if day.type == "OPERATING":
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
# use one of these instead
|
|
121
|
+
if day.type.value == "OPERATING":
|
|
122
|
+
...
|
|
123
|
+
from themeparks._generated.models import Type
|
|
124
|
+
if day.type is Type.OPERATING:
|
|
125
|
+
...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Audit any comparison of `.type` against a string literal before upgrading.
|
|
129
|
+
This is the one change here that will not announce itself.
|
|
130
|
+
|
|
131
|
+
- **BREAKING — the `PricedScheduleEntry` and `Price` models are gone.**
|
|
132
|
+
`PricedScheduleEntry` is now `ScheduleEntry`, and the inline `Price` model
|
|
133
|
+
is replaced by `PriceData`. Neither was exported from the package root, so
|
|
134
|
+
this only affects code importing from `themeparks._generated.models`
|
|
135
|
+
directly — a private module.
|
|
136
|
+
|
|
137
|
+
- The duplicate `EntityType1` and `EntityType2` enums collapse into
|
|
138
|
+
`EntityType`. Same members, same values.
|
|
139
|
+
|
|
140
|
+
## [2.0.1] - 2026-09-01
|
|
141
|
+
### Fixed
|
|
142
|
+
- `PriceData.amount` is now nullable. The API returns `null` when a paid queue
|
|
143
|
+
exists but the provider does not publish a price, and `0` only when the queue
|
|
144
|
+
is genuinely free. Both previously arrived as `0`, so an unknown price was
|
|
145
|
+
indistinguishable from a free one.
|
|
146
|
+
|
|
147
|
+
Before this, a single null amount on one attraction made pydantic reject the
|
|
148
|
+
**entire** live response for that park, not just the one field.
|
|
149
|
+
|
|
150
|
+
Code testing `if price.amount:` or `if not price.amount:` now conflates
|
|
151
|
+
"free" with "price unknown" — the exact confusion this change exists to
|
|
152
|
+
remove. Switch to an explicit check:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
if price.amount is None:
|
|
156
|
+
label = "price not published"
|
|
157
|
+
else:
|
|
158
|
+
label = f"{price.currency} {price.amount / 100:.2f}"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`price.formatted` is optional and may be absent when the amount is unknown,
|
|
162
|
+
so do not rely on it alone to detect the case.
|
|
163
|
+
|
|
164
|
+
## [2.0.0] - 2026-04-15
|
|
165
|
+
First stable v2 release. Identical surface to `2.0.0a1` after a brief alpha
|
|
166
|
+
soak; no code changes since `2.0.0a1`. Bumped `Development Status` classifier
|
|
167
|
+
to `Production/Stable`.
|
|
168
|
+
|
|
169
|
+
## [2.0.0a1] - 2026-04-15
|
|
170
|
+
### Added
|
|
171
|
+
- MkDocs Material documentation site with full API reference and a cookbook
|
|
172
|
+
(recipes for sorted wait times, 7-day schedules, geo-locations grouped by
|
|
173
|
+
entity type, every queue variant, and HTTP debugging).
|
|
174
|
+
- Top-level exports for `current_wait_time`, `iter_queues`, `parse_api_datetime`.
|
|
175
|
+
- Class-level docstrings on every public surface for readable API reference rendering.
|
|
176
|
+
- README sections explaining every queue variant (STANDBY, PAID_RETURN_TIME,
|
|
177
|
+
BOARDING_GROUP, etc.) and how to enable the httpx logger for HTTP debugging.
|
|
178
|
+
|
|
179
|
+
### Fixed
|
|
180
|
+
- `walk()` now makes a single API call instead of one per descendant — the
|
|
181
|
+
`/children` endpoint already returns the entire subtree recursively. Walking
|
|
182
|
+
Walt Disney World dropped from ~250 requests to 1.
|
|
183
|
+
- `get_entity_schedule_month` now zero-pads the month (`/schedule/2026/05`,
|
|
184
|
+
not `/schedule/2026/5`) per the API requirement.
|
|
185
|
+
- `RetryConfig` field renamed `max_attempts` → `max_retries` to match its
|
|
186
|
+
actual semantics (N retries beyond the first attempt = N+1 total calls).
|
|
187
|
+
- `APIError` now includes a server-body excerpt in the exception message;
|
|
188
|
+
`RateLimitError.__repr__` includes `retry_after`.
|
|
189
|
+
- `destinations.find()` now performs the loose, case-insensitive substring
|
|
190
|
+
match it had always promised in its docstring (was exact equality).
|
|
191
|
+
- Generated queue variant classes renamed (`STANDBY` → `StandbyQueue` etc.)
|
|
192
|
+
so users access `queue.STANDBY` instead of the awkward `queue.STANDBY_1`.
|
|
193
|
+
- `eval-type-backport` is now a conditional dep on Python 3.9 so pydantic
|
|
194
|
+
can evaluate PEP 604 union syntax in the generated models.
|
|
195
|
+
|
|
196
|
+
## [2.0.0a0] - 2026-04-14
|
|
197
|
+
### Added
|
|
198
|
+
- Full rewrite on pydantic v2 + httpx.
|
|
199
|
+
- Sync `ThemeParks` and `AsyncThemeParks` clients with shared core.
|
|
200
|
+
- Ergonomic `client.entity(id)` navigation, `walk()`, `schedule.range()`.
|
|
201
|
+
- Typed pydantic models, correctly handling nullable queue fields (fixes #1, #2).
|
|
202
|
+
- Default-on caching with per-endpoint TTLs and pluggable adapter.
|
|
203
|
+
- 429 `Retry-After` handling.
|
|
204
|
+
|
|
205
|
+
### Removed
|
|
206
|
+
- Legacy `openapi_client` top-level import and generated surface. See MIGRATION.md.
|
|
207
|
+
- `urllib3`-based transport.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: themeparks
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.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)` | 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. |
|
|
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,61 @@ 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
|
+
## History
|
|
259
|
+
|
|
260
|
+
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
261
|
+
rows as they arrive, so a resort's five years never has to fit in memory.
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from themeparks import ThemeParks
|
|
265
|
+
|
|
266
|
+
DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66"
|
|
267
|
+
|
|
268
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
269
|
+
history = tp.entity(DISNEYLAND).history
|
|
270
|
+
|
|
271
|
+
# What exists, and what your key may read. Same three fields whether the
|
|
272
|
+
# id is a park or a single ride.
|
|
273
|
+
span = history.span()
|
|
274
|
+
print(span.archive_from, span.recorded_to, span.retrievable_through)
|
|
275
|
+
|
|
276
|
+
# One summary row per park-local day, as (entity id, row).
|
|
277
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
278
|
+
print(row.date, entity_id, row.operatingMinutes, row.standby.p50 if row.standby else None)
|
|
279
|
+
|
|
280
|
+
# Every recorded change on one day.
|
|
281
|
+
for entity_id, row in history.changes("2026-09-20"):
|
|
282
|
+
print(row.time, entity_id, row.status)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Ask the park, not the rides.** Both history endpoints answer every entity in
|
|
286
|
+
a park in one request. Pulling the same data ride by ride is around a hundred
|
|
287
|
+
times more calls for a large resort, against the same budget. Pass a park id
|
|
288
|
+
and you are on the cheap path without having to know the expensive one exists.
|
|
289
|
+
|
|
290
|
+
**History has its own hourly budget**, separate from the per-minute rate limit.
|
|
291
|
+
A large backfill will hit it, and the wait can be most of an hour because that
|
|
292
|
+
is when the window rolls. The client will not sleep through that: past
|
|
293
|
+
`retry.max_retry_after` (120s) it stops retrying, and the history layer turns
|
|
294
|
+
the result into `BudgetExhaustedError` (a `RateLimitError`) carrying
|
|
295
|
+
`retry_after`, so you can checkpoint and come back:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from themeparks import BudgetExhaustedError
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
for entity_id, row in history.days(start, end):
|
|
302
|
+
write(entity_id, row)
|
|
303
|
+
last_day = row.date
|
|
304
|
+
except BudgetExhaustedError as exc:
|
|
305
|
+
checkpoint(last_day)
|
|
306
|
+
print(f"resume in {exc.retry_after:.0f}s")
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
A complete backfill script with resume and CSV output is in
|
|
310
|
+
[`examples/backfill.py`](examples/backfill.py); it pulls Disneyland Resort's
|
|
311
|
+
whole daily archive, 98,452 rows, in one run.
|
|
312
|
+
|
|
250
313
|
## Low-level escape hatch
|
|
251
314
|
|
|
252
315
|
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)` | 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. |
|
|
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,61 @@ 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
|
+
## History
|
|
220
|
+
|
|
221
|
+
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
222
|
+
rows as they arrive, so a resort's five years never has to fit in memory.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from themeparks import ThemeParks
|
|
226
|
+
|
|
227
|
+
DISNEYLAND = "7340550b-c14d-4def-80bb-acdb51d49a66"
|
|
228
|
+
|
|
229
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
230
|
+
history = tp.entity(DISNEYLAND).history
|
|
231
|
+
|
|
232
|
+
# What exists, and what your key may read. Same three fields whether the
|
|
233
|
+
# id is a park or a single ride.
|
|
234
|
+
span = history.span()
|
|
235
|
+
print(span.archive_from, span.recorded_to, span.retrievable_through)
|
|
236
|
+
|
|
237
|
+
# One summary row per park-local day, as (entity id, row).
|
|
238
|
+
for entity_id, row in history.days(span.archive_from, span.retrievable_through):
|
|
239
|
+
print(row.date, entity_id, row.operatingMinutes, row.standby.p50 if row.standby else None)
|
|
240
|
+
|
|
241
|
+
# Every recorded change on one day.
|
|
242
|
+
for entity_id, row in history.changes("2026-09-20"):
|
|
243
|
+
print(row.time, entity_id, row.status)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Ask the park, not the rides.** Both history endpoints answer every entity in
|
|
247
|
+
a park in one request. Pulling the same data ride by ride is around a hundred
|
|
248
|
+
times more calls for a large resort, against the same budget. Pass a park id
|
|
249
|
+
and you are on the cheap path without having to know the expensive one exists.
|
|
250
|
+
|
|
251
|
+
**History has its own hourly budget**, separate from the per-minute rate limit.
|
|
252
|
+
A large backfill will hit it, and the wait can be most of an hour because that
|
|
253
|
+
is when the window rolls. The client will not sleep through that: past
|
|
254
|
+
`retry.max_retry_after` (120s) it stops retrying, and the history layer turns
|
|
255
|
+
the result into `BudgetExhaustedError` (a `RateLimitError`) carrying
|
|
256
|
+
`retry_after`, so you can checkpoint and come back:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from themeparks import BudgetExhaustedError
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
for entity_id, row in history.days(start, end):
|
|
263
|
+
write(entity_id, row)
|
|
264
|
+
last_day = row.date
|
|
265
|
+
except BudgetExhaustedError as exc:
|
|
266
|
+
checkpoint(last_day)
|
|
267
|
+
print(f"resume in {exc.retry_after:.0f}s")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A complete backfill script with resume and CSV output is in
|
|
271
|
+
[`examples/backfill.py`](examples/backfill.py); it pulls Disneyland Resort's
|
|
272
|
+
whole daily archive, 98,452 rows, in one run.
|
|
273
|
+
|
|
211
274
|
## Low-level escape hatch
|
|
212
275
|
|
|
213
276
|
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,
|
|
@@ -14,6 +15,8 @@ from themeparks._transport import RetryConfig
|
|
|
14
15
|
__all__ = [
|
|
15
16
|
"APIError",
|
|
16
17
|
"AsyncThemeParks",
|
|
18
|
+
"BudgetExhaustedError",
|
|
19
|
+
"HistorySpan",
|
|
17
20
|
"Cache",
|
|
18
21
|
"CacheConfig",
|
|
19
22
|
"InMemoryLRUCache",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from importlib import metadata
|
|
5
6
|
from typing import Any, Callable
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -13,7 +14,23 @@ from themeparks._raw import AsyncRawClient, RawClient
|
|
|
13
14
|
from themeparks._transport import AsyncTransport, RetryConfig, SyncTransport
|
|
14
15
|
|
|
15
16
|
DEFAULT_BASE_URL = "https://api.themeparks.wiki/v1"
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _package_version() -> str:
|
|
20
|
+
"""Read the installed version rather than restating it.
|
|
21
|
+
|
|
22
|
+
This was a literal, and it said 2.0.0 in a package at 3.1.0: every request
|
|
23
|
+
the SDK made announced a version two majors old, and nothing failed. A
|
|
24
|
+
literal only stays right while someone remembers to change it, and nobody
|
|
25
|
+
did across two releases.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
return metadata.version("themeparks")
|
|
29
|
+
except metadata.PackageNotFoundError: # running from a source tree
|
|
30
|
+
return "0+unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PACKAGE_VERSION = _package_version()
|
|
17
34
|
|
|
18
35
|
|
|
19
36
|
def _default_user_agent() -> str:
|
|
@@ -89,6 +106,7 @@ class ThemeParks:
|
|
|
89
106
|
retry: RetryConfig | None = None,
|
|
90
107
|
cache: Cache | bool | CacheConfig | None = None,
|
|
91
108
|
transport: httpx.BaseTransport | None = None,
|
|
109
|
+
api_key: str | None = None,
|
|
92
110
|
) -> None:
|
|
93
111
|
self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
|
|
94
112
|
sync_t = SyncTransport(
|
|
@@ -96,6 +114,7 @@ class ThemeParks:
|
|
|
96
114
|
base_url=base_url,
|
|
97
115
|
user_agent=user_agent or _default_user_agent(),
|
|
98
116
|
retry=retry or RetryConfig(),
|
|
117
|
+
api_key=api_key,
|
|
99
118
|
)
|
|
100
119
|
cache_impl = _build_cache(cache)
|
|
101
120
|
inner: Any = _CachingSyncTransport(sync_t, cache_impl) if cache_impl else sync_t
|
|
@@ -146,6 +165,7 @@ class AsyncThemeParks:
|
|
|
146
165
|
retry: RetryConfig | None = None,
|
|
147
166
|
cache: Cache | bool | CacheConfig | None = None,
|
|
148
167
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
168
|
+
api_key: str | None = None,
|
|
149
169
|
) -> None:
|
|
150
170
|
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport)
|
|
151
171
|
async_t = AsyncTransport(
|
|
@@ -153,6 +173,7 @@ class AsyncThemeParks:
|
|
|
153
173
|
base_url=base_url,
|
|
154
174
|
user_agent=user_agent or _default_user_agent(),
|
|
155
175
|
retry=retry or RetryConfig(),
|
|
176
|
+
api_key=api_key,
|
|
156
177
|
)
|
|
157
178
|
cache_impl = _build_cache(cache)
|
|
158
179
|
inner: Any = _CachingAsyncTransport(async_t, cache_impl) if cache_impl else async_t
|
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
from collections.abc import AsyncIterator, Iterator
|
|
7
7
|
from datetime import date
|
|
8
8
|
|
|
9
|
+
from themeparks._ergonomic.history import AsyncHistoryApi, HistoryApi
|
|
9
10
|
from themeparks._generated.models import (
|
|
10
11
|
EntityChild,
|
|
11
12
|
EntityChildrenResponse,
|
|
@@ -76,6 +77,7 @@ class EntityHandle:
|
|
|
76
77
|
self._raw = raw
|
|
77
78
|
self.entity_id = entity_id
|
|
78
79
|
self.schedule = _ScheduleApi(raw, entity_id)
|
|
80
|
+
self.history = HistoryApi(raw, entity_id)
|
|
79
81
|
|
|
80
82
|
def get(self) -> EntityData:
|
|
81
83
|
return self._raw.get_entity(self.entity_id)
|
|
@@ -143,6 +145,7 @@ class AsyncEntityHandle:
|
|
|
143
145
|
self._raw = raw
|
|
144
146
|
self.entity_id = entity_id
|
|
145
147
|
self.schedule = _AsyncScheduleApi(raw, entity_id)
|
|
148
|
+
self.history = AsyncHistoryApi(raw, entity_id)
|
|
146
149
|
|
|
147
150
|
async def get(self) -> EntityData:
|
|
148
151
|
return await self._raw.get_entity(self.entity_id)
|