themeparks 3.3.0__tar.gz → 4.0.1__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.3.0 → themeparks-4.0.1}/CHANGELOG.md +162 -0
  2. {themeparks-3.3.0 → themeparks-4.0.1}/PKG-INFO +6 -2
  3. {themeparks-3.3.0 → themeparks-4.0.1}/README.md +5 -1
  4. {themeparks-3.3.0 → themeparks-4.0.1}/pyproject.toml +2 -2
  5. {themeparks-3.3.0 → themeparks-4.0.1}/tests/fixtures/README.md +13 -0
  6. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/__init__.py +8 -1
  7. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/history.py +48 -1
  8. themeparks-4.0.1/themeparks/_generated/models.py +1337 -0
  9. themeparks-4.0.1/themeparks/_models_base.py +29 -0
  10. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/backfill.py +572 -112
  11. themeparks-3.3.0/themeparks/_generated/models.py +0 -1121
  12. {themeparks-3.3.0 → themeparks-4.0.1}/.gitignore +0 -0
  13. {themeparks-3.3.0 → themeparks-4.0.1}/LICENSE +0 -0
  14. {themeparks-3.3.0 → themeparks-4.0.1}/MIGRATION.md +0 -0
  15. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_cache.py +0 -0
  16. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_client.py +0 -0
  17. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/__init__.py +0 -0
  18. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/dates.py +0 -0
  19. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/destinations.py +0 -0
  20. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/entity.py +0 -0
  21. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ergonomic/live.py +0 -0
  22. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_errors.py +0 -0
  23. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_generated/__init__.py +0 -0
  24. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_ratelimit.py +0 -0
  25. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_raw.py +0 -0
  26. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/_transport.py +0 -0
  27. {themeparks-3.3.0 → themeparks-4.0.1}/themeparks/py.typed +0 -0
