themeparks 3.0.0__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.
Files changed (24) hide show
  1. {themeparks-3.0.0 → themeparks-3.1.0}/CHANGELOG.md +84 -3
  2. {themeparks-3.0.0 → themeparks-3.1.0}/PKG-INFO +65 -2
  3. {themeparks-3.0.0 → themeparks-3.1.0}/README.md +64 -1
  4. {themeparks-3.0.0 → themeparks-3.1.0}/pyproject.toml +1 -1
  5. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/__init__.py +3 -0
  6. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_client.py +22 -1
  7. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_ergonomic/entity.py +3 -0
  8. themeparks-3.1.0/themeparks/_ergonomic/history.py +276 -0
  9. themeparks-3.1.0/themeparks/_generated/models.py +1121 -0
  10. themeparks-3.1.0/themeparks/_raw.py +216 -0
  11. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_transport.py +47 -10
  12. themeparks-3.0.0/themeparks/_generated/models.py +0 -452
  13. themeparks-3.0.0/themeparks/_raw.py +0 -101
  14. {themeparks-3.0.0 → themeparks-3.1.0}/.gitignore +0 -0
  15. {themeparks-3.0.0 → themeparks-3.1.0}/LICENSE +0 -0
  16. {themeparks-3.0.0 → themeparks-3.1.0}/MIGRATION.md +0 -0
  17. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_cache.py +0 -0
  18. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_ergonomic/__init__.py +0 -0
  19. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_ergonomic/dates.py +0 -0
  20. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_ergonomic/destinations.py +0 -0
  21. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_ergonomic/live.py +0 -0
  22. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_errors.py +0 -0
  23. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/_generated/__init__.py +0 -0
  24. {themeparks-3.0.0 → themeparks-3.1.0}/themeparks/py.typed +0 -0
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
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
+
3
79
  ## [3.0.0] - 2026-09-08
4
80
 
5
81
  ### Fixed
@@ -8,15 +84,20 @@
8
84
  park's schedule two different ways: precisely when nested under a
9
85
  destination, loosely when fetched directly. This client uses the direct
10
86
  path, so `purchases` was absent from the model entirely. Magic Kingdom
11
- serves 26 entries carrying purchases.
87
+ served 26 of 79 upcoming entries with purchases on the day this shipped.
12
88
 
13
89
  ```python
14
- sched = client.entity(park_id).schedule()
90
+ sched = client.entity(park_id).schedule.upcoming()
15
91
  for day in sched.schedule or []:
16
92
  for p in day.purchases or []:
17
- print(p.name, p.price.amount, p.price.currency)
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
18
95
  ```
19
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
+
20
101
  - **A null price on a schedule purchase no longer rejects the response.**
21
102
  2.0.1 made `PriceData.amount` nullable, but the schedule path used a
22
103
  second, inline price model that kept `amount` non-nullable. Both now use
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: themeparks
3
- Version: 3.0.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "themeparks"
7
- version = "3.0.0"
7
+ version = "3.1.0"
8
8
  description = "Official SDK for the ThemeParks.wiki API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
- PACKAGE_VERSION = "2.0.0"
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)