From 466bb73b51e865d3d4b3a79d45b71b3d7399749f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 14:28:17 +0000 Subject: [PATCH 1/7] test: detect models that silently drop MLB API values MLBBaseModel ignores unknown keys, so a field whose alias does not match the API exactly parses as None instead of raising. Issue #246 shipped that way in v0.7.1 and survived a fully green test run, because the stats tests assert on the response envelope and never on a stat value. Adds two layers of checking: - tests/test_model_alias_coverage.py runs offline against payloads recorded from the real API, asserting that every recorded key naming a declared field actually reaches it, and that no field in the package expects a casing the API does not use. - tests/external_tests/models/test_alias_drift_live.py repeats those checks against the live API so renames on MLB's side surface without a re-record. Fixtures are regenerated with tests/tools/record_api_fixtures.py. These tests fail on main; the following commit fixes the fields they report. Co-authored-by: Matthew Spah --- conftest.py | 7 + tests/__init__.py | 0 tests/alias_audit.py | 320 +++++++ .../models/test_alias_drift_live.py | 68 ++ tests/fixtures/api_keys.json | 866 ++++++++++++++++++ tests/fixtures/model_payloads.json | 789 ++++++++++++++++ tests/test_model_alias_coverage.py | 128 +++ tests/tools/record_api_fixtures.py | 87 ++ 8 files changed, 2265 insertions(+) create mode 100644 conftest.py create mode 100644 tests/__init__.py create mode 100644 tests/alias_audit.py create mode 100644 tests/external_tests/models/test_alias_drift_live.py create mode 100644 tests/fixtures/api_keys.json create mode 100644 tests/fixtures/model_payloads.json create mode 100644 tests/test_model_alias_coverage.py create mode 100644 tests/tools/record_api_fixtures.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..0bea1a72 --- /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/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/alias_audit.py b/tests/alias_audit.py new file mode 100644 index 00000000..1033204d --- /dev/null +++ b/tests/alias_audit.py @@ -0,0 +1,320 @@ +"""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 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. +SOTO = 665742 +SKUBAL = 669373 +REALMUTO = 592663 +PHILLIES = 147 +GAME_PK = 775296 +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}", + 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", + 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( + "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( + "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 effective_alias(model: type[MLBBaseModel], field_name: str) -> str: + """The key this field actually validates against, explicit or generated.""" + return model.model_fields[field_name].alias or field_name + + +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) + keys.update(effective_alias(model, name) for name in model.model_fields) + 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: + alias = effective_alias(model, name) + real = observed.get(normalize(alias)) + if real and alias not in real: + mismatches.append((model, name, alias, 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 00000000..7de0daef --- /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/fixtures/api_keys.json b/tests/fixtures/api_keys.json new file mode 100644 index 00000000..a71eba8f --- /dev/null +++ b/tests/fixtures/api_keys.json @@ -0,0 +1,866 @@ +{ + "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/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/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", + "ID571771", + "ID571970", + "ID573186", + "ID592450", + "ID592454", + "ID595014", + "ID596133", + "ID605131", + "ID605141", + "ID605280", + "ID606192", + "ID607074", + "ID607455", + "ID621035", + "ID621111", + "ID624431", + "ID641482", + "ID641703", + "ID641835", + "ID643410", + "ID650402", + "ID656427", + "ID656629", + "ID657077", + "ID657376", + "ID657612", + "ID660271", + "ID660813", + "ID661563", + "ID663757", + "ID664776", + "ID665742", + "ID665828", + "ID665862", + "ID666158", + "ID669224", + "ID669242", + "ID669257", + "ID676508", + "ID681624", + "ID681911", + "ID683011", + "ID689017", + "ID691176", + "ID808967", + "aX", + "aY", + "aZ", + "abbreviation", + "about", + "abstractGameCode", + "abstractGameState", + "actionIndex", + "actionPlayId", + "active", + "activeStatus", + "address1", + "age", + "aggregateTotals", + "airOuts", + "alerts", + "allPlays", + "allPositions", + "allStarDate", + "allStarStatus", + "ampm", + "assists", + "atBatIndex", + "atBats", + "atBatsPerHomeRun", + "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", + "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", + "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", + "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", + "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", + "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", + "strikeZoneTop", + "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", + "totalPitchers", + "totalPitches", + "totalPlateAppearances", + "totalRuns", + "totalSwings", + "trailColor", + "trajectory", + "trajectoryData", + "trajectoryPolynomialX", + "trajectoryPolynomialY", + "trajectoryPolynomialZ", + "triplePlays", + "triples", + "turfType", + "type", + "typeConfidence", + "tz", + "ubr", + "unit", + "useLastName", + "useName", + "used", + "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 00000000..c8030e75 --- /dev/null +++ b/tests/fixtures/model_payloads.json @@ -0,0 +1,789 @@ +{ + "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" + }, + "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" + }, + "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 + }, + "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 + }, + "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 00000000..26646500 --- /dev/null +++ b/tests/test_model_alias_coverage.py @@ -0,0 +1,128 @@ +"""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"), + ("catching_season", "strikeouts"), + ("hitting_sabermetrics", "wraa"), + ("hitting_sabermetrics", "wrc"), + ("hitting_sabermetrics", "wrc_plus"), + ("person", "nickname"), + ("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_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 00000000..5303fe2f --- /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") From 00931ce73df66175ec885c05ece64e45613794c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 14:33:51 +0000 Subject: [PATCH 2/7] fix(models): match field aliases to the casing the MLB API actually uses Completes the fix started in #244 and #247. The camelCase alias generator added in v0.7.1 only capitalises across underscores, so every field whose name is a single lowercase token kept expecting its own name: strikeouts, putouts, walkoffs, nickname, timezone. MLB sends strikeOuts, putOuts, walkOffs, nickName and timeZone, and because MLBBaseModel ignores unknown keys those values were discarded rather than raising. Corrects the 50 remaining fields, covering pitching and fielding splits, sabermetrics, Person and its subclasses, Season, Venue, standings and team records, schedule and game data, and pitch coordinates. Two spellings could not be derived from any rule, so they are pinned with a comment: MLB returns offseasonStartDate alongside offSeasonEndDate, and AdvancedPitchingSplit's strikeouts-to-walks ratio arrives as 'strikesoutsToWalks'. The latter accepts both spellings via AliasChoices in case MLB corrects the typo. Co-authored-by: Matthew Spah --- mlbstatsapi/models/data/data.py | 6 ++-- .../models/game/gamedata/attributes.py | 4 +-- mlbstatsapi/models/game/gamedata/gamedata.py | 2 +- mlbstatsapi/models/people/people.py | 6 ++-- mlbstatsapi/models/schedules/attributes.py | 2 +- mlbstatsapi/models/seasons/season.py | 12 ++++---- mlbstatsapi/models/standings/attributes.py | 6 ++-- mlbstatsapi/models/standings/standings.py | 2 +- mlbstatsapi/models/stats/fielding.py | 2 +- mlbstatsapi/models/stats/pitching.py | 28 +++++++++++-------- mlbstatsapi/models/stats/stats.py | 6 ++-- mlbstatsapi/models/teams/attributes.py | 2 +- mlbstatsapi/models/venues/venue.py | 2 +- tests/alias_audit.py | 28 ++++++++++++++----- 14 files changed, 65 insertions(+), 43 deletions(-) diff --git a/mlbstatsapi/models/data/data.py b/mlbstatsapi/models/data/data.py index 53e94806..3e3f6975 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 f6ed5786..483fb163 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 8a638223..674ea0ea 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 8eb5d42e..7fa14ede 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 9d0bdcab..27ba234c 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 da04dd61..5e09777d 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 2df98444..b82f4000 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 3dae0b56..e59c6e61 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 d13eeaf7..5b8d0ea2 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 diff --git a/mlbstatsapi/models/stats/pitching.py b/mlbstatsapi/models/stats/pitching.py index 3736c83f..9b4e7b86 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 ce118c34..7043db77 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 diff --git a/mlbstatsapi/models/teams/attributes.py b/mlbstatsapi/models/teams/attributes.py index ec2a44c5..02a54164 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 b43f669c..92739072 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/alias_audit.py b/tests/alias_audit.py index 1033204d..50141876 100644 --- a/tests/alias_audit.py +++ b/tests/alias_audit.py @@ -27,6 +27,8 @@ from typing import Any, Iterator import mlbstatsapi +from pydantic import AliasChoices + from mlbstatsapi.models.base import MLBBaseModel FIXTURE_DIR = Path(__file__).parent / "fixtures" @@ -195,15 +197,27 @@ def normalize(key: str) -> str: 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 actually validates against, explicit or generated.""" - return model.model_fields[field_name].alias or field_name + """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) - keys.update(effective_alias(model, name) for name in model.model_fields) + for name in model.model_fields: + keys.update(validation_aliases(model, name)) return keys @@ -260,10 +274,10 @@ def alias_mismatches(vocabulary: set[str]) -> list[tuple[type[MLBBaseModel], str mismatches = [] for model in iter_models(): for name in model.model_fields: - alias = effective_alias(model, name) - real = observed.get(normalize(alias)) - if real and alias not in real: - mismatches.append((model, name, alias, sorted(real))) + 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 From b4f71c2a9aad09578a1ef8a27569cd27bb76f698 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 14:33:57 +0000 Subject: [PATCH 3/7] fix(venue): send hydrate as one comma delimited value get_venue and get_venues set params['hydrate'] to a list, which requests encodes as repeated hydrate query params. MLB answers those without hydrating, so Venue.location and Venue.timezone were always None no matter what the aliases said. The existing venue test only checked hasattr, which passes on a pydantic model whether or not a value arrived, so this was invisible. Adds assertions on the hydrated values and on the encoded parameter. Co-authored-by: Matthew Spah --- mlbstatsapi/mlb_api.py | 8 ++++-- tests/external_tests/venue/test_venue.py | 32 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 40657ba5..6b0bfcba 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/tests/external_tests/venue/test_venue.py b/tests/external_tests/venue/test_venue.py index 5ed98f14..2abc9130 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") From c1da3028ffca271cfc1922ce216593f640ef799b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 14:34:10 +0000 Subject: [PATCH 4/7] ci: run the whole tests directory CI ran only tests/external_tests/, so tests/test_base_model.py and tests/test_gamepk_validation_regressions.py have never executed on a push, and the new alias coverage tests would not have either. Co-authored-by: Matthew Spah --- .github/workflows/build-and-test-mlbstatsapi-prd.yml | 4 ++-- .github/workflows/build-and-test-mlbstatsapi-test.yml | 4 ++-- .github/workflows/build-and-test.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-test-mlbstatsapi-prd.yml b/.github/workflows/build-and-test-mlbstatsapi-prd.yml index eb880280..3861eb90 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 0421b4e3..0fbe1f75 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 c5d1fe00..359755b3 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 From c726f0f874610b06f6459b77389fa7fe5cc7ef72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 15:47:29 +0000 Subject: [PATCH 5/7] test: crawl the shapes MLB only returns conditionally The first crawl took fielding stats from an outfielder, a person record from a living player and a game feed from before ABS challenges existed, so whole groups of keys were never observed and the guard stayed silent on them. Adds a catcher's fielding split, the outsAboveAverage stat type, a deceased player and a recent game feed, taking confirmed coverage of model fields from 96% to 98% (918 distinct keys, up from 835). Also asserts the recorded vocabulary still covers most of the model surface, so a failed or truncated re-record cannot quietly disable these checks. Co-authored-by: Matthew Spah --- tests/alias_audit.py | 38 +++++++- tests/fixtures/api_keys.json | 87 ++++++++++++++++++ tests/fixtures/model_payloads.json | 138 +++++++++++++++++++++++++++++ tests/test_model_alias_coverage.py | 27 ++++++ 4 files changed, 289 insertions(+), 1 deletion(-) diff --git a/tests/alias_audit.py b/tests/alias_audit.py index 50141876..08590752 100644 --- a/tests/alias_audit.py +++ b/tests/alias_audit.py @@ -38,12 +38,16 @@ 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. +# 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 @@ -57,6 +61,12 @@ 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])", @@ -75,6 +85,8 @@ 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", @@ -129,6 +141,18 @@ class PayloadSpec: 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", @@ -147,6 +171,18 @@ class PayloadSpec: 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", diff --git a/tests/fixtures/api_keys.json b/tests/fixtures/api_keys.json index a71eba8f..6eb96dbc 100644 --- a/tests/fixtures/api_keys.json +++ b/tests/fixtures/api_keys.json @@ -5,6 +5,9 @@ "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", @@ -21,6 +24,7 @@ "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", @@ -34,56 +38,108 @@ "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", @@ -104,6 +160,7 @@ "atBatIndex", "atBats", "atBatsPerHomeRun", + "attempts", "attendance", "attendanceAverageAway", "attendanceAverageHome", @@ -143,6 +200,7 @@ "baseRunning", "batSide", "batter", + "batterHotColdZoneStats", "batterHotColdZones", "batters", "battersFaced", @@ -182,6 +240,7 @@ "capacity", "captivatingIndex", "catcher", + "catcherERA", "catchersInterference", "caughtStealing", "caughtStealingPercentage", @@ -225,6 +284,10 @@ "datetime", "dayNight", "dayOfWeek", + "deathCity", + "deathCountry", + "deathDate", + "deathStateProvince", "decisions", "defaultCoordinates", "defense", @@ -276,6 +339,8 @@ "fieldInfo", "fieldList", "fielding", + "fieldingRunsPrevented", + "fieldingRunsPreventedUnrounded", "fileCode", "fip", "fipMinus", @@ -532,6 +597,18 @@ "outBase", "outNumber", "outs", + "outsAboveAverageBackLeft", + "outsAboveAverageBackLeftUnrounded", + "outsAboveAverageBackRight", + "outsAboveAverageBackRightUnrounded", + "outsAboveAverageBackStraight", + "outsAboveAverageBackStraightUnrounded", + "outsAboveAverageInLeft", + "outsAboveAverageInLeftUnrounded", + "outsAboveAverageInRight", + "outsAboveAverageInRightUnrounded", + "outsAboveAverageInStraight", + "outsAboveAverageInStraightUnrounded", "overallRecords", "pX", "pZ", @@ -728,7 +805,9 @@ "strikeOuts", "strikePercentage", "strikeZoneBottom", + "strikeZoneDepth", "strikeZoneTop", + "strikeZoneWidth", "strikeoutWalkRatio", "strikeoutsMinusWalksPercentage", "strikeoutsPer9", @@ -793,6 +872,12 @@ "totalHits", "totalInningsPlayed", "totalItems", + "totalOutsAboveAverage", + "totalOutsAboveAverageBack", + "totalOutsAboveAverageBackUnrounded", + "totalOutsAboveAverageIn", + "totalOutsAboveAverageInUnrounded", + "totalOutsAboveAverageUnrounded", "totalPitchers", "totalPitches", "totalPlateAppearances", @@ -815,6 +900,8 @@ "useLastName", "useName", "used", + "usedFailed", + "usedSuccessful", "vX0", "vY0", "vZ0", diff --git a/tests/fixtures/model_payloads.json b/tests/fixtures/model_payloads.json index c8030e75..987dd69d 100644 --- a/tests/fixtures/model_payloads.json +++ b/tests/fixtures/model_payloads.json @@ -21,6 +21,14 @@ "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" @@ -33,6 +41,14 @@ "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" @@ -277,6 +293,69 @@ "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, @@ -377,6 +456,65 @@ "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, diff --git a/tests/test_model_alias_coverage.py b/tests/test_model_alias_coverage.py index 26646500..86a54fa2 100644 --- a/tests/test_model_alias_coverage.py +++ b/tests/test_model_alias_coverage.py @@ -43,11 +43,17 @@ ("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"), @@ -59,6 +65,27 @@ ] +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)) From 8be836468ede423c8ab8b0aa8f43b161862f2b8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 15:47:37 +0000 Subject: [PATCH 6/7] fix(stats): catcher ERA arrives as catcherERA SimpleFieldingSplit.catcher_era read 'catcherEra'. The field only appears on a catcher's fielding split, which the earlier crawl never requested. Co-authored-by: Matthew Spah --- mlbstatsapi/models/stats/fielding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlbstatsapi/models/stats/fielding.py b/mlbstatsapi/models/stats/fielding.py index 5b8d0ea2..ed924a1a 100644 --- a/mlbstatsapi/models/stats/fielding.py +++ b/mlbstatsapi/models/stats/fielding.py @@ -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") From cec1699c181bdab51a1665a7230b3da6669fc8b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 15:47:37 +0000 Subject: [PATCH 7/7] fix(stats): nest OutsAboveAverage stats under stat get_player_stats(stats=['outsAboveAverage']) raised ValidationError with 17 missing fields for every player. OutsAboveAverage declared the statistics directly on the split, but MLB nests them under 'stat' like every other split type, so none of them were ever found and all were required. Moves them to an OutsAboveAverageSplit that the split holds as stat, matching PitchArsenal and the other Split subclasses, and makes them optional since this stat type returns zeroes or nothing at all for most players. Adds fieldingRunsPrevented, which the response includes but the model omitted. The float widening is deliberate: the API returns values such as 8.0. Co-authored-by: Matthew Spah --- mlbstatsapi/models/stats/stats.py | 52 +++++++++++++++++++------------ 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/mlbstatsapi/models/stats/stats.py b/mlbstatsapi/models/stats/stats.py index 7043db77..987b18ac 100644 --- a/mlbstatsapi/models/stats/stats.py +++ b/mlbstatsapi/models/stats/stats.py @@ -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):