themeparks 3.1.0__tar.gz → 3.3.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 → themeparks-3.3.0}/CHANGELOG.md +103 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/PKG-INFO +65 -5
- {themeparks-3.1.0 → themeparks-3.3.0}/README.md +64 -4
- {themeparks-3.1.0 → themeparks-3.3.0}/pyproject.toml +7 -1
- themeparks-3.3.0/tests/fixtures/README.md +37 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/__init__.py +3 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_client.py +57 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/history.py +68 -5
- themeparks-3.3.0/themeparks/_ratelimit.py +182 -0
- themeparks-3.3.0/themeparks/_transport.py +449 -0
- themeparks-3.3.0/themeparks/backfill.py +884 -0
- themeparks-3.1.0/themeparks/_transport.py +0 -252
- {themeparks-3.1.0 → themeparks-3.3.0}/.gitignore +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/LICENSE +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/MIGRATION.md +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_cache.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/__init__.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/dates.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/destinations.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/entity.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_ergonomic/live.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_errors.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_generated/__init__.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_generated/models.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/_raw.py +0 -0
- {themeparks-3.1.0 → themeparks-3.3.0}/themeparks/py.typed +0 -0
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.3.0] - 2026-09-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`themeparks-backfill`: the archive download as a command.** It was an example
|
|
8
|
+
to copy off GitHub. The first paying customer followed that link and had to
|
|
9
|
+
work out that the library needed installing, then what the arguments were,
|
|
10
|
+
then read a traceback. Now:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install themeparks
|
|
14
|
+
themeparks-backfill "Disneyland Park"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Takes a park or a **destination**, by name or id. A destination back fills
|
|
18
|
+
every park in it, one file each. `"Walt Disney World Resort"` is the handle
|
|
19
|
+
people actually have; four park uuids is not.
|
|
20
|
+
- `--list [text]` prints destinations with their parks underneath, and **needs
|
|
21
|
+
no key**, so you can find your park before deciding whether to pay.
|
|
22
|
+
- Refuses to guess between two matches. Two parks are named exactly
|
|
23
|
+
"Disneyland Park" (Anaheim and Paris), so the candidate list names the
|
|
24
|
+
destination as well.
|
|
25
|
+
- **Runs without a key**, reading the 7 days anonymous access allows, and says
|
|
26
|
+
what a key would add. It used to refuse to start with a message that
|
|
27
|
+
mentioned anonymous access in the same breath.
|
|
28
|
+
- NDJSON by default, `--format csv` for one wide row per entity per day.
|
|
29
|
+
- Checkpoints against the hourly history budget and exits 75 (`EX_TEMPFAIL`),
|
|
30
|
+
so a cron or timer retries rather than alerting. Re-running continues.
|
|
31
|
+
|
|
32
|
+
`python -m themeparks.backfill` is the same thing. `examples/backfill.py`
|
|
33
|
+
remains as a shim so existing links keep working.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **The history window recovery now actually works.** 3.2.0's `examples/backfill.py`
|
|
38
|
+
read `earliestAllowedDate` from the top level of the 403 body; the API nests it
|
|
39
|
+
under `error`. So the recovery shipped doing nothing and a Pro customer still
|
|
40
|
+
got a traceback on their first request. The tests passed because the fixture was
|
|
41
|
+
built from the formatted text in a traceback rather than a real response, so the
|
|
42
|
+
code and the test were wrong together. The fixture is now captured from
|
|
43
|
+
production and a test fails if anyone flattens it.
|
|
44
|
+
|
|
45
|
+
The underlying gap is in the API, not the client: `/history/coverage` reports
|
|
46
|
+
where the archive starts and where your window ends, and nothing about where
|
|
47
|
+
your window begins. Until it does, the 403 is the only place that date exists.
|
|
48
|
+
|
|
49
|
+
## [3.2.0] - 2026-09-26
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **The client reads the rate-limit headers, and acts on them.** Both meters,
|
|
54
|
+
the per-minute REST one and the separate hourly history budget, are exposed
|
|
55
|
+
on `client.rate_limit`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
tp.rate_limit.rest.remaining # 299
|
|
59
|
+
tp.rate_limit.history.remaining # on a history call
|
|
60
|
+
tp.rate_limit.rest.seconds_until_reset()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every field is optional, and `None` means the server did not say rather than
|
|
64
|
+
"nothing left". Use `.exhausted`, which is true only when the server said
|
|
65
|
+
zero. The per-minute figures ride most responses; the hourly history ones are
|
|
66
|
+
withheld from anything a shared cache may store, because they are per-caller;
|
|
67
|
+
an unmetered plan advertises nothing. A response served from a cache is
|
|
68
|
+
ignored entirely, because its figures belong to whoever populated the entry. `reset` is a
|
|
69
|
+
relative countdown frozen when it was read, so `seconds_until_reset()` ages
|
|
70
|
+
it rather than returning a stale number.
|
|
71
|
+
|
|
72
|
+
When a response says the window is spent, the next request now waits for the
|
|
73
|
+
advertised reset instead of sending one that is certain to be refused, and to
|
|
74
|
+
spend a unit of budget being refused. `RetryConfig(respect_remaining=False)`
|
|
75
|
+
turns it off.
|
|
76
|
+
|
|
77
|
+
The hourly history budget is new on the wire; before it there was nothing to
|
|
78
|
+
read.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **Calls may now block before sending.** When the server has said your window
|
|
83
|
+
is spent, or has issued a 429 that is still in force, the client waits rather
|
|
84
|
+
than sending a request that is certain to be refused. A call that used to
|
|
85
|
+
return in 200ms can now take up to `retry.max_retry_after` (120s) first. That
|
|
86
|
+
is a TOTAL across the call, not per wait: the shared 429 gate and the
|
|
87
|
+
spent-window wait stack, and before the budget existed a 429 carrying both a
|
|
88
|
+
`Retry-After` and a spent window blocked for 180 seconds under a 120 second
|
|
89
|
+
cap. Turn the two halves off with `RetryConfig(respect_remaining=False)` and
|
|
90
|
+
`RetryConfig(respect_429=False)`.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **`respect_429=False` did not opt out.** It raised the error the caller asked
|
|
95
|
+
for and then held their NEXT call for the full `Retry-After` anyway, because
|
|
96
|
+
the shared gate was closed regardless of the setting.
|
|
97
|
+
|
|
98
|
+
- **A 429 was waited out once per in-flight request.** The wait belongs to the
|
|
99
|
+
caller, not to whichever request met it, so ten concurrent requests each
|
|
100
|
+
slept their own `Retry-After` and then retried at the same instant,
|
|
101
|
+
re-tripping the limit together. It is now taken once, on a gate shared by the
|
|
102
|
+
whole client, with a little jitter so the waiters do not wake in unison. A
|
|
103
|
+
shorter wait arriving while a longer one is in force no longer brings the
|
|
104
|
+
gate forward.
|
|
105
|
+
|
|
3
106
|
## [3.1.0] - 2026-09-23
|
|
4
107
|
|
|
5
108
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: themeparks
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.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
|
|
@@ -132,7 +132,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
132
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. |
|
|
133
133
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
134
134
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
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
|
|
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. |
|
|
136
136
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
137
137
|
|
|
138
138
|
Example:
|
|
@@ -255,6 +255,50 @@ remaining keys are whatever fields that variant carries.
|
|
|
255
255
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
256
256
|
inputs.
|
|
257
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
|
+
|
|
258
302
|
## History
|
|
259
303
|
|
|
260
304
|
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
@@ -306,9 +350,25 @@ except BudgetExhaustedError as exc:
|
|
|
306
350
|
print(f"resume in {exc.retry_after:.0f}s")
|
|
307
351
|
```
|
|
308
352
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
353
|
+
### Or skip the code: there is a command
|
|
354
|
+
|
|
355
|
+
Installing the library installs `themeparks-backfill`, which does all of the
|
|
356
|
+
above and stops before the walls:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
themeparks-backfill "Disneyland Park" # a park, by name or id
|
|
360
|
+
themeparks-backfill "Walt Disney World Resort" # a destination: every park in it
|
|
361
|
+
themeparks-backfill --list disney # find an id. Needs no key.
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
It reads how far back your own key may ask and starts there, writes NDJSON or
|
|
365
|
+
`--format csv`, names every row with the park and the entity, records what it
|
|
366
|
+
has done so re-running never duplicates a file, and exits 75 when the hourly
|
|
367
|
+
history budget runs out so a scheduler retries rather than alerts.
|
|
368
|
+
|
|
369
|
+
`python -m themeparks.backfill` is the same thing, which is the one to use if
|
|
370
|
+
`pip install --user` put the script somewhere off your PATH. `themeparks-backfill
|
|
371
|
+
--help` has the rest.
|
|
312
372
|
|
|
313
373
|
## Low-level escape hatch
|
|
314
374
|
|
|
@@ -93,7 +93,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
93
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. |
|
|
94
94
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
95
95
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
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
|
|
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. |
|
|
97
97
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
98
98
|
|
|
99
99
|
Example:
|
|
@@ -216,6 +216,50 @@ remaining keys are whatever fields that variant carries.
|
|
|
216
216
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
217
217
|
inputs.
|
|
218
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
|
+
|
|
219
263
|
## History
|
|
220
264
|
|
|
221
265
|
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
@@ -267,9 +311,25 @@ except BudgetExhaustedError as exc:
|
|
|
267
311
|
print(f"resume in {exc.retry_after:.0f}s")
|
|
268
312
|
```
|
|
269
313
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
314
|
+
### Or skip the code: there is a command
|
|
315
|
+
|
|
316
|
+
Installing the library installs `themeparks-backfill`, which does all of the
|
|
317
|
+
above and stops before the walls:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
themeparks-backfill "Disneyland Park" # a park, by name or id
|
|
321
|
+
themeparks-backfill "Walt Disney World Resort" # a destination: every park in it
|
|
322
|
+
themeparks-backfill --list disney # find an id. Needs no key.
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
It reads how far back your own key may ask and starts there, writes NDJSON or
|
|
326
|
+
`--format csv`, names every row with the park and the entity, records what it
|
|
327
|
+
has done so re-running never duplicates a file, and exits 75 when the hourly
|
|
328
|
+
history budget runs out so a scheduler retries rather than alerts.
|
|
329
|
+
|
|
330
|
+
`python -m themeparks.backfill` is the same thing, which is the one to use if
|
|
331
|
+
`pip install --user` put the script somewhere off your PATH. `themeparks-backfill
|
|
332
|
+
--help` has the rest.
|
|
273
333
|
|
|
274
334
|
## Low-level escape hatch
|
|
275
335
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "themeparks"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.0"
|
|
8
8
|
description = "Official SDK for the ThemeParks.wiki API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -30,6 +30,12 @@ dependencies = [
|
|
|
30
30
|
"eval-type-backport>=0.2; python_version < '3.10'",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
+
[project.scripts]
|
|
34
|
+
# The archive backfill, as a command rather than a file to copy off GitHub.
|
|
35
|
+
# `pip install themeparks` then `themeparks-backfill "Disneyland Park"` is the
|
|
36
|
+
# whole path from nothing to a file of history.
|
|
37
|
+
themeparks-backfill = "themeparks.backfill:main"
|
|
38
|
+
|
|
33
39
|
[project.urls]
|
|
34
40
|
Homepage = "https://api.themeparks.wiki"
|
|
35
41
|
Source = "https://github.com/ThemeParks/ThemeParks_Python"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Fixtures captured from the live API
|
|
2
|
+
|
|
3
|
+
Not hand-written. Each file here was taken from a real response, and the header
|
|
4
|
+
below says which endpoint and when. That matters because of a specific failure
|
|
5
|
+
this project keeps repeating.
|
|
6
|
+
|
|
7
|
+
## Why this directory exists
|
|
8
|
+
|
|
9
|
+
On 2026-09-28 a 403-recovery fix shipped doing nothing. Its tests passed because
|
|
10
|
+
the fixture was built from the *formatted text of a traceback* rather than from a
|
|
11
|
+
response body, so the code and the test were wrong together and agreed with each
|
|
12
|
+
other. The memory note for that pattern is `self-confirming-harness`, and it was
|
|
13
|
+
its fifth occurrence in one day.
|
|
14
|
+
|
|
15
|
+
A fixture the author of the code also invented proves only that the two are
|
|
16
|
+
consistent. A fixture taken from the real thing can disagree.
|
|
17
|
+
|
|
18
|
+
## destinations_slice.json
|
|
19
|
+
|
|
20
|
+
`GET https://api.themeparks.wiki/v1/destinations`, captured 2026-09-28, trimmed
|
|
21
|
+
to 9 destinations and 20 parks and otherwise verbatim — every id, name and slug
|
|
22
|
+
is exactly what the API returned.
|
|
23
|
+
|
|
24
|
+
It is trimmed to keep, deliberately, every case that broke name resolution:
|
|
25
|
+
|
|
26
|
+
| Case | Why it is here |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `Walt Disney World® Resort` | U+00AE. The command's own documented example, `"Walt Disney World Resort"`, did not match it. |
|
|
29
|
+
| `LEGOLAND® Korea` | the same, on a destination whose park shares the name |
|
|
30
|
+
| `Walibi Rhône-Alpes` | U+00F4, so a normaliser must decompose accents |
|
|
31
|
+
| `Knott's Berry Farm` + `Knott’s Soak City` | ASCII `'` and U+2019 **in one destination**. Nobody types the curly one. |
|
|
32
|
+
| `Disneyland Park` ×2 | Anaheim and Paris, identical park names. The reason the candidate list names the destination. |
|
|
33
|
+
| `Hurricane Harbor` + `Hurricane Harbor Chicago` | an exact match with a substring rival: the shape that silently downloaded the wrong park |
|
|
34
|
+
| `Cedar Point` | destination name == park name, with a second park, so "exact destination beats exact park" is observable |
|
|
35
|
+
|
|
36
|
+
**Do not edit these by hand.** Re-capture them. If a name upstream has drifted,
|
|
37
|
+
that is a real change and the test should notice.
|
|
@@ -10,6 +10,7 @@ from themeparks._errors import (
|
|
|
10
10
|
ThemeParksError,
|
|
11
11
|
TimeoutError,
|
|
12
12
|
)
|
|
13
|
+
from themeparks._ratelimit import RateLimit, RateLimits
|
|
13
14
|
from themeparks._transport import RetryConfig
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
@@ -17,6 +18,8 @@ __all__ = [
|
|
|
17
18
|
"AsyncThemeParks",
|
|
18
19
|
"BudgetExhaustedError",
|
|
19
20
|
"HistorySpan",
|
|
21
|
+
"RateLimit",
|
|
22
|
+
"RateLimits",
|
|
20
23
|
"Cache",
|
|
21
24
|
"CacheConfig",
|
|
22
25
|
"InMemoryLRUCache",
|
|
@@ -10,6 +10,7 @@ import httpx
|
|
|
10
10
|
from themeparks._cache import Cache, CacheConfig, InMemoryLRUCache, ttl_for_path
|
|
11
11
|
from themeparks._ergonomic.destinations import AsyncDestinationsApi, DestinationsApi
|
|
12
12
|
from themeparks._ergonomic.entity import AsyncEntityHandle, EntityHandle
|
|
13
|
+
from themeparks._ratelimit import RateLimits
|
|
13
14
|
from themeparks._raw import AsyncRawClient, RawClient
|
|
14
15
|
from themeparks._transport import AsyncTransport, RetryConfig, SyncTransport
|
|
15
16
|
|
|
@@ -52,6 +53,16 @@ class _CachingSyncTransport:
|
|
|
52
53
|
self._inner = inner
|
|
53
54
|
self._cache = cache
|
|
54
55
|
|
|
56
|
+
@property
|
|
57
|
+
def rate_limit(self) -> RateLimits:
|
|
58
|
+
"""Whatever the inner transport last learned.
|
|
59
|
+
|
|
60
|
+
A cache HIT sends no request and so learns nothing, which is correct:
|
|
61
|
+
the figures then keep saying what the last real response said. They
|
|
62
|
+
are not invalidated by a hit, because a hit spent no budget either.
|
|
63
|
+
"""
|
|
64
|
+
return self._inner.rate_limit
|
|
65
|
+
|
|
55
66
|
def get(self, path: str) -> Any:
|
|
56
67
|
ttl = ttl_for_path(path)
|
|
57
68
|
if ttl > 0:
|
|
@@ -69,6 +80,16 @@ class _CachingAsyncTransport:
|
|
|
69
80
|
self._inner = inner
|
|
70
81
|
self._cache = cache
|
|
71
82
|
|
|
83
|
+
@property
|
|
84
|
+
def rate_limit(self) -> RateLimits:
|
|
85
|
+
"""Whatever the inner transport last learned.
|
|
86
|
+
|
|
87
|
+
A cache HIT sends no request and so learns nothing, which is correct:
|
|
88
|
+
the figures then keep saying what the last real response said. They
|
|
89
|
+
are not invalidated by a hit, because a hit spent no budget either.
|
|
90
|
+
"""
|
|
91
|
+
return self._inner.rate_limit
|
|
92
|
+
|
|
72
93
|
async def get(self, path: str) -> Any:
|
|
73
94
|
ttl = ttl_for_path(path)
|
|
74
95
|
if ttl > 0:
|
|
@@ -125,6 +146,24 @@ class ThemeParks:
|
|
|
125
146
|
)
|
|
126
147
|
self.destinations = DestinationsApi(raw=self.raw)
|
|
127
148
|
|
|
149
|
+
@property
|
|
150
|
+
def rate_limit(self) -> RateLimits:
|
|
151
|
+
"""What the server last said about your two budgets.
|
|
152
|
+
|
|
153
|
+
`rate_limit.rest` is the per-minute REST meter; `rate_limit.history`
|
|
154
|
+
is the separate hourly history budget. Every field can be None,
|
|
155
|
+
because every field can be legitimately absent: an unmetered plan
|
|
156
|
+
advertises nothing, and neither does a publicly cacheable response,
|
|
157
|
+
since the figures belong to whoever populated the cache.
|
|
158
|
+
|
|
159
|
+
None therefore means "the server did not say", never "nothing left".
|
|
160
|
+
|
|
161
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
162
|
+
tp.entity(park).live()
|
|
163
|
+
print(tp.rate_limit.rest.remaining) # e.g. 299
|
|
164
|
+
"""
|
|
165
|
+
return self.raw._t.rate_limit
|
|
166
|
+
|
|
128
167
|
def entity(self, entity_id: str) -> EntityHandle:
|
|
129
168
|
return self._entity_ctor(entity_id)
|
|
130
169
|
|
|
@@ -184,6 +223,24 @@ class AsyncThemeParks:
|
|
|
184
223
|
)
|
|
185
224
|
self.destinations = AsyncDestinationsApi(raw=self.raw)
|
|
186
225
|
|
|
226
|
+
@property
|
|
227
|
+
def rate_limit(self) -> RateLimits:
|
|
228
|
+
"""What the server last said about your two budgets.
|
|
229
|
+
|
|
230
|
+
`rate_limit.rest` is the per-minute REST meter; `rate_limit.history`
|
|
231
|
+
is the separate hourly history budget. Every field can be None,
|
|
232
|
+
because every field can be legitimately absent: an unmetered plan
|
|
233
|
+
advertises nothing, and neither does a publicly cacheable response,
|
|
234
|
+
since the figures belong to whoever populated the cache.
|
|
235
|
+
|
|
236
|
+
None therefore means "the server did not say", never "nothing left".
|
|
237
|
+
|
|
238
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
239
|
+
tp.entity(park).live()
|
|
240
|
+
print(tp.rate_limit.rest.remaining) # e.g. 299
|
|
241
|
+
"""
|
|
242
|
+
return self.raw._t.rate_limit
|
|
243
|
+
|
|
187
244
|
def entity(self, entity_id: str) -> AsyncEntityHandle:
|
|
188
245
|
return self._entity_ctor(entity_id)
|
|
189
246
|
|
|
@@ -111,17 +111,54 @@ def _reraise_if_too_long(exc: RateLimitError, max_wait: float) -> None:
|
|
|
111
111
|
raise exc
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
"""
|
|
116
|
-
|
|
114
|
+
class EntityRef(NamedTuple):
|
|
115
|
+
"""Who a history row belongs to, AS THE HISTORY RESPONSE REPORTS IT.
|
|
116
|
+
|
|
117
|
+
The name matters and the source of it matters more. A park's current
|
|
118
|
+
`/children` list gives today's name, which is the wrong label for a row
|
|
119
|
+
recorded years ago: rides are renamed, and stamping today's name on old data
|
|
120
|
+
quietly rewrites history. The history envelope carries its own `name` and
|
|
121
|
+
`entityType` per entity, and that is the name to use.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
id: str
|
|
125
|
+
name: str
|
|
126
|
+
entity_type: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ref(entity: Any) -> EntityRef:
|
|
130
|
+
kind = getattr(entity, "entityType", None)
|
|
131
|
+
# The generated models use an enum, and str(EntityType.SHOW) is
|
|
132
|
+
# "EntityType.SHOW". `.value` is what the API sends.
|
|
133
|
+
inner = getattr(kind, "value", kind)
|
|
134
|
+
return EntityRef(
|
|
135
|
+
entity.id, getattr(entity, "name", "") or "", "" if inner is None else str(inner)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _daily_entity_rows(envelope: DailyEnvelope) -> Iterator[tuple[EntityRef, HistoryDailyRow]]:
|
|
140
|
+
"""Yield (entity ref, row), keeping the name the response gave.
|
|
141
|
+
|
|
142
|
+
`_daily_rows` below is the same walk with the ref flattened to its id, kept
|
|
143
|
+
because `days()` has yielded `(id, row)` since 3.0 and that shape is public.
|
|
144
|
+
"""
|
|
117
145
|
entities = getattr(envelope, "entities", None)
|
|
118
146
|
if entities is not None:
|
|
119
147
|
for entity in entities:
|
|
148
|
+
ref = _ref(entity)
|
|
120
149
|
for row in entity.days or []:
|
|
121
|
-
yield (
|
|
150
|
+
yield (ref, row)
|
|
122
151
|
return
|
|
152
|
+
ref = _ref(envelope)
|
|
123
153
|
for row in getattr(envelope, "days", None) or []:
|
|
124
|
-
yield (
|
|
154
|
+
yield (ref, row)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _daily_rows(envelope: DailyEnvelope) -> Iterator[tuple[str, HistoryDailyRow]]:
|
|
158
|
+
"""Yield (entity id, row). A park envelope carries many entities; an entity
|
|
159
|
+
envelope carries its own rows, so both flatten to the same stream."""
|
|
160
|
+
for ref, row in _daily_entity_rows(envelope):
|
|
161
|
+
yield (ref.id, row)
|
|
125
162
|
|
|
126
163
|
|
|
127
164
|
def _raw_rows(envelope: RawEnvelope) -> Iterator[tuple[str, HistoryRow]]:
|
|
@@ -154,6 +191,29 @@ class HistoryApi:
|
|
|
154
191
|
"""
|
|
155
192
|
return _span(self.coverage())
|
|
156
193
|
|
|
194
|
+
def days_with_entities(
|
|
195
|
+
self,
|
|
196
|
+
start: str | _date | None = None,
|
|
197
|
+
end: str | _date | None = None,
|
|
198
|
+
*,
|
|
199
|
+
max_wait: float = DEFAULT_MAX_WAIT_SECONDS,
|
|
200
|
+
) -> Iterator[tuple[EntityRef, HistoryDailyRow]]:
|
|
201
|
+
"""`days()`, but each row arrives with the entity's name and type.
|
|
202
|
+
|
|
203
|
+
Use this when you are writing history to a file. The name comes from the
|
|
204
|
+
history response itself, so it is the label that response gives for those
|
|
205
|
+
rows rather than the park's current `/children` list -- rides get renamed,
|
|
206
|
+
and today's name on a row from three years ago is a quiet rewrite of the
|
|
207
|
+
record.
|
|
208
|
+
|
|
209
|
+
It also saves a request: the name is already in the payload, so nothing
|
|
210
|
+
needs to ask what an id refers to.
|
|
211
|
+
"""
|
|
212
|
+
envelope: DailyEnvelope | None = self._first_daily(start, end, max_wait)
|
|
213
|
+
while envelope is not None:
|
|
214
|
+
yield from _daily_entity_rows(envelope)
|
|
215
|
+
envelope = self._next_daily(envelope, max_wait)
|
|
216
|
+
|
|
157
217
|
def days(
|
|
158
218
|
self,
|
|
159
219
|
start: str | _date | None = None,
|
|
@@ -165,6 +225,9 @@ class HistoryApi:
|
|
|
165
225
|
|
|
166
226
|
Pages automatically. Given a park id this uses the park call, which
|
|
167
227
|
answers every entity in the park in one request.
|
|
228
|
+
|
|
229
|
+
`days_with_entities()` is the same stream with the entity's name and type
|
|
230
|
+
attached; this shape is kept because it is public API from 3.0.
|
|
168
231
|
"""
|
|
169
232
|
envelope: DailyEnvelope | None = self._first_daily(start, end, max_wait)
|
|
170
233
|
while envelope is not None:
|