@@ -1,5 +1,167 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.0.1] - 2026-09-28
4
+
5
+ ### Fixed
6
+
7
+ - **A run with no API key now says so when it finishes**, not only when it
8
+ starts. Without a key the command SUCCEEDS: it reads the 7 days anonymous
9
+ access allows, writes 433 rows of Magic Kingdom instead of about 94,000, and
10
+ exits 0. The notice was printed before a run that takes minutes, so it scrolled
11
+ away, and the last thing on screen was `done: 433 rows` -- which for someone
12
+ who has just paid for 400 days is indistinguishable from success. There is a
13
+ file, there is no error, and the number means nothing unless you already know
14
+ what it should have been.
15
+
16
+ The README and the command's own `--help` now `export THEMEPARKS_API_KEY`
17
+ before the example that needs it, and still say `--list` does not: finding a
18
+ park before you have paid is the point of that flag.
19
+
20
+ ### Added
21
+
22
+ - **A committed mutant list** (`tests/mutation/mutants.json`) and a nightly,
23
+ non-gating job that runs it. An author-written mutant list contains the
24
+ mutations that author's tests already catch -- one scored 14/14 on this package
25
+ while an independent sweep found 27 survivors, including the defect 4.0.0
26
+ exists to fix. The list is committed so a reviewer can see what is checked and,
27
+ more usefully, what is not. Its first run found two gaps, both closed here: a
28
+ spent hourly budget on a resumed run deleted the accumulated archive, and
29
+ `str(EntityType.SHOW)` reached a CSV cell as `EntityType.SHOW` rather than
30
+ `SHOW`.
31
+
32
+ ## [4.0.0] - 2026-09-28
33
+
34
+ **3.3.0 was yanked: incomplete CSV export and a resume defect.**
35
+
36
+ A major version because **the CSV header changed**: fifteen columns were added and
37
+ the order is now the schema's, so a reader that takes columns by position gets the
38
+ wrong ones rather than an error. Read by name. The library API is backward
39
+ compatible.
40
+
41
+ Everything here came out of porting `themeparks-backfill` to the JavaScript SDK
42
+ and then diffing the two outputs over the same park, and out of six reviews of the
43
+ result. Two independent implementations reading one API disagree in exactly the
44
+ places one of them is wrong. Magic Kingdom's full archive now comes back
45
+ **byte for byte identical** from both SDKs: 94,223 rows, 41 columns, the only
46
+ differences being today's row, which grows as the day elapses.
47
+
48
+ ### Fixed
49
+
50
+ - **`themeparks-backfill "magic kingdom"` wrote the wrong park name into every
51
+ row.** A name that matched one park by substring returned the formatted display
52
+ label, so `parkName` read `Magic Kingdom Park (Walt Disney World® Resort)` for
53
+ all ~94,000 rows, and the resolution echo printed the destination twice. Four
54
+ live names reached it.
55
+
56
+ - **The CSV was missing ten of the thirty-six fields the API sends, on every row.**
57
+ `unknownMinutes`, the whole `inParkHours` block (the day's numbers limited to the
58
+ park's published hours -- usually the ones you want, since a ride "down" at 2am
59
+ is not down), `extremeWaits` (how many readings of 480+ minutes are folded into
60
+ the statistics, which is how you spot a feed error), and three of `singleRider`'s
61
+ five percentiles while `standby` carried all five. On a five-year Magic Kingdom
62
+ export, 72,200 of 94,223 rows were missing their in-park statistics. **The column
63
+ list is now derived from the model**, so it cannot drift again.
64
+
65
+ - **Vendored models were stale, and pydantic drops what it does not declare**, so
66
+ those three fields were deleted at parse time for every caller of `days()`, not
67
+ just for the CSV. Models regenerated, and every model now keeps fields the schema
68
+ does not declare (`themeparks._models_base.ApiModel`, `extra="allow"`), so a
69
+ field the API adds tomorrow survives parsing and reaches `model_dump()` and the
70
+ NDJSON output before this SDK knows it exists. It does not reach the CSV, whose
71
+ columns come from the schema.
72
+
73
+ - **A resumed download duplicated a day.** The checkpoint was the newest row
74
+ written; the page it came from covered further, because an entity that stopped
75
+ reporting has no rows for the tail days. A rerun re-fetched a day already in the
76
+ file and appended every row of it again, breaking the `(entityId, date)` key --
77
+ on the exit-75 path, which is the ordinary path for a long back fill. The
78
+ checkpoint is now the day the server's own `next` URL starts on.
79
+
80
+ - **A failure on a resumed run deleted everything already downloaded.** `written
81
+ == 0` means "this process wrote nothing", not "the file is empty". The state file
82
+ survived pointing mid-archive, so the next run appended only the tail and
83
+ recorded `complete: true`. Same for a window that closes under a resumed run --
84
+ a key rotated out of a scheduler's environment, a lapsed subscription -- which
85
+ additionally exited 0, so the scheduler logged success, and became a permanent
86
+ trap.
87
+
88
+ - **Resuming across versions, formats or SDKs corrupted the file.** One state file
89
+ served both formats, so `ndjson` then `csv` then `ndjson` doubled every row in
90
+ the first file; and the state carried nothing about the header, so 3.3.0's
91
+ 19-column file resumed under this build appended 41-field rows beneath it. The
92
+ state file is now `<parkId>.<format>.backfill-state.json` and records the SDK,
93
+ its version, a state version and a fingerprint of the exact header. Anything that
94
+ does not match is refused with a message saying why, never resumed.
95
+
96
+ - **A network failure or timeout now exits 75, not 1**, so a scheduler retries
97
+ rather than alerting; anything the API actively rejected still exits 1. The
98
+ JavaScript SDK had these the other way round.
99
+
100
+ - **A carriage return in an entity name was written unquoted on Python 3.9 and
101
+ 3.10**, so one row parsed as two with every later column shifted. The `csv`
102
+ module's QUOTE_MINIMAL only quotes characters that appear in the line terminator,
103
+ and this command sets LF; 3.11 changed the module to always quote CR and LF, so
104
+ the defect was invisible on a modern interpreter and live on two supported ones.
105
+ The CSV writer now does its own minimal quoting, which also makes the output
106
+ byte-identical across Python versions rather than only within one.
107
+
108
+ - **UTC timestamps are written `Z`, not `+00:00`**, and CSV line endings are LF.
109
+ Between them these accounted for 39,201 differing lines against the JavaScript
110
+ SDK's output for no difference in meaning.
111
+
112
+ - **One park's failure no longer abandons the rest of a destination.** Every park
113
+ is tried, what failed is named at the end, and the exit code still says something
114
+ went wrong. A spent budget still stops everything, deliberately.
115
+
116
+ - **A failed park no longer leaves a 0-byte file** that reads as "this park has no
117
+ history", including when the budget runs out before the first page.
118
+
119
+ - **A network failure, a full disk or Ctrl-C is a sentence, not a traceback.**
120
+
121
+ - **The user agent named neither version.** It was the literal
122
+ `themeparks-backfill/1`, and it replaced the SDK's own, so a support question had
123
+ no version to work from at either end.
124
+
125
+ - **`--list <text>` reported the wrong total**, printing "all 1 parks" for a
126
+ destination with six -- on the one line whose whole job is that number.
127
+
128
+ - **An ambiguous name listed the wrong candidates**, widening to substrings and
129
+ offering a third park that was not what was typed. It now lists the ids of the
130
+ parks that actually match, sorted by name.
131
+
132
+ - **A collection of nested models would have produced phantom columns** and then an
133
+ `AttributeError` on the first row. Duplicate column names are now impossible at
134
+ import rather than a wrong number under a right-looking header.
135
+
136
+ - **The NDJSON identity columns could be overwritten by the row** once models kept
137
+ undeclared fields.
138
+
139
+ ### Added
140
+
141
+ - **The CSV carries a UTF-8 BOM**, so Excel on Windows stops rendering
142
+ `Walt Disney World® Resort` as mojibake.
143
+ - **A cell a spreadsheet would execute is prefixed with an apostrophe** (`=`, `+`,
144
+ `-`, `@`, tab, CR). Numeric cells are left alone, so a negative number stays a
145
+ number.
146
+ - **`on_page` on `days()` and `days_with_entities()`**, called once every row of a
147
+ page has been yielded, with a `HistoryPage` (`start`, `end`, `next_url`). The page
148
+ boundary is the server's own answer to "where do I carry on", and the rows cannot
149
+ tell you.
150
+ - **`--version`.**
151
+ - `EntityRef` and `HistoryPage` are exported from the package.
152
+ - `tests/fixtures/csv_contract.json`, an identical copy of which lives in the
153
+ JavaScript SDK. Both suites assert their column list against it, because this is
154
+ one command with two implementations and a customer using both should get one
155
+ file format.
156
+
157
+ ### Changed
158
+
159
+ - The `themeparks-backfill` entry point is `themeparks.backfill:cli`, which adds
160
+ the top-level error handling. `main()` is unchanged for anyone calling it.
161
+ - Model equality and `model_json_schema()` reflect `extra="allow"`: two responses
162
+ differing only in an undeclared field now compare unequal, and dumps may contain
163
+ fields the schema does not list.
164
+
3
165
  ## [3.3.0] - 2026-09-28
