diff --git a/docs/public-api.md b/docs/public-api.md index 6a3d6a7..260094c 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -323,6 +323,19 @@ get_attendance( get_draft(year_id: int, **params) get_awards(award_id: str, **params) get_homerun_derby(game_id, **params) +get_team_stats(team_id: int, stats: list, groups: list, **params) +get_players_stats_for_game(person_id: int, game_id: int, **params) +get_player_stats(person_id: int, stats: list, groups: list, **params) +get_stats(stats: list, groups: list, **params) +get_persons(person_ids: str | list[int], **params) +get_scheduled_games_by_date( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, +) +get_gamepace(season: str, sport_id=1, **params) get_team_id(team_name: str, search_key: str = 'name', **params) get_people_id( fullname: str, @@ -357,9 +370,30 @@ introduced by the async port. way its sibling game helpers do; missing linescore data falls through to an implicit `None`. -Every other `Mlb` endpoint method not listed above is not yet supported on -`AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the -tracked expansion plan. +The four stat methods return the same nested `dict` their sync counterparts +do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}}` +— and return `{}` on a 400–499 response, on a body with no `stats`, and on a +`stats` entry carrying no splits. Note that an unrecognized value in `stats` or +`groups` is not rejected; it produces the same empty `{}`. Valid values are +listed at `https://statsapi.mlb.com/api/v1/statTypes` and +`https://statsapi.mlb.com/api/v1/statGroups`. + +`AsyncMlb` now covers every endpoint method `Mlb` exposes. The only public +name that differs is lifecycle: `Mlb.close()` is spelled `AsyncMlb.aclose()`. + +`get_scheduled_games_by_date` inherits the same documented quirk as +`Mlb.get_scheduled_games_by_date`: it is annotated `list[ScheduleGames]` but +returns `None` when no `date`, `start_date`/`end_date` pair, or `gamePks` was +given to select with. This is preserved for parity, not introduced by the +async port. + +`get_gamepace` sends the same request on both clients but builds it +differently. `Mlb` embeds the season in the endpoint string +(`gamePace?season=2021`) and relies on Requests merging that query with the +rest of the parameters. HTTPX replaces a URL's existing query rather than +merging into it, so `AsyncMlb` passes the season as an ordinary parameter. +Callers see no difference; this matters only if you are reading the two +implementations side by side. ## Low-level adapter diff --git a/mlbstatsapi/_parsers/gamepace.py b/mlbstatsapi/_parsers/gamepace.py new file mode 100644 index 0000000..d04b3a5 --- /dev/null +++ b/mlbstatsapi/_parsers/gamepace.py @@ -0,0 +1,17 @@ +from mlbstatsapi.models.gamepace import GamePace + + +def parse_gamepace(data: dict) -> GamePace | None: + """Parse a GamePace from an MLB /gamePace response body. + + The endpoint keys its metrics by whichever of ``teams``, ``leagues`` or + ``sports`` the caller's ``orgType`` selected, so a body carrying none of + them has nothing to build from. + """ + if not data: + return None + + if not (data.get("teams") or data.get("leagues") or data.get("sports")): + return None + + return GamePace(**data) diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 39fc0d6..3179d4e 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -1,4 +1,4 @@ -from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.schedules import Schedule, ScheduleGames def parse_schedule(data: dict) -> Schedule | None: @@ -7,3 +7,19 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) + + +def parse_scheduled_games(data: dict) -> list[ScheduleGames]: + """Parse the games out of an MLB /schedule response body, flattened. + + The response nests games under one entry per date; this returns them as a + single list, dropping the date grouping. + """ + if not data or not data.get("dates"): + return [] + + return [ + ScheduleGames(**game) + for date in data["dates"] + for game in date["games"] + ] diff --git a/mlbstatsapi/_parsers/stats.py b/mlbstatsapi/_parsers/stats.py new file mode 100644 index 0000000..af05d62 --- /dev/null +++ b/mlbstatsapi/_parsers/stats.py @@ -0,0 +1,16 @@ +from mlbstatsapi import mlb_module + + +def parse_split_stats(data: dict) -> dict: + """Parse split stat data from an MLB stats response body. + + Shared by every stats endpoint -- ``/stats``, ``/people/{id}/stats``, + ``/teams/{id}/stats``, and ``/people/{id}/stats/game/{game_id}`` -- all of + which return the same ``stats`` envelope. + + Returns a dict keyed by stat group, then by stat type, or ``{}`` when the + response carries no stats. + """ + if not data or not data.get("stats"): + return {} + return mlb_module.create_split_data(data["stats"]) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 94a479e..839dbf6 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -19,14 +19,16 @@ parse_linescore, parse_plays, ) +from ._parsers.gamepace import parse_gamepace from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people from ._parsers.roster import parse_roster_coaches, parse_roster_players -from ._parsers.schedules import parse_schedule +from ._parsers.schedules import parse_schedule, parse_scheduled_games from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports from ._parsers.standings import parse_standings +from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_team, parse_teams from ._parsers.venues import parse_venue, parse_venues from .async_mlb_dataadapter import AsyncMlbDataAdapter @@ -36,10 +38,11 @@ from .models.divisions import Division from .models.drafts import Round from .models.game import BoxScore, Game, Linescore, Plays +from .models.gamepace import GamePace from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player -from .models.schedules import Schedule +from .models.schedules import Schedule, ScheduleGames from .models.seasons import Season from .models.sports import Sport from .models.standings import Standings @@ -2022,3 +2025,442 @@ async def get_homerun_derby( return None return parse_homerun_derby(mlb_data.data) + + async def get_team_stats( + self, + team_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns a split stat data for a team + + Async counterpart of ``Mlb.get_team_stats``. + + Parameters + ---------- + team_id : int + the team id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return team stats for a particular season, season=2018 + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_team_stats(133, ["season"], ["pitching"]) + {'pitching': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_players_stats_for_game( + self, + person_id: int, + game_id: int, + **params, + ) -> dict: + """ + Insert personId and gamePk to view stats for individual player based on a specific game. + + Fielding, Hitting, & Pitching gameLog Statistics as well as vsPlayer stats. + + Async counterpart of ``Mlb.get_players_stats_for_game``. + + Parameters + ---------- + person_id : int + the person id + game_id : int + the game id + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_players_stats_for_game(663728, 715757) + ... print(stats["stats"]["gameLog"]) + ... print(stats["hitting"]["playLog"]) + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats/game/{game_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_player_stats( + self, + person_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns stat data for a player + + Async counterpart of ``Mlb.get_player_stats``. + + Parameters + ---------- + person_id : int + the person id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return player stats for a particular season, season=2018 + eventType : str + Notes for individual events for playLog, playLog can be filered by individual events. + List of eventTypes can be found at https://statsapi.mlb.com/api/v1/eventTypes + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_stats : Get stats + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_player_stats(647351, ["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_stats( + self, + stats: list, + groups: list, + **params, + ) -> dict: + """ + return a stat dictionary + + Async counterpart of ``Mlb.get_stats``. + + Parameters + ---------- + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return stats for a particular season, season=2018 + teamId : int + Insert teamId to return statistics for a given team. Default to "Qualified" playerPool. + For a list of all teamIds : AsyncMlb.get_leagues() + leagueId : int + Insert leagueId to return statistics for a given league. Default to "Qualified" playerPool + For a list of all leagueIds : AsyncMlb.get_leagues() + gameType : str + Insert gameType to return statistics for a given sport or league based on gameType. Default to "Qualified" playerPool + Find available gameType at https://statsapi.mlb.com/api/v1/gameTypes + sportIds : int + Insert sportId to return statistics for a given sport. + For a list of all sportIds : AsyncMlb.get_sports() + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get player stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_stats(["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_persons( + self, + person_ids: str | list[int], + **params, + ) -> list[Person]: + """ + This endpoint returns statistical data and biographical information + for players, umpires, and coaches based on playerId. + + Async counterpart of ``Mlb.get_persons``. + + Parameters + ---------- + person_ids : str, list[int] + Insert personId(s) to return biographical information for a + specific player. Format '605151,592450' or [605151,592450] + + Other Parameters + ---------------- + hydrate : str + Insert hydration(s) to return statistical or biographical data + for a specific player(s). + Format stats(group=["statGroup1","statGroup2"], + type=["statType1","statType2"]). + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Person + returns a list of Person + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + AsyncMlb.get_people_id : Return person id from name. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... people = await mlb.get_persons("605151,592450") + [Person, Person] + """ + params["personIds"] = person_ids + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="people", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_people(mlb_data.data) + + async def get_scheduled_games_by_date( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, + ) -> list[ScheduleGames]: + """ + return game ids for a specific date and game status + + Async counterpart of ``Mlb.get_scheduled_games_by_date``. + + Parameters + ---------- + date : str + start date, 'yyyy-mm-dd' + start_date : str + Start date, 'yyyy-mm-dd' + end_date : str + end date, 'yyyy-mm-dd' + sport_id : int + sport id of schedule, defaults to 1 + + Other Parameters + ---------------- + leagueId : int, str + Insert leagueId to return all schedules based on a particular + scheduleType for a specific league. Usage: 1 or '1,11' + gamePks : int, str + Insert gamePks to return all schedules based on a particular + scheduleType for specific games. Usage: 531493 or '531493,531497' + venueIds : int + Insert venueId to return all schedules based on a particular + scheduleType for a specific venueId. + gameTypes : str + Insert gameTypes to return schedule information for all games in + particular gameTypes. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + Returns + ------- + list of ScheduleGames + returns a list of matching games + + See Also + -------- + AsyncMlb.get_game_ids : return a list of game ids + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... games = await mlb.get_scheduled_games_by_date("2022-10-13") + [ScheduleGames, ScheduleGames] + """ + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + **params, + ) + + # Mirrors Mlb.get_scheduled_games_by_date, which returns None -- not + # the empty list its annotation promises -- when no date selector was + # given. Preserved for parity, not introduced here. + if params is None: + return None + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_scheduled_games(mlb_data.data) + + async def get_gamepace( + self, + season: str, + sport_id=1, + **params, + ) -> GamePace | None: + """ + Get pace of game metrics for specific sport, league or team. + + Async counterpart of ``Mlb.get_gamepace``. + + Parameters + ---------- + season : str + Insert year to return a directory of pace of game metrics for a + given season. + sport_id : int + Insert a sportId to return a directory of pace of game metrics + for a specific sport, defaults to 1 + + Other Parameters + ---------------- + teamIds : int + Insert a teamIds to return directory of pace of game metrics for + a given team. Format '110' or '110,147' + leagueId : int + Insert leagueIds to return a directory of pace of game metrics + for a given league. Format '103' or '103,104' + leagueListId : str + Insert a unique League List Identifier to return a directory of + pace of game metrics for a specific league listId. + gameType : str + Insert gameType(s) to return a directory of pace of game metrics + for a specific gameType. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + orgType : str + Insert a orgType to return a directory of pace of game metrics + based on team, league or sport. + Available values : T- TEAM, L- LEAGUE, S- SPORT + includeChildren : bool + Insert includeChildren to return a directory of pace of game + metrics for all child teams in a given parent sport. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + GamePace + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... gamepace = await mlb.get_gamepace("2021") + GamePace + """ + # Mlb.get_gamepace embeds the season in the endpoint string + # ("gamePace?season=2021") and lets Requests merge that query with + # ep_params. HTTPX does not merge -- passing params replaces a query + # already present on the URL -- so copying that idiom here would drop + # the season silently. Passing it as a param produces the identical + # request on both clients. + params["season"] = season + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="gamePace", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_gamepace(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index b19c530..9ca1d42 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -36,8 +36,10 @@ from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport from ._parsers.standings import parse_standings +from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_teams, parse_team -from ._parsers.schedules import parse_schedule +from ._parsers.gamepace import parse_gamepace +from ._parsers.schedules import parse_schedule, parse_scheduled_games from ._parsers.venues import parse_venues, parse_venue from .mlb_dataadapter import ( @@ -868,18 +870,11 @@ def get_scheduled_games_by_date(self, date: str = None, params["sportId"] = sport_id - games = [] - mlb_data = self._mlb_adapter_v1.get(endpoint='schedule', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - if 'dates' in mlb_data.data and mlb_data.data['dates']: - for date in mlb_data.data['dates']: - for game in date['games']: - games.append(ScheduleGames(**game)) - - return games + return parse_scheduled_games(mlb_data.data) def get_game(self, game_id: int, **params) -> Union[Game, None]: """ @@ -1181,11 +1176,7 @@ def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, Non if 400 <= mlb_data.status_code <= 499: return None - if ('teams' in mlb_data.data and mlb_data.data['teams'] - or 'leagues' in mlb_data.data and mlb_data.data['leagues'] - or 'sports' in mlb_data.data and mlb_data.data['sports']): - - return GamePace(**mlb_data.data) + return parse_gamepace(mlb_data.data) def get_venue(self, venue_id: int, **params) -> Union[Venue, None]: """ @@ -2027,12 +2018,7 @@ def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> d if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> dict: """ @@ -2043,9 +2029,9 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> Parameters ---------- person_id : int - the team id - game_id : list - list of stat types + the person id + game_id : int + the game id Returns ------- @@ -2063,20 +2049,16 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> >>> mlb = Mlb() >>> player_id = 663728 >>> game_id = 715757 - >>> stats = mlb.get_player_stats_for_game(person_id=person_id, game_id=game_id) + >>> stats = mlb.get_players_stats_for_game(person_id=person_id, game_id=game_id) >>> print(stats['stats']['gameLog']) >>> print(stats['hitting']['playLog']) """ - mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}') + mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}', + ep_params=params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_player_stats(self, person_id: int, stats: list, groups: list, **params) -> dict: """ @@ -2125,12 +2107,7 @@ def get_player_stats(self, person_id: int, stats: list, groups: list, **params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_stats(self, stats: list, groups: list, **params: dict) -> dict: """ @@ -2184,11 +2161,6 @@ def get_stats(self, stats: list, groups: list, **params: dict) -> dict: if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) # This is to test pypi, please delete later diff --git a/tests/parsers/test_gamepace_parser.py b/tests/parsers/test_gamepace_parser.py new file mode 100644 index 0000000..e036a4e --- /dev/null +++ b/tests/parsers/test_gamepace_parser.py @@ -0,0 +1,65 @@ +from mlbstatsapi._parsers.gamepace import parse_gamepace +from mlbstatsapi.models.gamepace import GamePace + + +SPORT_PACE = { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, +} + +TEAM_PACE = dict( + SPORT_PACE, + team={"id": 133, "name": "Athletics", "link": "/api/v1/teams/133"}, +) + +LEAGUE_PACE = dict( + SPORT_PACE, + league={"id": 103, "name": "American League", "link": "/api/v1/league/103"}, +) + + +def test_parses_sports_pace(): + gamepace = parse_gamepace({"sports": [SPORT_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.sports) == 1 + assert gamepace.sports[0].season == "2021" + + +def test_parses_teams_pace(): + gamepace = parse_gamepace({"teams": [TEAM_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.teams) == 1 + + +def test_parses_leagues_pace(): + gamepace = parse_gamepace({"leagues": [LEAGUE_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.leagues) == 1 + + +def test_any_one_populated_key_is_enough(): + """The endpoint keys metrics by orgType, so only one of the three arrives.""" + gamepace = parse_gamepace({"teams": [], "leagues": [], "sports": [SPORT_PACE]}) + + assert isinstance(gamepace, GamePace) + + +def test_a_body_with_none_of_the_three_keys_returns_none(): + assert parse_gamepace({"copyright": "NOTICE"}) is None + + +def test_a_body_whose_keys_are_all_empty_returns_none(): + assert parse_gamepace({"teams": [], "leagues": [], "sports": []}) is None + + +def test_empty_body_returns_none(): + assert parse_gamepace({}) is None + assert parse_gamepace(None) is None diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py index fcd1b41..61c27f1 100644 --- a/tests/parsers/test_schedules.py +++ b/tests/parsers/test_schedules.py @@ -1,8 +1,8 @@ import pytest from pydantic import ValidationError -from mlbstatsapi._parsers.schedules import parse_schedule -from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi._parsers.schedules import parse_schedule, parse_scheduled_games +from mlbstatsapi.models.schedules import Schedule, ScheduleGames def test_parse_schedule(): @@ -48,3 +48,105 @@ def test_parse_schedule_requires_totals(): ] } ) + + +def _game(game_pk: int) -> dict: + return { + "gamePk": game_pk, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": f"/api/v1.1/game/{game_pk}/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": f"/api/v1/game/{game_pk}/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": f"14-{game_pk}-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", + } + + +def _date(date: str, *games: dict) -> dict: + return { + "date": date, + "totalItems": len(games), + "totalEvents": 0, + "totalGames": len(games), + "totalGamesInProgress": 0, + "games": list(games), + } + + +def test_parse_scheduled_games_builds_models(): + games = parse_scheduled_games({"dates": [_date("2022-10-13", _game(715757))]}) + + assert len(games) == 1 + assert isinstance(games[0], ScheduleGames) + assert games[0].game_pk == 715757 + + +def test_parse_scheduled_games_flattens_across_dates(): + """The response groups games by date; the parser drops that grouping.""" + games = parse_scheduled_games( + { + "dates": [ + _date("2022-10-13", _game(715757), _game(715758)), + _date("2022-10-14", _game(715759)), + ] + } + ) + + assert [game.game_pk for game in games] == [715757, 715758, 715759] + + +def test_parse_scheduled_games_with_no_dates_returns_empty_list(): + assert parse_scheduled_games({"dates": []}) == [] + assert parse_scheduled_games({}) == [] + assert parse_scheduled_games(None) == [] + + +def test_parse_scheduled_games_with_a_date_carrying_no_games_returns_empty_list(): + assert parse_scheduled_games({"dates": [_date("2022-10-13")]}) == [] diff --git a/tests/parsers/test_stats_parser.py b/tests/parsers/test_stats_parser.py new file mode 100644 index 0000000..97526e5 --- /dev/null +++ b/tests/parsers/test_stats_parser.py @@ -0,0 +1,88 @@ +from mlbstatsapi._parsers.stats import parse_split_stats +from mlbstatsapi.models.stats import Stat + + +HITTING_SEASON = { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": { + "gamesPlayed": 157, + "atBats": 586, + "hits": 160, + "homeRuns": 34, + "avg": ".273", + }, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"}, + } + ], +} + +PITCHING_SEASON = { + "type": {"displayName": "season"}, + "group": {"displayName": "pitching"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 28, "wins": 15, "losses": 9, "era": "2.33"}, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"}, + } + ], +} + + +def test_parses_a_single_group_and_type(): + stats = parse_split_stats({"stats": [HITTING_SEASON]}) + + assert list(stats) == ["hitting"] + assert list(stats["hitting"]) == ["season"] + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_keys_by_group_then_type(): + stats = parse_split_stats({"stats": [HITTING_SEASON, PITCHING_SEASON]}) + + assert set(stats) == {"hitting", "pitching"} + assert stats["hitting"]["season"].group == "hitting" + assert stats["pitching"]["season"].group == "pitching" + + +def test_carries_the_split_payload_through(): + stats = parse_split_stats({"stats": [HITTING_SEASON]}) + + split = stats["hitting"]["season"].splits[0] + assert split.season == "2022" + assert split.stat.home_runs == 34 + + +def test_missing_stats_key_returns_an_empty_mapping(): + assert parse_split_stats({}) == {} + + +def test_empty_stats_list_returns_an_empty_mapping(): + assert parse_split_stats({"stats": []}) == {} + + +def test_empty_body_returns_an_empty_mapping(): + assert parse_split_stats(None) == {} + + +def test_a_group_with_no_splits_is_skipped(): + """create_split_data drops entries carrying no splits rather than keying an empty Stat.""" + empty = dict(HITTING_SEASON, splits=[]) + + assert parse_split_stats({"stats": [empty]}) == {} + + +def test_a_group_with_no_splits_does_not_suppress_its_siblings(): + empty = dict(HITTING_SEASON, splits=[]) + + stats = parse_split_stats({"stats": [empty, PITCHING_SEASON]}) + + assert list(stats) == ["pitching"] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index afa87fe..87c68b3 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -25,6 +25,7 @@ import asyncio from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qsl import pytest @@ -44,6 +45,7 @@ from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 +from mlbstatsapi.models.gamepace import GamePace # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -51,6 +53,7 @@ from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.standings import Standings # noqa: E402 +from mlbstatsapi.models.stats import Stat # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 from mlbstatsapi.models.venues import Venue # noqa: E402 @@ -348,6 +351,116 @@ EXPECTED_VENUE = Venue(id=31, link="/api/v1/venues/31", name="PNC Park") # The two ways an endpoint legitimately comes back with nothing to parse. +SCHEDULED_GAME = { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", +} + +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [SCHEDULED_GAME], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + NO_RESULT_RESPONSES = { "404": httpx.Response(404, json={}), "empty 200": httpx.Response(200, json={}), @@ -451,14 +564,20 @@ def _flatten_params(params: dict) -> list[tuple[str, str]]: def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: - """Assert an observed request is the one ``Mlb`` would have made.""" + """Assert an observed request is the one ``Mlb`` would have made. + + Some Mlb endpoint strings carry their own query: get_gamepace embeds the + season, and get_awards ends in a bare "?". Requests merges that query with + ep_params, so the expectation is the two combined -- which is what either + client has to end up sending, however it chose to build the URL. + """ endpoint, params, ver = sync_request_for(method, *args, **kwargs) - # get_awards's endpoint string has a trailing "?" (harmless legacy cruft - # both Requests and HTTPX strip as an empty query separator), which never - # shows up in url.path. - assert request.url.path == f"/api/{ver}/{endpoint}".rstrip("?") - assert sorted(request.url.params.multi_items()) == _flatten_params(params) + path, _, embedded_query = endpoint.partition("?") + expected = _flatten_params(params) + list(parse_qsl(embedded_query)) + + assert request.url.path == f"/api/{ver}/{path}" + assert sorted(request.url.params.multi_items()) == sorted(expected) # --------------------------------------------------------------------------- @@ -1122,6 +1241,249 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_stats(["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_stats", ["season"], ["hitting"]) + assert list(stats) == ["hitting"] + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_player_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_player_stats(660271, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_player_stats", 660271, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_team_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_stats(133, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_team_stats", 133, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game(660271, 715757) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_players_stats_for_game", 660271, 715757 + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_forwards_extra_params(): + """The signature accepts **params, so they have to reach the query string.""" + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game( + 660271, 715757, eventType="single" + ) + + asyncio.run(scenario()) + + assert handler.request.url.params["eventType"] == "single" + + +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoints_return_an_empty_mapping_when_there_are_no_stats( + method, args, label +): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await getattr(mlb, method)(*args) + + assert asyncio.run(scenario()) == {} + + +def test_get_persons_request_matches_the_sync_client(): + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("660271,605151") + + people = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", "660271,605151") + assert people == [EXPECTED_PERSON] + + +def test_get_persons_accepts_a_list_of_ids(): + """The signature allows a list as well as a comma-delimited string.""" + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons([660271, 605151]) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", [660271, 605151]) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_returns_an_empty_list_when_there_are_no_people(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("1") + + assert asyncio.run(scenario()) == [] + + +def test_get_scheduled_games_by_date_request_matches_the_sync_client(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + games = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_scheduled_games_by_date", "2022-10-13" + ) + assert [game.game_pk for game in games] == [715757] + + +def test_get_scheduled_games_by_date_accepts_a_date_range(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date( + start_date="2022-10-13", end_date="2022-10-14" + ) + + asyncio.run(scenario()) + + assert_matches_sync( + handler.request, + "get_scheduled_games_by_date", + start_date="2022-10-13", + end_date="2022-10-14", + ) + + +def test_get_scheduled_games_by_date_accepts_game_pks_without_a_date(): + """gamePks is its own selector; no date is required alongside it.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date(gamePks=715757) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_scheduled_games_by_date", gamePks=715757) + + +def test_get_scheduled_games_by_date_without_a_selector_returns_none_without_requesting(): + """Mirrors Mlb, which returns None rather than [] when nothing selects a date.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_returns_an_empty_list_when_there_are_no_games(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + assert asyncio.run(scenario()) == [] + + +def test_get_gamepace_request_matches_the_sync_client(): + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + gamepace = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_gamepace", "2021") + assert isinstance(gamepace, GamePace) + assert gamepace.sports[0].season == "2021" + + +def test_get_gamepace_puts_the_season_in_the_query_string(): + """The season rides in the endpoint string rather than in ep_params.""" + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + asyncio.run(scenario()) + + assert handler.request.url.path == "/api/v1/gamePace" + assert handler.request.url.params["season"] == "2021" + assert handler.request.url.params["sportId"] == "1" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_returns_none_when_there_is_no_pace_data(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + assert asyncio.run(scenario()) is None + + def test_get_team_id_request_matches_the_sync_client(): handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) @@ -1371,6 +1733,13 @@ def test_public_signatures_match_the_sync_client(): "get_draft", "get_awards", "get_homerun_derby", + "get_stats", + "get_player_stats", + "get_team_stats", + "get_players_stats_for_game", + "get_persons", + "get_scheduled_games_by_date", + "get_gamepace", "get_game", "get_game_play_by_play", "get_game_line_score", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index b3cae60..d909bad 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -264,6 +264,16 @@ def _normalize_signature(fn: Any) -> str: "get_draft": "(year_id: int, **params)", "get_awards": "(award_id: str, **params)", "get_homerun_derby": "(game_id, **params)", + "get_team_stats": "(team_id: int, stats: list, groups: list, **params)", + "get_players_stats_for_game": "(person_id: int, game_id: int, **params)", + "get_player_stats": "(person_id: int, stats: list, groups: list, **params)", + "get_stats": "(stats: list, groups: list, **params)", + "get_persons": "(person_ids: str | list[int], **params)", + "get_scheduled_games_by_date": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), + "get_gamepace": "(season: str, sport_id=1, **params)", "get_team_id": "(team_name: str, search_key: str='name', **params)", "get_people_id": ( "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 1746136..38540ce 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -47,6 +47,7 @@ from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 +from mlbstatsapi.models.gamepace import GamePace # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -54,6 +55,7 @@ from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.standings import Standings # noqa: E402 +from mlbstatsapi.models.stats import Stat # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 from mlbstatsapi.models.venues import Venue # noqa: E402 @@ -341,6 +343,120 @@ }, } +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [ + { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", + } + ], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": { + "id": 108, + "name": "Los Angeles Angels", + "link": "/api/v1/teams/108", + }, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + # The canned transport failures, per client. Each pair is the closest # equivalent the two libraries offer, so the public exception is the only # thing being compared. @@ -723,6 +839,131 @@ def test_get_homerun_derby_success_parity(): assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) +def test_get_stats_success_parity(): + """A successful stats response parses to the same split mapping on both clients.""" + result = call_both("get_stats", ["season"], ["hitting"], payload=STATS_PAYLOAD) + + assert list(result.sync) == ["hitting"], "sync get_stats did not key by group" + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_player_stats_success_parity(): + """A successful player stats response parses the same on both clients.""" + result = call_both( + "get_player_stats", 660271, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_team_stats_success_parity(): + """A successful team stats response parses the same on both clients.""" + result = call_both( + "get_team_stats", 133, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/teams/133/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_players_stats_for_game_success_parity(): + """A successful per-game stats response parses the same on both clients.""" + result = call_both( + "get_players_stats_for_game", 660271, 715757, payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {}, + ) + + +def test_get_players_stats_for_game_forwards_params_on_both_clients(): + """Regression coverage: **params used to be accepted and silently dropped. + + ``get_players_stats_for_game`` advertises ``**params`` but never passed + ``ep_params`` to the adapter, so every caller-supplied keyword vanished + before the request was built. Both clients now forward them. + """ + result = call_both( + "get_players_stats_for_game", + 660271, + 715757, + eventType="single", + payload=STATS_PAYLOAD, + ) + + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {"eventType": "single"}, + ) + + +def test_get_persons_success_parity(): + """A successful people response parses to the same Person list on both clients.""" + result = call_both("get_persons", "660271", payload=PERSON_PAYLOAD) + + assert result.sync == [Person(**PERSON_PAYLOAD["people"][0])] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/people", {"personIds": "660271"}) + + +def test_get_scheduled_games_by_date_success_parity(): + """A successful schedule response parses to the same game list on both clients.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", payload=SCHEDULED_GAMES_PAYLOAD + ) + + assert [game.game_pk for game in result.sync] == [715757] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/schedule", + {"date": "2022-10-13", "sportId": "1"}, + ) + + +def test_get_gamepace_success_parity(): + """A successful gamePace response parses to the same GamePace on both clients. + + The season is the part that matters here. Mlb embeds it in the endpoint + string and relies on Requests merging that query with ep_params; HTTPX + replaces rather than merges, so AsyncMlb passes it as a param instead. + Asserting one shared request signature pins that the two routes converge. + """ + result = call_both("get_gamepace", "2021", payload=GAMEPACE_PAYLOAD) + + assert isinstance(result.sync, GamePace), "sync get_gamepace did not return a GamePace" + assert result.sync.sports[0].season == "2021" + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/gamePace", + {"season": "2021", "sportId": "1"}, + ) + + def test_get_team_id_success_parity(): """A matching name is resolved to the same id list on both clients.""" result = call_both( @@ -1056,6 +1297,74 @@ def test_get_homerun_derby_no_result_parity(label): ) +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoint_no_result_parity(method, args, label): + """Every no-result response returns an empty mapping on either client.""" + result = call_both(method, *args, **NO_RESULT_RESPONSES[label]) + + assert result.sync == {}, f"sync {method} returned {result.sync!r} for {label}" + assert result.asynchronous == {}, ( + f"async {method} returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_persons", "1", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_persons returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_persons returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", **NO_RESULT_RESPONSES[label] + ) + + assert result.sync == [], ( + f"sync get_scheduled_games_by_date returned {result.sync!r} for {label}" + ) + assert result.asynchronous == [], ( + f"async get_scheduled_games_by_date returned {result.asynchronous!r} for {label}" + ) + + +def test_get_scheduled_games_by_date_without_a_selector_parity(): + """Both clients return None -- not [] -- when nothing selects a date. + + The annotation promises list[ScheduleGames]. Mlb returns a bare None here + and AsyncMlb preserves that rather than quietly correcting it, so the two + stay interchangeable. + """ + assert call_sync("get_scheduled_games_by_date") is None + assert call_async("get_scheduled_games_by_date") is None + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_gamepace", "2021", **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_gamepace returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_gamepace returned {result.asynchronous!r} for {label}" + ) + + def test_get_homerun_derby_malformed_error_body_parity(): """Regression coverage: the bare-None-instead-of-return-None bug fix.