From 2a90f08414b5742ecd6844bd9b8ebbbbab875651 Mon Sep 17 00:00:00 2001 From: cubehouse <364237+cubehouse@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:25:47 +0000 Subject: [PATCH 1/2] chore: regenerate models from upstream spec --- themeparks/_generated/models.py | 265 ++++++++++++++++++++++++++++++-- 1 file changed, 255 insertions(+), 10 deletions(-) diff --git a/themeparks/_generated/models.py b/themeparks/_generated/models.py index f6545eb..58afa4e 100644 --- a/themeparks/_generated/models.py +++ b/themeparks/_generated/models.py @@ -5,13 +5,27 @@ from datetime import date as date_aliased from enum import Enum, IntEnum -from typing import Annotated +from typing import Annotated, Any from pydantic import AwareDatetime, ConfigDict, Field from themeparks._models_base import ApiModel +class AttractionType(Enum): + """ + Kind of attraction: a ride, a show, a means of transport, a parade, a meet-and-greet, or something else. UNKNOWN when the park does not say. + """ + + UNKNOWN = "UNKNOWN" + RIDE = "RIDE" + SHOW = "SHOW" + TRANSPORT = "TRANSPORT" + PARADE = "PARADE" + MEET_AND_GREET = "MEET_AND_GREET" + OTHER = "OTHER" + + class Success(Enum): """ Always false. @@ -77,7 +91,7 @@ class DiningAvailability(ApiModel): """ -class AttractionType(Enum): +class AttractionType1(Enum): """ Kind of attraction. Present on ATTRACTION entities. """ @@ -211,22 +225,37 @@ class HistoryCoverageKindSpan(ApiModel): class HistoryDailyExtremeWaits(ApiModel): """ - How many wait readings of 480 minutes or more the day's statistics include. Such readings are usually feed errors (values like 999); they are counted here as a flag and stay in every statistic. Counted per reading while OPERATING. Present only when there were some, and then with both counts. + How many wait readings of 480 minutes or more the day had. Such readings are usually feed errors (values like 999); they are counted here as a flag. Those at or below the destination's plausibility ceiling stay in every statistic; those above it are left out and also counted in implausibleWaits. Wait values above 1440 minutes (24 hours) are feed errors and are omitted. Counted per reading while OPERATING. Present only when there were some, and then with both counts. """ standby: int """ - Standby readings of 480 minutes or more included in the day's standby statistics. + Standby readings of 480 minutes or more. """ singleRider: int """ - Single-rider readings of 480 minutes or more included in the day's single-rider statistics. + Single-rider readings of 480 minutes or more. + """ + + +class HistoryDailyImplausibleWaits(ApiModel): + """ + How many wait readings above the destination's plausibility ceiling the day had: 600 minutes, or 300 at destinations whose longest genuine waits are far shorter (listed in the history notes). Such a reading is a value the park's feed or app shows that cannot be a real queue (a placeholder like 999, or an unlabelled code); the live API still reports it as the park published it, and the raw history keeps it, but the day's statistics leave it out as if no wait were showing. Counted per reading while OPERATING. Present only when there were some, and then with both counts. + """ + + standby: int + """ + Standby readings above the destination's plausibility ceiling, left out of the day's standby statistics. + """ + singleRider: int + """ + Single-rider readings above the destination's plausibility ceiling, left out of the day's single-rider statistics. """ class HistoryDailyStats(ApiModel): """ - Wait statistics for the day. p50, mean and p90 are weighted by how many minutes each wait was showing; min and max are the extremes of every value posted. Only minutes where the entity was OPERATING (not unknown) and the wait was valid count. A wait is valid during the run and on the day it was posted; the wait showing when a ride opens counts from the opening if it last changed within 24 hours; a wait carried over midnight does not count until it changes. Nothing the park reported is excluded: a reading of 480 minutes or more stays in these statistics and is counted in the row's `extremeWaits`. Absent when no minute counted. + Wait statistics for the day. p50, mean and p90 are weighted by how many minutes each wait was showing; min and max are the extremes of every value posted. Only minutes where the entity was OPERATING (not unknown) and the wait was valid count. A wait is valid during the run and on the day it was posted; the wait showing when a ride opens counts from the opening if it last changed within 24 hours; a wait carried over midnight does not count until it changes. Wait values above 1440 minutes (24 hours) are feed errors and are omitted. So is a reading above the destination's plausibility ceiling (600 minutes, or 300 at the destinations the history notes list), which is counted in the row's `implausibleWaits` instead. Otherwise, a reading of 480 minutes or more stays in these statistics and is counted in the row's `extremeWaits`. Absent when no minute counted. """ min: int @@ -247,7 +276,7 @@ class HistoryDailyStats(ApiModel): """ max: int """ - Highest wait posted during the counted minutes, including a brief spike. It can be a feed error: the row's extremeWaits counts readings of 480 minutes or more. + Highest wait posted during the counted minutes, including a brief spike. It can be a feed error: the row's extremeWaits counts readings of 480 minutes or more. It never exceeds the destination's plausibility ceiling (see implausibleWaits). """ @@ -389,6 +418,33 @@ class HistoryErrorWindowExceeded(ApiModel): error: Error9 +class HistoryKnownGap(ApiModel): + """ + A window where nothing was observed for this entity's destination and nothing can be recovered. /history has no rows inside it and /history/daily counts its minutes as unknown. Rows either side are real. + """ + + from_: Annotated[AwareDatetime, Field(alias="from")] + """ + First instant with no data (UTC, inclusive). + """ + to: AwareDatetime + """ + First instant with data again (UTC, exclusive). + """ + firstDay: date_aliased + """ + First park-local day the gap touches, partly or wholly. + """ + lastDay: date_aliased + """ + Last park-local day the gap touches, partly or wholly. + """ + reason: str + """ + Why the data is missing, in one sentence. + """ + + class Degraded(Enum): """ Present, and true, when we could not look far enough back for this response, so the opening may be missing a field we hold. Ask again in a minute for the full opening. @@ -690,6 +746,133 @@ class SchedulePriceType(Enum): ATTRACTION = "ATTRACTION" +class Success4(Enum): + """ + Always false. + """ + + boolean_False = False + + +class Type12(Enum): + Bad_request = "Bad request" + + +class Code4(IntEnum): + """ + Repeats the HTTP status. + """ + + integer_400 = 400 + + +class Error12(ApiModel): + type: Type12 + message: str + """ + Starts with "Validation failed: " and names each parameter that was refused and why, e.g. "q: Search term needs at least 3 letters or digits" or "parkId: parkId must be a UUID". + """ + code: Code4 | None = None + """ + Repeats the HTTP status. + """ + details: list[dict[str, Any]] | None = None + """ + One object per refused parameter. Each has a `message` and a `path` naming the parameter; its other fields are validation diagnostics and may change. + """ + + +class SearchInvalidQuery(ApiModel): + """ + 400: the search term or a parameter was refused before any search ran: `q` missing, shorter than 3 letters or digits (2 for Chinese, Japanese and Korean), over 100 characters or over the word limit; `types` not a comma-separated list of entity types; `limit` outside 1 to 25; `parkId` or `destinationId` not a UUID; or a parameter the endpoint does not take. Fix the request: retrying it unchanged gets the same answer. + """ + + success: Success4 + """ + Always false. + """ + error: Error12 + + +class Match(Enum): + """ + How the name matched. EXACT: the same name. PREFIX: the name starts with the term. WORD: a word, or every word, of the name starts with the term. CONTAINS: the term appears inside the name. FUZZY: close to a name, with typos forgiven. Results are ordered best first. + """ + + EXACT = "EXACT" + PREFIX = "PREFIX" + WORD = "WORD" + CONTAINS = "CONTAINS" + FUZZY = "FUZZY" + + +class SearchResultDestination(ApiModel): + """ + A destination, by id and name, and where in the world it is. + """ + + id: str + """ + Entity id. + """ + name: str + """ + Entity name. + """ + country: str + """ + ISO 3166-1 alpha-2 country code, for example US or GB. Hong Kong is HK and Macau is MO, never CN. Null until the destination has been placed. + """ + region: str + """ + State, province or nation in English, for example Florida or England. Never a county. Null where the country is no bigger than a region (HK, MO, SG), and until the destination has been placed. + """ + + +class SearchResultRef(ApiModel): + """ + A park or destination, by id and name. + """ + + id: str + """ + Entity id. + """ + name: str + """ + Entity name. + """ + + +class Type13(Enum): + Server_error = "Server error" + + +class Code5(IntEnum): + integer_503 = 503 + + +class Error13(ApiModel): + type: Type13 + message: str + """ + "Search is busy, try again shortly" or "Search is temporarily unavailable". + """ + code: Code5 | None = None + + +class SearchUnavailable(ApiModel): + """ + 503: search is busy, took too long, or has been switched off. Never cached. Wait the Retry-After header's number of seconds (2 when busy, 5 after a timeout, 60 when switched off), then retry once. + """ + + success: Success4 + """ + Always false. + """ + error: Error13 + + class V1MeBudget(ApiModel): """ One budget: the same figures the RateLimit-* and RateLimit-History-* headers carry. @@ -806,7 +989,7 @@ class EntityData(ApiModel): Entity name """ entityType: EntityType - attractionType: AttractionType | None = None + attractionType: AttractionType1 | None = None """ Kind of attraction. Present on ATTRACTION entities. """ @@ -867,6 +1050,10 @@ class HistoryCoverageDocument(ApiModel): """ Keyed by live-data path (status, queue.StandbyQueue, showtimes and so on), as /live and /history name them. Fields the entity never reported are left out. """ + knownGaps: list[HistoryKnownGap] + """ + Known archive gaps overlapping what this entity has recorded. Empty when none apply. + """ class HistoryDailyInParkHours(ApiModel): @@ -893,11 +1080,12 @@ class HistoryDailyInParkHours(ApiModel): standby: HistoryDailyStats | None = None singleRider: HistoryDailyStats | None = None extremeWaits: HistoryDailyExtremeWaits | None = None + implausibleWaits: HistoryDailyImplausibleWaits | None = None class HistoryDailyRow(ApiModel): """ - One day of an entity's history. standby, singleRider, extremeWaits, showCount and inParkHours are absent when there is nothing to report; a standby block needs a valid wait showing for at least one whole minute. A row without unknownMinutes was counted under earlier rules, and also lacks extremeWaits and inParkHours. + One day of an entity's history. standby, singleRider, extremeWaits, implausibleWaits, showCount and inParkHours are absent when there is nothing to report; a standby block needs a valid wait showing for at least one whole minute. A row without unknownMinutes was counted under earlier rules, and also lacks extremeWaits, implausibleWaits and inParkHours. """ date: date_aliased @@ -927,6 +1115,7 @@ class HistoryDailyRow(ApiModel): standby: HistoryDailyStats | None = None singleRider: HistoryDailyStats | None = None extremeWaits: HistoryDailyExtremeWaits | None = None + implausibleWaits: HistoryDailyImplausibleWaits | None = None showCount: int | None = None """ Distinct performance start times whose park-local day is this day. Present only for entities that published showtimes on the day. @@ -940,7 +1129,7 @@ class HistoryDailyRow(ApiModel): class HistoryParkCoverageDocument(ApiModel): """ - What history we hold across a whole PARK, returned by /history/coverage when the entity is a PARK. Its names map to the single-entity document: `fields` is `kinds`, and each `from` and `newest` is a `first` and `last`. It reports depth and breadth; it does not find missing days. + What history we hold across a whole PARK, returned by /history/coverage when the entity is a PARK. Its names map to the single-entity document: `fields` is `kinds`, and each `from` and `newest` is a `first` and `last`. It reports depth and breadth, and `knownGaps` lists the verified windows where nothing was observed; it does not search for other missing days. """ id: str @@ -961,6 +1150,10 @@ class HistoryParkCoverageDocument(ApiModel): """ Every entity of the park history is held for. """ + knownGaps: list[HistoryKnownGap] + """ + Known archive gaps overlapping what this park has recorded. Empty when none apply. + """ class HistoryParkEntityDaily(ApiModel): @@ -1033,6 +1226,39 @@ class SchedulePriceObject(ApiModel): """ +class SearchResult(ApiModel): + """ + One match. Many rides share a name: tell them apart by park and destination, and rides from shows and restaurants by entityType. + """ + + id: str + """ + Entity id. Use it with GET /v1/entity/{id}, /live, /children and /schedule. A park and the destination it belongs to often share a name: use the PARK id for that park's waits and hours. + """ + name: str + """ + Entity name. + """ + entityType: EntityType + attractionType: AttractionType | None = None + """ + Kind of attraction, so a ride can be told from a meet-and-greet or a parade. Null unless entityType is ATTRACTION, or when the kind is not recorded. + """ + match: Match + """ + How the name matched. EXACT: the same name. PREFIX: the name starts with the term. WORD: a word, or every word, of the name starts with the term. CONTAINS: the term appears inside the name. FUZZY: close to a name, with typos forgiven. Results are ordered best first. + """ + location: EntityLocation | None = None + park: SearchResultRef | None = None + """ + The park it is in. Null for a destination, a park, and anything not inside a park, such as a resort restaurant. + """ + destination: SearchResultDestination | None = None + """ + The destination it is in, and where that is. For a destination, itself. + """ + + class V1Me(ApiModel): """ Your plan and what is left of it. `rateLimit` is the request limit every call counts against, this one included. `historyRateLimit` is the hourly budget the history calls count against; reading it here does not spend it. Figures are per account, so every key on one account reports the same. @@ -1225,6 +1451,25 @@ class ScheduleEntry(ApiModel): """ +class SearchResponse(ApiModel): + """ + Response for GET /v1/search. + """ + + query: str + """ + The search term, as received and trimmed. + """ + results: list[SearchResult] + """ + Matches, best first. Empty when nothing matches. + """ + hasMore: bool + """ + True when more matched than were returned. Narrow the search with a longer term, types, parkId or destinationId. + """ + + class HistoryEnvelope(ApiModel): """ One entity's history. For a PARK the same call returns HistoryParkRawEnvelope instead. From 1cf1aa36cc2c7a2db25db4e4e77e5fa7bfe58593 Mon Sep 17 00:00:00 2001 From: Jamie Holding Date: Fri, 30 Oct 2026 20:48:33 +0000 Subject: [PATCH 2/2] backfill: adopt the four implausibleWaits columns in the CSV contract The regenerated HistoryDailyRow adds implausibleWaits and inParkHours.implausibleWaits, each with standby and singleRider counts. The CSV columns are derived from the schema, so the file now has 45 columns. The new ones sit after extremeWaitsSingleRider and after inParkHoursExtremeWaitsSingleRider, so the fingerprint changes and any in-flight resume is refused, which is what the fingerprint is for. csv_contract.json is the copy shared with the JavaScript SDK and is byte-identical there (same 45 columns, same fingerprint). The data column count assertion goes from 36 to 40 and the contract guard from 41 to 45. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 16 ++++++++++++++++ tests/fixtures/csv_contract.json | 6 +++++- tests/unit/test_backfill.py | 4 ++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a7cf8..672c52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Unreleased] + +### Added + +- **`themeparks-backfill` writes four more columns:** `implausibleWaitsStandby`, + `implausibleWaitsSingleRider`, `inParkHoursImplausibleWaitsStandby` and + `inParkHoursImplausibleWaitsSingleRider`. They count wait readings above the + destination's plausibility ceiling (600 minutes, or 300 at the destinations the + history notes list), which the API leaves out of the day's statistics. The cells + are empty on a day with none, as `extremeWaits*` are. The two standby and + single-rider pairs sit after `extremeWaitsSingleRider` and after + `inParkHoursExtremeWaitsSingleRider`, so the columns after them move right. A + file started by an earlier version is refused on resume instead of being + extended under a shifted header: finish it with that version or start a new + file. The Python and JavaScript SDKs write the same 45 columns. + ## [4.1.0] - 2026-09-29 ### Added diff --git a/tests/fixtures/csv_contract.json b/tests/fixtures/csv_contract.json index 8c5263b..ead7916 100644 --- a/tests/fixtures/csv_contract.json +++ b/tests/fixtures/csv_contract.json @@ -11,7 +11,7 @@ "SDK, copy this file to both repos, and expect both suites to fail until they", "agree." ], - "fingerprint": "1b6ea478049dc7ca", + "fingerprint": "8cbd164fcfb9698c", "columns": [ "parkId", "parkName", @@ -36,6 +36,8 @@ "singleRiderMax", "extremeWaitsStandby", "extremeWaitsSingleRider", + "implausibleWaitsStandby", + "implausibleWaitsSingleRider", "showCount", "inParkHoursScheduledMinutes", "inParkHoursOperatingMinutes", @@ -53,6 +55,8 @@ "inParkHoursSingleRiderMax", "inParkHoursExtremeWaitsStandby", "inParkHoursExtremeWaitsSingleRider", + "inParkHoursImplausibleWaitsStandby", + "inParkHoursImplausibleWaitsSingleRider", "changes" ] } diff --git a/tests/unit/test_backfill.py b/tests/unit/test_backfill.py index 4cebf12..65082b2 100644 --- a/tests/unit/test_backfill.py +++ b/tests/unit/test_backfill.py @@ -686,7 +686,7 @@ def flat(obj: dict[str, object], prefix: str = "") -> list[str]: # single-rider queue, so those columns are in the header and in no row. assert "singleRiderP90" in backfill.CSV_COLUMNS assert "singleRiderP90" not in seen - assert len(backfill.DATA_COLUMNS) == 36 + assert len(backfill.DATA_COLUMNS) == 40 def test_an_absent_stats_block_is_empty_cells_never_zeroes(self, tmp_path: Path) -> None: # A zero would read as "measured, and it was nothing". Absent means no @@ -1268,7 +1268,7 @@ def test_the_columns_match_the_checked_in_contract(self) -> None: def test_the_contract_is_not_trivially_satisfiable(self) -> None: # Guard the oracle: an empty or stub fixture would agree with anything. contract = json.loads((FIXTURES / "csv_contract.json").read_text(encoding="utf-8")) - assert len(contract["columns"]) == 41 + assert len(contract["columns"]) == 45 assert len(contract["fingerprint"]) == 16 assert contract["columns"][:5] == backfill.IDENTITY_COLUMNS