4
166
 
5
167
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: themeparks
3
- Version: 3.3.0
3
+ Version: 4.0.1
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
@@ -356,9 +356,13 @@ Installing the library installs `themeparks-backfill`, which does all of the
356
356
  above and stops before the walls:
357
357
 
358
358
  ```bash
359
+ # How far back it reaches is your plan, so set the key first: without one you get
360
+ # the 7 days anonymous access allows, and the run still succeeds, quietly.
361
+ export THEMEPARKS_API_KEY=tpw_your_key
362
+
359
363
  themeparks-backfill "Disneyland Park" # a park, by name or id
360
364
  themeparks-backfill "Walt Disney World Resort" # a destination: every park in it
361
- themeparks-backfill --list disney # find an id. Needs no key.
365
+ themeparks-backfill --list disney # find an id. This part needs no key.
362
366
  ```
363
367
 
364
368
  It reads how far back your own key may ask and starts there, writes NDJSON or
@@ -317,9 +317,13 @@ Installing the library installs `themeparks-backfill`, which does all of the
317
317
  above and stops before the walls:
318
318
 
319
319
  ```bash
320
+ # How far back it reaches is your plan, so set the key first: without one you get
321
+ # the 7 days anonymous access allows, and the run still succeeds, quietly.
322
+ export THEMEPARKS_API_KEY=tpw_your_key
323
+
320
324
  themeparks-backfill "Disneyland Park" # a park, by name or id
321
325
  themeparks-backfill "Walt Disney World Resort" # a destination: every park in it
322
- themeparks-backfill --list disney # find an id. Needs no key.
326
+ themeparks-backfill --list disney # find an id. This part needs no key.
323
327
  ```
