From 7d2f0ecd37eddd0bff6230c5f4e86cbaea27f32c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 15:11:09 -0700 Subject: [PATCH 1/2] feat(async): add the stat endpoint group to AsyncMlb Ports the last four stats methods to AsyncMlb at strict parity with Mlb: get_stats, get_player_stats, get_team_stats, and get_players_stats_for_game. All four sync methods ended in the same copy-pasted tail -- short-circuit on 400-499, then create_split_data(data['stats']) if present and truthy, else {}. That block existed four times in mlb_api.py. It moves to a shared _parsers/stats.py::parse_split_stats(), following the pattern the rest of the async port already uses, and both clients now call the one copy. Also fixes a real bug on the sync side while collapsing those copies: get_players_stats_for_game accepted **params and never passed ep_params to the adapter, so every caller-supplied keyword was silently discarded before the request was built. Both clients now forward them, covered by a named regression test in the parity suite. No new types, constants, or validation: an unrecognized stat type or group still yields {} rather than raising, matching sync exactly. docs/public-api.md notes that sharp edge alongside the newly supported methods. Docstring corrections on Mlb.get_players_stats_for_game: it described game_id as "list of stat types", person_id as "the team id", and its example called get_player_stats_for_game, which is not a method. Tests: 1016 passed (up from 974). tests/external_tests/stats/ 30 passed against the live API, confirming the sync refactor did not move behavior. Co-Authored-By: Claude Opus 5 --- docs/public-api.md | 12 ++ mlbstatsapi/_parsers/stats.py | 16 ++ mlbstatsapi/async_mlb.py | 229 +++++++++++++++++++++++++++++ mlbstatsapi/mlb_api.py | 40 ++--- tests/parsers/test_stats_parser.py | 88 +++++++++++ tests/test_async_mlb.py | 123 ++++++++++++++++ tests/test_public_api.py | 4 + tests/test_sync_async_parity.py | 128 ++++++++++++++++ 8 files changed, 611 insertions(+), 29 deletions(-) create mode 100644 mlbstatsapi/_parsers/stats.py create mode 100644 tests/parsers/test_stats_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 6a3d6a7..94310e4 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -323,6 +323,10 @@ 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_team_id(team_name: str, search_key: str = 'name', **params) get_people_id( fullname: str, @@ -357,6 +361,14 @@ introduced by the async port. way its sibling game helpers do; missing linescore data falls through to an implicit `None`. +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`. + 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. 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..fee87d0 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -27,6 +27,7 @@ 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 @@ -2022,3 +2023,231 @@ 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) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index b19c530..f4ef944 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -36,6 +36,7 @@ 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.venues import parse_venues, parse_venue @@ -2027,12 +2028,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 +2039,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 +2059,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 +2117,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 +2171,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_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..c6137b3 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -51,6 +51,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 +349,28 @@ 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. +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={}), @@ -1122,6 +1145,102 @@ 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_team_id_request_matches_the_sync_client(): handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) @@ -1371,6 +1490,10 @@ 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_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..7aba78f 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -264,6 +264,10 @@ 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_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..2f22bce 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -54,6 +54,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 +342,32 @@ }, } +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 +750,87 @@ 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_team_id_success_parity(): """A matching name is resolved to the same id list on both clients.""" result = call_both( @@ -1056,6 +1164,26 @@ 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}" + ) + + def test_get_homerun_derby_malformed_error_body_parity(): """Regression coverage: the bare-None-instead-of-return-None bug fix. From 113a8c0677f275d665425f83e5a88557072a4b9a Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 16:39:26 -0700 Subject: [PATCH 2/2] feat(async): add the last three endpoints, completing AsyncMlb coverage Ports get_persons, get_scheduled_games_by_date, and get_gamepace. AsyncMlb now exposes every endpoint method Mlb does; the only remaining public difference is that close() is spelled aclose(). Two more shared parsers, following the established pattern: _parsers/schedules.py gains parse_scheduled_games(), and _parsers/gamepace.py is new. Both replace inline loops/conditionals in mlb_api.py, so the two clients share one copy. Fixes an httpx/requests divergence that would have silently broken get_gamepace on the async side. Mlb builds that request as endpoint="gamePace?season=2021" with ep_params={"sportId": 1} and relies on Requests merging the endpoint's query with the params. HTTPX does not merge -- passing params replaces a query already on the URL -- so copying the sync idiom drops the season entirely and silently returns whatever the unfiltered endpoint gives back. AsyncMlb passes the season as an ordinary param instead, which produces a byte-identical request. Verified against the live API: async and sync return equal GamePace objects for season 2021. tests/test_async_mlb.py's assert_matches_sync() had the same blind spot -- it compared url.path against the raw endpoint string and would not have caught this. It now splits an endpoint's embedded query and folds it into the expected params, which is what Requests does, so the expectation is the merged query either client must end up sending. This also subsumes the get_awards trailing-? special case it previously carried. get_scheduled_games_by_date preserves Mlb's quirk of returning None rather than the [] its annotation promises when no date selector was given, asserted explicitly in the parity suite rather than left implicit. Tests: 1057 passed (up from 1016). tests/external_tests/ 148 passed, 1 skipped against the live API. Co-Authored-By: Claude Opus 5 --- docs/public-api.md | 28 ++- mlbstatsapi/_parsers/gamepace.py | 17 ++ mlbstatsapi/_parsers/schedules.py | 18 +- mlbstatsapi/async_mlb.py | 217 +++++++++++++++++++++- mlbstatsapi/mlb_api.py | 18 +- tests/parsers/test_gamepace_parser.py | 65 +++++++ tests/parsers/test_schedules.py | 106 ++++++++++- tests/test_async_mlb.py | 258 +++++++++++++++++++++++++- tests/test_public_api.py | 6 + tests/test_sync_async_parity.py | 181 ++++++++++++++++++ 10 files changed, 886 insertions(+), 28 deletions(-) create mode 100644 mlbstatsapi/_parsers/gamepace.py create mode 100644 tests/parsers/test_gamepace_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 94310e4..260094c 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -327,6 +327,15 @@ 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, @@ -369,9 +378,22 @@ do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}} listed at `https://statsapi.mlb.com/api/v1/statTypes` and `https://statsapi.mlb.com/api/v1/statGroups`. -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. +`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/async_mlb.py b/mlbstatsapi/async_mlb.py index fee87d0..839dbf6 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -19,11 +19,12 @@ 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 @@ -37,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 @@ -2251,3 +2253,214 @@ async def get_stats( 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 f4ef944..9ca1d42 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -38,7 +38,8 @@ 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 ( @@ -869,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]: """ @@ -1182,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]: """ 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/test_async_mlb.py b/tests/test_async_mlb.py index c6137b3..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 @@ -349,6 +351,94 @@ 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": [ { @@ -474,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) # --------------------------------------------------------------------------- @@ -1241,6 +1337,153 @@ async def scenario(): 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"}]})) @@ -1494,6 +1737,9 @@ def test_public_signatures_match_the_sync_client(): "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 7aba78f..d909bad 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -268,6 +268,12 @@ def _normalize_signature(fn: Any) -> str: "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 2f22bce..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 @@ -342,6 +343,94 @@ }, } +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": [ { @@ -831,6 +920,50 @@ def test_get_players_stats_for_game_forwards_params_on_both_clients(): ) +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( @@ -1184,6 +1317,54 @@ def test_stat_endpoint_no_result_parity(method, args, 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.