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.
Files changed (27) hide show
  1. themeparks-3.2.0/CHANGELOG.md +264 -0
  2. {themeparks-3.0.0 → themeparks-3.2.0}/PKG-INFO +109 -2
  3. {themeparks-3.0.0 → themeparks-3.2.0}/README.md +108 -1
  4. {themeparks-3.0.0 → themeparks-3.2.0}/pyproject.toml +1 -1
  5. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/__init__.py +6 -0
  6. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_client.py +79 -1
  7. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/entity.py +3 -0
  8. themeparks-3.2.0/themeparks/_ergonomic/history.py +276 -0
  9. themeparks-3.2.0/themeparks/_generated/models.py +1121 -0
  10. themeparks-3.2.0/themeparks/_ratelimit.py +182 -0
  11. themeparks-3.2.0/themeparks/_raw.py +216 -0
  12. themeparks-3.2.0/themeparks/_transport.py +449 -0
  13. themeparks-3.0.0/CHANGELOG.md +0 -126
  14. themeparks-3.0.0/themeparks/_generated/models.py +0 -452
  15. themeparks-3.0.0/themeparks/_raw.py +0 -101
  16. themeparks-3.0.0/themeparks/_transport.py +0 -215
  17. {themeparks-3.0.0 → themeparks-3.2.0}/.gitignore +0 -0
  18. {themeparks-3.0.0 → themeparks-3.2.0}/LICENSE +0 -0
  19. {themeparks-3.0.0 → themeparks-3.2.0}/MIGRATION.md +0 -0
  20. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_cache.py +0 -0
  21. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/__init__.py +0 -0
  22. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/dates.py +0 -0
  23. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/destinations.py +0 -0
  24. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_ergonomic/live.py +0 -0
  25. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_errors.py +0 -0
  26. {themeparks-3.0.0 → themeparks-3.2.0}/themeparks/_generated/__init__.py +0 -0
  27. {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.0.0
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
@@ -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.2.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,
@@ -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",