324
328
 
325
329
  It reads how far back your own key may ask and starts there, writes NDJSON or
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "themeparks"
7
- version = "3.3.0"
7
+ version = "4.0.1"
8
8
  description = "Official SDK for the ThemeParks.wiki API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -34,7 +34,7 @@ dependencies = [
34
34
  # The archive backfill, as a command rather than a file to copy off GitHub.
35
35
  # `pip install themeparks` then `themeparks-backfill "Disneyland Park"` is the
36
36
  # whole path from nothing to a file of history.
37
- themeparks-backfill = "themeparks.backfill:main"
37
+ themeparks-backfill = "themeparks.backfill:cli"
38
38
 
39
39
  [project.urls]
40
40
  Homepage = "https://api.themeparks.wiki"
@@ -35,3 +35,16 @@ It is trimmed to keep, deliberately, every case that broke name resolution:
35
35
 
36
36
  **Do not edit these by hand.** Re-capture them. If a name upstream has drifted,
37
37
  that is a real change and the test should notice.
38
+
39
+ ## mk_park_daily_page1.json / mk_park_daily_page2.json
40
+
41
+ Two consecutive pages of one real request, captured 2026-09-28:
42
+ `GET /entity/75ea578a-adc8-4116-a54d-dccb60765ef9/history/daily?from=2026-08-01&to=2026-09-20`
43
+ then its `next` followed verbatim. Trimmed to three entities (an attraction, a
44
+ show, a restaurant); `range`, `next` and every row are the server's.
45
+
46
+ They are the oracle for resumable paging. Page one covers through 2026-08-31 and
47
+ the server says carry on at 2026-09-01, but two of its three entities have no
48
+ rows after 2026-08-30 -- so a checkpoint taken from the newest ROW rewinds and
49
+ re-downloads days already written. The same files are in the JavaScript SDK, so
50
+ both ports are tested against identical bytes.
@@ -1,7 +1,12 @@
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
+ from themeparks._ergonomic.history import (
5
+ BudgetExhaustedError,
6
+ EntityRef,
7
+ HistoryPage,
8
+ HistorySpan,
9
+ )
5
10
  from themeparks._ergonomic.live import current_wait_time, iter_queues
