diff --git a/.github/workflows/build-and-test-mlbstatsapi-prd.yml b/.github/workflows/build-and-test-mlbstatsapi-prd.yml index eb88028..3861eb9 100644 --- a/.github/workflows/build-and-test-mlbstatsapi-prd.yml +++ b/.github/workflows/build-and-test-mlbstatsapi-prd.yml @@ -25,8 +25,8 @@ jobs: virtualenvs-in-project: true - name: Install dependencies run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ + - name: Test with pytest + run: poetry run pytest tests/ - name: Build package run: poetry build - name: Publish a Python distribution to PyPI diff --git a/.github/workflows/build-and-test-mlbstatsapi-test.yml b/.github/workflows/build-and-test-mlbstatsapi-test.yml index 0421b4e..0fbe1f7 100644 --- a/.github/workflows/build-and-test-mlbstatsapi-test.yml +++ b/.github/workflows/build-and-test-mlbstatsapi-test.yml @@ -25,8 +25,8 @@ jobs: virtualenvs-in-project: true - name: Install dependencies run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ + - name: Test with pytest + run: poetry run pytest tests/ - name: Build package run: poetry build - name: Publish package to TestPyPI diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c5d1fe0..359755b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -26,7 +26,7 @@ jobs: virtualenvs-in-project: true - name: Install dependencies run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ + - name: Test with pytest + run: poetry run pytest tests/ - name: Build package run: poetry build diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..0bea1a7 --- /dev/null +++ b/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +# tests/external_tests/... is collected from nested directories that are not packages, +# so the repository root is not always on sys.path by the time a test imports +# tests.alias_audit. +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 40657ba..6b0bfcb 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -1195,7 +1195,9 @@ def get_venue(self, venue_id: int, **params) -> Union[Venue, None]: >>> mlb.get_venue(31) Venue """ - params['hydrate'] = ['location', 'fieldInfo', 'timezone'] + # MLB expects one comma delimited hydrate value; a list is encoded as + # repeated query params and the hydrations are dropped. + params['hydrate'] = 'location,fieldInfo,timezone' mlb_data = self._mlb_adapter_v1.get(endpoint=f'venues/{venue_id}', ep_params=params) if 400 <= mlb_data.status_code <= 499: @@ -1241,7 +1243,9 @@ def get_venues(self, **params) -> List[Venue]: >>> mlb.get_venues() [Venue, Venue, Venue] """ - params['hydrate'] = ['location', 'fieldInfo', 'timezone'] + # MLB expects one comma delimited hydrate value; a list is encoded as + # repeated query params and the hydrations are dropped. + params['hydrate'] = 'location,fieldInfo,timezone' mlb_data = self._mlb_adapter_v1.get(endpoint='venues', ep_params=params) if 400 <= mlb_data.status_code <= 499: diff --git a/mlbstatsapi/models/data/data.py b/mlbstatsapi/models/data/data.py index 53e9480..3e3f697 100644 --- a/mlbstatsapi/models/data/data.py +++ b/mlbstatsapi/models/data/data.py @@ -77,8 +77,8 @@ class PitchCoordinates(MLBBaseModel): y : float Y coordinate where pitch crossed front of home plate. """ - ay: Optional[float] = None - az: Optional[float] = None + ay: Optional[float] = Field(default=None, alias="aY") + az: Optional[float] = Field(default=None, alias="aZ") pfx_x: Optional[float] = Field(default=None, alias="pfxX") pfx_z: Optional[float] = Field(default=None, alias="pfxZ") p_x: Optional[float] = Field(default=None, alias="pX") @@ -89,7 +89,7 @@ class PitchCoordinates(MLBBaseModel): x0: Optional[float] = None y0: Optional[float] = None z0: Optional[float] = None - ax: Optional[float] = None + ax: Optional[float] = Field(default=None, alias="aX") x: Optional[float] = None y: Optional[float] = None diff --git a/mlbstatsapi/models/game/gamedata/attributes.py b/mlbstatsapi/models/game/gamedata/attributes.py index f6ed578..483fb16 100644 --- a/mlbstatsapi/models/game/gamedata/attributes.py +++ b/mlbstatsapi/models/game/gamedata/attributes.py @@ -39,7 +39,7 @@ class GameDataGame(MLBBaseModel): gameday_type: str = Field(alias="gamedayType") tiebreaker: str game_number: int = Field(alias="gameNumber") - calendar_event_id: Optional[str] = Field(default=None, alias="calendarEventId") + calendar_event_id: Optional[str] = Field(default=None, alias="calendarEventID") season: str season_display: str = Field(alias="seasonDisplay") @@ -108,7 +108,7 @@ class GameStatus(MLBBaseModel): coded_game_state: str = Field(alias="codedGameState") detailed_state: str = Field(alias="detailedState") status_code: str = Field(alias="statusCode") - start_time_tbd: Optional[bool] = Field(default=None, alias="startTimeTbd") + start_time_tbd: Optional[bool] = Field(default=None, alias="startTimeTBD") abstract_game_code: str = Field(alias="abstractGameCode") reason: Optional[str] = None diff --git a/mlbstatsapi/models/game/gamedata/gamedata.py b/mlbstatsapi/models/game/gamedata/gamedata.py index 8a63822..674ea0e 100644 --- a/mlbstatsapi/models/game/gamedata/gamedata.py +++ b/mlbstatsapi/models/game/gamedata/gamedata.py @@ -76,7 +76,7 @@ class GameData(MLBBaseModel): game_info: Optional[GameInfo] = Field(default=None, alias="gameInfo") weather: Optional[GameWeather] = None official_scorer: Optional[Person] = Field(default=None, alias="officialScorer") - primary_data_caster: Optional[Person] = Field(default=None, alias="primaryDataCaster") + primary_data_caster: Optional[Person] = Field(default=None, alias="primaryDatacaster") secondary_data_caster: Optional[Person] = Field(default=None, alias="secondaryDataCaster") abs_challenges: Optional[AbsChallenges] = Field(default=None, alias="absChallenges") diff --git a/mlbstatsapi/models/people/people.py b/mlbstatsapi/models/people/people.py index 8eb5d42..7fa14ed 100644 --- a/mlbstatsapi/models/people/people.py +++ b/mlbstatsapi/models/people/people.py @@ -128,7 +128,7 @@ class Person(MLBBaseModel): use_name: Optional[str] = Field(default=None, alias="useName") middle_name: Optional[str] = Field(default=None, alias="middleName") boxscore_name: Optional[str] = Field(default=None, alias="boxscoreName") - nickname: Optional[str] = None + nickname: Optional[str] = Field(default=None, alias="nickName") draft_year: Optional[int] = Field(default=None, alias="draftYear") mlb_debut_date: Optional[str] = Field(default=None, alias="mlbDebutDate") name_first_last: Optional[str] = Field(default=None, alias="nameFirstLast") @@ -137,8 +137,8 @@ class Person(MLBBaseModel): last_first_name: Optional[str] = Field(default=None, alias="lastFirstName") last_init_name: Optional[str] = Field(default=None, alias="lastInitName") init_last_name: Optional[str] = Field(default=None, alias="initLastName") - full_fml_name: Optional[str] = Field(default=None, alias="fullFmlName") - full_lfm_name: Optional[str] = Field(default=None, alias="fullLfmName") + full_fml_name: Optional[str] = Field(default=None, alias="fullFMLName") + full_lfm_name: Optional[str] = Field(default=None, alias="fullLFMName") birth_country: Optional[str] = Field(default=None, alias="birthCountry") pronunciation: Optional[str] = None strike_zone_top: Optional[float] = Field(default=None, alias="strikeZoneTop") diff --git a/mlbstatsapi/models/schedules/attributes.py b/mlbstatsapi/models/schedules/attributes.py index 9d0bdca..27ba234 100644 --- a/mlbstatsapi/models/schedules/attributes.py +++ b/mlbstatsapi/models/schedules/attributes.py @@ -145,7 +145,7 @@ class ScheduleGames(MLBBaseModel): double_header: str = Field(alias="doubleHeader") gameday_type: str = Field(alias="gamedayType") tiebreaker: str - calendar_event_id: Optional[str] = Field(default=None, alias="calendarEventId") + calendar_event_id: Optional[str] = Field(default=None, alias="calendarEventID") season_display: str = Field(alias="seasonDisplay") day_night: str = Field(alias="dayNight") scheduled_innings: int = Field(alias="scheduledInnings") diff --git a/mlbstatsapi/models/seasons/season.py b/mlbstatsapi/models/seasons/season.py index da04dd6..5e09777 100644 --- a/mlbstatsapi/models/seasons/season.py +++ b/mlbstatsapi/models/seasons/season.py @@ -54,8 +54,8 @@ class Season(MLBBaseModel): """ season_id: str = Field(alias="seasonId") has_wildcard: Optional[bool] = Field(default=None, alias="hasWildcard") - preseason_start_date: Optional[str] = Field(default=None, alias="preseasonStartDate") - preseason_end_date: Optional[str] = Field(default=None, alias="preseasonEndDate") + preseason_start_date: Optional[str] = Field(default=None, alias="preSeasonStartDate") + preseason_end_date: Optional[str] = Field(default=None, alias="preSeasonEndDate") season_start_date: Optional[str] = Field(default=None, alias="seasonStartDate") spring_start_date: Optional[str] = Field(default=None, alias="springStartDate") spring_end_date: Optional[str] = Field(default=None, alias="springEndDate") @@ -64,11 +64,13 @@ class Season(MLBBaseModel): all_star_date: Optional[str] = Field(default=None, alias="allStarDate") first_date_2nd_half: Optional[str] = Field(default=None, alias="firstDate2ndHalf") regular_season_end_date: Optional[str] = Field(default=None, alias="regularSeasonEndDate") - postseason_start_date: Optional[str] = Field(default=None, alias="postseasonStartDate") - postseason_end_date: Optional[str] = Field(default=None, alias="postseasonEndDate") + postseason_start_date: Optional[str] = Field(default=None, alias="postSeasonStartDate") + postseason_end_date: Optional[str] = Field(default=None, alias="postSeasonEndDate") season_end_date: Optional[str] = Field(default=None, alias="seasonEndDate") + # MLB is inconsistent here: the start key is "offseasonStartDate" but the end key + # is "offSeasonEndDate". offseason_start_date: Optional[str] = Field(default=None, alias="offseasonStartDate") - offseason_end_date: Optional[str] = Field(default=None, alias="offseasonEndDate") + offseason_end_date: Optional[str] = Field(default=None, alias="offSeasonEndDate") season_level_gameday_type: Optional[str] = Field(default=None, alias="seasonLevelGamedayType") game_level_gameday_type: Optional[str] = Field(default=None, alias="gameLevelGamedayType") qualifier_plate_appearances: Optional[float] = Field(default=None, alias="qualifierPlateAppearances") diff --git a/mlbstatsapi/models/standings/attributes.py b/mlbstatsapi/models/standings/attributes.py index 2df9844..b82f400 100644 --- a/mlbstatsapi/models/standings/attributes.py +++ b/mlbstatsapi/models/standings/attributes.py @@ -96,9 +96,9 @@ class TeamRecords(TeamRecord): elimination_number_league: str = Field(alias="eliminationNumberLeague") elimination_number_division: str = Field(alias="eliminationNumberDivision") elimination_number_conference: str = Field(alias="eliminationNumberConference") - wildcard_elimination_number: Optional[str] = Field(default=None, alias="wildcardEliminationNumber") + wildcard_elimination_number: Optional[str] = Field(default=None, alias="wildCardEliminationNumber") run_differential: int = Field(alias="runDifferential") - wildcard_rank: Optional[str] = Field(default=None, alias="wildcardRank") - wildcard_leader: Optional[bool] = Field(default=None, alias="wildcardLeader") + wildcard_rank: Optional[str] = Field(default=None, alias="wildCardRank") + wildcard_leader: Optional[bool] = Field(default=None, alias="wildCardLeader") magic_number: Optional[str] = Field(default=None, alias="magicNumber") clinch_indicator: Optional[str] = Field(default=None, alias="clinchIndicator") diff --git a/mlbstatsapi/models/standings/standings.py b/mlbstatsapi/models/standings/standings.py index 3dae0b5..e59c6e6 100644 --- a/mlbstatsapi/models/standings/standings.py +++ b/mlbstatsapi/models/standings/standings.py @@ -34,4 +34,4 @@ class Standings(MLBBaseModel): last_updated: str = Field(alias="lastUpdated") team_records: List[TeamRecords] = Field(alias="teamRecords") sport: Optional[Sport] = None - roundrobin: Optional[dict] = None + roundrobin: Optional[dict] = Field(default=None, alias="roundRobin") diff --git a/mlbstatsapi/models/stats/fielding.py b/mlbstatsapi/models/stats/fielding.py index d13eeaf..ed924a1 100644 --- a/mlbstatsapi/models/stats/fielding.py +++ b/mlbstatsapi/models/stats/fielding.py @@ -71,7 +71,7 @@ class SimpleFieldingSplit(MLBBaseModel): stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") stolen_base_percentage: Optional[str] = Field(default=None, alias="stolenBasePercentage") assists: Optional[int] = None - putouts: Optional[int] = None + putouts: Optional[int] = Field(default=None, alias="putOuts") errors: Optional[int] = None chances: Optional[int] = None fielding: Optional[str] = None @@ -82,7 +82,7 @@ class SimpleFieldingSplit(MLBBaseModel): passed_ball: Optional[int] = Field(default=None, alias="passedBall") double_plays: Optional[int] = Field(default=None, alias="doublePlays") triple_plays: Optional[int] = Field(default=None, alias="triplePlays") - catcher_era: Optional[str] = Field(default=None, alias="catcherEra") + catcher_era: Optional[str] = Field(default=None, alias="catcherERA") catchers_interference: Optional[int] = Field(default=None, alias="catchersInterference") wild_pitches: Optional[int] = Field(default=None, alias="wildPitches") throwing_errors: Optional[int] = Field(default=None, alias="throwingErrors") diff --git a/mlbstatsapi/models/stats/pitching.py b/mlbstatsapi/models/stats/pitching.py index 3736c83..9b4e7b8 100644 --- a/mlbstatsapi/models/stats/pitching.py +++ b/mlbstatsapi/models/stats/pitching.py @@ -1,5 +1,5 @@ from typing import Optional, List, Any, ClassVar -from pydantic import Field, field_validator +from pydantic import AliasChoices, Field, field_validator from mlbstatsapi.models.base import MLBBaseModel from mlbstatsapi.models.people import Person, Pitcher, Batter from mlbstatsapi.models.teams import Team @@ -148,9 +148,9 @@ class SimplePitchingSplit(MLBBaseModel): age: Optional[int] = None games_played: Optional[int] = Field(default=None, alias="gamesPlayed") games_started: Optional[int] = Field(default=None, alias="gamesStarted") - flyouts: Optional[int] = None - groundouts: Optional[int] = None - airouts: Optional[int] = None + flyouts: Optional[int] = Field(default=None, alias="flyOuts") + groundouts: Optional[int] = Field(default=None, alias="groundOuts") + airouts: Optional[int] = Field(default=None, alias="airOuts") runs: Optional[int] = None doubles: Optional[int] = None triples: Optional[int] = None @@ -192,7 +192,7 @@ class SimplePitchingSplit(MLBBaseModel): wild_pitches: Optional[int] = Field(default=None, alias="wildPitches") pickoffs: Optional[int] = None total_bases: Optional[int] = Field(default=None, alias="totalBases") - groundouts_to_airouts: Optional[str] = Field(default=None, alias="groundoutsToAirouts") + groundouts_to_airouts: Optional[str] = Field(default=None, alias="groundOutsToAirouts") win_percentage: Optional[str] = Field(default=None, alias="winPercentage") pitches_per_inning: Optional[str] = Field(default=None, alias="pitchesPerInning") games_finished: Optional[int] = Field(default=None, alias="gamesFinished") @@ -328,7 +328,13 @@ class AdvancedPitchingSplit(MLBBaseModel): base_on_balls_per_9: Optional[str] = Field(default=None, alias="baseOnBallsPer9") home_runs_per_9: Optional[str] = Field(default=None, alias="homeRunsPer9") hits_per_9: Optional[str] = Field(default=None, alias="hitsPer9") - strikeouts_to_walks: Optional[str] = Field(default=None, alias="strikeoutsToWalks") + # MLB spells this key "strikesoutsToWalks". Both spellings are accepted so the + # field keeps working if they ever correct it. + strikeouts_to_walks: Optional[str] = Field( + default=None, + alias="strikesoutsToWalks", + validation_alias=AliasChoices("strikesoutsToWalks", "strikeoutsToWalks"), + ) stolen_bases: Optional[int] = Field(default=None, alias="stolenBases") caught_stealing: Optional[int] = Field(default=None, alias="caughtStealing") quality_starts: Optional[int] = Field(default=None, alias="qualityStarts") @@ -358,10 +364,10 @@ class AdvancedPitchingSplit(MLBBaseModel): home_runs_per_plate_appearance: Optional[str] = Field(default=None, alias="homeRunsPerPlateAppearance") walks_per_strikeout: Optional[str] = Field(default=None, alias="walksPerStrikeout") iso: Optional[str] = None - flyouts: Optional[int] = None - popouts: Optional[int] = None - lineouts: Optional[int] = None - groundouts: Optional[int] = None + flyouts: Optional[int] = Field(default=None, alias="flyOuts") + popouts: Optional[int] = Field(default=None, alias="popOuts") + lineouts: Optional[int] = Field(default=None, alias="lineOuts") + groundouts: Optional[int] = Field(default=None, alias="groundOuts") fly_hits: Optional[int] = Field(default=None, alias="flyHits") pop_hits: Optional[int] = Field(default=None, alias="popHits") line_hits: Optional[int] = Field(default=None, alias="lineHits") @@ -371,7 +377,7 @@ class AdvancedPitchingSplit(MLBBaseModel): bequeathed_runners: Optional[int] = Field(default=None, alias="bequeathedRunners") bequeathed_runners_scored: Optional[int] = Field(default=None, alias="bequeathedRunnersScored") innings_pitched_per_game: Optional[str] = Field(default=None, alias="inningsPitchedPerGame") - flyball_percentage: Optional[str] = Field(default=None, alias="flyballPercentage") + flyball_percentage: Optional[str] = Field(default=None, alias="flyBallPercentage") class PitchingSabermetrics(Split): diff --git a/mlbstatsapi/models/stats/stats.py b/mlbstatsapi/models/stats/stats.py index ce118c3..987b18a 100644 --- a/mlbstatsapi/models/stats/stats.py +++ b/mlbstatsapi/models/stats/stats.py @@ -93,9 +93,9 @@ class Sabermetrics(MLBBaseModel): Weighted stolen base runs. """ woba: Optional[float] = None - wraa: Optional[float] = None - wrc: Optional[float] = None - wrc_plus: Optional[float] = Field(default=None, alias="wrcPlus") + wraa: Optional[float] = Field(default=None, alias="wRaa") + wrc: Optional[float] = Field(default=None, alias="wRc") + wrc_plus: Optional[float] = Field(default=None, alias="wRcPlus") rar: Optional[float] = None war: Optional[float] = None batting: Optional[float] = None @@ -291,32 +291,44 @@ def empty_dict_to_none(cls, v: Any) -> Any: return v +class OutsAboveAverageSplit(MLBBaseModel): + """ + A class to represent outs above average statistics. + + NOTE: This stat type returns an empty list, or keys with the value 0. + """ + attempts: Optional[int] = None + fielding_runs_prevented: Optional[float] = Field(default=None, alias="fieldingRunsPrevented") + fielding_runs_prevented_unrounded: Optional[float] = Field(default=None, alias="fieldingRunsPreventedUnrounded") + total_outs_above_average_back: Optional[float] = Field(default=None, alias="totalOutsAboveAverageBack") + total_outs_above_average_back_unrounded: Optional[float] = Field(default=None, alias="totalOutsAboveAverageBackUnrounded") + outs_above_average_back_straight: Optional[float] = Field(default=None, alias="outsAboveAverageBackStraight") + outs_above_average_back_straight_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageBackStraightUnrounded") + outs_above_average_back_left: Optional[float] = Field(default=None, alias="outsAboveAverageBackLeft") + outs_above_average_back_left_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageBackLeftUnrounded") + outs_above_average_back_right: Optional[float] = Field(default=None, alias="outsAboveAverageBackRight") + outs_above_average_back_right_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageBackRightUnrounded") + total_outs_above_average_in: Optional[float] = Field(default=None, alias="totalOutsAboveAverageIn") + total_outs_above_average_in_unrounded: Optional[float] = Field(default=None, alias="totalOutsAboveAverageInUnrounded") + outs_above_average_in_straight: Optional[float] = Field(default=None, alias="outsAboveAverageInStraight") + outs_above_average_in_straight_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageInStraightUnrounded") + outs_above_average_in_left: Optional[float] = Field(default=None, alias="outsAboveAverageInLeft") + outs_above_average_in_left_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageInLeftUnrounded") + outs_above_average_in_right: Optional[float] = Field(default=None, alias="outsAboveAverageInRight") + outs_above_average_in_right_unrounded: Optional[float] = Field(default=None, alias="outsAboveAverageInRightUnrounded") + + class OutsAboveAverage(Split): """ A class to represent an outs above average statistic. - NOTE: This stat type returns an empty list, or keys with the value 0. + Attributes + ---------- + stat : OutsAboveAverageSplit + The outs above average statistics. """ _stat: ClassVar[List[str]] = ['outsAboveAverage'] - attempts: int - total_outs_above_average_back: int = Field(alias="totalOutsAboveAverageBack") - total_outs_above_average_back_unrounded: int = Field(alias="totalOutsAboveAverageBackUnrounded") - outs_above_average_back_straight: int = Field(alias="outsAboveAverageBackStraight") - outs_above_average_back_straight_unrounded: int = Field(alias="outsAboveAverageBackStraightUnrounded") - outs_above_average_back_left: int = Field(alias="outsAboveAverageBackLeft") - outs_above_average_back_left_unrounded: int = Field(alias="outsAboveAverageBackLeftUnrounded") - outs_above_average_back_right: int = Field(alias="outsAboveAverageBackRight") - outs_above_average_back_right_unrounded: int = Field(alias="outsAboveAverageBackRightUnrounded") - total_outs_above_average_in: int = Field(alias="totalOutsAboveAverageIn") - total_outs_above_average_in_unrounded: int = Field(alias="totalOutsAboveAverageInUnrounded") - outs_above_average_in_straight: int = Field(alias="outsAboveAverageInStraight") - outs_above_average_in_straight_unrounded: int = Field(alias="outsAboveAverageInStraightUnrounded") - outs_above_average_in_left: int = Field(alias="outsAboveAverageInLeft") - outs_above_average_in_left_unrounded: int = Field(alias="outsAboveAverageInLeftUnrounded") - outs_above_average_in_right: int = Field(alias="outsAboveAverageInRight") - outs_above_average_in_right_unrounded: int = Field(alias="outsAboveAverageInRightUnrounded") - player: Person - game_type: str = Field(alias="gameType") + stat: OutsAboveAverageSplit class PlayerGameLogStat(Split): diff --git a/mlbstatsapi/models/teams/attributes.py b/mlbstatsapi/models/teams/attributes.py index ec2a44c..02a5416 100644 --- a/mlbstatsapi/models/teams/attributes.py +++ b/mlbstatsapi/models/teams/attributes.py @@ -153,7 +153,7 @@ class TeamRecord(MLBBaseModel): The winning percentage of the team. """ games_played: int = Field(alias="gamesPlayed") - wildcard_games_back: Optional[str] = Field(default=None, alias="wildcardGamesBack") + wildcard_games_back: Optional[str] = Field(default=None, alias="wildCardGamesBack") league_games_back: str = Field(alias="leagueGamesBack") spring_league_games_back: Optional[str] = Field(default=None, alias="springLeagueGamesBack") sport_games_back: str = Field(alias="sportGamesBack") diff --git a/mlbstatsapi/models/venues/venue.py b/mlbstatsapi/models/venues/venue.py index b43f669..9273907 100644 --- a/mlbstatsapi/models/venues/venue.py +++ b/mlbstatsapi/models/venues/venue.py @@ -31,7 +31,7 @@ class Venue(MLBBaseModel): link: str name: Optional[str] = None location: Optional[Location] = None - timezone: Optional[TimeZone] = None + timezone: Optional[TimeZone] = Field(default=None, alias="timeZone") field_info: Optional[FieldInfo] = Field(default=None, alias="fieldInfo") active: Optional[bool] = None season: Optional[str] = None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/alias_audit.py b/tests/alias_audit.py new file mode 100644 index 0000000..0859075 --- /dev/null +++ b/tests/alias_audit.py @@ -0,0 +1,370 @@ +"""Helpers for detecting drift between model field aliases and MLB API response keys. + +``MLBBaseModel`` is configured with ``extra="ignore"``, so a field whose alias does not +exactly match the key MLB sends is not a validation error: the value is discarded and +the field silently keeps its ``None`` default. Issue #246 is one instance of this. When +v0.7.1 stopped lowercasing every response key and moved to a camelCase alias generator, +fields whose names are a single lowercase token -- ``strikeouts``, ``putouts``, +``walkoffs``, ``nickname`` -- were left expecting ``strikeouts`` while the API sends +``strikeOuts``, so they never populated again. + +The helpers here drive two checks: + +* every key MLB sends that looks like a declared field must actually reach that field +* every field's alias must match the casing MLB uses for that key + +Both run offline against recorded fixtures, and again against the live API under +``tests/external_tests`` so that renames on MLB's side surface too. +""" + +from __future__ import annotations + +import importlib +import json +import pkgutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +import mlbstatsapi +from pydantic import AliasChoices + +from mlbstatsapi.models.base import MLBBaseModel + +FIXTURE_DIR = Path(__file__).parent / "fixtures" +API_KEYS_FIXTURE = FIXTURE_DIR / "api_keys.json" +PAYLOADS_FIXTURE = FIXTURE_DIR / "model_payloads.json" + +BASE_V1 = "https://statsapi.mlb.com/api/v1" +BASE_V1_1 = "https://statsapi.mlb.com/api/v1.1" + +# Fixed identifiers so a re-record produces a comparable snapshot. Several exist only +# to reach keys MLB omits from an ordinary response: catcher-only fielding stats, the +# death fields on a deceased player, and a game recent enough to carry ABS challenges. +SOTO = 665742 +SKUBAL = 669373 +REALMUTO = 592663 +AARON = 110001 +PHILLIES = 147 +GAME_PK = 775296 +ABS_GAME_PK = 823837 +SEASON = 2025 + +# Endpoints crawled to build the vocabulary of real API keys. Hydrations are requested +# generously: a key that never appears in a response cannot be checked for drift. +SNAPSHOT_ENDPOINTS = ( + f"{BASE_V1}/people/{SOTO}/stats?stats=season,career,seasonAdvanced,careerAdvanced," + "yearByYear,gameLog,byDayOfWeek,byMonth,homeAndAway,winLoss,expectedStatistics," + f"sabermetrics,pitchArsenal,hotColdZones&group=hitting&season={SEASON}", + f"{BASE_V1}/people/{SKUBAL}/stats?stats=season,career,seasonAdvanced,careerAdvanced," + f"yearByYear,gameLog,pitchArsenal,sabermetrics,expectedStatistics&group=pitching&season={SEASON}", + f"{BASE_V1}/people/{SOTO}/stats?stats=season,career,seasonAdvanced,yearByYear,gameLog" + f"&group=fielding&season={SEASON}", + f"{BASE_V1}/people/{REALMUTO}/stats?stats=season,career,yearByYear&group=catching&season={SEASON}", + # A catcher's fielding split carries catcherERA and passedBall, which an outfielder's + # does not. + f"{BASE_V1}/people/{REALMUTO}/stats?stats=season,career&group=fielding&season={SEASON}", + f"{BASE_V1}/people/{SOTO}/stats?stats=outsAboveAverage&group=fielding&season={SEASON}", + # Death and nickname fields are only present for players they apply to. + f"{BASE_V1}/people/{AARON}", + f"{BASE_V1}/teams/{PHILLIES}/stats?stats=season,seasonAdvanced,career" + f"&group=hitting,pitching,fielding&season={SEASON}", + f"{BASE_V1}/people/{SOTO}?hydrate=stats(group=[hitting],type=[season])", + f"{BASE_V1}/teams/{PHILLIES}?hydrate=venue,league,division,sport", + f"{BASE_V1}/teams/{PHILLIES}/roster?rosterType=active", + f"{BASE_V1}/schedule?sportId=1&date={SEASON}-07-04&hydrate=linescore,team,venue,decisions," + "probablePitcher,weather,officials,broadcasts", + f"{BASE_V1}/venues/3313?hydrate=location,fieldInfo,timezone", + f"{BASE_V1}/standings?leagueId=103&season={SEASON}&standingsTypes=regularSeason", + f"{BASE_V1}/draft/2024", + f"{BASE_V1}/attendance?teamId={PHILLIES}&season={SEASON}", + f"{BASE_V1}/gamePace?season={SEASON}", + f"{BASE_V1}/awards/MLBHOF/recipients", + f"{BASE_V1}/seasons/{SEASON}?sportId=1", + f"{BASE_V1}/divisions?sportId=1", + f"{BASE_V1}/league?sportId=1", + f"{BASE_V1}/sports", + f"{BASE_V1_1}/game/{GAME_PK}/feed/live", + # ABS challenges only appear on games played once the system was in use. + f"{BASE_V1_1}/game/{ABS_GAME_PK}/feed/live", + f"{BASE_V1}/game/{GAME_PK}/boxscore", + f"{BASE_V1}/game/{GAME_PK}/linescore", + f"{BASE_V1}/game/{GAME_PK}/playByPlay", + f"{BASE_V1}/homeRunDerby/511101", +) + + +@dataclass(frozen=True) +class PayloadSpec: + """Locates one real payload to record and check a model against. + + Exactly one of ``path`` and ``probe`` is used. ``path`` indexes into the response; + ``probe`` selects the first nested dict containing that key, which is more durable + for deeply nested payloads such as the live game feed. + """ + + label: str + model: str + url: str + path: tuple[Any, ...] | None = None + probe: str | None = None + + +PAYLOAD_SPECS = ( + PayloadSpec( + "hitting_season", + "mlbstatsapi.models.stats.hitting:SimpleHittingSplit", + f"{BASE_V1}/people/{SOTO}/stats?stats=season&group=hitting&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "hitting_season_advanced", + "mlbstatsapi.models.stats.hitting:AdvancedHittingSplit", + f"{BASE_V1}/people/{SOTO}/stats?stats=seasonAdvanced&group=hitting&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "pitching_season", + "mlbstatsapi.models.stats.pitching:SimplePitchingSplit", + f"{BASE_V1}/people/{SKUBAL}/stats?stats=season&group=pitching&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "pitching_season_advanced", + "mlbstatsapi.models.stats.pitching:AdvancedPitchingSplit", + f"{BASE_V1}/people/{SKUBAL}/stats?stats=seasonAdvanced&group=pitching&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "fielding_season", + "mlbstatsapi.models.stats.fielding:SimpleFieldingSplit", + f"{BASE_V1}/people/{SOTO}/stats?stats=season&group=fielding&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "fielding_season_catcher", + "mlbstatsapi.models.stats.fielding:SimpleFieldingSplit", + f"{BASE_V1}/people/{REALMUTO}/stats?stats=season&group=fielding&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "outs_above_average", + "mlbstatsapi.models.stats.stats:OutsAboveAverage", + f"{BASE_V1}/people/{SOTO}/stats?stats=outsAboveAverage&group=fielding&season={SEASON}", + path=("stats", 0, "splits", 0), + ), + PayloadSpec( + "catching_season", + "mlbstatsapi.models.stats.catching:SimpleCatchingSplit", + f"{BASE_V1}/people/{REALMUTO}/stats?stats=season&group=catching&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "hitting_sabermetrics", + "mlbstatsapi.models.stats.stats:Sabermetrics", + f"{BASE_V1}/people/{SOTO}/stats?stats=sabermetrics&group=hitting&season={SEASON}", + path=("stats", 0, "splits", 0, "stat"), + ), + PayloadSpec( + "person", + "mlbstatsapi.models.people.people:Person", + f"{BASE_V1}/people/{SOTO}", + path=("people", 0), + ), + PayloadSpec( + "person_deceased", + "mlbstatsapi.models.people.people:Person", + f"{BASE_V1}/people/{AARON}", + path=("people", 0), + ), + PayloadSpec( + "abs_challenge_info", + "mlbstatsapi.models.game.gamedata.attributes:AbsChallengeInfo", + f"{BASE_V1_1}/game/{ABS_GAME_PK}/feed/live", + probe="usedSuccessful", + ), + PayloadSpec( + "season", + "mlbstatsapi.models.seasons.season:Season", + f"{BASE_V1}/seasons/{SEASON}?sportId=1", + path=("seasons", 0), + ), + PayloadSpec( + "venue", + "mlbstatsapi.models.venues.venue:Venue", + f"{BASE_V1}/venues/3313?hydrate=location,fieldInfo,timezone", + path=("venues", 0), + ), + PayloadSpec( + "team_records", + "mlbstatsapi.models.standings.attributes:TeamRecords", + f"{BASE_V1}/standings?leagueId=103&season={SEASON}&standingsTypes=regularSeason", + path=("records", 0, "teamRecords", 0), + ), + PayloadSpec( + "schedule_game", + "mlbstatsapi.models.schedules.attributes:ScheduleGames", + f"{BASE_V1}/schedule?sportId=1&date={SEASON}-07-04", + path=("dates", 0, "games", 0), + ), + PayloadSpec( + "game_status", + "mlbstatsapi.models.game.gamedata.attributes:GameStatus", + f"{BASE_V1_1}/game/{GAME_PK}/feed/live", + probe="startTimeTBD", + ), + PayloadSpec( + "game_data_game", + "mlbstatsapi.models.game.gamedata.attributes:GameDataGame", + f"{BASE_V1_1}/game/{GAME_PK}/feed/live", + probe="calendarEventID", + ), + PayloadSpec( + "pitch_coordinates", + "mlbstatsapi.models.data.data:PitchCoordinates", + f"{BASE_V1}/game/{GAME_PK}/playByPlay", + probe="aX", + ), +) + + +def normalize(key: str) -> str: + """Reduce a key to the form that makes ``strikeouts`` and ``strikeOuts`` comparable.""" + return key.replace("_", "").lower() + + +def validation_aliases(model: type[MLBBaseModel], field_name: str) -> list[str]: + """Every key this field validates against, explicit, generated or a set of choices.""" + field = model.model_fields[field_name] + alias = field.validation_alias + if isinstance(alias, AliasChoices): + return [choice for choice in alias.choices if isinstance(choice, str)] + if isinstance(alias, str): + return [alias] + return [field.alias or field_name] + + +def effective_alias(model: type[MLBBaseModel], field_name: str) -> str: + """The key this field is primarily expected to arrive under.""" + return validation_aliases(model, field_name)[0] + + +def accepted_keys(model: type[MLBBaseModel]) -> set[str]: + """Keys that populate a field, given ``populate_by_name`` accepts names too.""" + keys = set(model.model_fields) + for name in model.model_fields: + keys.update(validation_aliases(model, name)) + return keys + + +def resolve(dotted: str) -> type[MLBBaseModel]: + module, _, name = dotted.partition(":") + return getattr(importlib.import_module(module), name) + + +def iter_models() -> Iterator[type[MLBBaseModel]]: + """Every model in the package, with submodules imported so subclasses are registered.""" + for module in pkgutil.walk_packages(mlbstatsapi.__path__, f"{mlbstatsapi.__name__}."): + try: + importlib.import_module(module.name) + except ImportError: + continue + + def descend(cls): + for subclass in cls.__subclasses__(): + yield subclass + yield from descend(subclass) + + yield from sorted(set(descend(MLBBaseModel)), key=lambda c: (c.__module__, c.__name__)) + + +def dropped_keys(model: type[MLBBaseModel], payload: dict) -> list[tuple[str, str]]: + """Keys in ``payload`` that name a declared field but miss it on casing. + + Returns ``(api_key, field_name)`` pairs. Keys with no matching field are ignored: + MLB adds fields the library has not modelled yet, which is not drift. + """ + accepted = accepted_keys(model) + by_normalized = {normalize(name): name for name in model.model_fields} + missed = [] + for key in payload: + if key in accepted: + continue + field = by_normalized.get(normalize(key)) + if field is not None: + missed.append((key, field)) + return sorted(missed) + + +def alias_mismatches(vocabulary: set[str]) -> list[tuple[type[MLBBaseModel], str, str, list[str]]]: + """Fields whose alias differs only in casing from a key the API really sends. + + Returns ``(model, field_name, expected_alias, observed_keys)``. A field is only + reported when the API demonstrably uses that key, so unused or stale fields stay + quiet rather than producing noise. + """ + observed: dict[str, set[str]] = {} + for key in vocabulary: + observed.setdefault(normalize(key), set()).add(key) + + mismatches = [] + for model in iter_models(): + for name in model.model_fields: + aliases = validation_aliases(model, name) + real = set().union(*(observed.get(normalize(a), set()) for a in aliases)) + if real and not real.intersection(aliases): + mismatches.append((model, name, aliases[0], sorted(real))) + return mismatches + + +def collect_keys(payload: Any, into: set[str] | None = None) -> set[str]: + """Every distinct dict key anywhere in a response.""" + keys = set() if into is None else into + if isinstance(payload, dict): + for key, value in payload.items(): + keys.add(key) + collect_keys(value, keys) + elif isinstance(payload, list): + for value in payload: + collect_keys(value, keys) + return keys + + +def extract(payload: Any, spec: PayloadSpec) -> dict: + """Pull the sub-payload a spec points at.""" + if spec.path is not None: + for step in spec.path: + payload = payload[step] + return payload + + stack = [payload] + while stack: + current = stack.pop(0) + if isinstance(current, dict): + if spec.probe in current: + return current + stack.extend(current.values()) + elif isinstance(current, list): + stack.extend(current) + raise LookupError(f"no dict containing {spec.probe!r} in response for {spec.label}") + + +def load_api_keys() -> set[str]: + return set(json.loads(API_KEYS_FIXTURE.read_text())["keys"]) + + +def load_payloads() -> dict[str, dict]: + return json.loads(PAYLOADS_FIXTURE.read_text())["payloads"] + + +def describe_dropped(model: type[MLBBaseModel], payload: dict, missed: list[tuple[str, str]]) -> str: + lines = [ + f"{model.__name__} silently drops {len(missed)} value(s) returned by the MLB API.", + "Each field below keeps its None default because its alias does not match the " + "key the API sends. Set an explicit Field(alias=...) to fix it:", + "", + ] + for key, field in missed: + alias = effective_alias(model, field) + lines.append(f" {field}: expects {alias!r}, API sends {key!r} = {payload[key]!r}") + return "\n".join(lines) diff --git a/tests/external_tests/models/test_alias_drift_live.py b/tests/external_tests/models/test_alias_drift_live.py new file mode 100644 index 0000000..7de0dae --- /dev/null +++ b/tests/external_tests/models/test_alias_drift_live.py @@ -0,0 +1,68 @@ +"""Live counterpart to tests/test_model_alias_coverage.py. + +The offline tests compare models against recorded fixtures, which only catch drift the +library introduces. This module runs the same checks against the API as it responds +today, so a rename on MLB's side surfaces even when nobody has re-recorded fixtures. +""" + +import json +import urllib.error +import urllib.request + +import pytest + +from tests import alias_audit + +TIMEOUT = 60 + + +def fetch(url): + try: + with urllib.request.urlopen(url, timeout=TIMEOUT) as response: + return json.load(response) + except (urllib.error.URLError, TimeoutError) as exc: + pytest.skip(f"MLB API unreachable ({exc})") + + +@pytest.fixture(scope="module") +def live_api_keys(): + keys = set() + for url in alias_audit.SNAPSHOT_ENDPOINTS: + alias_audit.collect_keys(fetch(url), keys) + return keys + + +def test_live_api_field_aliases_match(live_api_keys): + mismatches = alias_audit.alias_mismatches(live_api_keys) + report = [ + f" {model.__name__}.{field} reads {expects!r}, API now sends {', '.join(observed)}" + for model, field, expects, observed in mismatches + ] + assert not mismatches, "\n".join( + [f"{len(mismatches)} field(s) no longer match the live MLB API:", *report] + ) + + +def test_recorded_fixtures_are_not_stale(live_api_keys): + """Keys the live API added since the last recording are invisible to the offline tests.""" + new_keys = live_api_keys - alias_audit.load_api_keys() + modelled = {alias_audit.normalize(key) for key in new_keys} + relevant = { + alias_audit.effective_alias(model, name) + for model in alias_audit.iter_models() + for name in model.model_fields + if alias_audit.normalize(alias_audit.effective_alias(model, name)) in modelled + } + assert not relevant, ( + "the live API returns keys matching these fields that are missing from " + f"tests/fixtures/api_keys.json: {sorted(relevant)}. " + "Re-run python tests/tools/record_api_fixtures.py" + ) + + +@pytest.mark.parametrize("spec", alias_audit.PAYLOAD_SPECS, ids=lambda spec: spec.label) +def test_live_payload_populates_every_matching_field(spec): + payload = alias_audit.extract(fetch(spec.url), spec) + model = alias_audit.resolve(spec.model) + missed = alias_audit.dropped_keys(model, payload) + assert not missed, alias_audit.describe_dropped(model, payload, missed) diff --git a/tests/external_tests/venue/test_venue.py b/tests/external_tests/venue/test_venue.py index 5ed98f1..2abc913 100644 --- a/tests/external_tests/venue/test_venue.py +++ b/tests/external_tests/venue/test_venue.py @@ -1,7 +1,10 @@ import unittest +from unittest.mock import patch + from pydantic import ValidationError from mlbstatsapi.models.venues import Venue from mlbstatsapi import Mlb +from mlbstatsapi.mlb_dataadapter import MlbResult class TestVenue(unittest.TestCase): @@ -34,3 +37,32 @@ def test_venue_attributes(self): self.assertTrue(hasattr(self.venue, "timezone")) self.assertTrue(hasattr(self.venue, "field_info")) self.assertTrue(hasattr(self.venue, "active")) + + def test_venue_hydrated_attributes_are_populated(self): + """get_venue requests location, fieldInfo and timezone, so they must arrive. + + hasattr passes on any pydantic model whether or not the value came through, + which is why the hydrate parameter could be sent in a form MLB ignored without + any test noticing. + """ + self.assertIsNotNone(self.venue.location) + self.assertIsNotNone(self.venue.timezone) + self.assertIsNotNone(self.venue.field_info) + self.assertEqual(self.venue.location.city, "Pittsburgh") + self.assertEqual(self.venue.timezone.id, "America/New_York") + + def test_venue_hydrate_is_sent_as_one_comma_delimited_value(self): + """A list becomes repeated hydrate params, which MLB answers without hydrating.""" + captured = {} + + def capture(endpoint, ep_params=None, data=None): + captured.update(ep_params or {}) + return MlbResult(200, "OK", {}) + + with patch.object(self.mlb._mlb_adapter_v1, "get", side_effect=capture): + self.mlb.get_venue(31) + self.assertEqual(captured["hydrate"], "location,fieldInfo,timezone") + + captured.clear() + self.mlb.get_venues() + self.assertEqual(captured["hydrate"], "location,fieldInfo,timezone") diff --git a/tests/fixtures/api_keys.json b/tests/fixtures/api_keys.json new file mode 100644 index 0000000..6eb96db --- /dev/null +++ b/tests/fixtures/api_keys.json @@ -0,0 +1,953 @@ +{ + "recorded_at": "2026-07-31", + "endpoints": [ + "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=season,career,seasonAdvanced,careerAdvanced,yearByYear,gameLog,byDayOfWeek,byMonth,homeAndAway,winLoss,expectedStatistics,sabermetrics,pitchArsenal,hotColdZones&group=hitting&season=2025", + "https://statsapi.mlb.com/api/v1/people/669373/stats?stats=season,career,seasonAdvanced,careerAdvanced,yearByYear,gameLog,pitchArsenal,sabermetrics,expectedStatistics&group=pitching&season=2025", + "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=season,career,seasonAdvanced,yearByYear,gameLog&group=fielding&season=2025", + "https://statsapi.mlb.com/api/v1/people/592663/stats?stats=season,career,yearByYear&group=catching&season=2025", + "https://statsapi.mlb.com/api/v1/people/592663/stats?stats=season,career&group=fielding&season=2025", + "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=outsAboveAverage&group=fielding&season=2025", + "https://statsapi.mlb.com/api/v1/people/110001", + "https://statsapi.mlb.com/api/v1/teams/147/stats?stats=season,seasonAdvanced,career&group=hitting,pitching,fielding&season=2025", + "https://statsapi.mlb.com/api/v1/people/665742?hydrate=stats(group=[hitting],type=[season])", + "https://statsapi.mlb.com/api/v1/teams/147?hydrate=venue,league,division,sport", + "https://statsapi.mlb.com/api/v1/teams/147/roster?rosterType=active", + "https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=2025-07-04&hydrate=linescore,team,venue,decisions,probablePitcher,weather,officials,broadcasts", + "https://statsapi.mlb.com/api/v1/venues/3313?hydrate=location,fieldInfo,timezone", + "https://statsapi.mlb.com/api/v1/standings?leagueId=103&season=2025&standingsTypes=regularSeason", + "https://statsapi.mlb.com/api/v1/draft/2024", + "https://statsapi.mlb.com/api/v1/attendance?teamId=147&season=2025", + "https://statsapi.mlb.com/api/v1/gamePace?season=2025", + "https://statsapi.mlb.com/api/v1/awards/MLBHOF/recipients", + "https://statsapi.mlb.com/api/v1/seasons/2025?sportId=1", + "https://statsapi.mlb.com/api/v1/divisions?sportId=1", + "https://statsapi.mlb.com/api/v1/league?sportId=1", + "https://statsapi.mlb.com/api/v1/sports", + "https://statsapi.mlb.com/api/v1.1/game/775296/feed/live", + "https://statsapi.mlb.com/api/v1.1/game/823837/feed/live", + "https://statsapi.mlb.com/api/v1/game/775296/boxscore", + "https://statsapi.mlb.com/api/v1/game/775296/linescore", + "https://statsapi.mlb.com/api/v1/game/775296/playByPlay", + "https://statsapi.mlb.com/api/v1/homeRunDerby/511101" + ], + "keys": [ + "ID500743", + "ID518489", + "ID518692", + "ID519203", + "ID519317", + "ID543037", + "ID543339", + "ID547180", + "ID554430", + "ID571771", + "ID571970", + "ID573186", + "ID592450", + "ID592454", + "ID592663", + "ID595014", + "ID596117", + "ID596133", + "ID605131", + "ID605141", + "ID605280", + "ID605400", + "ID606192", + "ID607074", + "ID607208", + "ID607455", + "ID621035", + "ID621111", + "ID621237", + "ID624431", + "ID624641", + "ID640459", + "ID641482", + "ID641703", + "ID641835", + "ID643410", + "ID645261", + "ID650402", + "ID650559", + "ID650911", + "ID656427", + "ID656537", + "ID656629", + "ID656848", + "ID656941", + "ID657077", + "ID657376", + "ID657612", + "ID660271", + "ID660813", + "ID661395", + "ID661563", + "ID662144", + "ID663757", + "ID663767", + "ID663969", + "ID664126", + "ID664761", + "ID664776", + "ID665052", + "ID665742", + "ID665828", + "ID665862", + "ID665923", + "ID666158", + "ID666200", + "ID667463", + "ID669016", + "ID669065", + "ID669199", + "ID669224", + "ID669242", + "ID669257", + "ID669364", + "ID672640", + "ID676083", + "ID676508", + "ID676534", + "ID676604", + "ID677870", + "ID679775", + "ID680742", + "ID681082", + "ID681624", + "ID681715", + "ID681911", + "ID683011", + "ID686751", + "ID687282", + "ID687473", + "ID688363", + "ID689017", + "ID689147", + "ID689414", + "ID690978", + "ID691176", + "ID691587", + "ID691594", + "ID691788", + "ID702222", + "ID805300", + "ID806188", + "ID808967", + "aX", + "aY", + "aZ", + "abbreviation", + "about", + "absChallenges", + "abstractGameCode", + "abstractGameState", + "actionIndex", + "actionPlayId", + "active", + "activeStatus", + "address1", + "age", + "aggregateTotals", + "airOuts", + "alerts", + "allPlays", + "allPositions", + "allStarDate", + "allStarStatus", + "ampm", + "assists", + "atBatIndex", + "atBats", + "atBatsPerHomeRun", + "attempts", + "attendance", + "attendanceAverageAway", + "attendanceAverageHome", + "attendanceAverageYtd", + "attendanceHigh", + "attendanceHighDate", + "attendanceHighGame", + "attendanceLow", + "attendanceLowDate", + "attendanceLowGame", + "attendanceOpeningAverage", + "attendanceTotal", + "attendanceTotalAway", + "attendanceTotalHome", + "availability", + "availabilityCode", + "availabilityId", + "availabilityText", + "availableForStreaming", + "averageSpeed", + "averageValue", + "avg", + "awards", + "away", + "awayScore", + "awayTeamNoHitter", + "awayTeamPerfectGame", + "azimuthAngle", + "babip", + "balks", + "ballColor", + "balls", + "ballsInPlay", + "base", + "baseOnBalls", + "baseOnBallsPer9", + "baseRunning", + "batSide", + "batter", + "batterHotColdZoneStats", + "batterHotColdZones", + "batters", + "battersFaced", + "battersFacedPerGame", + "batting", + "battingOrder", + "bench", + "bequeathedRunners", + "bequeathedRunnersScored", + "birthCity", + "birthCountry", + "birthDate", + "birthStateProvince", + "bisPlayerId", + "blownSaves", + "blurb", + "bonusTime", + "bottom", + "bottomSeed", + "boxscore", + "boxscoreName", + "breakAngle", + "breakHorizontal", + "breakLength", + "breakVertical", + "breakVerticalInduced", + "breakY", + "breaks", + "broadcastDate", + "broadcasts", + "bullpen", + "buntsFailed", + "buntsMissedTipped", + "calendarEventID", + "call", + "callSign", + "capacity", + "captivatingIndex", + "catcher", + "catcherERA", + "catchersInterference", + "caughtStealing", + "caughtStealingPercentage", + "center", + "challengeTeamId", + "chances", + "city", + "clinchIndicator", + "clinched", + "clockStopped", + "clubName", + "code", + "codedGameState", + "color", + "colorSpace", + "colorSpaceFull", + "complete", + "completeGames", + "condition", + "conferenceGamesBack", + "conferencesInUse", + "content", + "coordX", + "coordY", + "coordinates", + "copyright", + "count", + "country", + "credit", + "credits", + "currentAge", + "currentInning", + "currentInningOrdinal", + "currentPlay", + "currentRound", + "currentRoundTimeLeft", + "currentTeam", + "date", + "dateTime", + "dates", + "datetime", + "dayNight", + "dayOfWeek", + "deathCity", + "deathCountry", + "deathDate", + "deathStateProvince", + "decisions", + "defaultCoordinates", + "defense", + "description", + "detailedState", + "details", + "disengagementNum", + "displayName", + "displayPickNumber", + "division", + "divisionChamp", + "divisionGamesBack", + "divisionLeader", + "divisionRank", + "divisionRecords", + "divisions", + "divisionsInUse", + "doubleHeader", + "doublePlays", + "doubles", + "draftType", + "draftYear", + "drafts", + "earned", + "earnedRuns", + "elevation", + "eliminationNumber", + "eliminationNumberConference", + "eliminationNumberDivision", + "eliminationNumberLeague", + "eliminationNumberSport", + "end", + "endIndex", + "endSpeed", + "endTime", + "era", + "eraMinus", + "errors", + "event", + "eventDate", + "eventNumber", + "eventType", + "events", + "exemptions", + "exli", + "expectedRecords", + "extension", + "extraBaseHits", + "fieldInfo", + "fieldList", + "fielding", + "fieldingRunsPrevented", + "fieldingRunsPreventedUnrounded", + "fileCode", + "fip", + "fipMinus", + "first", + "firstDate2ndHalf", + "firstLastName", + "firstName", + "firstPitch", + "firstYearOfPlay", + "flags", + "flyBallPercentage", + "flyHits", + "flyOuts", + "franchiseName", + "freeGame", + "freeGameStatus", + "freeGameType", + "freeGameTypeCode", + "freeGameTypeDesc", + "freeGameTypeId", + "freeGameTypeText", + "freeGameTypes", + "fromCatcher", + "fullFMLName", + "fullLFMName", + "fullName", + "game", + "gameData", + "gameDate", + "gameDateBroadcastGuid", + "gameDurationMinutes", + "gameEvents", + "gameGuid", + "gameInfo", + "gameLevelGamedayType", + "gameNumber", + "gamePk", + "gameScore", + "gameStatus", + "gameType", + "gamedayType", + "games", + "gamesAwayTotal", + "gamesBack", + "gamesFinished", + "gamesHomeTotal", + "gamesInSeries", + "gamesPitched", + "gamesPlayed", + "gamesStarted", + "gamesTotal", + "gender", + "gidp", + "gidpOpp", + "gidpPercentage", + "gmli", + "groundHits", + "groundIntoDoublePlay", + "groundIntoTriplePlay", + "groundOuts", + "groundOutsToAirouts", + "group", + "halfInning", + "hardness", + "hasChallenges", + "hasOut", + "hasPlayoffPoints", + "hasReview", + "hasSplitSeason", + "hasWildCard", + "hasWildcard", + "headshotLink", + "height", + "hitBatsmen", + "hitByPitch", + "hitData", + "hitDistance", + "hitSpeed", + "hits", + "hitsPer9", + "hitsPer9Inn", + "hitsPerGame", + "hitsPerRun", + "hittingGameScore", + "holds", + "home", + "homeAway", + "homeRun", + "homeRuns", + "homeRunsPer9", + "homeRunsPerPlateAppearance", + "homeScore", + "homeTeamNoHitter", + "homeTeamPerfectGame", + "id", + "ifNecessary", + "ifNecessaryDescription", + "inHole", + "inProgress", + "inTieBreaker", + "index", + "info", + "inheritedRunners", + "inheritedRunnersScored", + "initLastName", + "inli", + "inning", + "inningBreakLength", + "inningHalf", + "inningState", + "innings", + "inningsPitched", + "inningsPitchedPerGame", + "inningsPlayedPerGame", + "intentionalWalks", + "isBall", + "isBaseRunningPlay", + "isBonusTime", + "isComplete", + "isCurrentBatter", + "isCurrentPitcher", + "isDrafted", + "isHome", + "isHomeRun", + "isInPlay", + "isMultiDay", + "isNational", + "isOnBench", + "isOut", + "isOverturned", + "isPass", + "isPitch", + "isPlayer", + "isPrimaryCalendar", + "isScoringEvent", + "isScoringPlay", + "isStarted", + "isStrike", + "isSubstitute", + "isSubstitution", + "isTie", + "isTieBreaker", + "isTopInning", + "isVerified", + "isWin", + "isWinner", + "iso", + "jerseyNumber", + "label", + "landingPosX", + "landingPosY", + "language", + "lastDate1stHalf", + "lastFirstName", + "lastInitName", + "lastName", + "lastPlayedDate", + "lastUpdated", + "latitude", + "launchAngle", + "launchSpeed", + "leaders", + "league", + "leagueGamesBack", + "leagueRank", + "leagueRecord", + "leagueRecords", + "leagues", + "left", + "leftCenter", + "leftLine", + "leftOnBase", + "lineHits", + "lineOuts", + "linescore", + "link", + "liveData", + "location", + "locationName", + "logicalEvents", + "longitude", + "loser", + "losses", + "magicNumber", + "matchup", + "matchups", + "maxValue", + "md", + "measuredTimeInterval", + "mediaId", + "mediaState", + "mediaStateCode", + "mediaStateId", + "mediaStateText", + "menOnBase", + "metaData", + "metric", + "metricId", + "middleName", + "minValue", + "mlbDebutDate", + "month", + "moundVisits", + "movement", + "movementReason", + "mvpdAuthRequired", + "name", + "nameFirstLast", + "nameMatrilineal", + "nameShort", + "nameSlug", + "nameSuffix", + "nameTitle", + "nickName", + "noHitter", + "nonGameGuid", + "note", + "notes", + "num", + "numGames", + "numHomeRuns", + "numLeagues", + "numOccurrences", + "numPlayoffTeams", + "numPoints", + "numTeams", + "numWildcardTeams", + "numberOfPitches", + "obp", + "offSeasonEndDate", + "offense", + "official", + "officialDate", + "officialScorer", + "officialType", + "officialVenue", + "officials", + "offseasonStartDate", + "offset", + "offsetAtGameTime", + "onDeck", + "openingsTotal", + "openingsTotalAway", + "openingsTotalHome", + "openingsTotalLost", + "openingsTotalYtd", + "opponent", + "ops", + "order", + "ordinalNum", + "orgCode", + "originBase", + "originalDate", + "outBase", + "outNumber", + "outs", + "outsAboveAverageBackLeft", + "outsAboveAverageBackLeftUnrounded", + "outsAboveAverageBackRight", + "outsAboveAverageBackRightUnrounded", + "outsAboveAverageBackStraight", + "outsAboveAverageBackStraightUnrounded", + "outsAboveAverageInLeft", + "outsAboveAverageInLeftUnrounded", + "outsAboveAverageInRight", + "outsAboveAverageInRightUnrounded", + "outsAboveAverageInStraight", + "outsAboveAverageInStraightUnrounded", + "overallRecords", + "pX", + "pZ", + "parentOrgId", + "parentOrgName", + "parentTeamId", + "passedBall", + "pct", + "people", + "percentage", + "perfectGame", + "person", + "pfxX", + "pfxZ", + "phone", + "pickNumber", + "pickRound", + "pickValue", + "pickoffAttempts", + "pickoffs", + "picks", + "pitchData", + "pitchHand", + "pitchIndex", + "pitchNumber", + "pitchSpeed", + "pitcher", + "pitcherHotColdZones", + "pitchers", + "pitchersPerGame", + "pitchesPer9Inn", + "pitchesPerGame", + "pitchesPerInning", + "pitchesPerPitcher", + "pitchesPerPlateAppearance", + "pitchesThrown", + "pitching", + "pitchingGameScore", + "pitchingNotes", + "pk", + "plateAppearances", + "plateAppearancesPer9Inn", + "plateAppearancesPerGame", + "plateTime", + "playEndTime", + "playEvents", + "playId", + "playIndex", + "player", + "players", + "plays", + "playsByInning", + "pli", + "points", + "popHits", + "popOuts", + "position", + "positional", + "positionsPlayed", + "postGameShow", + "postOnFirst", + "postOnSecond", + "postOnThird", + "postSeasonEndDate", + "postSeasonStartDate", + "postalCode", + "prPortalCalculatedFields", + "preGameShow", + "preSeasonEndDate", + "preSeasonStartDate", + "primaryDatacaster", + "primaryNumber", + "primaryPosition", + "probablePitcher", + "probablePitchers", + "pronunciation", + "publicFacing", + "putOuts", + "qualifierOutsPitched", + "qualifierPlateAppearances", + "qualityStarts", + "ra9War", + "rangeFactorPer9Inn", + "rangeFactorPerGame", + "rank", + "rar", + "rbi", + "reachedOnError", + "record", + "recordSource", + "records", + "regularSeasonEndDate", + "regularSeasonStartDate", + "remaining", + "replacedPlayer", + "replacement", + "resolutionFull", + "resolutionShort", + "responsiblePitcher", + "result", + "reverseHomeAwayStatus", + "review", + "reviewDetails", + "reviewType", + "right", + "rightCenter", + "rightLine", + "roofType", + "roster", + "rosterType", + "round", + "roundPickNumber", + "roundRobin", + "rounds", + "runDifferential", + "runSupport", + "runner", + "runnerGoing", + "runnerIndex", + "runners", + "runs", + "runsAllowed", + "runsPer9Inn", + "runsPerGame", + "runsScored", + "runsScoredPer9", + "sacBunts", + "sacFlies", + "save", + "saveOpportunities", + "saves", + "scheduledInnings", + "school", + "schoolClass", + "score", + "scoringPlays", + "scoutingReport", + "sd", + "season", + "seasonDateInfo", + "seasonDisplay", + "seasonEndDate", + "seasonId", + "seasonLevelGamedayType", + "seasonStartDate", + "seasonState", + "seasonStats", + "seasons", + "second", + "seed", + "seriesDescription", + "seriesGameNumber", + "seriesNumber", + "shortName", + "shortstop", + "shutouts", + "signingBonus", + "slg", + "sortOrder", + "spd", + "spinDirection", + "spinRate", + "splitRecords", + "splitSquad", + "splits", + "sport", + "sportGamesBack", + "sportRank", + "sports", + "springEndDate", + "springLeague", + "springLeagueGamesBack", + "springStartDate", + "springVenue", + "standingsType", + "start", + "startIndex", + "startSpeed", + "startTime", + "startTimeTBD", + "started", + "stat", + "state", + "stateAbbrev", + "stats", + "status", + "statusCode", + "stolenBasePercentage", + "stolenBases", + "streak", + "streakCode", + "streakNumber", + "streakType", + "strikeOuts", + "strikePercentage", + "strikeZoneBottom", + "strikeZoneDepth", + "strikeZoneTop", + "strikeZoneWidth", + "strikeoutWalkRatio", + "strikeoutsMinusWalksPercentage", + "strikeoutsPer9", + "strikeoutsPer9Inn", + "strikeoutsPerPlateAppearance", + "strikes", + "strikesoutsToWalks", + "summary", + "swingAndMisses", + "team", + "teamCode", + "teamId", + "teamName", + "teamRecords", + "teamStats", + "teamUnearned", + "teams", + "temp", + "third", + "throwingErrors", + "tieBreaker", + "tieBreakerNum", + "tiebreaker", + "ties", + "time", + "timePer77PlateAppearances", + "timePer7InnGame", + "timePer7InnGameWithoutExtraInn", + "timePer9Inn", + "timePer9InnGame", + "timePerExtraInnGame", + "timePerGame", + "timePerHit", + "timePerPitch", + "timePerPlateAppearance", + "timePerRun", + "timeRemaining", + "timeRemainingSeconds", + "timeStamp", + "timeZone", + "title", + "top", + "topDerbyHitData", + "topPerformers", + "topSeed", + "total7InnGames", + "total7InnGamesCompletedEarly", + "total7InnGamesScheduled", + "total7InnGamesWithoutExtraInn", + "total9InnGames", + "total9InnGamesCompletedEarly", + "total9InnGamesScheduled", + "total9InnGamesWithoutExtraInn", + "totalBases", + "totalDistance", + "totalEvents", + "totalExtraInnGames", + "totalExtraInnTime", + "totalGameTime", + "totalGames", + "totalGamesInProgress", + "totalHits", + "totalInningsPlayed", + "totalItems", + "totalOutsAboveAverage", + "totalOutsAboveAverageBack", + "totalOutsAboveAverageBackUnrounded", + "totalOutsAboveAverageIn", + "totalOutsAboveAverageInUnrounded", + "totalOutsAboveAverageUnrounded", + "totalPitchers", + "totalPitches", + "totalPlateAppearances", + "totalRuns", + "totalSwings", + "trailColor", + "trajectory", + "trajectoryData", + "trajectoryPolynomialX", + "trajectoryPolynomialY", + "trajectoryPolynomialZ", + "triplePlays", + "triples", + "turfType", + "type", + "typeConfidence", + "tz", + "ubr", + "unit", + "useLastName", + "useName", + "used", + "usedFailed", + "usedSuccessful", + "vX0", + "vY0", + "vZ0", + "validTimeInterval", + "value", + "venue", + "venues", + "videoResolution", + "violation", + "votes", + "wGdp", + "wLeague", + "wRaa", + "wRc", + "wRcPlus", + "wSb", + "wait", + "walkOffs", + "walksPer9Inn", + "walksPerPlateAppearance", + "walksPerStrikeout", + "war", + "weather", + "weight", + "whiffPercentage", + "whip", + "wildCardEliminationNumber", + "wildCardGamesBack", + "wildCardLeader", + "wildCardRank", + "wildPitches", + "winPercentage", + "wind", + "winner", + "winningPercentage", + "wins", + "woba", + "wobaCon", + "x", + "x0", + "xfip", + "y", + "y0", + "year", + "z0", + "zone", + "zones" + ] +} diff --git a/tests/fixtures/model_payloads.json b/tests/fixtures/model_payloads.json new file mode 100644 index 0000000..987dd69 --- /dev/null +++ b/tests/fixtures/model_payloads.json @@ -0,0 +1,927 @@ +{ + "recorded_at": "2026-07-31", + "specs": { + "hitting_season": { + "model": "mlbstatsapi.models.stats.hitting:SimpleHittingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=season&group=hitting&season=2025" + }, + "hitting_season_advanced": { + "model": "mlbstatsapi.models.stats.hitting:AdvancedHittingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=seasonAdvanced&group=hitting&season=2025" + }, + "pitching_season": { + "model": "mlbstatsapi.models.stats.pitching:SimplePitchingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/669373/stats?stats=season&group=pitching&season=2025" + }, + "pitching_season_advanced": { + "model": "mlbstatsapi.models.stats.pitching:AdvancedPitchingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/669373/stats?stats=seasonAdvanced&group=pitching&season=2025" + }, + "fielding_season": { + "model": "mlbstatsapi.models.stats.fielding:SimpleFieldingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=season&group=fielding&season=2025" + }, + "fielding_season_catcher": { + "model": "mlbstatsapi.models.stats.fielding:SimpleFieldingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/592663/stats?stats=season&group=fielding&season=2025" + }, + "outs_above_average": { + "model": "mlbstatsapi.models.stats.stats:OutsAboveAverage", + "url": "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=outsAboveAverage&group=fielding&season=2025" + }, + "catching_season": { + "model": "mlbstatsapi.models.stats.catching:SimpleCatchingSplit", + "url": "https://statsapi.mlb.com/api/v1/people/592663/stats?stats=season&group=catching&season=2025" + }, + "hitting_sabermetrics": { + "model": "mlbstatsapi.models.stats.stats:Sabermetrics", + "url": "https://statsapi.mlb.com/api/v1/people/665742/stats?stats=sabermetrics&group=hitting&season=2025" + }, + "person": { + "model": "mlbstatsapi.models.people.people:Person", + "url": "https://statsapi.mlb.com/api/v1/people/665742" + }, + "person_deceased": { + "model": "mlbstatsapi.models.people.people:Person", + "url": "https://statsapi.mlb.com/api/v1/people/110001" + }, + "abs_challenge_info": { + "model": "mlbstatsapi.models.game.gamedata.attributes:AbsChallengeInfo", + "url": "https://statsapi.mlb.com/api/v1.1/game/823837/feed/live" + }, + "season": { + "model": "mlbstatsapi.models.seasons.season:Season", + "url": "https://statsapi.mlb.com/api/v1/seasons/2025?sportId=1" + }, + "venue": { + "model": "mlbstatsapi.models.venues.venue:Venue", + "url": "https://statsapi.mlb.com/api/v1/venues/3313?hydrate=location,fieldInfo,timezone" + }, + "team_records": { + "model": "mlbstatsapi.models.standings.attributes:TeamRecords", + "url": "https://statsapi.mlb.com/api/v1/standings?leagueId=103&season=2025&standingsTypes=regularSeason" + }, + "schedule_game": { + "model": "mlbstatsapi.models.schedules.attributes:ScheduleGames", + "url": "https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=2025-07-04" + }, + "game_status": { + "model": "mlbstatsapi.models.game.gamedata.attributes:GameStatus", + "url": "https://statsapi.mlb.com/api/v1.1/game/775296/feed/live" + }, + "game_data_game": { + "model": "mlbstatsapi.models.game.gamedata.attributes:GameDataGame", + "url": "https://statsapi.mlb.com/api/v1.1/game/775296/feed/live" + }, + "pitch_coordinates": { + "model": "mlbstatsapi.models.data.data:PitchCoordinates", + "url": "https://statsapi.mlb.com/api/v1/game/775296/playByPlay" + } + }, + "payloads": { + "hitting_season": { + "age": 26, + "gamesPlayed": 160, + "groundOuts": 156, + "airOuts": 139, + "runs": 120, + "doubles": 20, + "triples": 1, + "homeRuns": 43, + "strikeOuts": 137, + "baseOnBalls": 127, + "intentionalWalks": 13, + "hits": 152, + "hitByPitch": 3, + "avg": ".263", + "atBats": 577, + "obp": ".396", + "slg": ".525", + "ops": ".921", + "caughtStealing": 4, + "stolenBases": 38, + "stolenBasePercentage": ".905", + "caughtStealingPercentage": ".095", + "groundIntoDoublePlay": 17, + "numberOfPitches": 2968, + "plateAppearances": 715, + "totalBases": 303, + "rbi": 105, + "leftOnBase": 248, + "sacBunts": 1, + "sacFlies": 6, + "babip": ".270", + "groundOutsToAirouts": "1.12", + "catchersInterference": 1, + "atBatsPerHomeRun": "13.42" + }, + "hitting_season_advanced": { + "age": 26, + "plateAppearances": 715, + "totalBases": 303, + "leftOnBase": 248, + "sacBunts": 1, + "sacFlies": 6, + "babip": ".270", + "extraBaseHits": 64, + "hitByPitch": 3, + "gidp": 17, + "gidpOpp": 92, + "numberOfPitches": 2968, + "pitchesPerPlateAppearance": "4.151", + "walksPerPlateAppearance": ".178", + "strikeoutsPerPlateAppearance": ".192", + "homeRunsPerPlateAppearance": ".060", + "walksPerStrikeout": ".927", + "iso": ".262", + "reachedOnError": 7, + "walkOffs": 0, + "flyOuts": 81, + "totalSwings": 1047, + "swingAndMisses": 233, + "ballsInPlay": 447, + "popOuts": 29, + "lineOuts": 29, + "groundOuts": 156, + "flyHits": 42, + "popHits": 0, + "lineHits": 64, + "groundHits": 46 + }, + "pitching_season": { + "age": 28, + "gamesPlayed": 31, + "gamesStarted": 31, + "groundOuts": 160, + "airOuts": 168, + "runs": 55, + "doubles": 26, + "triples": 2, + "homeRuns": 18, + "strikeOuts": 241, + "baseOnBalls": 33, + "intentionalWalks": 0, + "hits": 141, + "hitByPitch": 5, + "avg": ".200", + "atBats": 706, + "obp": ".240", + "slg": ".319", + "ops": ".559", + "caughtStealing": 4, + "stolenBases": 3, + "stolenBasePercentage": ".429", + "caughtStealingPercentage": ".571", + "groundIntoDoublePlay": 10, + "numberOfPitches": 2849, + "era": "2.21", + "inningsPitched": "195.1", + "wins": 13, + "losses": 6, + "saves": 0, + "saveOpportunities": 0, + "holds": 0, + "blownSaves": 0, + "earnedRuns": 48, + "whip": "0.89", + "battersFaced": 748, + "outs": 586, + "gamesPitched": 31, + "completeGames": 1, + "shutouts": 1, + "strikes": 2003, + "strikePercentage": ".700", + "hitBatsmen": 5, + "balks": 2, + "wildPitches": 3, + "pickoffs": 1, + "totalBases": 225, + "groundOutsToAirouts": "0.95", + "winPercentage": ".684", + "pitchesPerInning": "14.59", + "gamesFinished": 0, + "strikeoutWalkRatio": "7.30", + "strikeoutsPer9Inn": "11.10", + "walksPer9Inn": "1.52", + "hitsPer9Inn": "6.50", + "runsScoredPer9": "2.53", + "homeRunsPer9": "0.83", + "inheritedRunners": 0, + "inheritedRunnersScored": 0, + "catchersInterference": 0, + "sacBunts": 2, + "sacFlies": 2 + }, + "pitching_season_advanced": { + "age": 28, + "winningPercentage": ".684", + "runsScoredPer9": "2.53", + "battersFaced": 748, + "babip": ".274", + "obp": ".240", + "slg": ".319", + "ops": ".559", + "strikeoutsPer9": "11.10", + "baseOnBallsPer9": "1.52", + "homeRunsPer9": "0.83", + "hitsPer9": "6.50", + "strikesoutsToWalks": "7.30", + "inheritedRunners": 0, + "inheritedRunnersScored": 0, + "bequeathedRunners": 12, + "bequeathedRunnersScored": 4, + "stolenBases": 3, + "caughtStealing": 4, + "qualityStarts": 21, + "gamesFinished": 0, + "doubles": 26, + "triples": 2, + "gidp": 10, + "gidpOpp": 59, + "wildPitches": 3, + "balks": 2, + "pickoffs": 1, + "totalSwings": 1552, + "swingAndMisses": 508, + "strikeoutsMinusWalksPercentage": ".278", + "gidpPercentage": ".169", + "battersFacedPerGame": "24.13", + "inningsPitchedPerGame": "6.30", + "buntsFailed": 9, + "buntsMissedTipped": 0, + "whiffPercentage": ".325", + "flyBallPercentage": ".273", + "ballsInPlay": 469, + "runSupport": 100, + "strikePercentage": ".700", + "pitchesPerInning": "14.59", + "pitchesPerPlateAppearance": "3.809", + "walksPerPlateAppearance": ".044", + "strikeoutsPerPlateAppearance": ".322", + "homeRunsPerPlateAppearance": ".024", + "walksPerStrikeout": ".137", + "iso": ".119", + "flyOuts": 96, + "popOuts": 36, + "lineOuts": 36, + "groundOuts": 160, + "flyHits": 32, + "popHits": 0, + "lineHits": 74, + "groundHits": 35 + }, + "fielding_season": { + "age": 26, + "gamesPlayed": 157, + "gamesStarted": 157, + "assists": 8, + "putOuts": 249, + "errors": 1, + "chances": 258, + "fielding": ".996", + "position": { + "code": "9", + "name": "Outfielder", + "type": "Outfielder", + "abbreviation": "RF" + }, + "rangeFactorPerGame": "1.64", + "rangeFactorPer9Inn": "1.68", + "innings": "1374.0", + "games": 157, + "doublePlays": 1, + "triplePlays": 0, + "throwingErrors": 0 + }, + "fielding_season_catcher": { + "age": 34, + "gamesPlayed": 132, + "gamesStarted": 132, + "caughtStealing": 30, + "stolenBases": 71, + "stolenBasePercentage": ".703", + "caughtStealingPercentage": ".297", + "assists": 37, + "putOuts": 1216, + "errors": 6, + "chances": 1259, + "fielding": ".995", + "position": { + "code": "2", + "name": "Catcher", + "type": "Catcher", + "abbreviation": "C" + }, + "rangeFactorPerGame": "9.49", + "rangeFactorPer9Inn": "9.80", + "innings": "1151.1", + "games": 132, + "passedBall": 2, + "doublePlays": 10, + "triplePlays": 0, + "catcherERA": "3.76", + "catchersInterference": 0, + "wildPitches": 31, + "throwingErrors": 5, + "pickoffs": 0 + }, + "outs_above_average": { + "stat": { + "attempts": 221, + "totalOutsAboveAverage": 18, + "totalOutsAboveAverageUnrounded": 18.2073, + "totalOutsAboveAverageBack": 8, + "totalOutsAboveAverageBackUnrounded": 8.2395, + "outsAboveAverageBackStraight": 2, + "outsAboveAverageBackStraightUnrounded": 2.2066, + "outsAboveAverageBackLeft": 6, + "outsAboveAverageBackLeftUnrounded": 5.8617, + "outsAboveAverageBackRight": 2, + "outsAboveAverageBackRightUnrounded": 2.3779, + "totalOutsAboveAverageIn": 9, + "totalOutsAboveAverageInUnrounded": 9.2358, + "outsAboveAverageInStraight": 3, + "outsAboveAverageInStraightUnrounded": 2.5209, + "outsAboveAverageInLeft": 3, + "outsAboveAverageInLeftUnrounded": 3.1752, + "outsAboveAverageInRight": 4, + "outsAboveAverageInRightUnrounded": 3.5397, + "fieldingRunsPrevented": 16, + "fieldingRunsPreventedUnrounded": 16.3866 + }, + "player": { + "id": 665742, + "fullName": "Juan Soto", + "link": "/api/v1/people/665742" + }, + "gameType": "R" + }, + "catching_season": { + "age": 34, + "gamesPlayed": 132, + "runs": 512, + "homeRuns": 149, + "strikeOuts": 1197, + "baseOnBalls": 356, + "intentionalWalks": 8, + "hits": 1043, + "hitByPitch": 36, + "avg": ".239", + "atBats": 4363, + "obp": ".300", + "slg": ".393", + "ops": ".693", + "caughtStealing": 30, + "stolenBases": 71, + "stolenBasePercentage": ".703", + "caughtStealingPercentage": ".297", + "earnedRuns": 481, + "battersFaced": 4794, + "gamesPitched": 132, + "hitBatsmen": 36, + "wildPitches": 31, + "pickoffs": 0, + "totalBases": 1714, + "strikeoutWalkRatio": "3.36", + "catchersInterference": 0, + "sacBunts": 14, + "sacFlies": 25, + "passedBall": 2, + "pickoffAttempts": 3 + }, + "hitting_sabermetrics": { + "woba": 0.389828, + "wRaa": 44.5678, + "wRc": 129.051, + "wRcPlus": 155.931, + "rar": 56.6263, + "war": 5.79358, + "batting": 47.3973, + "fielding": -9.83266, + "baseRunning": 2.67478, + "positional": -7.39197, + "wLeague": 2.00282, + "replacement": 21.776, + "spd": 5.62199, + "ubr": 0.0, + "wGdp": 0.0, + "wSb": 4.60859 + }, + "person": { + "id": 665742, + "fullName": "Juan Soto", + "link": "/api/v1/people/665742", + "firstName": "Juan", + "lastName": "Soto", + "primaryNumber": "22", + "birthDate": "1998-10-25", + "currentAge": 27, + "birthCity": "Santo Domingo", + "birthCountry": "Dominican Republic", + "height": "6' 1\"", + "weight": 224, + "active": true, + "primaryPosition": { + "code": "7", + "name": "Outfielder", + "type": "Outfielder", + "abbreviation": "LF" + }, + "useName": "Juan", + "useLastName": "Soto", + "middleName": "Jose", + "boxscoreName": "Soto, J", + "nickName": "Soto Pacheco", + "gender": "M", + "nameMatrilineal": "Pacheco", + "isPlayer": true, + "isVerified": true, + "mlbDebutDate": "2018-05-15", + "batSide": { + "code": "L", + "description": "Left" + }, + "pitchHand": { + "code": "L", + "description": "Left" + }, + "nameFirstLast": "Juan Soto", + "nameSlug": "juan-soto-665742", + "firstLastName": "Juan Soto", + "lastFirstName": "Soto, Juan", + "lastInitName": "Soto, J", + "initLastName": "J Soto", + "fullFMLName": "Juan Jose Soto", + "fullLFMName": "Soto, Juan Jose", + "strikeZoneTop": 3.259, + "strikeZoneBottom": 1.645 + }, + "person_deceased": { + "id": 110001, + "fullName": "Hank Aaron", + "link": "/api/v1/people/110001", + "firstName": "Henry", + "lastName": "Aaron", + "primaryNumber": "44", + "birthDate": "1934-02-05", + "currentAge": 86, + "birthCity": "Mobile", + "birthStateProvince": "AL", + "birthCountry": "USA", + "height": "6' 0\"", + "weight": 180, + "active": false, + "primaryPosition": { + "code": "9", + "name": "Outfielder", + "type": "Outfielder", + "abbreviation": "RF" + }, + "useName": "Hank", + "useLastName": "Aaron", + "middleName": "Louis", + "boxscoreName": "Aaron", + "nickName": "Hammerin' Hank", + "gender": "M", + "isPlayer": true, + "isVerified": true, + "deathDate": "2021-01-22", + "deathCity": "Atlanta", + "deathStateProvince": "GA", + "deathCountry": "USA", + "lastPlayedDate": "1976-10-03", + "mlbDebutDate": "1954-04-13", + "batSide": { + "code": "R", + "description": "Right" + }, + "pitchHand": { + "code": "R", + "description": "Right" + }, + "nameFirstLast": "Hank Aaron", + "nameSlug": "hank-aaron-110001", + "firstLastName": "Hank Aaron", + "lastFirstName": "Aaron, Hank", + "lastInitName": "Aaron, H", + "initLastName": "H Aaron", + "fullFMLName": "Henry Louis Aaron", + "fullLFMName": "Aaron, Henry Louis", + "strikeZoneTop": 3.21, + "strikeZoneBottom": 1.62 + }, + "abs_challenge_info": { + "usedSuccessful": 0, + "usedFailed": 0, + "remaining": 2 + }, + "season": { + "seasonId": "2025", + "hasWildcard": true, + "preSeasonStartDate": "2025-01-01", + "preSeasonEndDate": "2025-02-19", + "seasonStartDate": "2025-02-20", + "springStartDate": "2025-02-20", + "springEndDate": "2025-03-25", + "regularSeasonStartDate": "2025-03-18", + "lastDate1stHalf": "2025-07-14", + "allStarDate": "2025-07-15", + "firstDate2ndHalf": "2025-07-18", + "regularSeasonEndDate": "2025-09-28", + "postSeasonStartDate": "2025-09-30", + "postSeasonEndDate": "2025-11-01", + "seasonEndDate": "2025-11-01", + "offseasonStartDate": "2025-11-02", + "offSeasonEndDate": "2025-12-31", + "seasonLevelGamedayType": "P", + "gameLevelGamedayType": "P", + "qualifierPlateAppearances": 3.1, + "qualifierOutsPitched": 3.0 + }, + "venue": { + "id": 3313, + "name": "Yankee Stadium", + "link": "/api/v1/venues/3313", + "location": { + "address1": "One East 161st Street", + "city": "Bronx", + "state": "New York", + "stateAbbrev": "NY", + "postalCode": "10451", + "defaultCoordinates": { + "latitude": 40.82919482, + "longitude": -73.9264977 + }, + "azimuthAngle": 75.0, + "elevation": 55, + "country": "USA", + "phone": "(718) 293-4300" + }, + "timeZone": { + "tz": "EDT", + "id": "America/New_York", + "offset": -4, + "offsetAtGameTime": -4 + }, + "fieldInfo": { + "capacity": 47309, + "turfType": "Grass", + "roofType": "Open", + "leftLine": 318, + "leftCenter": 399, + "center": 408, + "rightCenter": 385, + "rightLine": 314 + }, + "active": true, + "season": "2026" + }, + "team_records": { + "team": { + "id": 141, + "name": "Blue Jays", + "link": "/api/v1/teams/141" + }, + "season": "2025", + "streak": { + "streakCode": "W4", + "streakType": "wins", + "streakNumber": 4 + }, + "clinchIndicator": "z", + "divisionRank": "1", + "leagueRank": "1", + "sportRank": "3", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "-", + "springLeagueGamesBack": "-", + "sportGamesBack": "-", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": { + "wins": 94, + "losses": 68, + "ties": 0, + "pct": ".580" + }, + "lastUpdated": "2026-01-21T00:09:57Z", + "records": { + "splitRecords": [ + { + "wins": 54, + "losses": 27, + "type": "home", + "pct": ".667" + }, + { + "wins": 40, + "losses": 41, + "type": "away", + "pct": ".494" + }, + { + "wins": 24, + "losses": 16, + "type": "left", + "pct": ".600" + }, + { + "wins": 18, + "losses": 3, + "type": "leftHome", + "pct": ".857" + }, + { + "wins": 6, + "losses": 13, + "type": "leftAway", + "pct": ".316" + }, + { + "wins": 36, + "losses": 24, + "type": "rightHome", + "pct": ".600" + }, + { + "wins": 34, + "losses": 28, + "type": "rightAway", + "pct": ".548" + }, + { + "wins": 70, + "losses": 52, + "type": "right", + "pct": ".574" + }, + { + "wins": 5, + "losses": 5, + "type": "lastTen", + "pct": ".500" + }, + { + "wins": 10, + "losses": 4, + "type": "extraInning", + "pct": ".714" + }, + { + "wins": 27, + "losses": 20, + "type": "oneRun", + "pct": ".574" + }, + { + "wins": 49, + "losses": 41, + "type": "winners", + "pct": ".544" + }, + { + "wins": 35, + "losses": 33, + "type": "day", + "pct": ".515" + }, + { + "wins": 59, + "losses": 35, + "type": "night", + "pct": ".628" + }, + { + "wins": 36, + "losses": 39, + "type": "grass", + "pct": ".480" + }, + { + "wins": 58, + "losses": 29, + "type": "turf", + "pct": ".667" + } + ], + "divisionRecords": [ + { + "wins": 19, + "losses": 12, + "pct": ".613", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200" + } + }, + { + "wins": 29, + "losses": 23, + "pct": ".558", + "division": { + "id": 201, + "name": "American League East", + "link": "/api/v1/divisions/201" + } + }, + { + "wins": 16, + "losses": 15, + "pct": ".516", + "division": { + "id": 202, + "name": "American League Central", + "link": "/api/v1/divisions/202" + } + } + ], + "overallRecords": [ + { + "wins": 54, + "losses": 27, + "type": "home", + "pct": ".667" + }, + { + "wins": 40, + "losses": 41, + "type": "away", + "pct": ".494" + } + ], + "leagueRecords": [ + { + "wins": 64, + "losses": 50, + "pct": ".561", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103" + } + }, + { + "wins": 30, + "losses": 18, + "pct": ".625", + "league": { + "id": 104, + "name": "National League", + "link": "/api/v1/league/104" + } + } + ], + "expectedRecords": [ + { + "wins": 88, + "losses": 74, + "type": "xWinLoss", + "pct": ".543" + }, + { + "wins": 88, + "losses": 74, + "type": "xWinLossSeason", + "pct": ".543" + } + ] + }, + "runsAllowed": 721, + "runsScored": 798, + "divisionChamp": true, + "divisionLeader": true, + "hasWildcard": true, + "clinched": true, + "eliminationNumber": "-", + "eliminationNumberSport": "-", + "eliminationNumberLeague": "-", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "-", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 94, + "losses": 68, + "runDifferential": 77, + "winningPercentage": ".580" + }, + "schedule_game": { + "gamePk": 777245, + "gameGuid": "4f542907-a4ba-4994-98bf-8153b3a7bfa5", + "link": "/api/v1.1/game/777245/feed/live", + "gameType": "R", + "season": "2025", + "gameDate": "2025-07-04T15:05:00Z", + "officialDate": "2025-07-04", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": false, + "abstractGameCode": "F" + }, + "teams": { + "away": { + "team": { + "id": 111, + "name": "Boston Red Sox", + "link": "/api/v1/teams/111" + }, + "leagueRecord": { + "wins": 44, + "losses": 45, + "ties": 0, + "pct": ".494" + }, + "score": 11, + "isWinner": true, + "splitSquad": false, + "seriesNumber": 29 + }, + "home": { + "team": { + "id": 120, + "name": "Washington Nationals", + "link": "/api/v1/teams/120" + }, + "leagueRecord": { + "wins": 37, + "losses": 51, + "ties": 0, + "pct": ".420" + }, + "score": 2, + "isWinner": false, + "splitSquad": false, + "seriesNumber": 29 + } + }, + "venue": { + "id": 3309, + "name": "Nationals Park", + "link": "/api/v1/venues/3309" + }, + "content": { + "link": "/api/v1/game/777245/content" + }, + "isTie": false, + "gameNumber": 1, + "publicFacing": true, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-777245-2025-07-04", + "seasonDisplay": "2025", + "dayNight": "day", + "scheduledInnings": 9, + "reverseHomeAwayStatus": false, + "inningBreakLength": 120, + "gamesInSeries": 3, + "seriesGameNumber": 1, + "seriesDescription": "Regular Season", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game" + }, + "game_status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": false, + "abstractGameCode": "F" + }, + "game_data_game": { + "pk": 775296, + "type": "W", + "doubleHeader": "N", + "id": "2024/10/30/lanmlb-nyamlb-1", + "gamedayType": "P", + "tiebreaker": "N", + "gameNumber": 1, + "calendarEventID": "14-775296-2024-10-30", + "season": "2024", + "seasonDisplay": "2024" + }, + "pitch_coordinates": { + "aY": 25.35436038269367, + "aZ": -44.40859335204685, + "pfxX": 6.918497370104339, + "pfxZ": -9.480114985189488, + "pX": -0.08731244336026654, + "pZ": 1.1122271636053997, + "vX0": 0.43020717584758905, + "vY0": -114.1854313684875, + "vZ0": -0.3490221612532247, + "x": 120.33, + "y": 208.75, + "x0": -1.1752701285942835, + "y0": 50.00047134117033, + "z0": 5.719812290733207, + "aX": 8.932325350787155 + } + } +} diff --git a/tests/test_model_alias_coverage.py b/tests/test_model_alias_coverage.py new file mode 100644 index 0000000..86a54fa --- /dev/null +++ b/tests/test_model_alias_coverage.py @@ -0,0 +1,155 @@ +"""Guards against models silently dropping values the MLB API returns. + +Because ``MLBBaseModel`` ignores unknown keys, a field whose alias does not match the +API exactly fails quietly: no exception, just a permanent ``None``. That is how issue +#246 shipped in v0.7.1 and went unnoticed through a full green test run -- the existing +stats tests assert on the response envelope (``group``, ``type``, ``total_splits``) but +never on a stat value. + +These tests run offline against fixtures recorded from the real API. Regenerate them +with ``python tests/tools/record_api_fixtures.py``. +""" + +import pytest + +from tests import alias_audit +from tests.alias_audit import PAYLOAD_SPECS + +RECORDED_PAYLOADS = alias_audit.load_payloads() +OBSERVED_API_KEYS = alias_audit.load_api_keys() + +# The specific fields reported in issue #246 and found alongside it. Listing them by +# name keeps the original bug reports executable, and covers the one case the generic +# check above cannot see: MLB spells AdvancedPitchingSplit's field "strikesoutsToWalks", +# a typo on their side that no casing rule derives from "strikeouts_to_walks". +REPORTED_REGRESSIONS = [ + ("hitting_season", "strikeouts"), + ("hitting_season", "groundouts"), + ("hitting_season", "airouts"), + ("hitting_season", "groundouts_to_airouts"), + ("hitting_season_advanced", "flyouts"), + ("hitting_season_advanced", "groundouts"), + ("hitting_season_advanced", "lineouts"), + ("hitting_season_advanced", "popouts"), + ("hitting_season_advanced", "walkoffs"), + ("pitching_season", "strikeouts"), + ("pitching_season", "groundouts"), + ("pitching_season", "airouts"), + ("pitching_season", "groundouts_to_airouts"), + ("pitching_season_advanced", "flyouts"), + ("pitching_season_advanced", "groundouts"), + ("pitching_season_advanced", "lineouts"), + ("pitching_season_advanced", "popouts"), + ("pitching_season_advanced", "flyball_percentage"), + ("pitching_season_advanced", "strikeouts_to_walks"), + ("fielding_season", "putouts"), + ("fielding_season_catcher", "putouts"), + ("fielding_season_catcher", "catcher_era"), + ("outs_above_average", "stat"), + ("catching_season", "strikeouts"), + ("hitting_sabermetrics", "wraa"), + ("hitting_sabermetrics", "wrc"), + ("hitting_sabermetrics", "wrc_plus"), + ("person", "nickname"), + ("person_deceased", "death_date"), + ("person_deceased", "death_city"), + ("abs_challenge_info", "used_successful"), + ("season", "preseason_start_date"), + ("season", "postseason_end_date"), + ("venue", "timezone"), + ("team_records", "wildcard_games_back"), + ("schedule_game", "calendar_event_id"), + ("game_status", "start_time_tbd"), + ("game_data_game", "calendar_event_id"), + ("pitch_coordinates", "ax"), +] + + +def test_recorded_keys_cover_most_of_the_model_surface(): + """A failed or truncated re-record would disable the checks below without failing. + + The remaining few percent are keys MLB only sends in situations the crawl cannot + reach on demand -- resumed and rescheduled games, ejections, injury play events -- + plus Stat.total_splits, which mlb_module computes rather than reads. + """ + fields = [ + (model, name) for model in alias_audit.iter_models() for name in model.model_fields + ] + observed = {alias_audit.normalize(key) for key in OBSERVED_API_KEYS} + covered = sum( + alias_audit.normalize(alias_audit.effective_alias(model, name)) in observed + for model, name in fields + ) + assert covered / len(fields) > 0.9, ( + f"only {covered} of {len(fields)} model fields appear in " + "tests/fixtures/api_keys.json; re-run python tests/tools/record_api_fixtures.py" + ) + + +def test_every_payload_spec_was_recorded(): + """A spec that failed to record would quietly shrink coverage of the tests below.""" + missing = sorted({spec.label for spec in PAYLOAD_SPECS} - set(RECORDED_PAYLOADS)) + assert not missing, ( + f"no recorded payload for {missing}; re-run tests/tools/record_api_fixtures.py" + ) + + +@pytest.mark.parametrize("spec", PAYLOAD_SPECS, ids=lambda spec: spec.label) +def test_recorded_payload_populates_every_matching_field(spec): + """Every recorded key that names a declared field must reach that field.""" + payload = RECORDED_PAYLOADS.get(spec.label) + if payload is None: + pytest.skip(f"{spec.label} was not recorded") + + model = alias_audit.resolve(spec.model) + missed = alias_audit.dropped_keys(model, payload) + assert not missed, alias_audit.describe_dropped(model, payload, missed) + + +@pytest.mark.parametrize("spec", PAYLOAD_SPECS, ids=lambda spec: spec.label) +def test_recorded_payload_parses_without_error(spec): + payload = RECORDED_PAYLOADS.get(spec.label) + if payload is None: + pytest.skip(f"{spec.label} was not recorded") + + alias_audit.resolve(spec.model)(**payload) + + +def test_field_aliases_match_observed_api_casing(): + """Check every model in the package, not just the ones with a recorded payload. + + A field is only reported when the crawled endpoints prove MLB uses that key, so + fields the API no longer returns do not produce noise. + """ + mismatches = alias_audit.alias_mismatches(OBSERVED_API_KEYS) + if not mismatches: + return + + report = [ + f"{len(mismatches)} field(s) expect a key the MLB API does not send, so they " + "always parse as None:", + "", + f" {'MODEL':<26} {'FIELD':<28} {'EXPECTS':<26} API SENDS", + ] + for model, field, expects, observed in mismatches: + report.append(f" {model.__name__:<26} {field:<28} {expects:<26} {', '.join(observed)}") + pytest.fail("\n".join(report)) + + +@pytest.mark.parametrize( + ("label", "field"), REPORTED_REGRESSIONS, ids=lambda value: str(value) +) +def test_reported_regression_fields_populate(label, field): + payload = RECORDED_PAYLOADS.get(label) + if payload is None: + pytest.skip(f"{label} was not recorded") + + spec = next(spec for spec in PAYLOAD_SPECS if spec.label == label) + model = alias_audit.resolve(spec.model) + alias = alias_audit.effective_alias(model, field) + + parsed = model(**payload) + assert getattr(parsed, field) is not None, ( + f"{model.__name__}.{field} is None. It reads {alias!r}; the recorded payload " + f"has {sorted(k for k in payload if alias.lower() in k.lower())}." + ) diff --git a/tests/tools/record_api_fixtures.py b/tests/tools/record_api_fixtures.py new file mode 100644 index 0000000..5303fe2 --- /dev/null +++ b/tests/tools/record_api_fixtures.py @@ -0,0 +1,87 @@ +"""Re-record the API fixtures used by tests/test_model_alias_coverage.py. + +Run from the repository root when MLB adds or renames response fields: + + python tests/tools/record_api_fixtures.py + +This is the only part of the alias-drift checks that touches the network. It writes +tests/fixtures/api_keys.json (every key the crawled endpoints return) and +tests/fixtures/model_payloads.json (real payloads for the models under check), both of +which are committed so the tests themselves run offline. +""" + +from __future__ import annotations + +import datetime +import json +import sys +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tests import alias_audit # noqa: E402 + +TIMEOUT = 60 + + +def fetch(url: str): + with urllib.request.urlopen(url, timeout=TIMEOUT) as response: + return json.load(response) + + +def record_api_keys() -> int: + keys: set[str] = set() + for url in alias_audit.SNAPSHOT_ENDPOINTS: + try: + alias_audit.collect_keys(fetch(url), keys) + except Exception as exc: # noqa: BLE001 - report and keep crawling + print(f" ! {url.split('?')[0]}: {exc}", file=sys.stderr) + + alias_audit.API_KEYS_FIXTURE.write_text( + json.dumps( + { + "recorded_at": datetime.date.today().isoformat(), + "endpoints": list(alias_audit.SNAPSHOT_ENDPOINTS), + "keys": sorted(keys), + }, + indent=2, + ) + + "\n" + ) + return len(keys) + + +def record_payloads() -> int: + payloads = {} + responses: dict[str, object] = {} + for spec in alias_audit.PAYLOAD_SPECS: + try: + if spec.url not in responses: + responses[spec.url] = fetch(spec.url) + payloads[spec.label] = alias_audit.extract(responses[spec.url], spec) + except Exception as exc: # noqa: BLE001 - report and keep going + print(f" ! {spec.label}: {exc}", file=sys.stderr) + + alias_audit.PAYLOADS_FIXTURE.write_text( + json.dumps( + { + "recorded_at": datetime.date.today().isoformat(), + "specs": { + spec.label: {"model": spec.model, "url": spec.url} + for spec in alias_audit.PAYLOAD_SPECS + }, + "payloads": payloads, + }, + indent=2, + sort_keys=False, + ) + + "\n" + ) + return len(payloads) + + +if __name__ == "__main__": + alias_audit.FIXTURE_DIR.mkdir(parents=True, exist_ok=True) + print(f"recorded {record_api_keys()} distinct API keys") + print(f"recorded {record_payloads()} model payloads")