6
11
  from themeparks._errors import (
7
12
  APIError,
@@ -17,6 +22,8 @@ __all__ = [
17
22
  "APIError",
18
23
  "AsyncThemeParks",
19
24
  "BudgetExhaustedError",
25
+ "EntityRef",
26
+ "HistoryPage",
20
27
  "HistorySpan",
21
28
  "RateLimit",
22
29
  "RateLimits",
@@ -20,7 +20,7 @@ the cheap path here without having to know the expensive one exists.
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
- from collections.abc import AsyncIterator, Iterator
23
+ from collections.abc import AsyncIterator, Callable, Iterator
24
24
  from datetime import date as _date
25
25
  from typing import Any, NamedTuple, Union
26
26
 
@@ -111,6 +111,28 @@ def _reraise_if_too_long(exc: RateLimitError, max_wait: float) -> None:
111
111
  raise exc
112
112
 
113
113
 
114
+ class HistoryPage(NamedTuple):
115
+ """One page of daily history, as the server described it.
116
+
117
+ `start` and `end` are the park-local days the page ACTUALLY covered, which is
118
+ not the range you asked for: a park daily call serves at most 31 days, so a
119
+ 50-day request comes back as 31 days plus a `next`. `next_url` is the URL of
120
+ the following page, or None on the last one.
121
+
122
+ This exists for resumable downloads. A checkpoint taken from the ROWS is
123
+ wrong in both directions: the newest row's date can be earlier than the page
124
+ covered, because an entity that stopped reporting has no rows for the tail
125
+ days, so resuming there re-fetches days already written and duplicates them;
126
+ and a half-written page is indistinguishable from a finished one. The page
127
+ boundary is the server's own answer to "where do I carry on", so it is the
128
+ only safe checkpoint.
129
+ """
130
+
131
+ start: str
132
+ end: str
133
+ next_url: str | None
134
+
135
+
114
136
  class EntityRef(NamedTuple):
115
137
  """Who a history row belongs to, AS THE HISTORY RESPONSE REPORTS IT.
116
138
 
@@ -136,6 +158,17 @@ def _ref(entity: Any) -> EntityRef:
136
158
  )
137
159
 
138
160
 
161
+ def _page_of(envelope: DailyEnvelope) -> HistoryPage:
162
+ """The page an envelope represents, for :class:`HistoryPage`'s callers."""
163
+ rng = getattr(envelope, "range", None)
164
+ nxt = getattr(envelope, "next", None)
165
+ return HistoryPage(
166
+ getattr(rng, "from_", "") or "",
167
+ getattr(rng, "to", "") or "",
168
+ nxt or None,
169
+ )
170
+
171
+
139
172
  def _daily_entity_rows(envelope: DailyEnvelope) -> Iterator[tuple[EntityRef, HistoryDailyRow]]:
140
173
  """Yield (entity ref, row), keeping the name the response gave.
141
174
 
@@ -197,6 +230,7 @@ class HistoryApi:
197
230
  end: str | _date | None = None,
198
231
  *,
199
232
  max_wait: float = DEFAULT_MAX_WAIT_SECONDS,
233
+ on_page: Callable[[HistoryPage], None] | None = None,
200
234
  ) -> Iterator[tuple[EntityRef, HistoryDailyRow]]:
201
235
  """`days()`, but each row arrives with the entity's name and type.
202
236
 
@@ -208,10 +242,17 @@ class HistoryApi:
208
242
 
209
243
  It also saves a request: the name is already in the payload, so nothing
210
244
  needs to ask what an id refers to.
245
+
246
+ `on_page` is called once every row of a page has been yielded, with a
247
+ :class:`HistoryPage`. Checkpoint on that, never on the last row you saw.
211
248
  """
212
249
  envelope: DailyEnvelope | None = self._first_daily(start, end, max_wait)
213
250
  while envelope is not None:
214
251
  yield from _daily_entity_rows(envelope)
252
+ # AFTER the rows, never before: a caller checkpointing on this has to
253
+ # be able to trust that everything the page held is already written.
254
+ if on_page is not None:
255
+ on_page(_page_of(envelope))
215
256
  envelope = self._next_daily(envelope, max_wait)
216
257
 
217
258
  def days(
@@ -220,6 +261,7 @@ class HistoryApi:
220
261
  end: str | _date | None = None,
221
262
  *,
222
263
  max_wait: float = DEFAULT_MAX_WAIT_SECONDS,
264
+ on_page: Callable[[HistoryPage], None] | None = None,
223
265
  ) -> Iterator[tuple[str, HistoryDailyRow]]:
224
266
  """One summary row per park-local day, as (entity id, row).
225
267
 
@@ -232,6 +274,8 @@ class HistoryApi:
232
274
  envelope: DailyEnvelope | None = self._first_daily(start, end, max_wait)
233
275
  while envelope is not None:
234
276
  yield from _daily_rows(envelope)
277
+ if on_page is not None:
278
+ on_page(_page_of(envelope))
235
279
  envelope = self._next_daily(envelope, max_wait)
236
280
 
237
281
  def _first_daily(
@@ -300,6 +344,7 @@ class AsyncHistoryApi:
300
344
  end: str | _date | None = None,
301
345
  *,
302
346
  max_wait: float = DEFAULT_MAX_WAIT_SECONDS,
347
+ on_page: Callable[[HistoryPage], None] | None = None,
303
348
  ) -> AsyncIterator[tuple[str, HistoryDailyRow]]:
304
349
  try:
305
350
  envelope: DailyEnvelope | None = await self._raw.get_entity_history_daily(
@@ -311,6 +356,8 @@ class AsyncHistoryApi:
311
356
  while envelope is not None:
312
357
  for pair in _daily_rows(envelope):
313
358
  yield pair
359
+ if on_page is not None:
360
+ on_page(_page_of(envelope))
314
361
  nxt = getattr(envelope, "next", None)
315
362
  if not nxt:
316
363
  return