From 8a2695e004d22b2e2c6892c0296bc7c25ad7efd5 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 11:24:27 -0700 Subject: [PATCH 01/13] feat(async): expand AsyncMlb endpoint coverage for sport/league/division and team roster/coaches Adds get_sport(s), get_league(s), get_division(s), get_team_roster, and get_team_coaches to AsyncMlb, per issue #305's plan to expand async coverage in small, reviewable batches. New shared parsers (_parsers/sports.py, leagues.py, divisions.py, roster.py) are reused by both Mlb and AsyncMlb; Mlb's existing methods are refactored internally onto them with no behavior change. AsyncMlb docstrings are mirrored from their Mlb counterparts. Adds parser tests, AsyncMlb endpoint tests, sync/async parity tests, and live external smoke tests for the new endpoints, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 12 + mlbstatsapi/_parsers/divisions.py | 21 + mlbstatsapi/_parsers/leagues.py | 21 + mlbstatsapi/_parsers/roster.py | 24 + mlbstatsapi/_parsers/sports.py | 21 + mlbstatsapi/async_mlb.py | 745 +++++++++++++++++- mlbstatsapi/mlb_api.py | 53 +- .../async_mlb/test_async_mlb_smoke.py | 60 +- tests/parsers/test_divisions.py | 49 ++ tests/parsers/test_leagues.py | 43 + tests/parsers/test_roster_parser.py | 65 ++ tests/parsers/test_sports.py | 43 + tests/test_async_mlb.py | 228 +++++- tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 166 +++- 15 files changed, 1507 insertions(+), 52 deletions(-) create mode 100644 mlbstatsapi/_parsers/divisions.py create mode 100644 mlbstatsapi/_parsers/leagues.py create mode 100644 mlbstatsapi/_parsers/roster.py create mode 100644 mlbstatsapi/_parsers/sports.py create mode 100644 tests/parsers/test_divisions.py create mode 100644 tests/parsers/test_leagues.py create mode 100644 tests/parsers/test_roster_parser.py create mode 100644 tests/parsers/test_sports.py diff --git a/docs/public-api.md b/docs/public-api.md index 0e44a0dc..a8ea0abb 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -275,6 +275,8 @@ The currently supported awaitable endpoint methods are: ```text get_team(team_id: int, **params) get_teams(sport_id: int = 1, **params) +get_team_roster(team_id: int, **params) +get_team_coaches(team_id: int, **params) get_person(player_id: int, **params) get_people(sport_id: int = 1, **params) get_schedule( @@ -285,8 +287,18 @@ get_schedule( team_id: int = None, **params, ) +get_sport(sport_id: int, **params) +get_sports(**params) +get_league(league_id: int, **params) +get_leagues(**params) +get_division(division_id: int, **params) +get_divisions(**params) ``` +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. + ## Low-level adapter `MlbDataAdapter` is the public low-level HTTP adapter. diff --git a/mlbstatsapi/_parsers/divisions.py b/mlbstatsapi/_parsers/divisions.py new file mode 100644 index 00000000..7a791aff --- /dev/null +++ b/mlbstatsapi/_parsers/divisions.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.divisions import Division + + +def parse_divisions(data: dict) -> list[Division]: + """Parse Division models from an MLB /divisions response body. + + Expects the full response, e.g. ``{"divisions": [...]}``, not the inner list. + """ + if not data or not data.get("divisions"): + return [] + return [Division(**division) for division in data["divisions"]] + + +def parse_division(data: dict) -> Division | None: + """Parse a Division from a single division payload.""" + divisions = parse_divisions(data) + + if not divisions: + return None + + return divisions[0] diff --git a/mlbstatsapi/_parsers/leagues.py b/mlbstatsapi/_parsers/leagues.py new file mode 100644 index 00000000..c866dfd4 --- /dev/null +++ b/mlbstatsapi/_parsers/leagues.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.leagues import League + + +def parse_leagues(data: dict) -> list[League]: + """Parse League models from an MLB /leagues response body. + + Expects the full response, e.g. ``{"leagues": [...]}``, not the inner list. + """ + if not data or not data.get("leagues"): + return [] + return [League(**league) for league in data["leagues"]] + + +def parse_league(data: dict) -> League | None: + """Parse a League from a single league payload.""" + leagues = parse_leagues(data) + + if not leagues: + return None + + return leagues[0] diff --git a/mlbstatsapi/_parsers/roster.py b/mlbstatsapi/_parsers/roster.py new file mode 100644 index 00000000..9ee36dbf --- /dev/null +++ b/mlbstatsapi/_parsers/roster.py @@ -0,0 +1,24 @@ +from mlbstatsapi import mlb_module +from mlbstatsapi.models.people import Coach, Player + + +def parse_roster_players(data: dict) -> list[Player]: + """Parse Player models from an MLB /teams/{id}/roster response body.""" + if not data or not data.get("roster"): + return [] + return [ + Player(**mlb_module.merge_keys(player, ["person"])) for player in data["roster"] + ] + + +def parse_roster_coaches(data: dict) -> list[Coach]: + """Parse Coach models from an MLB /teams/{id}/coaches response body. + + The coaches endpoint reuses the same ``roster`` envelope as the player + roster endpoint. + """ + if not data or not data.get("roster"): + return [] + return [ + Coach(**mlb_module.merge_keys(coach, ["person"])) for coach in data["roster"] + ] diff --git a/mlbstatsapi/_parsers/sports.py b/mlbstatsapi/_parsers/sports.py new file mode 100644 index 00000000..4872aa48 --- /dev/null +++ b/mlbstatsapi/_parsers/sports.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.sports import Sport + + +def parse_sports(data: dict) -> list[Sport]: + """Parse Sport models from an MLB /sports response body. + + Expects the full response, e.g. ``{"sports": [...]}``, not the inner list. + """ + if not data or not data.get("sports"): + return [] + return [Sport(**sport) for sport in data["sports"]] + + +def parse_sport(data: dict) -> Sport | None: + """Parse a Sport from a single sport payload.""" + sports = parse_sports(data) + + if not sports: + return None + + return sports[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 12cac734..3271d915 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,13 +6,20 @@ from typing import TYPE_CHECKING from ._helpers.schedule import build_schedule_params +from ._parsers.divisions import parse_division, parse_divisions +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.sports import parse_sport, parse_sports from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType -from .models.people import Person +from .models.divisions import Division +from .models.leagues import League +from .models.people import Coach, Person, Player from .models.schedules import Schedule +from .models.sports import Sport from .models.teams import Team if TYPE_CHECKING: @@ -72,6 +79,62 @@ async def get_team( team_id: int, **params, ) -> Team | None: + """ + Returns a team based on teamId. + + Async counterpart of ``Mlb.get_team``. + + Parameters + ---------- + team_id : int + Insert teamId to return a directory of team information for a + particular club. + + Other Parameters + ---------------- + season : int + Insert year to return a directory of team information for a + particular club in a specific season. + sportId : int + Insert a sportId to return a directory of team information for a + particular club in a sport. + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. Format "league,venue" + Available Hydrations: + previousSchedule + nextSchedule + venue + social + deviceProperties + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + person + sport + division + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Team + returns a Team from team id + + See Also + -------- + AsyncMlb.get_teams : Return a list of Teams from sport id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... team = await mlb.get_team(133) + Team + """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"teams/{team_id}", ep_params=params, @@ -87,10 +150,71 @@ async def get_teams( sport_id: int = 1, **params, ) -> list[Team]: - """Return every Team for a sport id. - - Async counterpart of ``Mlb.get_teams``; see that method for the - supported keyword parameters. + """ + return the all Teams + + Async counterpart of ``Mlb.get_teams``. + + Parameters + ---------- + sport_id : int + Insert sportId to return team information for a particular sportId + + Other Parameters + ---------------- + season : str + Insert year to return team information for a particular season. + leagueIds : int + Insert leagueId to return team information for particular league. + activeStatus : str + Insert activeStatus to populate a teams based on active/inactive + status for a given season. There are three status types: Y, N, B + allStarStatuses : str + Insert allStarStatuses to populate a teams based on Allstar status + for a given season. There are two status types: Y and N + sportIds : str + Insert sportId to return team information for a particular sportId + Usage: '1' or '1,11,12' + gameType : str + Insert gameType to return team information for a particular + gameType. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. Format "league,venue" + Available Hydrations: + previousSchedule + nextSchedule + venue + social + deviceProperties + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + person + sport + division + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Teams + returns a list of teams + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... teams = await mlb.get_teams() + [Team, Team, Team] """ params["sportId"] = sport_id @@ -104,11 +228,168 @@ async def get_teams( return parse_teams(mlb_data.data) + async def get_team_roster( + self, + team_id: int, + **params, + ) -> list[Player]: + """ + return the team player roster + + Async counterpart of ``Mlb.get_team_roster``. + + Parameters + ---------- + team_id : int + teamId to return a directory of players based on roster status for + a particular club. + + Other Parameters + ---------------- + rosterType : str + Insert teamId to return a directory of players based on roster + status for a particular club. rosterType's include 40Man, + fullSeason, fullRoster, nonRosterInvitees, active, allTime, + depthChart, gameday, and coach. + season : str + Insert year to return a directory of players based on roster + status for a particular club in a specific season. + date : str + Insert date to return a directory of players based on roster + status for a particular club on a specific date. + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. The hydration for Teams contains "person" which has + subhydrations Format "person(subHydration1, subHydrations2)" + Available Hydrations: + "person" + Hydrations Available Through Person + hydrations + awards + currentTeam + team + rosterEntries + relatives + transactions + social + education + stats + draft + mixedFeed + articles + video + xrefId + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of players + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + AsyncMlb.get_team_coaches : Return a list of Coaches from team id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... roster = await mlb.get_team_roster(133) + [Player, Player, Player] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/roster", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_roster_players(mlb_data.data) + + async def get_team_coaches( + self, + team_id: int, + **params, + ) -> list[Coach]: + """ + Return a directory of coaches for a particular team. + + Async counterpart of ``Mlb.get_team_coaches``. + + Parameters + ---------- + team_id : int + Insert teamId to return a directory of coaches for a given team. + + Other Parameters + ---------------- + season : str + Insert year to return a directory of players based on roster status for a particular club in a specific season. + date : str + Insert date to return a directory of players based on roster status for a particular club on a specific date. + fields : str + Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Coaches + returns a list of Coaches + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + AsyncMlb.get_team_roster : Return a list of Players from team id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... coaches = await mlb.get_team_coaches(133) + [Coach, Coach, Coach] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/coaches", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_roster_coaches(mlb_data.data) + async def get_person( self, player_id: int, **params, ) -> Person | None: + """ + This endpoint returns statistical data and biographical information + for a player,coach or umpire based on playerId. + + Async counterpart of ``Mlb.get_person``. + + Parameters + ---------- + player_id : int + Insert personId for a specific player, coach or umpire based on + playerId. + + Returns + ------- + Person + Returns a Person + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... person = await mlb.get_person(660271) + Person + """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"people/{player_id}", ep_params=params, @@ -124,10 +405,40 @@ async def get_people( sport_id: int = 1, **params, ) -> list[Person]: - """Return every player for a sport id. + """ + return the all players for sportid Async counterpart of ``Mlb.get_people``, which reads the ``sports/{sport_id}/players`` endpoint rather than ``people``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return player information for a particular + sport. + + Other Parameters + ---------------- + season : str + Insert year to return player information for a particular season. + gameType : str + Insert gameType to return player information for a particular + gameType. + + Returns + ------- + list + Returns a list of People + + See Also + -------- + AsyncMlb.get_person : Return Person from id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... people = await mlb.get_people() + [Person, Person, Person] """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"sports/{sport_id}/players", @@ -149,7 +460,162 @@ async def get_schedule( team_id: int = None, **params, ) -> Schedule | None: - + """ + return the schedule created from the included params. + + Async counterpart of ``Mlb.get_schedule``. + + Calling get_schedule without startDate or endDate results in a schedule returned + for todays date. Calling with startDate and endDate as the same date returns a + schedule for just that desired date. Different results in the schedule for multiple + days. + + Parameters + ---------- + date : str + Date + start_date : str "yyyy-mm-dd" + Start date + end_date : str "yyyy-mm-dd" + End date + sport_id : int + sport id of schedule defaults to 1 + team_id : int + get schedule for team with team_id + + 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 + + scheduleType : str + Insert one or mutliple of the three available scheduleTypes to + return data for a particular schedule. Format "games,events,xref" + eventTypes : str + Insert one or mutliple of the three available eventTypes to + return data for a particular schedule. Format "primary,secondary" + There are two different schedule eventTypes: + primary- returns calendar/schedule pages. + secondary returns ticket pages. + hydrate : str + Insert Hydration(s) to return data for any available schedule + hydration. The hydrations for schedule contain "venue" and "team" + which have subhydrations. + Format "team(subHydration1, subHydrations2)" + Available Hydrations: + tickets + game(content) + game(content(all)) + game(content(media(all))) + game(content(editorial(all))) + game(content(highlights(all))) + game(content(editorial(preview))) + game(content(editorial(recap))) + game(content(editorial(articles))) + game(content(editorial(wrap))) + game(content(media(epg))) + game(content(media(milestones))) + game(content(highlights(scoreboard))) + game(content(highlights(scoreboardPreview))) + game(content(highlights(highlights))) + game(content(highlights(gamecenter))) + game(content(highlights(milestone))) + game(content(highlights(live))) + game(content(media(featured))) + game(content(summary)) + game(content(gamenotes)) + game(tickets) + game(atBatTickets) + game(promotions) + game(atBatPromotions) + game(sponsorships) + lineup + linescore + linescore(matchup) + linescore(runners) + linescore(defense) + decisions + scoringplays + broadcasts + broadcasts(all) + radioBroadcasts + metadata + game(seriesSummary) + seriesStatus + event(performers) + event(promotions) + event(timezone) + event(tickets) + event(venue) + event(designations) + event(game) + event(status) + weather + officials + probablePitcher + venue + relatedVenues + parentVenues + residentVenues + relatedVenues(venue) + parentVenues(venue) + residentVenues(venue) + location + social + relatedApplications + timezone + menu + metadata + performers + images + schedule + nextSchedule + previousSchedule + ticketManagement + xrefId + team + previousSchedule + nextSchedule + venue + springVenue + social + deviceProperties + game(promotions) + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + videos + person + sport + standings + division + xref + + Returns + ------- + Schedule + returns the Schedule for the dates + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... schedule = await mlb.get_schedule(start_date="2021-08-01", end_date="2021-08-11") + Schedule + """ params = build_schedule_params( date=date, start_date=start_date, @@ -172,3 +638,268 @@ async def get_schedule( return None return parse_schedule(mlb_data.data) + + async def get_sport( + self, + sport_id: int, + **params, + ) -> Sport | None: + """ + return sport object from sport_id + + Async counterpart of ``Mlb.get_sport``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of sport(s). + For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Sport + + See Also + -------- + AsyncMlb.get_sports : return a list of sports + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... sport = await mlb.get_sport(1) + Sport + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"sports/{sport_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_sport(mlb_data.data) + + async def get_sports( + self, + **params, + ) -> list[Sport]: + """ + return all sports + + Async counterpart of ``Mlb.get_sports``. + + Returns + ------- + list of Sports + returns a list of sport objects + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_sport : return a sport from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... sports = await mlb.get_sports() + [Sport, Sport, Sport] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="sports", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_sports(mlb_data.data) + + async def get_league( + self, + league_id: int, + **params, + ) -> League | None: + """ + return league + + Async counterpart of ``Mlb.get_league``. + + Parameters + ---------- + league_id : int + leagueId to return league information for a specific league + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + League + + See Also + -------- + AsyncMlb.get_leagues : return a list of Leagues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... league = await mlb.get_league(103) + League + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"leagues/{league_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_league(mlb_data.data) + + async def get_leagues( + self, + **params, + ) -> list[League]: + """ + return all leagues + + Async counterpart of ``Mlb.get_leagues``. + + Returns + ------- + list of Leagues + + Other Parameters + ---------------- + leagueId : str + leagueId(s) to return league information for specific leagues. + Format '103,104' + sportId : int + Insert sportId to return league information for a specific sport. + For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports + seasons : str + Insert year(s) to return league information for a specific season. + Format '2017,2018' + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_league : return a League from league id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... leagues = await mlb.get_leagues() + [League, League, League] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="leagues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_leagues(mlb_data.data) + + async def get_division( + self, + division_id: int, + **params, + ) -> Division | None: + """ + Returns a division based on divisionId, + + Async counterpart of ``Mlb.get_division``. + + Parameters + ---------- + division_id : int + divisionId to return a directory of division(s) for a specific division. + + Returns + ------- + Division + returns a Division + + See Also + -------- + AsyncMlb.get_divisions : return a list of Divisions + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... division = await mlb.get_division(200) + Division + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"divisions/{division_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_division(mlb_data.data) + + async def get_divisions( + self, + **params, + ) -> list[Division]: + """ + return all divisons + + Async counterpart of ``Mlb.get_divisions``. + + Other Parameters + ---------------- + divisionId : str + Insert divisionId(s) to return a directory of division(s) for a + specific division. Format '200,201' + leagueId : int + Insert leagueId to return a directory of division(s) for all + divisions in a specific league. + sportId : int + Insert a sportId to return a directory of division(s) for all + divisions in a specific sport. + + Returns + ------- + list of Divisions + returns a list of all divisions + + See Also + -------- + AsyncMlb.get_division : return a Division from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... divisions = await mlb.get_divisions() + [Division, Division, Division] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="divisions", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_divisions(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ace2c570..fa23befd 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,7 +23,11 @@ from mlbstatsapi.models.standings import Standings +from ._parsers.divisions import parse_divisions, parse_division +from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person +from ._parsers.roster import parse_roster_coaches, parse_roster_players +from ._parsers.sports import parse_sports, parse_sport from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule @@ -573,13 +577,7 @@ def get_team_roster(self, team_id: int, **params) -> List[Player]: if 400 <= mlb_data.status_code <= 499: return [] - players = [] - - if 'roster' in mlb_data.data and mlb_data.data['roster']: - for player in mlb_data.data['roster']: - players.append(Player(**mlb_module.merge_keys(player, ['person']))) - - return players + return parse_roster_players(mlb_data.data) def get_team_coaches(self, team_id: int, **params) -> List[Coach]: """ @@ -623,13 +621,7 @@ def get_team_coaches(self, team_id: int, **params) -> List[Coach]: if 400 <= mlb_data.status_code <= 499: return [] - coaches = [] - - if 'roster' in mlb_data.data and mlb_data.data['roster']: - for coach in mlb_data.data['roster']: - coaches.append(Coach(**mlb_module.merge_keys(coach, ['person']))) - - return coaches + return parse_roster_coaches(mlb_data.data) def get_schedule(self, date: str = None, @@ -1381,9 +1373,7 @@ def get_sport(self, sport_id: int, **params) -> Union[Sport, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'sports' in mlb_data.data and mlb_data.data['sports']: - for sport in mlb_data.data['sports']: - return Sport(**sport) + return parse_sport(mlb_data.data) def get_sports(self, **params) -> List[Sport]: """ @@ -1416,12 +1406,7 @@ def get_sports(self, **params) -> List[Sport]: if 400 <= mlb_data.status_code <= 499: return [] - sports = [] - - if 'sports' in mlb_data.data and mlb_data.data['sports']: - sports = [Sport(**sport) for sport in mlb_data.data['sports']] - - return sports + return parse_sports(mlb_data.data) def get_sport_id(self, sport_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1503,9 +1488,7 @@ def get_league(self, league_id: int, **params) -> Union[League, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - for league in mlb_data.data['leagues']: - return League(**league) + return parse_league(mlb_data.data) def get_leagues(self, **params) -> List[League]: """ @@ -1546,12 +1529,7 @@ def get_leagues(self, **params) -> List[League]: if 400 <= mlb_data.status_code <= 499: return [] - leagues = [] - - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - leagues = [League(**league) for league in mlb_data.data['leagues']] - - return leagues + return parse_leagues(mlb_data.data) def get_league_id(self, league_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1626,9 +1604,7 @@ def get_division(self, division_id: int, **params) -> Union[Division, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - for division in mlb_data.data['divisions']: - return Division(**division) + return parse_division(mlb_data.data) def get_divisions(self, **params) -> List[Division]: """ @@ -1667,12 +1643,7 @@ def get_divisions(self, **params) -> List[Division]: if 400 <= mlb_data.status_code <= 499: return [] - divisions = [] - - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - divisions = [Division(**division) for division in mlb_data.data['divisions']] - - return divisions + return parse_divisions(mlb_data.data) def get_division_id(self, division_name: str, search_key: str = 'name', **params) -> List[int]: diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 9e39cf6a..8d3f7238 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -1,8 +1,11 @@ import asyncio from mlbstatsapi import AsyncMlb -from mlbstatsapi.models.people import Person +from mlbstatsapi.models.divisions import Division +from mlbstatsapi.models.leagues import League +from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.sports import Sport from mlbstatsapi.models.teams import Team @@ -17,6 +20,28 @@ async def scenario(): asyncio.run(scenario()) +def test_async_get_team_roster(): + async def scenario(): + async with AsyncMlb() as mlb: + roster = await mlb.get_team_roster(133) + + assert roster + assert isinstance(roster[0], Player) + + asyncio.run(scenario()) + + +def test_async_get_team_coaches(): + async def scenario(): + async with AsyncMlb() as mlb: + coaches = await mlb.get_team_coaches(133) + + assert coaches + assert isinstance(coaches[0], Coach) + + asyncio.run(scenario()) + + def test_async_get_person(): async def scenario(): async with AsyncMlb() as mlb: @@ -37,3 +62,36 @@ async def scenario(): assert schedule.dates asyncio.run(scenario()) + + +def test_async_get_sport(): + async def scenario(): + async with AsyncMlb() as mlb: + sport = await mlb.get_sport(1) + + assert isinstance(sport, Sport) + assert sport.id == 1 + + asyncio.run(scenario()) + + +def test_async_get_league(): + async def scenario(): + async with AsyncMlb() as mlb: + league = await mlb.get_league(103) + + assert isinstance(league, League) + assert league.id == 103 + + asyncio.run(scenario()) + + +def test_async_get_division(): + async def scenario(): + async with AsyncMlb() as mlb: + division = await mlb.get_division(200) + + assert isinstance(division, Division) + assert division.id == 200 + + asyncio.run(scenario()) diff --git a/tests/parsers/test_divisions.py b/tests/parsers/test_divisions.py new file mode 100644 index 00000000..0e1bbeda --- /dev/null +++ b/tests/parsers/test_divisions.py @@ -0,0 +1,49 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.divisions import parse_division, parse_divisions +from mlbstatsapi.models.divisions import Division + + +def test_parse_divisions(): + """parse_divisions reads the MLB divisions envelope and returns Division models.""" + assert parse_divisions({}) == [] + assert parse_divisions({"divisions": []}) == [] + + divisions = parse_divisions( + { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"}, + {"id": 201, "link": "/api/v1/divisions/201", "name": "American League East"}, + ] + } + ) + + assert divisions == [ + Division(id=200, link="/api/v1/divisions/200", name="American League West"), + Division(id=201, link="/api/v1/divisions/201", name="American League East"), + ] + + +def test_parse_division(): + """parse_division builds a Division from one division payload.""" + assert parse_division({}) is None + + division = parse_division( + { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] + } + ) + + assert isinstance(division, Division) + assert division == Division( + id=200, link="/api/v1/divisions/200", name="American League West" + ) + + +def test_parse_division_requires_link(): + """Division requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_division({"divisions": [{"id": 200, "name": "American League West"}]}) diff --git a/tests/parsers/test_leagues.py b/tests/parsers/test_leagues.py new file mode 100644 index 00000000..efcea7e2 --- /dev/null +++ b/tests/parsers/test_leagues.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.leagues import parse_league, parse_leagues +from mlbstatsapi.models.leagues import League + + +def test_parse_leagues(): + """parse_leagues reads the MLB leagues envelope and returns League models.""" + assert parse_leagues({}) == [] + assert parse_leagues({"leagues": []}) == [] + + leagues = parse_leagues( + { + "leagues": [ + {"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}, + {"id": 104, "link": "/api/v1/leagues/104", "name": "National League"}, + ] + } + ) + + assert leagues == [ + League(id=103, link="/api/v1/leagues/103", name="American League"), + League(id=104, link="/api/v1/leagues/104", name="National League"), + ] + + +def test_parse_league(): + """parse_league builds a League from one league payload.""" + assert parse_league({}) is None + + league = parse_league( + {"leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}]} + ) + + assert isinstance(league, League) + assert league == League(id=103, link="/api/v1/leagues/103", name="American League") + + +def test_parse_league_requires_link(): + """League requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_league({"leagues": [{"id": 103, "name": "American League"}]}) diff --git a/tests/parsers/test_roster_parser.py b/tests/parsers/test_roster_parser.py new file mode 100644 index 00000000..c8c11e26 --- /dev/null +++ b/tests/parsers/test_roster_parser.py @@ -0,0 +1,65 @@ +from mlbstatsapi._parsers.roster import parse_roster_coaches, parse_roster_players +from mlbstatsapi.models.people import Coach, Player + + +PLAYER_ROSTER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} + +COACH_ROSTER_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} + + +def test_parse_roster_players(): + """parse_roster_players merges the nested person dict and returns Players.""" + assert parse_roster_players({}) == [] + assert parse_roster_players({"roster": []}) == [] + + players = parse_roster_players(PLAYER_ROSTER_PAYLOAD) + + assert players == [ + Player( + id=675961, + full_name="Alika Williams", + link="/api/v1/people/675961", + jersey_number="12", + status={"code": "A", "description": "Active"}, + parent_team_id=133, + ) + ] + + +def test_parse_roster_coaches(): + """parse_roster_coaches merges the nested person dict and returns Coaches.""" + assert parse_roster_coaches({}) == [] + assert parse_roster_coaches({"roster": []}) == [] + + coaches = parse_roster_coaches(COACH_ROSTER_PAYLOAD) + + assert coaches == [ + Coach( + id=117276, + full_name="Mark Kotsay", + link="/api/v1/people/117276", + jersey_number="7", + job="Manager", + job_id="MNGR", + title="Manager", + ) + ] diff --git a/tests/parsers/test_sports.py b/tests/parsers/test_sports.py new file mode 100644 index 00000000..a97f0615 --- /dev/null +++ b/tests/parsers/test_sports.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.sports import parse_sport, parse_sports +from mlbstatsapi.models.sports import Sport + + +def test_parse_sports(): + """parse_sports reads the MLB sports envelope and returns Sport models.""" + assert parse_sports({}) == [] + assert parse_sports({"sports": []}) == [] + + sports = parse_sports( + { + "sports": [ + {"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}, + {"id": 11, "link": "/api/v1/sports/11", "name": "Triple-A"}, + ] + } + ) + + assert sports == [ + Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball"), + Sport(id=11, link="/api/v1/sports/11", name="Triple-A"), + ] + + +def test_parse_sport(): + """parse_sport builds a Sport from one sport payload.""" + assert parse_sport({}) is None + + sport = parse_sport( + {"sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}]} + ) + + assert isinstance(sport, Sport) + assert sport == Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball") + + +def test_parse_sport_requires_link(): + """Sport requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_sport({"sports": [{"id": 1, "name": "Major League Baseball"}]}) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index cafb2637..788907e5 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -38,8 +38,11 @@ from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 -from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.leagues import League # noqa: E402 +from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -47,6 +50,38 @@ PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } +SPORT_PAYLOAD = { + "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}] +} +LEAGUE_PAYLOAD = { + "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}] +} +DIVISION_PAYLOAD = { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] +} +ROSTER_PLAYER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} +ROSTER_COACH_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -68,6 +103,28 @@ EXPECTED_PERSON = Person( id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" ) +EXPECTED_SPORT = Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball") +EXPECTED_LEAGUE = League(id=103, link="/api/v1/leagues/103", name="American League") +EXPECTED_DIVISION = Division( + id=200, link="/api/v1/divisions/200", name="American League West" +) +EXPECTED_ROSTER_PLAYER = Player( + id=675961, + full_name="Alika Williams", + link="/api/v1/people/675961", + jersey_number="12", + status={"code": "A", "description": "Active"}, + parent_team_id=133, +) +EXPECTED_ROSTER_COACH = Coach( + id=117276, + full_name="Mark Kotsay", + link="/api/v1/people/117276", + jersey_number="7", + job="Manager", + job_id="MNGR", + title="Manager", +) # The two ways an endpoint legitimately comes back with nothing to parse. NO_RESULT_RESPONSES = { @@ -406,6 +463,159 @@ async def scenario(): assert_matches_sync(handler.request, "get_people", 11, season="2021") +def test_get_sport_requests_the_sport_endpoint_and_parses_the_result(): + handler = _Handler(_json(SPORT_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport(1) + + sport = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_sport", 1) + assert sport == EXPECTED_SPORT + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_sport_returns_none_when_there_is_no_sport(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport(1) + + assert asyncio.run(scenario()) is None + + +def test_get_sports_request_matches_the_sync_client(): + handler = _Handler(_json({"sports": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sports() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_sports") + + +def test_get_league_requests_the_league_endpoint_and_parses_the_result(): + handler = _Handler(_json(LEAGUE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league(103) + + league = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_league", 103) + assert league == EXPECTED_LEAGUE + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_league_returns_none_when_there_is_no_league(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league(103) + + assert asyncio.run(scenario()) is None + + +def test_get_leagues_request_matches_the_sync_client(): + handler = _Handler(_json({"leagues": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_leagues() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_leagues") + + +def test_get_division_requests_the_division_endpoint_and_parses_the_result(): + handler = _Handler(_json(DIVISION_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division(200) + + division = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_division", 200) + assert division == EXPECTED_DIVISION + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_division_returns_none_when_there_is_no_division(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division(200) + + assert asyncio.run(scenario()) is None + + +def test_get_divisions_request_matches_the_sync_client(): + handler = _Handler(_json({"divisions": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_divisions() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_divisions") + + +def test_get_team_roster_requests_the_roster_endpoint_and_parses_the_result(): + handler = _Handler(_json(ROSTER_PLAYER_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_roster(133, rosterType="40Man") + + roster = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_team_roster", 133, rosterType="40Man") + assert roster == [EXPECTED_ROSTER_PLAYER] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_roster_returns_empty_list_when_there_is_no_roster(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_roster(133) + + assert asyncio.run(scenario()) == [] + + +def test_get_team_coaches_requests_the_coaches_endpoint_and_parses_the_result(): + handler = _Handler(_json(ROSTER_COACH_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_coaches(133) + + coaches = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_team_coaches", 133) + assert coaches == [EXPECTED_ROSTER_COACH] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_coaches_returns_empty_list_when_there_are_no_coaches(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_coaches(133) + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -415,7 +625,21 @@ def test_public_signatures_match_the_sync_client(): """Argument names, kinds, and defaults must not drift from Mlb's.""" import inspect - for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): + for name in ( + "get_team", + "get_teams", + "get_team_roster", + "get_team_coaches", + "get_person", + "get_people", + "get_schedule", + "get_sport", + "get_sports", + "get_league", + "get_leagues", + "get_division", + "get_divisions", + ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index b76164dc..44b1bed6 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -238,12 +238,20 @@ def _normalize_signature(fn: Any) -> str: "__aexit__": "(exc_type, exc, traceback)", "get_team": "(team_id: int, **params)", "get_teams": "(sport_id: int=1, **params)", + "get_team_roster": "(team_id: int, **params)", + "get_team_coaches": "(team_id: int, **params)", "get_person": "(player_id: int, **params)", "get_people": "(sport_id: int=1, **params)", "get_schedule": ( "(date: str=None, start_date: str=None, end_date: str=None, " "sport_id: int=1, team_id: int=None, **params)" ), + "get_sport": "(sport_id: int, **params)", + "get_sports": "(**params)", + "get_league": "(league_id: int, **params)", + "get_leagues": "(**params)", + "get_division": "(division_id: int, **params)", + "get_divisions": "(**params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 813e0507..ed19bd44 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -42,8 +42,11 @@ MlbTimeoutError, MlbTransportError, ) -from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.leagues import League # noqa: E402 +from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -51,6 +54,38 @@ PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } +SPORT_PAYLOAD = { + "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}] +} +LEAGUE_PAYLOAD = { + "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}] +} +DIVISION_PAYLOAD = { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] +} +ROSTER_PLAYER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} +ROSTER_COACH_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -315,6 +350,80 @@ def test_get_schedule_range_team_and_sport_request_parity(): ) +def test_get_sport_success_parity(): + """A successful sport response parses to the same Sport on both clients.""" + result = call_both("get_sport", 1, payload=SPORT_PAYLOAD) + + assert isinstance(result.sync, Sport), "sync get_sport did not return a Sport" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 1, + "/api/v1/sports/1", + "Major League Baseball", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/sports/1", {}) + + +def test_get_league_success_parity(): + """A successful league response parses to the same League on both clients.""" + result = call_both("get_league", 103, payload=LEAGUE_PAYLOAD) + + assert isinstance(result.sync, League), "sync get_league did not return a League" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 103, + "/api/v1/leagues/103", + "American League", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/leagues/103", {}) + + +def test_get_division_success_parity(): + """A successful division response parses to the same Division on both clients.""" + result = call_both("get_division", 200, payload=DIVISION_PAYLOAD) + + assert isinstance(result.sync, Division), "sync get_division did not return a Division" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 200, + "/api/v1/divisions/200", + "American League West", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/divisions/200", {}) + + +def test_get_team_roster_success_parity(): + """A successful roster response parses to the same Players on both clients.""" + result = call_both("get_team_roster", 133, payload=ROSTER_PLAYER_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Player), ( + "sync get_team_roster did not return a list of Player" + ) + assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].jersey_number) == ( + 675961, + "Alika Williams", + "12", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams/133/roster", {}) + + +def test_get_team_coaches_success_parity(): + """A successful coaches response parses to the same Coaches on both clients.""" + result = call_both("get_team_coaches", 133, payload=ROSTER_COACH_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Coach), ( + "sync get_team_coaches did not return a list of Coach" + ) + assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].job) == ( + 117276, + "Mark Kotsay", + "Manager", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams/133/coaches", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -355,6 +464,61 @@ def test_get_schedule_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_sport_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_sport", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_sport returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_sport returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_league_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_league", 103, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_league returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_league returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_division_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_division", 200, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_division returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_division returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_roster_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_team_roster", 133, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_team_roster returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_team_roster returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_coaches_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_team_coaches", 133, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_team_coaches returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_team_coaches returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 19dc8a709198d2c37d675b373e5be4f021670a6d Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 11:41:14 -0700 Subject: [PATCH 02/13] feat(async): add get_season/get_seasons to AsyncMlb Continues issue #305's endpoint expansion. Adds a shared _parsers/seasons.py parser reused by both Mlb (refactored internally, no behavior change) and AsyncMlb, mirrors Mlb's docstrings onto the new AsyncMlb methods, and covers them with parser, endpoint, sync/async parity, and live external smoke tests. Renames the new tests/parsers/test_seasons.py to test_seasons_parser.py to avoid a pytest basename collision with the existing tests/external_tests/seasons/test_seasons.py (no test __init__.py packages exist yet, so basenames must be unique repo-wide); issue #322 tracks resolving this class of collision for good. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 2 + mlbstatsapi/_parsers/seasons.py | 21 ++++ mlbstatsapi/async_mlb.py | 115 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 13 +- .../async_mlb/test_async_mlb_smoke.py | 12 ++ tests/parsers/test_seasons_parser.py | 32 +++++ tests/test_async_mlb.py | 40 ++++++ tests/test_public_api.py | 2 + tests/test_sync_async_parity.py | 23 ++++ 9 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 mlbstatsapi/_parsers/seasons.py create mode 100644 tests/parsers/test_seasons_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index a8ea0abb..cf190266 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -293,6 +293,8 @@ get_league(league_id: int, **params) get_leagues(**params) get_division(division_id: int, **params) get_divisions(**params) +get_season(season_id: str, sport_id: int = 1, **params) +get_seasons(sport_id: int = 1, **params) ``` Every other `Mlb` endpoint method not listed above is not yet supported on diff --git a/mlbstatsapi/_parsers/seasons.py b/mlbstatsapi/_parsers/seasons.py new file mode 100644 index 00000000..4bb1ca8e --- /dev/null +++ b/mlbstatsapi/_parsers/seasons.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.seasons import Season + + +def parse_seasons(data: dict) -> list[Season]: + """Parse Season models from an MLB /seasons response body. + + Expects the full response, e.g. ``{"seasons": [...]}``, not the inner list. + """ + if not data or not data.get("seasons"): + return [] + return [Season(**season) for season in data["seasons"]] + + +def parse_season(data: dict) -> Season | None: + """Parse a Season from a single season payload.""" + seasons = parse_seasons(data) + + if not seasons: + return None + + return seasons[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 3271d915..e9e66b9a 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -11,6 +11,7 @@ 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.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter @@ -19,6 +20,7 @@ from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule +from .models.seasons import Season from .models.sports import Sport from .models.teams import Team @@ -903,3 +905,116 @@ async def get_divisions( return [] return parse_divisions(mlb_data.data) + + async def get_season( + self, + season_id: str, + sport_id: int = 1, + **params, + ) -> Season | None: + """ + return a season object for seasonid and sportid + + Async counterpart of ``Mlb.get_season``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of seasons for a specific sport. + season_id : str + Insert year to return season information for a particular season. + + Other Parameters + ---------------- + withGameTypeDates : bool, optional + Insert a withGameTypeDates to return season information for all gameTypes. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Season + returns a season object + + See Also + -------- + AsyncMlb.get_seasons : return a list of seasons + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... season = await mlb.get_season(season_id="2021", sport_id=1) + Season + """ + if sport_id is not None: + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"seasons/{season_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_season(mlb_data.data) + + async def get_seasons( + self, + sport_id: int = 1, + **params, + ) -> list[Season]: + """ + return a season object for sportid + + Async counterpart of ``Mlb.get_seasons``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of seasons for a specific + sport. + + Other Parameters + ---------------- + divisionId : int, optional + Insert divisionId to return a directory of seasons for a specific + division. + leagueId : int, optional + Insert leagueId to return a directory of seasons in a specific + league. + withGameTypeDates : bool, optional + Insert a withGameTypeDates to return season information for all + gameTypes. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Season + returns a season object + + See Also + -------- + AsyncMlb.get_season : return a Season from season id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... seasons = await mlb.get_seasons(1) + [Season, Season, Season, Season] + """ + if sport_id is not None: + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="seasons/all", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_seasons(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index fa23befd..ed9354cf 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -27,6 +27,7 @@ from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players +from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule @@ -1730,9 +1731,7 @@ def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season: if 400 <= mlb_data.status_code <= 499: return None - if 'seasons' in mlb_data.data and mlb_data.data['seasons']: - for season in mlb_data.data['seasons']: - return Season(**season) + return parse_season(mlb_data.data) def get_seasons(self, sport_id: int = 1, **params) -> List[Season]: """ @@ -1787,13 +1786,7 @@ def get_seasons(self, sport_id: int = 1, **params) -> List[Season]: if 400 <= mlb_data.status_code <= 499: return [] - season_list = [] - - if 'seasons' in mlb_data.data and mlb_data.data['seasons']: - for season in mlb_data.data['seasons']: - season_list.append(Season(**season)) - - return season_list + return parse_seasons(mlb_data.data) def get_standings(self, league_id: int, season: str, **params): """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 8d3f7238..f0143f47 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.seasons import Season from mlbstatsapi.models.sports import Sport from mlbstatsapi.models.teams import Team @@ -95,3 +96,14 @@ async def scenario(): assert division.id == 200 asyncio.run(scenario()) + + +def test_async_get_season(): + async def scenario(): + async with AsyncMlb() as mlb: + season = await mlb.get_season("2021") + + assert isinstance(season, Season) + assert season.season_id == "2021" + + asyncio.run(scenario()) diff --git a/tests/parsers/test_seasons_parser.py b/tests/parsers/test_seasons_parser.py new file mode 100644 index 00000000..a9844e8d --- /dev/null +++ b/tests/parsers/test_seasons_parser.py @@ -0,0 +1,32 @@ +from mlbstatsapi._parsers.seasons import parse_season, parse_seasons +from mlbstatsapi.models.seasons import Season + + +def test_parse_seasons(): + """parse_seasons reads the MLB seasons envelope and returns Season models.""" + assert parse_seasons({}) == [] + assert parse_seasons({"seasons": []}) == [] + + seasons = parse_seasons( + { + "seasons": [ + {"seasonId": "2021", "hasWildcard": True}, + {"seasonId": "2022", "hasWildcard": True}, + ] + } + ) + + assert seasons == [ + Season(seasonId="2021", hasWildcard=True), + Season(seasonId="2022", hasWildcard=True), + ] + + +def test_parse_season(): + """parse_season builds a Season from one season payload.""" + assert parse_season({}) is None + + season = parse_season({"seasons": [{"seasonId": "2021", "hasWildcard": True}]}) + + assert isinstance(season, Season) + assert season == Season(seasonId="2021", hasWildcard=True) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 788907e5..653982b7 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -82,6 +83,7 @@ } ] } +SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -125,6 +127,7 @@ job_id="MNGR", title="Manager", ) +EXPECTED_SEASON = Season(seasonId="2021", hasWildcard=True) # The two ways an endpoint legitimately comes back with nothing to parse. NO_RESULT_RESPONSES = { @@ -616,6 +619,41 @@ async def scenario(): assert asyncio.run(scenario()) == [] +def test_get_season_requests_the_season_endpoint_and_parses_the_result(): + handler = _Handler(_json(SEASON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_season("2021") + + season = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_season", "2021") + assert season == EXPECTED_SEASON + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_season_returns_none_when_there_is_no_season(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_season("2021") + + assert asyncio.run(scenario()) is None + + +def test_get_seasons_request_matches_the_sync_client(): + handler = _Handler(_json({"seasons": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_seasons(11) + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_seasons", 11) + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -639,6 +677,8 @@ def test_public_signatures_match_the_sync_client(): "get_leagues", "get_division", "get_divisions", + "get_season", + "get_seasons", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 44b1bed6..ad32a50d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -252,6 +252,8 @@ def _normalize_signature(fn: Any) -> str: "get_leagues": "(**params)", "get_division": "(division_id: int, **params)", "get_divisions": "(**params)", + "get_season": "(season_id: str, sport_id: int=1, **params)", + "get_seasons": "(sport_id: int=1, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index ed19bd44..c984e780 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -86,6 +87,7 @@ } ] } +SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -424,6 +426,16 @@ def test_get_team_coaches_success_parity(): assert result.request == ("GET", "/api/v1/teams/133/coaches", {}) +def test_get_season_success_parity(): + """A successful season response parses to the same Season on both clients.""" + result = call_both("get_season", "2021", payload=SEASON_PAYLOAD) + + assert isinstance(result.sync, Season), "sync get_season did not return a Season" + assert (result.sync.season_id, result.sync.has_wildcard) == ("2021", True) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/seasons/2021", {"sportId": "1"}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -519,6 +531,17 @@ def test_get_team_coaches_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_season_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_season", "2021", **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_season returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_season returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From ee945e4803607dde9e1527d348f4a6557ecd3dec Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:16:43 -0700 Subject: [PATCH 03/13] feat(async): add get_venue/get_venues and get_standings/get_attendance to AsyncMlb Continues issue #305's endpoint expansion in two closely related groups that landed together: - get_venue/get_venues: new shared _parsers/venues.py, reused by Mlb (refactored internally, no behavior change) and AsyncMlb. get_venue faithfully preserves Mlb's documented quirk of returning [] (not None) on a 400-499 status while still falling through to None on an empty 200. Also fixes the shared assert_matches_sync test helper, which only handled scalar query params and silently mis-compared get_venue's list-valued hydrate param; it now correctly expands list values into repeated query pairs, matching how Requests/HTTPX actually serialize them. - get_standings/get_attendance: new shared _parsers/standings.py and _parsers/attendance.py. While porting get_attendance, found and fixed a real bug in Mlb: its "at least one of team_id/league_id/league_list_id" guard used any(required_args), which iterates dict keys (always truthy) instead of values, so the guard never fired and a bare get_attendance() call silently issued an unfiltered request. Fixed to any(required_args.values()) with a regression test proving the guard now short-circuits, and both clients port the corrected behavior identically. Each endpoint gets parser, AsyncMlb endpoint, sync/async parity, and live external smoke test coverage, plus the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md are extended accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 20 ++ mlbstatsapi/_parsers/attendance.py | 8 + mlbstatsapi/_parsers/standings.py | 8 + mlbstatsapi/_parsers/venues.py | 21 ++ mlbstatsapi/async_mlb.py | 266 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 31 +- .../async_mlb/test_async_mlb_smoke.py | 35 +++ tests/parsers/test_attendance_parser.py | 50 ++++ tests/parsers/test_standings_parser.py | 92 ++++++ tests/parsers/test_venues.py | 32 +++ tests/test_async_mlb.py | 252 ++++++++++++++++- tests/test_mlb_attendance.py | 88 ++++++ tests/test_public_api.py | 7 + tests/test_sync_async_parity.py | 212 ++++++++++++++ 14 files changed, 1099 insertions(+), 23 deletions(-) create mode 100644 mlbstatsapi/_parsers/attendance.py create mode 100644 mlbstatsapi/_parsers/standings.py create mode 100644 mlbstatsapi/_parsers/venues.py create mode 100644 tests/parsers/test_attendance_parser.py create mode 100644 tests/parsers/test_standings_parser.py create mode 100644 tests/parsers/test_venues.py create mode 100644 tests/test_mlb_attendance.py diff --git a/docs/public-api.md b/docs/public-api.md index cf190266..845ea70b 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -295,8 +295,22 @@ get_division(division_id: int, **params) get_divisions(**params) get_season(season_id: str, sport_id: int = 1, **params) get_seasons(sport_id: int = 1, **params) +get_venue(venue_id: int, **params) +get_venues(**params) +get_standings(league_id: int, season: str, **params) +get_attendance( + team_id: int = None, + league_id: int = None, + league_list_id: str = None, + **params, +) ``` +`get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is +annotated `Venue | None` but returns `[]` (not `None`) on a 400–499 response, +matching the sync behavior noted above. This is preserved for parity, not +introduced by the async port. + 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. @@ -537,6 +551,12 @@ Notes and known conflicts (documented, not redesigned by this contract): * `get_homerun_derby` currently executes a bare `None` expression on 400–499 instead of `return None`, so execution may continue. A focused bugfix is recommended. +* `get_attendance`'s "at least one of `team_id`/`league_id`/`league_list_id`" + guard previously used `any(required_args)`, which iterates dict keys + (always truthy) rather than values, so the guard never actually fired. This + was fixed to `any(required_args.values())` while porting the endpoint to + `AsyncMlb` (issue #305); calling either client with no identifier now + returns `None` without making a request, as already documented above. * Nested Pydantic model fields are not frozen by this contract. ## Return-contract boundaries diff --git a/mlbstatsapi/_parsers/attendance.py b/mlbstatsapi/_parsers/attendance.py new file mode 100644 index 00000000..275cb1b7 --- /dev/null +++ b/mlbstatsapi/_parsers/attendance.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.attendances import Attendance + + +def parse_attendance(data: dict) -> Attendance | None: + """Parse an Attendance from an MLB /attendance response body.""" + if not data or not data.get("records"): + return None + return Attendance(**data) diff --git a/mlbstatsapi/_parsers/standings.py b/mlbstatsapi/_parsers/standings.py new file mode 100644 index 00000000..ff22603c --- /dev/null +++ b/mlbstatsapi/_parsers/standings.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.standings import Standings + + +def parse_standings(data: dict) -> list[Standings]: + """Parse Standings models from an MLB /standings response body.""" + if not data or not data.get("records"): + return [] + return [Standings(**standing) for standing in data["records"]] diff --git a/mlbstatsapi/_parsers/venues.py b/mlbstatsapi/_parsers/venues.py new file mode 100644 index 00000000..7004b19c --- /dev/null +++ b/mlbstatsapi/_parsers/venues.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.venues import Venue + + +def parse_venues(data: dict) -> list[Venue]: + """Parse Venue models from an MLB /venues response body. + + Expects the full response, e.g. ``{"venues": [...]}``, not the inner list. + """ + if not data or not data.get("venues"): + return [] + return [Venue(**venue) for venue in data["venues"]] + + +def parse_venue(data: dict) -> Venue | None: + """Parse a Venue from a single venue payload.""" + venues = parse_venues(data) + + if not venues: + return None + + return venues[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index e9e66b9a..1768fafd 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from ._helpers.schedule import build_schedule_params +from ._parsers.attendance import parse_attendance from ._parsers.divisions import parse_division, parse_divisions from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people @@ -13,16 +14,21 @@ from ._parsers.schedules import parse_schedule from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports +from ._parsers.standings import parse_standings from ._parsers.teams import parse_team, parse_teams +from ._parsers.venues import parse_venue, parse_venues from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType +from .models.attendances import Attendance from .models.divisions import Division from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule from .models.seasons import Season from .models.sports import Sport +from .models.standings import Standings from .models.teams import Team +from .models.venues import Venue if TYPE_CHECKING: import httpx @@ -1018,3 +1024,263 @@ async def get_seasons( return [] return parse_seasons(mlb_data.data) + + async def get_venue( + self, + venue_id: int, + **params, + ) -> Venue | None: + """ + returns venue directorial information for all available venues in the Stats API. + + Async counterpart of ``Mlb.get_venue``. + + Parameters + ---------- + venue_id : int + venueId to return venue directorial information based venueId. + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + + Returns + ------- + Venue + + See Also + -------- + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... venue = await mlb.get_venue(31) + Venue + """ + params["hydrate"] = ["location", "fieldInfo", "timezone"] + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"venues/{venue_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + # Documented quirk: this returns [] rather than None here, unlike + # every other single-resource endpoint, matching Mlb.get_venue. + # See docs/public-api.md. + return [] + + return parse_venue(mlb_data.data) + + async def get_venues( + self, + **params, + ) -> list[Venue]: + """ + return all venues + + Async counterpart of ``Mlb.get_venues``. + + Returns + ------- + list of Venues + returns a list of Venues + + Other Parameters + ---------------- + venueIds : int, List[int] + Insert venueId to return venue directorial information based + venueId. + sportIds : int, List[int] + Insert sportIds to return venue directorial information based a + given sport(s). For a list of all sports: + https://statsapi.mlb.com/api/v1/sports + season : int + Insert year to return venue directorial information for a given + season. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_venue : return a Venue + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... venues = await mlb.get_venues() + [Venue, Venue, Venue] + """ + params["hydrate"] = ["location", "fieldInfo", "timezone"] + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="venues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_venues(mlb_data.data) + + async def get_standings( + self, + league_id: int, + season: str, + **params, + ) -> list[Standings]: + """ + return a list of standings for league_id and season + + Async counterpart of ``Mlb.get_standings``. + + Parameters + ---------- + league_id : str + Insert leagueId to return all standings based on a particular + standingType for a specific league. + season : str + Insert year to return all standings based on a particular year. + + Other Parameters + ---------------- + standingsTypes : str + Insert standingType to return all standings based on a particular + year. + Description of all standingTypes: + regularSeason - Regular Season Standings + wildCard - Wild card standings + divisionLeaders - Division Leader standings + wildCardWithLeaders - Wild card standings with Division + Leaders firstHalf - First half standings. Only valid for + leagues with a split season + (Mexican League). + secondHalf - Second half standings. Only valid for leagues + with a split season (Mexican League). + springTraining - Spring Training Standings + postseason - Postseason Standings + byDivision - Standings by Division + byConference - Standings by Conference + byLeague - Standings by League + Find standingTypes at https://statsapi.mlb.com/api/v1/standingsTypes + date : str + Insert date to return standing information for on a particular + date. Format: MM/DD/YYYY + hydrate : str + Insert Hydration(s) to return data for any available standings + hydration. Format "team,league" + Available Hydrations: + team + league + division + sport + conference + record(conference) + record(division) + fields : str + Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Standings + returns a list of Standings + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... standings = await mlb.get_standings(103, "2022") + [Standings, Standings, Standings] + """ + if league_id is not None: + params["leagueId"] = league_id + + if season is not None: + params["season"] = season + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="standings", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_standings(mlb_data.data) + + async def get_attendance( + self, + team_id: int = None, + league_id: int = None, + league_list_id: str = None, + **params, + ) -> Attendance | None: + """ + returns attendance data based on teamId, leagueId, or leagueListId. + + Async counterpart of ``Mlb.get_attendance``. + + Required Parameters (at least one) + ---------- + team_id : int + Insert a teamId to return directory of attendnace for a given team + league_id : int + Insert leagueId(s) to return a directory of attendanace for a + specific league. Format '103,104' + league_list_id : str + Insert a unique League List Identifier to return a directory of + attendanace for a specific league listId. + Available values : milb_full, milb_short, milb_complex, milb_all, + milb_all_nomex, milb_all_domestic, milb_noncomp, + milb_noncomp_nomex, milb_domcomp, milb_intcomp, win_noabl, + win_caribbean, win_all, abl, mlb, mlb_hist, mlb_milb, + mlb_milb_hist, mlb_milb_win, baseball_all + + Parameters + ---------- + season : int + Insert year(s) to return a directory of attendance for a given + season. Season year number format yyyy + date : str 'yyyy-mm-dd' + Insert date to return information for attendance on a particular + date. Format: MM/DD/YYYY + gametype : str + Insert gameType(s) a directory of attendance for a given gameType. + For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + Returns + ------- + Attendance + + See Also + -------- + AsyncMlb.get_leagues : return a list of Leagues + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... attendance = await mlb.get_attendance(team_id=133, season=2022) + Attendance + """ + required_args = {"teamId": team_id, "leagueId": league_id, "leagueListId": league_list_id} + + if not any(required_args.values()): + return None + + for arg_name, arg_value in required_args.items(): + if arg_value: + params[arg_name] = arg_value + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="attendance", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_attendance(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ed9354cf..4339bd81 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,14 +23,17 @@ from mlbstatsapi.models.standings import Standings +from ._parsers.attendance import parse_attendance from ._parsers.divisions import parse_divisions, parse_division from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport +from ._parsers.standings import parse_standings from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule +from ._parsers.venues import parse_venues, parse_venue from .mlb_dataadapter import ( DEFAULT_TIMEOUT, @@ -1241,11 +1244,11 @@ def get_venue(self, venue_id: int, **params) -> Union[Venue, None]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'venues/{venue_id}', ep_params=params) if 400 <= mlb_data.status_code <= 499: + # Documented quirk: this returns [] rather than None here, unlike + # every other single-resource endpoint. See docs/public-api.md. return [] - if 'venues' in mlb_data.data and mlb_data.data['venues']: - for venue in mlb_data.data['venues']: - return Venue(**venue) + return parse_venue(mlb_data.data) def get_venues(self, **params) -> List[Venue]: """ @@ -1289,12 +1292,7 @@ def get_venues(self, **params) -> List[Venue]: if 400 <= mlb_data.status_code <= 499: return [] - venues = [] - - if 'venues' in mlb_data.data and mlb_data.data['venues']: - venues = [Venue(**venue) for venue in mlb_data.data['venues']] - - return venues + return parse_venues(mlb_data.data) def get_venue_id(self, venue_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1857,14 +1855,8 @@ def get_standings(self, league_id: int, season: str, **params): mlb_data = self._mlb_adapter_v1.get(endpoint=f'standings', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - standings_list = [] - if 'records' in mlb_data.data and mlb_data.data['records']: - for standing in mlb_data.data['records']: - standings_list.append(Standings(**standing)) - - return standings_list + return parse_standings(mlb_data.data) def get_attendance(self, team_id: int = None, league_id: int = None, @@ -1919,8 +1911,8 @@ def get_attendance(self, team_id: int = None, league_id: int = None, """ required_args = {'teamId': team_id, 'leagueId': league_id, 'leagueListId': league_list_id} - if not any(required_args): - return + if not any(required_args.values()): + return None # let's create a list of the args passed # this will filter out None @@ -1932,8 +1924,7 @@ def get_attendance(self, team_id: int = None, league_id: int = None, if 400 <= mlb_data.status_code <= 499: return None - if 'records' in mlb_data.data and mlb_data.data['records']: - return Attendance(**mlb_data.data) + return parse_attendance(mlb_data.data) def get_draft(self, year_id: int, **params) -> List[Round]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index f0143f47..e765c26e 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -1,13 +1,16 @@ import asyncio from mlbstatsapi import AsyncMlb +from mlbstatsapi.models.attendances import Attendance from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule from mlbstatsapi.models.seasons import Season from mlbstatsapi.models.sports import Sport +from mlbstatsapi.models.standings import Standings from mlbstatsapi.models.teams import Team +from mlbstatsapi.models.venues import Venue def test_async_get_team(): @@ -107,3 +110,35 @@ async def scenario(): assert season.season_id == "2021" asyncio.run(scenario()) + + +def test_async_get_venue(): + async def scenario(): + async with AsyncMlb() as mlb: + venue = await mlb.get_venue(31) + + assert isinstance(venue, Venue) + assert venue.id == 31 + + asyncio.run(scenario()) + + +def test_async_get_standings(): + async def scenario(): + async with AsyncMlb() as mlb: + standings = await mlb.get_standings(103, "2022") + + assert standings + assert isinstance(standings[0], Standings) + + asyncio.run(scenario()) + + +def test_async_get_attendance(): + async def scenario(): + async with AsyncMlb() as mlb: + attendance = await mlb.get_attendance(team_id=133, season=2022) + + assert isinstance(attendance, Attendance) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_attendance_parser.py b/tests/parsers/test_attendance_parser.py new file mode 100644 index 00000000..4b101d54 --- /dev/null +++ b/tests/parsers/test_attendance_parser.py @@ -0,0 +1,50 @@ +from mlbstatsapi._parsers.attendance import parse_attendance +from mlbstatsapi.models.attendances import Attendance + + +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} + + +def test_parse_attendance(): + """parse_attendance builds an Attendance when records is non-empty.""" + assert parse_attendance({}) is None + assert parse_attendance({"records": []}) is None + + attendance = parse_attendance(ATTENDANCE_PAYLOAD) + + assert isinstance(attendance, Attendance) + assert attendance.aggregate_totals.attendance_total == 2896460 + assert attendance.records[0].team.name == "Oakland Athletics" diff --git a/tests/parsers/test_standings_parser.py b/tests/parsers/test_standings_parser.py new file mode 100644 index 00000000..8e7af17a --- /dev/null +++ b/tests/parsers/test_standings_parser.py @@ -0,0 +1,92 @@ +from mlbstatsapi._parsers.standings import parse_standings +from mlbstatsapi.models.standings import Standings + + +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} + + +def test_parse_standings(): + """parse_standings reads the MLB standings envelope and returns Standings models.""" + assert parse_standings({}) == [] + assert parse_standings({"records": []}) == [] + + standings = parse_standings({"records": [STANDINGS_RECORD]}) + + assert standings == [Standings(**STANDINGS_RECORD)] + assert standings[0].team_records[0].team.name == "Yankees" diff --git a/tests/parsers/test_venues.py b/tests/parsers/test_venues.py new file mode 100644 index 00000000..879a7fc8 --- /dev/null +++ b/tests/parsers/test_venues.py @@ -0,0 +1,32 @@ +from mlbstatsapi._parsers.venues import parse_venue, parse_venues +from mlbstatsapi.models.venues import Venue + + +def test_parse_venues(): + """parse_venues reads the MLB venues envelope and returns Venue models.""" + assert parse_venues({}) == [] + assert parse_venues({"venues": []}) == [] + + venues = parse_venues( + { + "venues": [ + {"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}, + {"id": 1, "link": "/api/v1/venues/1", "name": "Angel Stadium"}, + ] + } + ) + + assert venues == [ + Venue(id=31, link="/api/v1/venues/31", name="PNC Park"), + Venue(id=1, link="/api/v1/venues/1", name="Angel Stadium"), + ] + + +def test_parse_venue(): + """parse_venue builds a Venue from one venue payload.""" + assert parse_venue({}) is None + + venue = parse_venue({"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]}) + + assert isinstance(venue, Venue) + assert venue == Venue(id=31, link="/api/v1/venues/31", name="PNC Park") diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 653982b7..4bcc74f8 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -38,13 +38,16 @@ from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 +from mlbstatsapi.models.attendances import Attendance # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 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.teams import Team # noqa: E402 +from mlbstatsapi.models.venues import Venue # noqa: E402 TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} @@ -84,6 +87,119 @@ ] } SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} +VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]} +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} +STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]} +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -128,6 +244,7 @@ title="Manager", ) EXPECTED_SEASON = Season(seasonId="2021", hasWildcard=True) +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. NO_RESULT_RESPONSES = { @@ -208,7 +325,26 @@ def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: getattr(sync_mlb, method)(*args, **kwargs) call = sync_mlb._mlb_adapter_v1.get.call_args - return call.kwargs["endpoint"], call.kwargs["ep_params"] + # Most Mlb methods pass endpoint as a keyword; get_attendance passes it + # positionally, so fall back to the first positional argument. + endpoint = call.kwargs["endpoint"] if "endpoint" in call.kwargs else call.args[0] + return endpoint, call.kwargs["ep_params"] + + +def _flatten_params(params: dict) -> list[tuple[str, str]]: + """Expand a params dict into (key, str(value)) pairs, list values repeated. + + Mirrors how both Requests and HTTPX serialize a list-valued query + parameter: as the same key repeated once per item, e.g. + ``?hydrate=a&hydrate=b`` rather than a single comma-joined value. + """ + pairs: list[tuple[str, str]] = [] + for key, value in params.items(): + if isinstance(value, list): + pairs.extend((key, str(item)) for item in value) + else: + pairs.append((key, str(value))) + return sorted(pairs) def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: @@ -216,8 +352,7 @@ def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> endpoint, params = sync_request_for(method, *args, **kwargs) assert request.url.path == f"/api/v1/{endpoint}" - # Query values arrive as strings, whatever type the client passed in. - assert dict(request.url.params) == {k: str(v) for k, v in params.items()} + assert sorted(request.url.params.multi_items()) == _flatten_params(params) # --------------------------------------------------------------------------- @@ -654,6 +789,113 @@ async def scenario(): assert_matches_sync(handler.request, "get_seasons", 11) +def test_get_venue_requests_the_venue_endpoint_and_parses_the_result(): + handler = _Handler(_json(VENUE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(31) + + venue = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_venue", 31) + assert venue == EXPECTED_VENUE + + +def test_get_venue_returns_empty_list_on_404(): + """get_venue mirrors Mlb's documented quirk: [] rather than None on 4xx.""" + handler = _Handler(NO_RESULT_RESPONSES["404"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(1) + + assert asyncio.run(scenario()) == [] + + +def test_get_venue_returns_none_on_empty_200(): + """Unlike the 4xx quirk, an empty 200 falls through to the normal None.""" + handler = _Handler(NO_RESULT_RESPONSES["empty 200"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(1) + + assert asyncio.run(scenario()) is None + + +def test_get_venues_request_matches_the_sync_client(): + handler = _Handler(_json({"venues": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venues() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_venues") + + +def test_get_standings_requests_the_standings_endpoint_and_parses_the_result(): + handler = _Handler(_json(STANDINGS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_standings(103, "2022") + + standings = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_standings", 103, "2022") + assert standings == [Standings(**STANDINGS_RECORD)] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_standings_returns_empty_list_when_there_are_no_standings(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_standings(103, "2022") + + assert asyncio.run(scenario()) == [] + + +def test_get_attendance_requests_the_attendance_endpoint_and_parses_the_result(): + handler = _Handler(_json(ATTENDANCE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance(team_id=133) + + attendance = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_attendance", team_id=133) + assert isinstance(attendance, Attendance) + assert attendance.aggregate_totals.attendance_total == 2896460 + + +def test_get_attendance_without_an_identifier_returns_none_without_requesting(): + """Regression coverage for the any(dict) vs any(dict.values()) guard bug.""" + handler = _Handler(_json(ATTENDANCE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_attendance_returns_none_when_there_is_no_attendance(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance(team_id=133) + + assert asyncio.run(scenario()) is None + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -679,6 +921,10 @@ def test_public_signatures_match_the_sync_client(): "get_divisions", "get_season", "get_seasons", + "get_venue", + "get_venues", + "get_standings", + "get_attendance", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_mlb_attendance.py b/tests/test_mlb_attendance.py new file mode 100644 index 00000000..da9f1153 --- /dev/null +++ b/tests/test_mlb_attendance.py @@ -0,0 +1,88 @@ +"""Offline coverage for Mlb.get_attendance, including a regression test for a +guard bug found while porting this endpoint to AsyncMlb (issue #305): +``any(required_args)`` iterates dict keys (always truthy) instead of values, +so the documented "at least one of team_id/league_id/league_list_id" guard +never actually fired. Fixed to ``any(required_args.values())``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from mlbstatsapi import Mlb +from mlbstatsapi.mlb_dataadapter import MlbResult +from mlbstatsapi.models.attendances import Attendance + + +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} + + +def test_get_attendance_with_no_identifier_does_not_request_and_returns_none(): + """Regression test: no team/league/league-list id must short-circuit.""" + with Mlb() as mlb: + mock = MagicMock() + mlb._mlb_adapter_v1.get = mock + + result = mlb.get_attendance() + + assert result is None + mock.assert_not_called() + + +def test_get_attendance_with_team_id_requests_and_parses_the_result(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data=ATTENDANCE_PAYLOAD) + ) + + result = mlb.get_attendance(team_id=133) + + assert isinstance(result, Attendance) + assert result.aggregate_totals.attendance_total == 2896460 + mlb._mlb_adapter_v1.get.assert_called_once_with( + "attendance", ep_params={"teamId": 133} + ) + + +def test_get_attendance_returns_none_on_client_error(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=404, message=None, data={}) + ) + + assert mlb.get_attendance(team_id=133) is None diff --git a/tests/test_public_api.py b/tests/test_public_api.py index ad32a50d..5021baca 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -254,6 +254,13 @@ def _normalize_signature(fn: Any) -> str: "get_divisions": "(**params)", "get_season": "(season_id: str, sport_id: int=1, **params)", "get_seasons": "(sport_id: int=1, **params)", + "get_venue": "(venue_id: int, **params)", + "get_venues": "(**params)", + "get_standings": "(league_id: int, season: str, **params)", + "get_attendance": ( + "(team_id: int=None, league_id: int=None, " + "league_list_id: str=None, **params)" + ), } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index c984e780..c9c65d8e 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -42,13 +42,16 @@ MlbTimeoutError, MlbTransportError, ) +from mlbstatsapi.models.attendances import Attendance # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 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.teams import Team # noqa: E402 +from mlbstatsapi.models.venues import Venue # noqa: E402 TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} @@ -88,6 +91,119 @@ ] } SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} +VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]} +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} +STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]} +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -436,6 +552,45 @@ def test_get_season_success_parity(): assert result.request == ("GET", "/api/v1/seasons/2021", {"sportId": "1"}) +def test_get_venue_success_parity(): + """A successful venue response parses to the same Venue on both clients.""" + result = call_both("get_venue", 31, payload=VENUE_PAYLOAD) + + assert isinstance(result.sync, Venue), "sync get_venue did not return a Venue" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 31, + "/api/v1/venues/31", + "PNC Park", + ) + assert result.asynchronous == result.sync + # hydrate is sent as a repeated query param (?hydrate=a&hydrate=b&...); + # request_signature's dict(parse_qsl(...)) keeps only the last value, so + # this only proves the two clients agree, not the full query string. + assert result.request == ("GET", "/api/v1/venues/31", {"hydrate": "timezone"}) + + +def test_get_standings_success_parity(): + """A successful standings response parses to the same Standings on both clients.""" + result = call_both("get_standings", 103, "2022", payload=STANDINGS_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Standings), ( + "sync get_standings did not return a list of Standings" + ) + assert result.sync[0].team_records[0].team.name == "Yankees" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/standings", {"leagueId": "103", "season": "2022"}) + + +def test_get_attendance_success_parity(): + """A successful attendance response parses to the same Attendance on both clients.""" + result = call_both("get_attendance", team_id=133, payload=ATTENDANCE_PAYLOAD) + + assert isinstance(result.sync, Attendance), "sync get_attendance did not return an Attendance" + assert result.sync.aggregate_totals.attendance_total == 2896460 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/attendance", {"teamId": "133"}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -542,6 +697,63 @@ def test_get_season_no_result_parity(label): ) +def test_get_venue_no_result_parity_404(): + """404 hits Mlb.get_venue's documented quirk: [] rather than None.""" + result = call_both("get_venue", 1, status=404, payload={}) + + assert result.sync == [], f"sync get_venue returned {result.sync!r} for 404" + assert result.asynchronous == [], ( + f"async get_venue returned {result.asynchronous!r} for 404" + ) + + +@pytest.mark.parametrize("label", ["empty 200", "empty body"]) +def test_get_venue_no_result_parity_non_4xx(label): + """Unlike the 404 quirk, a non-4xx empty response falls through to None.""" + result = call_both("get_venue", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_venue returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_venue returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_standings_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_standings", 103, "2022", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_standings returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_standings returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_attendance_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_attendance", team_id=133, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_attendance returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_attendance returned {result.asynchronous!r} for {label}" + ) + + +def test_get_attendance_without_an_identifier_parity(): + """Regression coverage: the any(dict) vs any(dict.values()) guard bug fix.""" + sync_requests: list = [] + async_requests: list = [] + + sync_result = call_sync("get_attendance", observed=sync_requests) + async_result = call_async("get_attendance", observed=async_requests) + + assert sync_result is None + assert async_result is None + assert sync_requests == [] + assert async_requests == [] + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From be7aca80fadbd9a537a4714b3bf8eefed47deeac Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:32:55 -0700 Subject: [PATCH 04/13] feat(async): add get_draft/get_awards to AsyncMlb Continues issue #305's endpoint expansion. Adds shared _parsers/draft.py and _parsers/awards.py, reused by Mlb (refactored internally, no behavior change) and AsyncMlb. get_awards preserves Mlb's endpoint string with a trailing "?" (awards/{id}/recipients?); both Requests and HTTPX treat it as a harmless empty query separator, so it's kept as-is for parity rather than "fixed". That trailing "?" exposed a gap in the shared assert_matches_sync test helper: it compared request.url.path against the raw endpoint string, but neither client's parsed path retains a trailing "?". Fixed with a .rstrip("?") normalization. Covers both endpoints with parser, AsyncMlb endpoint, sync/async parity, and live external smoke tests, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 2 + mlbstatsapi/_parsers/awards.py | 8 ++ mlbstatsapi/_parsers/draft.py | 16 +++ mlbstatsapi/async_mlb.py | 106 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 18 +-- .../async_mlb/test_async_mlb_smoke.py | 24 ++++ tests/parsers/test_awards_parser.py | 23 ++++ tests/parsers/test_draft_parser.py | 13 +++ tests/test_async_mlb.py | 67 ++++++++++- tests/test_public_api.py | 2 + tests/test_sync_async_parity.py | 54 +++++++++ 11 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 mlbstatsapi/_parsers/awards.py create mode 100644 mlbstatsapi/_parsers/draft.py create mode 100644 tests/parsers/test_awards_parser.py create mode 100644 tests/parsers/test_draft_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 845ea70b..cbe7095d 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -304,6 +304,8 @@ get_attendance( league_list_id: str = None, **params, ) +get_draft(year_id: int, **params) +get_awards(award_id: str, **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is diff --git a/mlbstatsapi/_parsers/awards.py b/mlbstatsapi/_parsers/awards.py new file mode 100644 index 00000000..f800b4ba --- /dev/null +++ b/mlbstatsapi/_parsers/awards.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.awards import Award + + +def parse_awards(data: dict) -> list[Award]: + """Parse Award models from an MLB /awards/{id}/recipients response body.""" + if not data or not data.get("awards"): + return [] + return [Award(**award) for award in data["awards"]] diff --git a/mlbstatsapi/_parsers/draft.py b/mlbstatsapi/_parsers/draft.py new file mode 100644 index 00000000..466f181e --- /dev/null +++ b/mlbstatsapi/_parsers/draft.py @@ -0,0 +1,16 @@ +from mlbstatsapi.models.drafts import Round + + +def parse_draft(data: dict) -> list[Round]: + """Parse Round models from an MLB /draft/{year} response body. + + Expects the full response, e.g. ``{"drafts": {"rounds": [...]}}``. + """ + if not data or not data.get("drafts"): + return [] + + rounds = data["drafts"].get("rounds") + if not rounds: + return [] + + return [Round(**round_data) for round_data in rounds] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 1768fafd..ace73e15 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -7,7 +7,9 @@ from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance +from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions +from ._parsers.draft import parse_draft 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 @@ -20,7 +22,9 @@ from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType from .models.attendances import Attendance +from .models.awards import Award from .models.divisions import Division +from .models.drafts import Round from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule @@ -1284,3 +1288,105 @@ async def get_attendance( return None return parse_attendance(mlb_data.data) + + async def get_draft( + self, + year_id: int, + **params, + ) -> list[Round]: + """ + return a draft object for year_id + + Async counterpart of ``Mlb.get_draft``. + + Parameters + ---------- + year_id : int + Insert a year_id to return a directory of seasons for a specific sport. + + Other Parameters + ---------------- + round : str + Insert a round to return biographical and financial data for a specific round in a Rule 4 draft. + name : str + Insert the first letter of a draftees last name to return their Rule 4 biographical and financial data. + school : str + Insert the first letter of a draftees school to return their Rule 4 biographical and financial data. + state : str + Insert state to return a list of Rule 4 draftees from that given state + country : str + Insert state to return a list of Rule 4 draftees from that given state + position : str + Insert the position to return Rule 4 biographical and financial data for a players drafted at that position. + teamId : int + Insert teamId to return Rule 4 biographical and financial data for all picks made by a specific team. + playerId : int + Insert MLB playerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft. + bisPlayerId : int + Insert bisPlayerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft. + + Returns + ------- + list of DraftPicks + returns a list of DraftPicks + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... rounds = await mlb.get_draft(2019) + [Round, Round, Round] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"draft/{year_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_draft(mlb_data.data) + + async def get_awards( + self, + award_id: str, + **params, + ) -> list[Award]: + """ + return a list of awards for award_id + + Async counterpart of ``Mlb.get_awards``. + + Parameters + ---------- + award_id : str + Insert a awardId to return a directory of players for a given award. + + Other Parameters + ---------------- + sportId : int + Insert a sportId to return a directory of players for a given award in a specific sport. + leagueId : int, List[int] + Insert leagueId(s) to return a directory of players for a given award in a specific league. Format '103,104' + season : int, List[int] + Insert year(s) to return a directory of players for a given award in a given season. Format '2016,2017' + + Returns + ------- + list of Awards + returns a list of awards + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... awards = await mlb.get_awards("ALMVP") + [Award, Award, Award] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"awards/{award_id}/recipients?", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_awards(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 4339bd81..0ed9036d 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -24,7 +24,9 @@ from ._parsers.attendance import parse_attendance +from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division +from ._parsers.draft import parse_draft from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -1971,13 +1973,7 @@ def get_draft(self, year_id: int, **params) -> List[Round]: if 400 <= mlb_data.status_code <= 499: return [] - round_list = [] - - if 'drafts' in mlb_data.data and mlb_data.data['drafts']: - if mlb_data.data['drafts']['rounds']: - for round in mlb_data.data['drafts']['rounds']: - round_list.append(Round(**round)) - return round_list + return parse_draft(mlb_data.data) def get_awards(self, award_id: str, **params) -> List[Award]: """ @@ -2011,14 +2007,8 @@ def get_awards(self, award_id: str, **params) -> List[Award]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'awards/{award_id}/recipients?', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - awards_list = [] - if 'awards' in mlb_data.data and mlb_data.data['awards']: - for award in mlb_data.data['awards']: - awards_list.append(Award(**award)) - - return awards_list + return parse_awards(mlb_data.data) def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index e765c26e..55525cd4 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -2,7 +2,9 @@ from mlbstatsapi import AsyncMlb from mlbstatsapi.models.attendances import Attendance +from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division +from mlbstatsapi.models.drafts import Round from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule @@ -142,3 +144,25 @@ async def scenario(): assert isinstance(attendance, Attendance) asyncio.run(scenario()) + + +def test_async_get_draft(): + async def scenario(): + async with AsyncMlb() as mlb: + rounds = await mlb.get_draft(2019) + + assert rounds + assert isinstance(rounds[0], Round) + + asyncio.run(scenario()) + + +def test_async_get_awards(): + async def scenario(): + async with AsyncMlb() as mlb: + awards = await mlb.get_awards("ALMVP") + + assert awards + assert isinstance(awards[0], Award) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_awards_parser.py b/tests/parsers/test_awards_parser.py new file mode 100644 index 00000000..0a3da9c4 --- /dev/null +++ b/tests/parsers/test_awards_parser.py @@ -0,0 +1,23 @@ +from mlbstatsapi._parsers.awards import parse_awards +from mlbstatsapi.models.awards import Award + + +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} + + +def test_parse_awards(): + """parse_awards reads the MLB awards envelope and returns Award models.""" + assert parse_awards({}) == [] + assert parse_awards({"awards": []}) == [] + + awards = parse_awards({"awards": [AWARD_PAYLOAD]}) + + assert awards == [Award(**AWARD_PAYLOAD)] + assert awards[0].player.full_name == "Aaron Judge" diff --git a/tests/parsers/test_draft_parser.py b/tests/parsers/test_draft_parser.py new file mode 100644 index 00000000..eb96f333 --- /dev/null +++ b/tests/parsers/test_draft_parser.py @@ -0,0 +1,13 @@ +from mlbstatsapi._parsers.draft import parse_draft +from mlbstatsapi.models.drafts import Round + + +def test_parse_draft(): + """parse_draft reads the nested drafts.rounds envelope and returns Round models.""" + assert parse_draft({}) == [] + assert parse_draft({"drafts": {}}) == [] + assert parse_draft({"drafts": {"rounds": []}}) == [] + + rounds = parse_draft({"drafts": {"rounds": [{"round": "1"}, {"round": "1B"}]}}) + + assert rounds == [Round(round="1"), Round(round="1B")] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 4bcc74f8..ea4c70f7 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -39,7 +39,9 @@ from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.attendances import Attendance # noqa: E402 +from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -200,6 +202,16 @@ "attendanceTotalHome": 787902, }, } +DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}} +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} +AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -351,7 +363,10 @@ def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> """Assert an observed request is the one ``Mlb`` would have made.""" endpoint, params = sync_request_for(method, *args, **kwargs) - assert request.url.path == f"/api/v1/{endpoint}" + # 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/v1/{endpoint}".rstrip("?") assert sorted(request.url.params.multi_items()) == _flatten_params(params) @@ -896,6 +911,54 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_draft_requests_the_draft_endpoint_and_parses_the_result(): + handler = _Handler(_json(DRAFT_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_draft(2019) + + rounds = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_draft", 2019) + assert rounds == [Round(round="1")] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_draft_returns_empty_list_when_there_is_no_draft(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_draft(2019) + + assert asyncio.run(scenario()) == [] + + +def test_get_awards_requests_the_awards_endpoint_and_parses_the_result(): + handler = _Handler(_json(AWARDS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_awards("ALMVP") + + awards = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_awards", "ALMVP") + assert awards == [Award(**AWARD_PAYLOAD)] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_awards_returns_empty_list_when_there_are_no_awards(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_awards("ALMVP") + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -925,6 +988,8 @@ def test_public_signatures_match_the_sync_client(): "get_venues", "get_standings", "get_attendance", + "get_draft", + "get_awards", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 5021baca..7fd7ed7e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -261,6 +261,8 @@ def _normalize_signature(fn: Any) -> str: "(team_id: int=None, league_id: int=None, " "league_list_id: str=None, **params)" ), + "get_draft": "(year_id: int, **params)", + "get_awards": "(award_id: str, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index c9c65d8e..e1533b6f 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -43,7 +43,9 @@ MlbTransportError, ) from mlbstatsapi.models.attendances import Attendance # noqa: E402 +from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -204,6 +206,16 @@ "attendanceTotalHome": 787902, }, } +DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}} +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} +AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -591,6 +603,26 @@ def test_get_attendance_success_parity(): assert result.request == ("GET", "/api/v1/attendance", {"teamId": "133"}) +def test_get_draft_success_parity(): + """A successful draft response parses to the same Round list on both clients.""" + result = call_both("get_draft", 2019, payload=DRAFT_PAYLOAD) + + assert result.sync == [Round(round="1")], "sync get_draft did not return the round" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/draft/2019", {}) + + +def test_get_awards_success_parity(): + """A successful awards response parses to the same Award list on both clients.""" + result = call_both("get_awards", "ALMVP", payload=AWARDS_PAYLOAD) + + assert result.sync == [Award(**AWARD_PAYLOAD)], "sync get_awards did not return the award" + assert result.asynchronous == result.sync + # The endpoint string has a trailing "?"; both clients strip it as an + # empty query separator, so it never appears in the request path. + assert result.request == ("GET", "/api/v1/awards/ALMVP/recipients", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -754,6 +786,28 @@ def test_get_attendance_without_an_identifier_parity(): assert async_requests == [] +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_draft_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_draft", 2019, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_draft returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_draft returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_awards_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_awards", "ALMVP", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_awards returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_awards returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 99a88ed8de81b166cf04cd2bec1cd37aed64b054 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:44:58 -0700 Subject: [PATCH 05/13] feat(async): add get_homerun_derby to AsyncMlb Continues issue #305's endpoint expansion. Fixes a bug in Mlb.get_homerun_derby found while porting it: the 400-499 guard was a bare `None` expression instead of `return None`, so a 4xx response body containing a truthy "status" key would fall through into HomeRunDerby(**data) and raise ValidationError instead of returning None. Fixed to `return None`, following the same fix-then-port approach used for get_attendance. Adds a shared _parsers/homerunderby.py reused by both Mlb (refactored internally) and AsyncMlb, plus parser, endpoint, sync/async parity (including a dedicated regression test for the malformed-error-body case), and live external smoke test coverage. Extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 9 ++- mlbstatsapi/_parsers/homerunderby.py | 8 ++ mlbstatsapi/async_mlb.py | 46 +++++++++++ mlbstatsapi/mlb_api.py | 8 +- .../async_mlb/test_async_mlb_smoke.py | 11 +++ tests/parsers/test_homerunderby_parser.py | 40 ++++++++++ tests/test_async_mlb.py | 51 ++++++++++++ tests/test_mlb_homerun_derby.py | 79 +++++++++++++++++++ tests/test_public_api.py | 1 + tests/test_sync_async_parity.py | 62 +++++++++++++++ 10 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 mlbstatsapi/_parsers/homerunderby.py create mode 100644 tests/parsers/test_homerunderby_parser.py create mode 100644 tests/test_mlb_homerun_derby.py diff --git a/docs/public-api.md b/docs/public-api.md index cbe7095d..8b550595 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -306,6 +306,7 @@ get_attendance( ) get_draft(year_id: int, **params) get_awards(award_id: str, **params) +get_homerun_derby(game_id, **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is @@ -550,9 +551,11 @@ Notes and known conflicts (documented, not redesigned by this contract): * `get_venue` is annotated to return `Venue | None` but currently returns `[]` on 400–499 statuses. Treat the implementation shape as the observed behavior until a focused fix lands. -* `get_homerun_derby` currently executes a bare `None` expression on 400–499 - instead of `return None`, so execution may continue. A focused bugfix is - recommended. +* `get_homerun_derby` previously executed a bare `None` expression on + 400–499 instead of `return None`, so a 4xx response whose body happened to + contain a truthy `status` key would have continued into + `HomeRunDerby(**data)` and raised instead of returning `None`. Fixed to + `return None` while porting the endpoint to `AsyncMlb` (issue #305). * `get_attendance`'s "at least one of `team_id`/`league_id`/`league_list_id`" guard previously used `any(required_args)`, which iterates dict keys (always truthy) rather than values, so the guard never actually fired. This diff --git a/mlbstatsapi/_parsers/homerunderby.py b/mlbstatsapi/_parsers/homerunderby.py new file mode 100644 index 00000000..2167a526 --- /dev/null +++ b/mlbstatsapi/_parsers/homerunderby.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +def parse_homerun_derby(data: dict) -> HomeRunDerby | None: + """Parse a HomeRunDerby from an MLB /homeRunDerby/{gamePk} response body.""" + if not data or not data.get("status"): + return None + return HomeRunDerby(**data) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index ace73e15..86a9ea8a 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -10,6 +10,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions from ._parsers.draft import parse_draft +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 @@ -25,6 +26,7 @@ from .models.awards import Award from .models.divisions import Division from .models.drafts import Round +from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule @@ -1390,3 +1392,47 @@ async def get_awards( return [] return parse_awards(mlb_data.data) + + async def get_homerun_derby( + self, + game_id, + **params, + ) -> HomeRunDerby | None: + """ + The homerun derby endpoint on the Stats API allows for users to + request information from the MLB database pertaining to the + homerun derby. This is endpoint contains Statcast trajectory, + launchSpeed, launchAngle, & hit coordinates data. Also a timeRemaning + string is added to track the progress of the derby in real time. + + Async counterpart of ``Mlb.get_homerun_derby``. + + Parameters + ---------- + game_id : int + Insert gamePk to return HomerunDerby data for a specific gamePk. + + Other Parameters + ---------------- + fields : str + Format: Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + HomeRunDerby object + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... derby = await mlb.get_homerun_derby(511101) + HomeRunDerby + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"homeRunDerby/{game_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_homerun_derby(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 0ed9036d..e01ab83c 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -27,6 +27,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division from ._parsers.draft import parse_draft +from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -2040,10 +2041,9 @@ def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]: """ mlb_data = self._mlb_adapter_v1.get(endpoint=f'homeRunDerby/{game_id}', ep_params=params) if 400 <= mlb_data.status_code <= 499: - None - - if 'status' in mlb_data.data and mlb_data.data['status']: - return HomeRunDerby(**mlb_data.data) + return None + + return parse_homerun_derby(mlb_data.data) def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> dict: diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 55525cd4..43551090 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.drafts import Round +from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule @@ -166,3 +167,13 @@ async def scenario(): assert isinstance(awards[0], Award) asyncio.run(scenario()) + + +def test_async_get_homerun_derby(): + async def scenario(): + async with AsyncMlb() as mlb: + derby = await mlb.get_homerun_derby(511101) + + assert isinstance(derby, HomeRunDerby) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_homerunderby_parser.py b/tests/parsers/test_homerunderby_parser.py new file mode 100644 index 00000000..86138dd9 --- /dev/null +++ b/tests/parsers/test_homerunderby_parser.py @@ -0,0 +1,40 @@ +from mlbstatsapi._parsers.homerunderby import parse_homerun_derby +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} + + +def test_parse_homerun_derby(): + """parse_homerun_derby builds a HomeRunDerby when status is present.""" + assert parse_homerun_derby({}) is None + assert parse_homerun_derby({"status": {}}) is None + + derby = parse_homerun_derby(HOMERUN_DERBY_PAYLOAD) + + assert isinstance(derby, HomeRunDerby) + assert derby.status.state == "Final" + assert derby.info.name == "Home Run Derby" diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index ea4c70f7..6a7965a4 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # 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 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -212,6 +213,30 @@ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, } AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -959,6 +984,31 @@ async def scenario(): assert asyncio.run(scenario()) == [] +def test_get_homerun_derby_requests_the_homerunderby_endpoint_and_parses_the_result(): + handler = _Handler(_json(HOMERUN_DERBY_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_homerun_derby(511101) + + derby = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_homerun_derby", 511101) + assert isinstance(derby, HomeRunDerby) + assert derby.status.state == "Final" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_homerun_derby_returns_none_when_there_is_no_derby(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_homerun_derby(1) + + assert asyncio.run(scenario()) is None + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -990,6 +1040,7 @@ def test_public_signatures_match_the_sync_client(): "get_attendance", "get_draft", "get_awards", + "get_homerun_derby", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_mlb_homerun_derby.py b/tests/test_mlb_homerun_derby.py new file mode 100644 index 00000000..89150da3 --- /dev/null +++ b/tests/test_mlb_homerun_derby.py @@ -0,0 +1,79 @@ +"""Offline coverage for Mlb.get_homerun_derby, including a regression test for +a bug found while porting this endpoint to AsyncMlb (issue #305): the 400-499 +branch executed a bare ``None`` expression instead of ``return None``, so +execution fell through to the parsing logic below. In the common case that +logic still landed on None (an error response rarely has a truthy "status" +key), but a 404 or compatibility-mode 4xx response that happened to include +one would have raised a ValidationError instead of cleanly returning None. +Fixed to ``return None``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from mlbstatsapi import Mlb +from mlbstatsapi.mlb_dataadapter import MlbResult +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} + + +def test_get_homerun_derby_requests_and_parses_the_result(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data=HOMERUN_DERBY_PAYLOAD) + ) + + result = mlb.get_homerun_derby(511101) + + assert isinstance(result, HomeRunDerby) + assert result.status.state == "Final" + + +def test_get_homerun_derby_returns_none_on_client_error(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=404, message=None, data={}) + ) + + assert mlb.get_homerun_derby(1) is None + + +def test_get_homerun_derby_returns_none_without_raising_on_a_malformed_error_body(): + """Regression test: a 4xx body with a truthy "status" key must not reach + HomeRunDerby(**data) and raise, now that the guard actually returns.""" + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult( + status_code=404, message=None, data={"status": "error"} + ) + ) + + assert mlb.get_homerun_derby(1) is None diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7fd7ed7e..58d71e52 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -263,6 +263,7 @@ def _normalize_signature(fn: Any) -> str: ), "get_draft": "(year_id: int, **params)", "get_awards": "(award_id: str, **params)", + "get_homerun_derby": "(game_id, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index e1533b6f..b38346f1 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # 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 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -216,6 +217,30 @@ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, } AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -623,6 +648,18 @@ def test_get_awards_success_parity(): assert result.request == ("GET", "/api/v1/awards/ALMVP/recipients", {}) +def test_get_homerun_derby_success_parity(): + """A successful homerun derby response parses to the same object on both clients.""" + result = call_both("get_homerun_derby", 511101, payload=HOMERUN_DERBY_PAYLOAD) + + assert isinstance(result.sync, HomeRunDerby), ( + "sync get_homerun_derby did not return a HomeRunDerby" + ) + assert result.sync.status.state == "Final" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -808,6 +845,31 @@ def test_get_awards_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_homerun_derby_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_homerun_derby", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_homerun_derby returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_homerun_derby 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. + + A 4xx body with a truthy "status" key must not reach HomeRunDerby(**data) + and raise on either client, now that the guard actually returns. + """ + result = call_both( + "get_homerun_derby", 1, status=404, payload={"status": "error"} + ) + + assert result.sync is None + assert result.asynchronous is None + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 673f68fee5b686bf2a4e9bb51d4dc78021dc6c2d Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 13:07:20 -0700 Subject: [PATCH 06/13] feat(async): add get_*_id name-lookup helpers to AsyncMlb Continues issue #305's endpoint expansion. Adds get_team_id, get_people_id, get_sport_id, get_league_id, get_division_id, and get_venue_id to AsyncMlb. Extracts the identical filter logic these six Mlb methods each duplicated (case-insensitive search_key match, collect id, skip KeyError) into a single shared helper, _helpers/id_lookup.py::find_ids_by_key, and refactors Mlb onto it (no behavior change, verified against the live API). Each AsyncMlb method mirrors its sync counterpart's docstring and preserves its individual quirks (which methods set a trimming `fields` param, and that get_venue_id does not set `hydrate` the way get_venue/get_venues do). Adds a new tests/helpers/ directory (mirroring tests/parsers/) for the shared helper's unit tests, plus endpoint, sync/async parity, and live external smoke test coverage for all six methods, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 11 + mlbstatsapi/_helpers/id_lookup.py | 15 + mlbstatsapi/async_mlb.py | 280 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 70 +---- .../async_mlb/test_async_mlb_smoke.py | 60 ++++ tests/helpers/test_id_lookup.py | 35 +++ tests/test_async_mlb.py | 82 +++++ tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 78 +++++ 9 files changed, 577 insertions(+), 62 deletions(-) create mode 100644 mlbstatsapi/_helpers/id_lookup.py create mode 100644 tests/helpers/test_id_lookup.py diff --git a/docs/public-api.md b/docs/public-api.md index 8b550595..70c314d8 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -307,6 +307,17 @@ get_attendance( get_draft(year_id: int, **params) get_awards(award_id: str, **params) get_homerun_derby(game_id, **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, +) +get_sport_id(sport_name: str, search_key: str = 'name', **params) +get_league_id(league_name: str, search_key: str = 'name', **params) +get_division_id(division_name: str, search_key: str = 'name', **params) +get_venue_id(venue_name: str, search_key: str = 'name', **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is diff --git a/mlbstatsapi/_helpers/id_lookup.py b/mlbstatsapi/_helpers/id_lookup.py new file mode 100644 index 00000000..c663c3f4 --- /dev/null +++ b/mlbstatsapi/_helpers/id_lookup.py @@ -0,0 +1,15 @@ +def find_ids_by_key(items: list[dict], search_key: str, value: str) -> list[int]: + """Return the ids of items whose ``search_key`` value case-insensitively matches ``value``. + + Shared by every ``Mlb``/``AsyncMlb`` ``get_*_id`` name-lookup helper. An + item missing ``search_key`` or ``id`` is silently skipped, matching the + historical per-endpoint behavior. + """ + ids = [] + for item in items: + try: + if item[search_key].lower() == value.lower(): + ids.append(item["id"]) + except KeyError: + continue + return ids diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 86a9ea8a..d977738c 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING +from ._helpers.id_lookup import find_ids_by_key from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance from ._parsers.awards import parse_awards @@ -242,6 +243,58 @@ async def get_teams( return parse_teams(mlb_data.data) + async def get_team_id( + self, + team_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return a team Id + + Async counterpart of ``Mlb.get_team_id``. + + Parameters + ---------- + team_name : str + Teams name + + search_key : str + search key search json for matching team_name + + Other Parameters + ---------------- + sportId : int + sport id number for team search + + Returns + ------- + list of ints + returns a list of matching team ids + + See Also + -------- + AsyncMlb.get_teams : Return a list of Teams from sport id. + AsyncMlb.get_team : Return a Team from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_team_id("Athletics") + [133] + """ + params["fields"] = "teams,id,name" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="teams", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("teams") or [], search_key, team_name) + async def get_team_roster( self, team_id: int, @@ -464,6 +517,60 @@ async def get_people( return parse_people(mlb_data.data) + async def get_people_id( + self, + fullname: str, + sport_id: int = 1, + search_key: str = "fullName", + **params, + ) -> list[int]: + """ + Returns specific player information based on players fullname + + Async counterpart of ``Mlb.get_people_id``. + + Parameters + ---------- + fullname : str + Person full name + sport_id : int + Insert sportId to return player information for particular sport. + + Other Parameters + ---------------- + season : int + Insert year to return player information for a particular season. + gameType : str + Insert gameType to return player information for a particular + gameType. + + Returns + ------- + list of int + Returns a list of person ids + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + AsyncMlb.get_person : Return Person from id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_people_id("Ty France") + [664034] + """ + params["fields"] = "people,id,fullName" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"sports/{sport_id}/players", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("people") or [], search_key, fullname) async def get_schedule( self, @@ -739,6 +846,50 @@ async def get_sports( return parse_sports(mlb_data.data) + async def get_sport_id( + self, + sport_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return sport id + + Async counterpart of ``Mlb.get_sport_id``. + + Parameters + ---------- + sport_name : str + Sport name + search_key : str + search key name + + Returns + ------- + list of ints + returns a list of sport ids + + See Also + -------- + AsyncMlb.get_sports : return a list of sports + AsyncMlb.get_sport : return a sport from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_sport_id("Major League Baseball") + [1] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="sports", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("sports") or [], search_key, sport_name) + async def get_league( self, league_id: int, @@ -832,6 +983,49 @@ async def get_leagues( return parse_leagues(mlb_data.data) + async def get_league_id( + self, + league_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return league id + + Async counterpart of ``Mlb.get_league_id``. + + Parameters + ---------- + league_name : str + League name + + Returns + ------- + list of ints + + See Also + -------- + AsyncMlb.get_league : return a League from league id + AsyncMlb.get_leagues : return a list of Leagues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_league_id('American League') + [103] + """ + params["fields"] = "leagues,id,name" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="leagues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("leagues") or [], search_key, league_name) + async def get_division( self, division_id: int, @@ -918,6 +1112,50 @@ async def get_divisions( return parse_divisions(mlb_data.data) + async def get_division_id( + self, + division_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return division id + + Async counterpart of ``Mlb.get_division_id``. + + Parameters + ---------- + division_name : str + Division name + search_key : str + search key name + + Returns + ------- + list of ints + returns a matching list of division ids + + See Also + -------- + AsyncMlb.get_division : return a Division from id + AsyncMlb.get_divisions : return a list of Divisions + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_division_id('American League West') + [200] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="divisions", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("divisions") or [], search_key, division_name) + async def get_season( self, season_id: str, @@ -1132,6 +1370,48 @@ async def get_venues( return parse_venues(mlb_data.data) + async def get_venue_id( + self, + venue_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return venue id + + Async counterpart of ``Mlb.get_venue_id``. + + Parameters + ---------- + venue_name : str + venue name + + Returns + ------- + list of ints + returns a list of matching venue ints + + See Also + -------- + AsyncMlb.get_venue : return a Venue + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_venue_id('PNC Park') + [31] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="venues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("venues") or [], search_key, venue_name) + async def get_standings( self, league_id: int, diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index e01ab83c..2b620120 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,6 +23,7 @@ from mlbstatsapi.models.standings import Standings +from ._helpers.id_lookup import find_ids_by_key from ._parsers.attendance import parse_attendance from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division @@ -302,16 +303,7 @@ def get_people_id(self, fullname: str, sport_id: int = 1, if 400 <= mlb_data.status_code <= 499: return [] - player_ids = [] - - if 'people' in mlb_data.data and mlb_data.data['people']: - for person in mlb_data.data['people']: - try: - if person[search_key].lower() == fullname.lower(): - player_ids.append(person['id']) - except KeyError: - continue - return player_ids + return find_ids_by_key(mlb_data.data.get('people') or [], search_key, fullname) def get_teams(self, sport_id: int = 1, **params) -> List[Team]: """ @@ -499,16 +491,7 @@ def get_team_id(self, team_name: str, if 400 <= mlb_data.status_code <= 499: return [] - team_ids = [] - - if 'teams' in mlb_data.data and mlb_data.data['teams']: - for team in mlb_data.data['teams']: - try: - if team[search_key].lower() == team_name.lower(): - team_ids.append(team['id']) - except (KeyError): - continue - return team_ids + return find_ids_by_key(mlb_data.data.get('teams') or [], search_key, team_name) def get_team_roster(self, team_id: int, **params) -> List[Player]: """ @@ -1327,16 +1310,7 @@ def get_venue_id(self, venue_name: str, if 400 <= mlb_data.status_code <= 499: return [] - venue_ids = [] - - if 'venues' in mlb_data.data and mlb_data.data['venues']: - for venue in mlb_data.data['venues']: - try: - if venue[search_key].lower() == venue_name.lower(): - venue_ids.append(venue['id']) - except KeyError: - continue - return venue_ids + return find_ids_by_key(mlb_data.data.get('venues') or [], search_key, venue_name) def get_sport(self, sport_id: int, **params) -> Union[Sport, None]: """ @@ -1443,17 +1417,7 @@ def get_sport_id(self, sport_name: str, if 400 <= mlb_data.status_code <= 499: return [] - sport_ids = [] - - if 'sports' in mlb_data.data and mlb_data.data['sports']: - for sport in mlb_data.data['sports']: - try: - if sport[search_key].lower() == sport_name.lower(): - sport_ids.append(sport['id']) - except KeyError: - continue - - return sport_ids + return find_ids_by_key(mlb_data.data.get('sports') or [], search_key, sport_name) def get_league(self, league_id: int, **params) -> Union[League, None]: """ @@ -1565,16 +1529,7 @@ def get_league_id(self, league_name: str, if 400 <= mlb_data.status_code <= 499: return [] - league_ids = [] - - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - for league in mlb_data.data['leagues']: - try: - if league[search_key].lower() == league_name.lower(): - league_ids.append(league['id']) - except KeyError: - continue - return league_ids + return find_ids_by_key(mlb_data.data.get('leagues') or [], search_key, league_name) def get_division(self, division_id: int, **params) -> Union[Division, None]: """ @@ -1679,17 +1634,8 @@ def get_division_id(self, division_name: str, mlb_data = self._mlb_adapter_v1.get(endpoint='divisions', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - division_ids = [] - - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - for division in mlb_data.data['divisions']: - try: - if division[search_key].lower() == division_name.lower(): - division_ids.append(division['id']) - except KeyError: - continue - return division_ids + + return find_ids_by_key(mlb_data.data.get('divisions') or [], search_key, division_name) def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 43551090..e6cb148b 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -177,3 +177,63 @@ async def scenario(): assert isinstance(derby, HomeRunDerby) asyncio.run(scenario()) + + +def test_async_get_team_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_team_id("Athletics") + + assert ids == [133] + + asyncio.run(scenario()) + + +def test_async_get_people_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_people_id("Ty France") + + assert ids == [664034] + + asyncio.run(scenario()) + + +def test_async_get_sport_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_sport_id("Major League Baseball") + + assert ids == [1] + + asyncio.run(scenario()) + + +def test_async_get_league_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_league_id("American League") + + assert ids == [103] + + asyncio.run(scenario()) + + +def test_async_get_division_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_division_id("American League West") + + assert ids == [200] + + asyncio.run(scenario()) + + +def test_async_get_venue_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_venue_id("PNC Park") + + assert ids == [31] + + asyncio.run(scenario()) diff --git a/tests/helpers/test_id_lookup.py b/tests/helpers/test_id_lookup.py new file mode 100644 index 00000000..85ce9bab --- /dev/null +++ b/tests/helpers/test_id_lookup.py @@ -0,0 +1,35 @@ +from mlbstatsapi._helpers.id_lookup import find_ids_by_key + + +def test_find_ids_by_key_matches_case_insensitively(): + items = [ + {"id": 133, "name": "Athletics"}, + {"id": 147, "name": "Yankees"}, + ] + + assert find_ids_by_key(items, "name", "athletics") == [133] + + +def test_find_ids_by_key_returns_every_match(): + items = [ + {"id": 1, "name": "Duplicate"}, + {"id": 2, "name": "Duplicate"}, + {"id": 3, "name": "Other"}, + ] + + assert find_ids_by_key(items, "name", "Duplicate") == [1, 2] + + +def test_find_ids_by_key_returns_empty_list_for_no_match(): + assert find_ids_by_key([{"id": 1, "name": "Athletics"}], "name", "Yankees") == [] + assert find_ids_by_key([], "name", "Athletics") == [] + + +def test_find_ids_by_key_skips_items_missing_the_search_key_or_id(): + items = [ + {"id": 1}, + {"name": "Athletics"}, + {"id": 2, "name": "Athletics"}, + ] + + assert find_ids_by_key(items, "name", "Athletics") == [2] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 6a7965a4..1613acfb 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1009,6 +1009,82 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_team_id_request_matches_the_sync_client(): + handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_id("Athletics") + + assert asyncio.run(scenario()) == [133] + assert_matches_sync(handler.request, "get_team_id", "Athletics") + + +def test_get_team_id_returns_empty_list_when_there_is_no_match(): + handler = _Handler(_json({"teams": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_id("Nonexistent") + + assert asyncio.run(scenario()) == [] + + +def test_get_people_id_request_matches_the_sync_client(): + handler = _Handler(_json({"people": [{"id": 664034, "fullName": "Ty France"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_people_id("Ty France") + + assert asyncio.run(scenario()) == [664034] + assert_matches_sync(handler.request, "get_people_id", "Ty France") + + +def test_get_sport_id_request_matches_the_sync_client(): + handler = _Handler(_json({"sports": [{"id": 1, "name": "Major League Baseball"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport_id("Major League Baseball") + + assert asyncio.run(scenario()) == [1] + assert_matches_sync(handler.request, "get_sport_id", "Major League Baseball") + + +def test_get_league_id_request_matches_the_sync_client(): + handler = _Handler(_json({"leagues": [{"id": 103, "name": "American League"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league_id("American League") + + assert asyncio.run(scenario()) == [103] + assert_matches_sync(handler.request, "get_league_id", "American League") + + +def test_get_division_id_request_matches_the_sync_client(): + handler = _Handler(_json({"divisions": [{"id": 200, "name": "American League West"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division_id("American League West") + + assert asyncio.run(scenario()) == [200] + assert_matches_sync(handler.request, "get_division_id", "American League West") + + +def test_get_venue_id_request_matches_the_sync_client(): + handler = _Handler(_json({"venues": [{"id": 31, "name": "PNC Park"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue_id("PNC Park") + + assert asyncio.run(scenario()) == [31] + assert_matches_sync(handler.request, "get_venue_id", "PNC Park") + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -1021,21 +1097,27 @@ def test_public_signatures_match_the_sync_client(): for name in ( "get_team", "get_teams", + "get_team_id", "get_team_roster", "get_team_coaches", "get_person", "get_people", + "get_people_id", "get_schedule", "get_sport", "get_sports", + "get_sport_id", "get_league", "get_leagues", + "get_league_id", "get_division", "get_divisions", + "get_division_id", "get_season", "get_seasons", "get_venue", "get_venues", + "get_venue_id", "get_standings", "get_attendance", "get_draft", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 58d71e52..6e7d16bc 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -264,6 +264,14 @@ 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_id": "(team_name: str, search_key: str='name', **params)", + "get_people_id": ( + "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" + ), + "get_sport_id": "(sport_name: str, search_key: str='name', **params)", + "get_league_id": "(league_name: str, search_key: str='name', **params)", + "get_division_id": "(division_name: str, search_key: str='name', **params)", + "get_venue_id": "(venue_name: str, search_key: str='name', **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index b38346f1..edab2b34 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -660,6 +660,84 @@ def test_get_homerun_derby_success_parity(): assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) +def test_get_team_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_team_id", "Athletics", payload={"teams": [{"id": 133, "name": "Athletics"}]} + ) + + assert result.sync == [133] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams", {"fields": "teams,id,name"}) + + +def test_get_people_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_people_id", + "Ty France", + payload={"people": [{"id": 664034, "fullName": "Ty France"}]}, + ) + + assert result.sync == [664034] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/sports/1/players", + {"fields": "people,id,fullName"}, + ) + + +def test_get_sport_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_sport_id", + "Major League Baseball", + payload={"sports": [{"id": 1, "name": "Major League Baseball"}]}, + ) + + assert result.sync == [1] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/sports", {}) + + +def test_get_league_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_league_id", + "American League", + payload={"leagues": [{"id": 103, "name": "American League"}]}, + ) + + assert result.sync == [103] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/leagues", {"fields": "leagues,id,name"}) + + +def test_get_division_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_division_id", + "American League West", + payload={"divisions": [{"id": 200, "name": "American League West"}]}, + ) + + assert result.sync == [200] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/divisions", {}) + + +def test_get_venue_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_venue_id", "PNC Park", payload={"venues": [{"id": 31, "name": "PNC Park"}]} + ) + + assert result.sync == [31] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/venues", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- From b1b11d30223e0c7543658a232b5a1ad538f48c01 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 16:32:40 -0700 Subject: [PATCH 07/13] feat(async): add v1.1 adapter support and the game endpoint group to AsyncMlb Continues issue #305's endpoint expansion with its largest batch: the game group (get_game, get_game_play_by_play, get_game_line_score, get_game_box_score, get_game_ids). get_game uses the v1.1 live feed endpoint, which AsyncMlb previously had no support for. AsyncMlb now constructs a second AsyncMlbDataAdapter for v1.1 that shares one HTTPX client with the v1 adapter, mirroring Mlb's shared-Session pattern: the v1 adapter resolves and owns the client (library-created when the caller passes none), and v1.1 borrows it without ever closing it directly. Sharing the client this way exposed a retry-budget bug in AsyncMlbDataAdapter: retry eligibility was tied 1:1 to "does this adapter own its client", so a naive v1.1 adapter borrowing v1's client would never retry, even when the underlying client is library-owned. Mlb doesn't have this problem because its retry policy is mounted once on the shared Session, not per adapter version. Fixed by adding a retries_enabled parameter to AsyncMlbDataAdapter, decoupling close-ownership from retry eligibility, defaulting to prior behavior for standalone use. Verified directly and with dedicated regression tests that both adapters now retry exactly when the shared client is library-owned, and neither retries with a caller-injected client. Adds a shared _parsers/games.py reused by both Mlb (refactored internally) and AsyncMlb, faithfully preserving get_game_line_score's documented missing 400-499 guard. Also generalizes the shared sync_request_for/assert_matches_sync test helpers, which only knew about the v1 adapter and hardcoded the /api/v1/ prefix, to check both adapters and use whichever one actually fired. Full parser, endpoint, sync/async parity, and live external smoke test coverage for all five endpoints, plus the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md (including a new "API versions used by AsyncMlb" section paralleling Mlb's) are extended accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 28 ++ mlbstatsapi/_parsers/games.py | 40 +++ mlbstatsapi/async_mlb.py | 295 ++++++++++++++++++ mlbstatsapi/async_mlb_dataadapter.py | 21 +- mlbstatsapi/mlb_api.py | 22 +- .../async_mlb/test_async_mlb_smoke.py | 53 ++++ tests/parsers/test_games.py | 127 ++++++++ tests/test_async_mlb.py | 269 +++++++++++++++- tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 186 +++++++++++ 10 files changed, 1019 insertions(+), 30 deletions(-) create mode 100644 mlbstatsapi/_parsers/games.py create mode 100644 tests/parsers/test_games.py diff --git a/docs/public-api.md b/docs/public-api.md index 70c314d8..93bbdfde 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -268,6 +268,18 @@ One `AsyncMlb` instance supports concurrent in-flight requests on the same event loop. Concurrency is caller-controlled. Cross-event-loop use is not promised. +### API versions used by `AsyncMlb` + +`AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share +one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint +methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. The `v1` +adapter resolves and owns the shared client (library-created when the caller +passes none to `AsyncMlb`, otherwise the caller's own); the `v1.1` adapter +borrows that same client and never closes it directly. Retry eligibility +follows the shared client's ownership on both adapters, not which adapter +version issues a given request, matching `Mlb`'s single retry policy mounted +on the shared `Session`. + ### Endpoint methods The currently supported awaitable endpoint methods are: @@ -318,6 +330,17 @@ get_sport_id(sport_name: str, search_key: str = 'name', **params) get_league_id(league_name: str, search_key: str = 'name', **params) get_division_id(division_name: str, search_key: str = 'name', **params) get_venue_id(venue_name: str, search_key: str = 'name', **params) +get_game(game_id: int, **params) +get_game_play_by_play(game_id: int, **params) +get_game_line_score(game_id: int, **params) +get_game_box_score(game_id: int, **params) +get_game_ids( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, +) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is @@ -325,6 +348,11 @@ annotated `Venue | None` but returns `[]` (not `None`) on a 400–499 response, matching the sync behavior noted above. This is preserved for parity, not introduced by the async port. +`get_game_line_score` inherits the same documented quirk as +`Mlb.get_game_line_score`: it does not short-circuit on a 400–499 status the +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. diff --git a/mlbstatsapi/_parsers/games.py b/mlbstatsapi/_parsers/games.py new file mode 100644 index 00000000..de22e912 --- /dev/null +++ b/mlbstatsapi/_parsers/games.py @@ -0,0 +1,40 @@ +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays + + +def parse_game(data: dict, game_id: int) -> Game | None: + """Parse a Game from an MLB /game/{id}/feed/live response body.""" + if not data or data.get("gamePk") != game_id: + return None + return Game(**data) + + +def parse_plays(data: dict) -> Plays | None: + """Parse Plays from an MLB /game/{id}/playByPlay response body.""" + if not data or not data.get("allPlays"): + return None + return Plays(**data) + + +def parse_linescore(data: dict) -> Linescore | None: + """Parse a Linescore from an MLB /game/{id}/linescore response body.""" + if not data or not data.get("teams"): + return None + return Linescore(**data) + + +def parse_boxscore(data: dict) -> BoxScore | None: + """Parse a BoxScore from an MLB /game/{id}/boxscore response body.""" + if not data or not data.get("teams"): + return None + return BoxScore(**data) + + +def parse_game_ids(data: dict) -> list[int]: + """Parse gamePks out of an MLB /schedule response body.""" + if not data or not data.get("dates"): + return [] + return [ + game["gamePk"] + for date in data["dates"] + for game in date["games"] + ] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index d977738c..5e044c61 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -11,6 +11,13 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions from ._parsers.draft import parse_draft +from ._parsers.games import ( + parse_boxscore, + parse_game, + parse_game_ids, + parse_linescore, + parse_plays, +) from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people @@ -27,6 +34,7 @@ from .models.awards import Award from .models.divisions import Division from .models.drafts import Round +from .models.game import BoxScore, Game, Linescore, Plays from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player @@ -55,6 +63,11 @@ def __init__( ): self._logger = logger or logging.getLogger(__name__) + # One client is shared by the v1 and v1.1 adapters, mirroring Mlb's + # shared-Session pattern. The v1 adapter resolves and owns the client + # (library-created when the caller passes none); the v1.1 adapter + # borrows that same client and never closes it itself, but still + # retries exactly when the shared client is library-owned. self._mlb_adapter_v1 = AsyncMlbDataAdapter( hostname=hostname, ver="v1", @@ -63,6 +76,15 @@ def __init__( client=client, strict_http=strict_http, ) + self._mlb_adapter_v1_1 = AsyncMlbDataAdapter( + hostname=hostname, + ver="v1.1", + logger=self._logger, + timeout=timeout, + client=self._mlb_adapter_v1._client, + strict_http=strict_http, + retries_enabled=self._mlb_adapter_v1._owns_client, + ) async def aclose(self) -> None: """Close library-owned async resources.""" @@ -760,6 +782,279 @@ async def get_schedule( return parse_schedule(mlb_data.data) + async def get_game( + self, + game_id: int, + **params, + ) -> Game | None: + """ + Return the game for a specific game id + Gumbo Live Feed for a specific gamePk. + + Async counterpart of ``Mlb.get_game``. Uses the ``v1.1`` live feed + endpoint, like the sync client. + + Parameters + ---------- + game_id : int + Insert gamePk to return the GUMBO live feed for a specific game. + + Other Parameters + ---------------- + timecode : str + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS. + Return timecodes from timecodes endpoint + https://statsapi.mlb.com/api/v1.1/game/534196/feed/live/timestamps + hydrate : str + Insert hydration(s) to return putout credits or defensive + positioning data for all plays in a particular game. + Format 'credits,alignment,flags' + Available Hydrations: + credits + alignment + flags + officials + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Game + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... game = await mlb.get_game(662242) + Game + """ + mlb_data = await self._mlb_adapter_v1_1.get( + endpoint=f"game/{game_id}/feed/live", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_game(mlb_data.data, game_id) + + async def get_game_play_by_play( + self, + game_id: int, + **params, + ) -> Plays | None: + """ + return the playbyplay of a game for a specific game id + + Async counterpart of ``Mlb.get_game_play_by_play``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Plays + + See Also + -------- + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... plays = await mlb.get_game_play_by_play(662242) + Plays + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/playByPlay", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_plays(mlb_data.data) + + async def get_game_line_score( + self, + game_id: int, + **params, + ) -> Linescore | None: + """ + return the Linescore of a game for a specific game id + + Async counterpart of ``Mlb.get_game_line_score``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Linescore + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... linescore = await mlb.get_game_line_score(662242) + Linescore + """ + # Documented quirk: unlike its sibling game helpers, this does not + # short-circuit on a 400-499 status; missing linescore data falls + # through to an implicit None below. See docs/public-api.md. + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/linescore", + ep_params=params, + ) + + return parse_linescore(mlb_data.data) + + async def get_game_box_score( + self, + game_id: int, + **params, + ) -> BoxScore | None: + """ + return the boxscore of a game for a specific game id + + Async counterpart of ``Mlb.get_game_box_score``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + BoxScore + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... boxscore = await mlb.get_game_box_score(662242) + BoxScore + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/boxscore", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_boxscore(mlb_data.data) + + async def get_game_ids( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, + ) -> list[int]: + """ + return game ids for a specific date and game status + + Async counterpart of ``Mlb.get_game_ids``. + + Parameters + ---------- + date : str + date, 'yyyy-mm-dd' + start_date : str + start date, 'yyyy-mm-dd' + end_date : str + end date, 'yyyy-mm-dd' + spord_id : int + spord id of schedule defaults to 1 + + Returns + ------- + list of ints + returns a list of matching game ids + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_game_ids(date="2022-09-26") + """ + if start_date and end_date: + params["startDate"] = start_date + params["endDate"] = end_date + elif date and not (start_date or end_date): + params["date"] = date + else: + return None + + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_game_ids(mlb_data.data) + async def get_sport( self, sport_id: int, diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 65f29749..c91109c0 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -43,12 +43,21 @@ def __init__( client: httpx.AsyncClient | None = None, *, strict_http: bool = True, + retries_enabled: bool | None = None, ): self.url = f"https://{hostname}/api/{ver}/" self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None + # Retry eligibility normally follows client ownership, like the sync + # adapter (retries are mounted on the Session, not per MlbDataAdapter + # version). AsyncMlb overrides this for its v1.1 adapter, which + # borrows the v1 adapter's client rather than owning it directly, so + # both adapters retry exactly when the shared client is library-owned. + self._retries_enabled = ( + self._owns_client if retries_enabled is None else retries_enabled + ) self._retry_policy = create_retry_policy() if client is None: @@ -208,7 +217,7 @@ async def _request_with_retries( ) except httpx.ReadTimeout as exc: - max_attempts = policy.read + 1 if self._owns_client else 1 + max_attempts = policy.read + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -221,7 +230,7 @@ async def _request_with_retries( # Caught before httpx.TimeoutException: a connect timeout is a # timeout for the caller, but it spends the connect budget so # the retry accounting matches the sync policy. - max_attempts = policy.connect + 1 if self._owns_client else 1 + max_attempts = policy.connect + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -231,7 +240,7 @@ async def _request_with_retries( continue except httpx.ConnectError as exc: - max_attempts = policy.connect + 1 if self._owns_client else 1 + max_attempts = policy.connect + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -244,7 +253,7 @@ async def _request_with_retries( continue except httpx.TimeoutException as exc: - max_attempts = policy.total + 1 if self._owns_client else 1 + max_attempts = policy.total + 1 if self._retries_enabled else 1 if attempt >= max_attempts: raise MlbTimeoutError("Request failed") from exc @@ -256,7 +265,7 @@ async def _request_with_retries( continue except httpx.RequestError as exc: - max_attempts = policy.total + 1 if self._owns_client else 1 + max_attempts = policy.total + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -265,7 +274,7 @@ async def _request_with_retries( await self._sleep_before_retry(attempt=attempt, response=None) continue - max_attempts = policy.status + 1 if self._owns_client else 1 + max_attempts = policy.status + 1 if self._retries_enabled else 1 if response.status_code not in policy.status_forcelist or attempt >= max_attempts: return response diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 2b620120..b19c5308 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -28,6 +28,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division from ._parsers.draft import parse_draft +from ._parsers.games import parse_boxscore, parse_game, parse_game_ids, parse_linescore, parse_plays from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person @@ -934,8 +935,7 @@ def get_game(self, game_id: int, **params) -> Union[Game, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'gamePk' in mlb_data.data and mlb_data.data['gamePk'] == game_id: - return Game(**mlb_data.data) + return parse_game(mlb_data.data, game_id) def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]: """ @@ -979,8 +979,7 @@ def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'allPlays' in mlb_data.data and mlb_data.data['allPlays']: - return Plays(**mlb_data.data) + return parse_plays(mlb_data.data) def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]: """ @@ -1022,8 +1021,7 @@ def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'game/{game_id}/linescore', ep_params=params) - if 'teams' in mlb_data.data and mlb_data.data['teams']: - return Linescore(**mlb_data.data) + return parse_linescore(mlb_data.data) def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]: """ @@ -1067,8 +1065,7 @@ def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'teams' in mlb_data.data and mlb_data.data['teams']: - return BoxScore(**mlb_data.data) + return parse_boxscore(mlb_data.data) def get_game_ids(self, date: str = None, @@ -1121,14 +1118,7 @@ def get_game_ids(self, date: str = None, if 400 <= mlb_data.status_code <= 499: return [] - game_ids = [] - - if 'dates' in mlb_data.data and mlb_data.data['dates']: - for date in mlb_data.data['dates']: - for game in date['games']: - game_ids.append(game['gamePk']) - - return game_ids + return parse_game_ids(mlb_data.data) def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, None]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index e6cb148b..949b9919 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.drafts import Round +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player @@ -237,3 +238,55 @@ async def scenario(): assert ids == [31] asyncio.run(scenario()) + + +def test_async_get_game(): + async def scenario(): + async with AsyncMlb() as mlb: + game = await mlb.get_game(717911) + + assert isinstance(game, Game) + assert game.id == 717911 + + asyncio.run(scenario()) + + +def test_async_get_game_play_by_play(): + async def scenario(): + async with AsyncMlb() as mlb: + plays = await mlb.get_game_play_by_play(717911) + + assert isinstance(plays, Plays) + assert plays.all_plays + + asyncio.run(scenario()) + + +def test_async_get_game_line_score(): + async def scenario(): + async with AsyncMlb() as mlb: + linescore = await mlb.get_game_line_score(717911) + + assert isinstance(linescore, Linescore) + + asyncio.run(scenario()) + + +def test_async_get_game_box_score(): + async def scenario(): + async with AsyncMlb() as mlb: + boxscore = await mlb.get_game_box_score(717911) + + assert isinstance(boxscore, BoxScore) + + asyncio.run(scenario()) + + +def test_async_get_game_ids(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_game_ids(date="2023-06-03") + + assert 717911 in ids + + asyncio.run(scenario()) diff --git a/tests/parsers/test_games.py b/tests/parsers/test_games.py new file mode 100644 index 00000000..6fa9a538 --- /dev/null +++ b/tests/parsers/test_games.py @@ -0,0 +1,127 @@ +from mlbstatsapi._parsers.games import ( + parse_boxscore, + parse_game, + parse_game_ids, + parse_linescore, + parse_plays, +) +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays + + +GAME_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} + +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} + +TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": TEAM_PAYLOAD}, + "offense": {"team": TEAM_PAYLOAD}, +} + +BOXSCORE_SIDE = { + "team": TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} + +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} + + +def test_parse_game(): + """parse_game only accepts a payload whose gamePk matches the requested id.""" + assert parse_game({}, 717911) is None + assert parse_game({"gamePk": 1, "link": "x"}, 717911) is None + + game = parse_game(GAME_PAYLOAD, 717911) + + assert isinstance(game, Game) + assert game.id == 717911 + + +def test_parse_plays(): + """parse_plays requires a non-empty allPlays list.""" + assert parse_plays({}) is None + assert parse_plays({"allPlays": []}) is None + + plays = parse_plays(PLAYS_PAYLOAD) + + assert isinstance(plays, Plays) + assert len(plays.all_plays) == 1 + + +def test_parse_linescore(): + """parse_linescore requires a non-empty teams object.""" + assert parse_linescore({}) is None + assert parse_linescore({"teams": {}}) is None + + linescore = parse_linescore(LINESCORE_PAYLOAD) + + assert isinstance(linescore, Linescore) + assert linescore.scheduled_innings == 9 + + +def test_parse_boxscore(): + """parse_boxscore requires a non-empty teams object.""" + assert parse_boxscore({}) is None + assert parse_boxscore({"teams": {}}) is None + + boxscore = parse_boxscore(BOXSCORE_PAYLOAD) + + assert isinstance(boxscore, BoxScore) + + +def test_parse_game_ids(): + """parse_game_ids flattens dates -> games -> gamePk.""" + assert parse_game_ids({}) == [] + assert parse_game_ids({"dates": []}) == [] + + assert parse_game_ids(SCHEDULE_WITH_GAMES_PAYLOAD) == [1, 2, 3] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 1613acfb..08a98d2e 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 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.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -237,6 +238,68 @@ "bonusTime": False, }, } +GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} +GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": GAME_TEAM_PAYLOAD}, + "offense": {"team": GAME_TEAM_PAYLOAD}, +} +BOXSCORE_SIDE = { + "team": GAME_TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -349,23 +412,31 @@ def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: await mlb._mlb_adapter_v1._client.aclose() -def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: - """Return the endpoint and params ``Mlb`` builds for a call. +def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict, str]: + """Return the endpoint, params, and API version ``Mlb`` builds for a call. - The adapter is stubbed, so this reaches no network; it just reads back what - the synchronous client asked for. + The adapters are stubbed, so this reaches no network; it just reads back + what the synchronous client asked for. Most methods call the v1 adapter; + get_game calls v1.1, so both are stubbed and whichever one was actually + called wins. """ with Mlb() as sync_mlb: sync_mlb._mlb_adapter_v1.get = MagicMock( return_value=MlbResult(status_code=200, message=None, data={}) ) + sync_mlb._mlb_adapter_v1_1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data={}) + ) getattr(sync_mlb, method)(*args, **kwargs) - call = sync_mlb._mlb_adapter_v1.get.call_args + if sync_mlb._mlb_adapter_v1.get.called: + call, ver = sync_mlb._mlb_adapter_v1.get.call_args, "v1" + else: + call, ver = sync_mlb._mlb_adapter_v1_1.get.call_args, "v1.1" # Most Mlb methods pass endpoint as a keyword; get_attendance passes it # positionally, so fall back to the first positional argument. endpoint = call.kwargs["endpoint"] if "endpoint" in call.kwargs else call.args[0] - return endpoint, call.kwargs["ep_params"] + return endpoint, call.kwargs["ep_params"], ver def _flatten_params(params: dict) -> list[tuple[str, str]]: @@ -386,12 +457,12 @@ 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.""" - endpoint, params = sync_request_for(method, *args, **kwargs) + 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/v1/{endpoint}".rstrip("?") + assert request.url.path == f"/api/{ver}/{endpoint}".rstrip("?") assert sorted(request.url.params.multi_items()) == _flatten_params(params) @@ -512,6 +583,48 @@ async def scenario(): asyncio.run(scenario()) +def test_v1_and_v1_1_adapters_share_one_client(): + """One client is shared by both adapters, mirroring Mlb's shared Session.""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + assert mlb._mlb_adapter_v1._client is mlb._mlb_adapter_v1_1._client + # Only v1 tracks close-ownership of the shared client; v1.1 must + # never double-close it. + assert mlb._mlb_adapter_v1._owns_client is True + assert mlb._mlb_adapter_v1_1._owns_client is False + + asyncio.run(scenario()) + + +def test_v1_1_adapter_retries_when_the_shared_client_is_library_owned(): + """Retry eligibility follows the shared client's ownership, not which + adapter version issues the request (matching Mlb, which configures + retries once on the shared Session).""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + assert mlb._mlb_adapter_v1._retries_enabled is True + assert mlb._mlb_adapter_v1_1._retries_enabled is True + + asyncio.run(scenario()) + + +def test_v1_1_adapter_does_not_retry_with_a_caller_injected_client(): + handler = _Handler(_json(TEAM_PAYLOAD)) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + async def scenario(): + try: + async with AsyncMlb(client=client) as mlb: + assert mlb._mlb_adapter_v1._retries_enabled is False + assert mlb._mlb_adapter_v1_1._retries_enabled is False + finally: + await client.aclose() + + asyncio.run(scenario()) + + def test_aclose_is_idempotent(): """Closing more than once, however the caller mixes the forms, is safe.""" @@ -1085,6 +1198,141 @@ async def scenario(): assert_matches_sync(handler.request, "get_venue_id", "PNC Park") +def test_get_game_requests_the_v1_1_feed_endpoint_and_parses_the_result(): + handler = _Handler(_json(GAME_FEED_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game(717911) + + game = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game", 717911) + assert isinstance(game, Game) + assert game.id == 717911 + assert handler.request.url.path == "/api/v1.1/game/717911/feed/live" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_returns_none_when_there_is_no_game(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_play_by_play_requests_the_playbyplay_endpoint_and_parses_the_result(): + handler = _Handler(_json(PLAYS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_play_by_play(717911) + + plays = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_play_by_play", 717911) + assert isinstance(plays, Plays) + assert len(plays.all_plays) == 1 + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_play_by_play_returns_none_when_there_are_no_plays(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_play_by_play(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_line_score_requests_the_linescore_endpoint_and_parses_the_result(): + handler = _Handler(_json(LINESCORE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_line_score(717911) + + linescore = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_line_score", 717911) + assert isinstance(linescore, Linescore) + assert linescore.scheduled_innings == 9 + + +def test_get_game_line_score_returns_none_on_an_empty_200_without_a_status_guard(): + """get_game_line_score has no 400-499 guard; documented in public-api.md.""" + handler = _Handler(NO_RESULT_RESPONSES["empty 200"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_line_score(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_box_score_requests_the_boxscore_endpoint_and_parses_the_result(): + handler = _Handler(_json(BOXSCORE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_box_score(717911) + + boxscore = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_box_score", 717911) + assert isinstance(boxscore, BoxScore) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_box_score_returns_none_when_there_is_no_boxscore(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_box_score(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_ids_requests_the_schedule_endpoint_and_parses_the_result(): + handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_ids(date="2022-09-26") + + game_ids = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_ids", date="2022-09-26") + assert game_ids == [1, 2, 3] + + +def test_get_game_ids_without_a_selector_returns_none_without_requesting(): + handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_ids() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_ids_returns_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_game_ids(date="2022-09-26") + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -1123,6 +1371,11 @@ def test_public_signatures_match_the_sync_client(): "get_draft", "get_awards", "get_homerun_derby", + "get_game", + "get_game_play_by_play", + "get_game_line_score", + "get_game_box_score", + "get_game_ids", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 6e7d16bc..b3cae605 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -272,6 +272,14 @@ def _normalize_signature(fn: Any) -> str: "get_league_id": "(league_name: str, search_key: str='name', **params)", "get_division_id": "(division_name: str, search_key: str='name', **params)", "get_venue_id": "(venue_name: str, search_key: str='name', **params)", + "get_game": "(game_id: int, **params)", + "get_game_play_by_play": "(game_id: int, **params)", + "get_game_line_score": "(game_id: int, **params)", + "get_game_box_score": "(game_id: int, **params)", + "get_game_ids": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index edab2b34..17461364 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 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.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -241,6 +242,68 @@ "bonusTime": False, }, } +GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} +GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": GAME_TEAM_PAYLOAD}, + "offense": {"team": GAME_TEAM_PAYLOAD}, +} +BOXSCORE_SIDE = { + "team": GAME_TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -738,6 +801,65 @@ def test_get_venue_id_success_parity(): assert result.request == ("GET", "/api/v1/venues", {}) +def test_get_game_success_parity(): + """A successful game feed response parses to the same Game on both clients, + hitting the v1.1 endpoint on both.""" + result = call_both("get_game", 717911, payload=GAME_FEED_PAYLOAD) + + assert isinstance(result.sync, Game), "sync get_game did not return a Game" + assert result.sync.id == 717911 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1.1/game/717911/feed/live", {}) + + +def test_get_game_play_by_play_success_parity(): + """A successful play-by-play response parses to the same Plays on both clients.""" + result = call_both("get_game_play_by_play", 717911, payload=PLAYS_PAYLOAD) + + assert isinstance(result.sync, Plays), ( + "sync get_game_play_by_play did not return a Plays" + ) + assert len(result.sync.all_plays) == 1 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/playByPlay", {}) + + +def test_get_game_line_score_success_parity(): + """A successful linescore response parses to the same Linescore on both clients.""" + result = call_both("get_game_line_score", 717911, payload=LINESCORE_PAYLOAD) + + assert isinstance(result.sync, Linescore), ( + "sync get_game_line_score did not return a Linescore" + ) + assert result.sync.scheduled_innings == 9 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/linescore", {}) + + +def test_get_game_box_score_success_parity(): + """A successful boxscore response parses to the same BoxScore on both clients.""" + result = call_both("get_game_box_score", 717911, payload=BOXSCORE_PAYLOAD) + + assert isinstance(result.sync, BoxScore), ( + "sync get_game_box_score did not return a BoxScore" + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/boxscore", {}) + + +def test_get_game_ids_success_parity(): + """A successful schedule response resolves to the same gamePk list on both clients.""" + result = call_both("get_game_ids", date="2022-09-26", payload=SCHEDULE_WITH_GAMES_PAYLOAD) + + assert result.sync == [1, 2, 3] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/schedule", + {"date": "2022-09-26", "sportId": "1"}, + ) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -948,6 +1070,70 @@ def test_get_homerun_derby_malformed_error_body_parity(): assert result.asynchronous is None +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_no_result_parity(label): + """Every no-result response returns None on either client (v1.1 endpoint).""" + result = call_both("get_game", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_game returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_game returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_play_by_play_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_game_play_by_play", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_play_by_play returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_play_by_play returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_line_score_no_result_parity(label): + """Every no-result response returns None on either client, even without + get_game_line_score's missing 400-499 guard (documented quirk).""" + result = call_both("get_game_line_score", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_line_score returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_line_score returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_box_score_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_game_box_score", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_box_score returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_box_score returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_ids_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both( + "get_game_ids", date="2022-09-26", **NO_RESULT_RESPONSES[label] + ) + + assert result.sync == [], f"sync get_game_ids returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_game_ids returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From abfc10d00cc7066e1131972f49b8c7f1bd6f18ce Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 17:08:03 -0700 Subject: [PATCH 08/13] refactor(async): replace the AsyncMlbDataAdapter retries_enabled constructor param with a private setter Follow-up to the v1.1 adapter sharing added for get_game (issue #305). The previous fix added retries_enabled: bool | None to AsyncMlbDataAdapter.__init__ so AsyncMlb could tell its borrowed v1.1 adapter that the shared client is library-owned, even though v1.1 itself received a non-None client and would otherwise conclude it's using a caller-injected client and disable retries. That parameter looked like public, supported configuration on a documented class's constructor, when it was really an internal coordination detail: only AsyncMlb, which actually owns the shared transport, has the information to make that call, and no standalone AsyncMlbDataAdapter caller should reach for it. Removes the constructor parameter entirely (its public signature is back to exactly what it was before the v1.1 work) and replaces it with a private _set_retries_enabled() method, explicitly documented as an internal coordination hook rather than public API. AsyncMlb.__init__ now calls that method instead of reassigning the private _retries_enabled attribute directly, so the coordination point is discoverable and testable in isolation rather than an undeclared attribute poke. Adds two unit tests directly on the new setter: one proving it only affects retry eligibility and never grants close-ownership over a client the adapter didn't create, and one proving the override changes actual retry behavior (a scripted transient failure is retried), not just a stored flag. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- mlbstatsapi/async_mlb.py | 7 ++++++- mlbstatsapi/async_mlb_dataadapter.py | 28 +++++++++++++++++-------- tests/test_async_mlb_dataadapter.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 5e044c61..5ff49738 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -83,8 +83,13 @@ def __init__( timeout=timeout, client=self._mlb_adapter_v1._client, strict_http=strict_http, - retries_enabled=self._mlb_adapter_v1._owns_client, ) + # AsyncMlb, not either adapter, actually owns this shared transport, + # so it is the one that knows whether the client is library-owned. + # The v1.1 adapter received a non-None client above, so it would + # otherwise conclude it's using a caller-injected client and disable + # retries even when the client is really library-owned via v1. + self._mlb_adapter_v1_1._set_retries_enabled(self._mlb_adapter_v1._owns_client) async def aclose(self) -> None: """Close library-owned async resources.""" diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index c91109c0..8e180657 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -43,21 +43,20 @@ def __init__( client: httpx.AsyncClient | None = None, *, strict_http: bool = True, - retries_enabled: bool | None = None, ): self.url = f"https://{hostname}/api/{ver}/" self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None - # Retry eligibility normally follows client ownership, like the sync - # adapter (retries are mounted on the Session, not per MlbDataAdapter - # version). AsyncMlb overrides this for its v1.1 adapter, which - # borrows the v1 adapter's client rather than owning it directly, so - # both adapters retry exactly when the shared client is library-owned. - self._retries_enabled = ( - self._owns_client if retries_enabled is None else retries_enabled - ) + # Retry eligibility follows client ownership by default, like the + # sync adapter (retries are mounted on the Session, not per + # MlbDataAdapter version). This is not a constructor knob: a caller + # that owns this adapter's transport (AsyncMlb, for its v1.1 adapter + # sharing v1's client) may call _set_retries_enabled() after + # construction, since it — not this adapter — is the one that knows + # whether the shared client is actually library-owned. + self._retries_enabled = self._owns_client self._retry_policy = create_retry_policy() if client is None: @@ -323,3 +322,14 @@ async def aclose(self) -> None: if self._owns_client and not self._closed: await self._client.aclose() self._closed = True + + def _set_retries_enabled(self, enabled: bool) -> None: + """Override retry eligibility for a borrowed, non-owned client. + + Internal coordination hook, not public API: only a caller that + actually owns this adapter's transport (AsyncMlb, wiring up its v1.1 + adapter to share the v1 adapter's client) should call this. Standalone + use never needs it; retry eligibility already follows client + ownership by default. + """ + self._retries_enabled = enabled diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 28605b87..f2ef0f58 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -148,6 +148,37 @@ def test_retry_policy_matches_library_default(): assert_library_retry_policy(adapter._retry_policy) +def test_set_retries_enabled_does_not_change_client_ownership(): + """The private coordination hook AsyncMlb uses for its v1.1 adapter only + overrides retry eligibility; it must never grant close-ownership over a + client this adapter did not create.""" + handler = _ScriptedHandler(_response(200)) + adapter = _injected_adapter(handler) + assert adapter._retries_enabled is False + + adapter._set_retries_enabled(True) + + assert adapter._retries_enabled is True + assert adapter._owns_client is False + + +def test_set_retries_enabled_true_makes_an_injected_client_retry(): + """An injected client normally gets zero retries; overriding the flag + must actually change retry behavior, not just the stored value.""" + handler = _ScriptedHandler(_response(503), _response(200)) + + async def scenario(): + adapter = _injected_adapter(handler) + adapter._set_retries_enabled(True) + + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 2 + + def test_200_succeeds_with_no_retry(): handler = _ScriptedHandler(_response(200)) From 458d86813fe7948bc98e27cf88e336b0843f1adc Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 18:20:58 -0700 Subject: [PATCH 09/13] refactor(async): move retries and client ownership onto a shared transport AsyncMlb's ownership pointed the wrong way: __init__ built the v1 adapter with client=client, let that adapter create the shared httpx.AsyncClient, then reached back into self._mlb_adapter_v1._client to construct the v1.1 adapter. aclose() delegated the parent's shutdown to one of its children, with the other child required not to close the thing it shares. Underneath that, AsyncMlbDataAdapter._owns_client answered two different questions at once ("who closes this client" and "am I allowed to retry"), which is why _set_retries_enabled() existed at all: to undo the wrong conclusion the v1.1 adapter reached about retry eligibility after receiving a non-None client. Mlb does not have this problem because it does not implement retries -- it configures them, once, by mounting an HTTPAdapter carrying the retry policy onto the Session it creates. HTTPX has the same extension seam: AsyncClient(transport=...) accepts any AsyncBaseTransport, the position HTTPAdapter occupies in Requests. Moving the retry loop there makes retries a property of the client, so two adapters sharing one client share one policy by construction and cannot disagree about it. Adds a private mlbstatsapi/_async_transport.py: - MlbAsyncRetryTransport wraps an inner transport (default httpx.AsyncHTTPTransport()) with the existing create_retry_policy() budget and backoff accounting, moved verbatim from AsyncMlbDataAdapter._request_with_retries /_sleep_before_retry. On an exhausted budget it re-raises the underlying httpx exception rather than translating it -- transports are contractually expected to raise httpx errors, translation stays with the adapter. - create_library_async_client() is the async counterpart of _configure_library_session(): builds a client with the package User-Agent and MlbAsyncRetryTransport() mounted. AsyncMlb.__init__ now owns the client exactly as Mlb.__init__ owns the Session (self._owns_client, self._client, self._closed, created via create_library_async_client() when the caller passes none) and hands that one client to both adapters. aclose() closes self._client directly instead of delegating to the v1 adapter. AsyncMlbDataAdapter's public constructor is unchanged. get() now calls self._client.get() directly, wrapped in the same two except clauses the sync adapter's error path mirrors (TimeoutException -> MlbTimeoutError, RequestError -> MlbTransportError; ConnectTimeout/ConnectError fall into the right one because ConnectTimeout is a TimeoutException subclass). Deleted _set_retries_enabled, _retries_enabled, _retry_policy, _request_with_retries, and _sleep_before_retry. Retargets the test seam from patching httpx.AsyncClient to patching httpx.AsyncHTTPTransport inside _async_transport, so tests exercise the real client, the real retry transport, and the real headers with a MockTransport at the bottom. _owned_adapter/_injected_adapter keep their names; a new _retry_policy_of() accessor reads the policy from the client's transport for tests that mutate it. Replaces the two _set_retries_enabled tests with three that assert the new shape: a library-created client mounts MlbAsyncRetryTransport, an injected client's transport is left exactly as supplied, and mounting MlbAsyncRetryTransport on an injected client makes it retry (the caller-facing opt-in). In test_async_mlb.py, ownership assertions move from the adapters to AsyncMlb itself, and the cleanup-failure test mocks mlb.aclose rather than an adapter's. Testing: - poetry run pytest tests/ --ignore=tests/external_tests: 973 passed before this change (checked out from the unmodified branch tip), 974 after (net +1: two _set_retries_enabled tests removed, three new transport-ownership tests added). - tests/test_sync_async_parity.py: 96 passed, unchanged -- AsyncMlb's observable behavior did not move. - tests/external_tests/async_mlb/: 26 passed against the live API. Risk: internal-only change to a private transport layer behind AsyncMlb's and AsyncMlbDataAdapter's unchanged public constructors. Retry budgets, backoff timing, exception mapping, 404/strict_http behavior, the User-Agent, and caller-injected-client ownership rules are all covered by tests and were not changed on purpose. Intentionally left out: MlbAsyncRetryTransport stays private. Exporting it would turn "an injected client gets no library retries" from a limitation into a documented opt-in, which is a public-API decision -- see the PR description. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 18 +-- mlbstatsapi/_async_transport.py | 165 +++++++++++++++++++++++++ mlbstatsapi/async_mlb.py | 36 +++--- mlbstatsapi/async_mlb_dataadapter.py | 177 ++++----------------------- tests/test_async_mlb.py | 68 +++++----- tests/test_async_mlb_dataadapter.py | 96 ++++++++------- 6 files changed, 307 insertions(+), 253 deletions(-) create mode 100644 mlbstatsapi/_async_transport.py diff --git a/docs/public-api.md b/docs/public-api.md index 93bbdfde..6a3d6a75 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -272,13 +272,17 @@ promised. `AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint -methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. The `v1` -adapter resolves and owns the shared client (library-created when the caller -passes none to `AsyncMlb`, otherwise the caller's own); the `v1.1` adapter -borrows that same client and never closes it directly. Retry eligibility -follows the shared client's ownership on both adapters, not which adapter -version issues a given request, matching `Mlb`'s single retry policy mounted -on the shared `Session`. +methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. `AsyncMlb` +owns the shared client, exactly as `Mlb` owns the shared `Session`: it creates +one when the caller passes none, closes only a client it created, and hands +the same client to both adapters. + +Retries are a property of that client, not of either adapter. A +library-created client is built with the library retry transport mounted on +it, the way a library-created `Session` is built with the library retry +adapters mounted on it, so both API versions retry identically without either +adapter holding retry state. A caller-injected client keeps whatever transport +its caller mounted. ### Endpoint methods diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py new file mode 100644 index 00000000..816e34e4 --- /dev/null +++ b/mlbstatsapi/_async_transport.py @@ -0,0 +1,165 @@ +"""Retry-aware HTTPX transport for the async client. + +The synchronous side does not implement retries. It *configures* them: ``Mlb`` +mounts an ``HTTPAdapter`` carrying the library ``Retry`` policy onto the +Session it creates, and from that point on every ``session.get()`` retries +without any caller — ``MlbDataAdapter`` included — knowing retries exist. + +HTTPX has the same seam. ``AsyncClient(transport=...)`` accepts any +``AsyncBaseTransport``, which is the position ``HTTPAdapter`` occupies in +Requests. Putting the retry loop there instead of inside +``AsyncMlbDataAdapter`` gives the async side the sync structure: + +* Adapters call ``client.get()`` and are unaware of retries. +* The retry policy travels with the client, so two adapters sharing one client + share one policy by construction. Neither adapter holds retry state, so + neither can disagree with the other about it. +* A caller-injected client keeps whatever transport its caller mounted, so + "the library does not touch an injected client" needs no flag to enforce. + +A caller who wants library retry behavior on a client they own mounts this +transport themselves, mirroring the documented sync recipe for +``create_retry_policy()``. +""" + +import asyncio + +from ._async_support import import_httpx +from .mlb_dataadapter import _build_user_agent, create_retry_policy + +httpx = import_httpx() + + +class MlbAsyncRetryTransport(httpx.AsyncBaseTransport): + """Wrap an HTTPX transport with the library's bounded retry policy. + + Failures spend the same retry budget the sync policy spends: + + ReadTimeout -> read budget + ConnectTimeout -> connect budget + ConnectError -> connect budget + other TimeoutException -> total budget + other RequestError -> total budget + retryable HTTP status -> status budget + + Exhausting a budget re-raises the underlying HTTPX exception. Translating + those into the library's public exception types stays with the adapter, so + this class satisfies the transport contract HTTPX documents: transports + raise HTTPX errors. + """ + + def __init__( + self, + inner: httpx.AsyncBaseTransport | None = None, + *, + retry_policy=None, + ): + self._inner = inner if inner is not None else httpx.AsyncHTTPTransport() + self._retry_policy = ( + retry_policy if retry_policy is not None else create_retry_policy() + ) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + policy = self._retry_policy + + attempt = 0 + while True: + attempt += 1 + try: + response = await self._inner.handle_async_request(request) + + except httpx.ReadTimeout: + if attempt > policy.read: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.ConnectTimeout: + # Caught before httpx.TimeoutException: a connect timeout is a + # timeout for the caller, but it spends the connect budget so + # the retry accounting matches the sync policy. + if attempt > policy.connect: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.ConnectError: + if attempt > policy.connect: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.TimeoutException: + if attempt > policy.total: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.RequestError: + if attempt > policy.total: + raise + await self._backoff(attempt=attempt, response=None) + continue + + if ( + response.status_code not in policy.status_forcelist + or attempt > policy.status + ): + return response + + # The response is discarded, so release it before another attempt + # rather than leaving a connection checked out of the pool. + delay = self._delay_for(attempt=attempt, response=response) + await response.aclose() + if delay > 0: + await asyncio.sleep(delay) + + async def _backoff( + self, + *, + attempt: int, + response: httpx.Response | None, + ) -> None: + delay = self._delay_for(attempt=attempt, response=response) + if delay > 0: + await asyncio.sleep(delay) + + def _delay_for( + self, + *, + attempt: int, + response: httpx.Response | None, + ) -> float: + policy = self._retry_policy + + if policy.respect_retry_after_header and response is not None: + retry_after = policy.get_retry_after(response) + if retry_after: + return retry_after + + # Mirrors urllib3's Retry.get_backoff_time(): no delay before the + # first retry, exponential thereafter, capped at backoff_max. + if attempt <= 1: + return 0.0 + + return min( + policy.backoff_factor * (2 ** (attempt - 1)), + policy.backoff_max, + ) + + async def aclose(self) -> None: + await self._inner.aclose() + + +def create_library_async_client() -> httpx.AsyncClient: + """Build the async client the library creates and owns. + + The counterpart of ``_configure_library_session()`` on the sync side: + library defaults are applied here, at creation, and only to clients the + library creates. Passing headers to the constructor replaces just the + User-Agent, so HTTPX's other default headers survive. + """ + return httpx.AsyncClient( + headers={"User-Agent": _build_user_agent()}, + transport=MlbAsyncRetryTransport(), + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 5ff49738..94a479e5 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING +from ._async_transport import create_library_async_client from ._helpers.id_lookup import find_ids_by_key from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance @@ -63,17 +64,23 @@ def __init__( ): self._logger = logger or logging.getLogger(__name__) - # One client is shared by the v1 and v1.1 adapters, mirroring Mlb's - # shared-Session pattern. The v1 adapter resolves and owns the client - # (library-created when the caller passes none); the v1.1 adapter - # borrows that same client and never closes it itself, but still - # retries exactly when the shared client is library-owned. + # One client is shared by the v1 and v1.1 adapters, and this client + # owns it, mirroring Mlb's shared-Session pattern. The library closes + # only clients it creates; caller-injected clients remain caller-owned. + # The versioned User-Agent and the retry transport are applied only to + # library-created clients. + self._owns_client = client is None + if client is None: + self._client = create_library_async_client() + else: + self._client = client + self._closed = False self._mlb_adapter_v1 = AsyncMlbDataAdapter( hostname=hostname, ver="v1", logger=self._logger, timeout=timeout, - client=client, + client=self._client, strict_http=strict_http, ) self._mlb_adapter_v1_1 = AsyncMlbDataAdapter( @@ -81,19 +88,18 @@ def __init__( ver="v1.1", logger=self._logger, timeout=timeout, - client=self._mlb_adapter_v1._client, + client=self._client, strict_http=strict_http, ) - # AsyncMlb, not either adapter, actually owns this shared transport, - # so it is the one that knows whether the client is library-owned. - # The v1.1 adapter received a non-None client above, so it would - # otherwise conclude it's using a caller-injected client and disable - # retries even when the client is really library-owned via v1. - self._mlb_adapter_v1_1._set_retries_enabled(self._mlb_adapter_v1._owns_client) async def aclose(self) -> None: - """Close library-owned async resources.""" - await self._mlb_adapter_v1.aclose() + """Close the HTTP client when this client owns it. + + Safe to call more than once. Caller-injected clients are left alone. + """ + if self._owns_client and not self._closed: + await self._client.aclose() + self._closed = True async def __aenter__(self) -> "AsyncMlb": return self diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 8e180657..ec676316 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -1,9 +1,9 @@ -import asyncio import logging from typing import Dict from ._async_support import import_httpx +from ._async_transport import create_library_async_client from .exceptions import ( MlbDecodeError, MlbTimeoutError, @@ -13,8 +13,6 @@ DEFAULT_TIMEOUT, MlbResult, TimeoutType, - _build_user_agent, - create_retry_policy, ) from ._http import ( @@ -49,25 +47,16 @@ def __init__( self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None - # Retry eligibility follows client ownership by default, like the - # sync adapter (retries are mounted on the Session, not per - # MlbDataAdapter version). This is not a constructor knob: a caller - # that owns this adapter's transport (AsyncMlb, for its v1.1 adapter - # sharing v1's client) may call _set_retries_enabled() after - # construction, since it — not this adapter — is the one that knows - # whether the shared client is actually library-owned. - self._retries_enabled = self._owns_client - self._retry_policy = create_retry_policy() if client is None: - # Only a library-owned client gets the package User-Agent. Passing - # it to the constructor replaces just that header, so httpx's other - # default headers (Accept, Accept-Encoding, Connection) survive. - self._client = httpx.AsyncClient( - headers={"User-Agent": _build_user_agent()}, - ) + # A library-created client carries the package User-Agent and the + # library retry transport. Retries are a property of the client, + # not of this adapter, exactly as they are a property of the + # Session on the sync side. + self._client = create_library_async_client() else: - # An injected client stays exactly as the caller configured it. + # An injected client stays exactly as the caller configured it, + # retry transport included or not. self._client = client self._closed = False @@ -100,8 +89,20 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> ) ) - self._logger.debug(logline_post) - response = await self._request_with_retries(full_url, ep_params) + try: + self._logger.debug(logline_post) + response = await self._client.get( + url=full_url, + params=ep_params, + timeout=self._translate_timeout(self._timeout), + ) + + except httpx.TimeoutException as exc: + self._logger.error(msg=(str(exc))) + raise MlbTimeoutError("Request failed") from exc + except httpx.RequestError as exc: + self._logger.error(msg=(str(exc))) + raise MlbTransportError("Request failed") from exc status_code = response.status_code @@ -181,129 +182,6 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> data=response_data, ) - async def _request_with_retries( - self, - full_url: str, - ep_params: Dict, - ) -> httpx.Response: - """Issue the GET call, retrying with bounded backoff when this - adapter owns its httpx.AsyncClient. - - An injected client is called exactly once; its retry behavior stays - under caller control, matching the sync adapter's session-ownership - rule. - - Failures spend the retry budget the sync policy would spend, and - surface the public exception the sync adapter raises: - - ReadTimeout -> read budget -> MlbTimeoutError - ConnectTimeout -> connect budget -> MlbTimeoutError - ConnectError -> connect budget -> MlbTransportError - other TimeoutException -> total budget -> MlbTimeoutError - other RequestError -> total budget -> MlbTransportError - retryable HTTP status -> status budget - """ - policy = self._retry_policy - - attempt = 0 - while True: - attempt += 1 - try: - response = await self._client.get( - url=full_url, - params=ep_params, - timeout=self._translate_timeout(self._timeout), - ) - - except httpx.ReadTimeout as exc: - max_attempts = policy.read + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - except httpx.ConnectTimeout as exc: - # Caught before httpx.TimeoutException: a connect timeout is a - # timeout for the caller, but it spends the connect budget so - # the retry accounting matches the sync policy. - max_attempts = policy.connect + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - except httpx.ConnectError as exc: - max_attempts = policy.connect + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTransportError("Request failed") from exc - - await self._sleep_before_retry( - attempt=attempt, - response=None, - ) - continue - - except httpx.TimeoutException as exc: - max_attempts = policy.total + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry( - attempt=attempt, - response=None, - ) - continue - - except httpx.RequestError as exc: - max_attempts = policy.total + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTransportError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - max_attempts = policy.status + 1 if self._retries_enabled else 1 - - if response.status_code not in policy.status_forcelist or attempt >= max_attempts: - return response - - await self._sleep_before_retry(attempt=attempt, response=response) - - async def _sleep_before_retry( - self, - *, - attempt: int, - response: httpx.Response | None, - ) -> None: - policy = self._retry_policy - - if policy.respect_retry_after_header and response is not None: - retry_after = policy.get_retry_after(response) - if retry_after: - await asyncio.sleep(retry_after) - return - - # Mirrors urllib3's Retry.get_backoff_time(): no delay before the - # first retry, exponential thereafter, capped at backoff_max. - delay = 0.0 if attempt <= 1 else min( - policy.backoff_factor * (2 ** (attempt - 1)), - policy.backoff_max, - ) - - if delay > 0: - await asyncio.sleep(delay) - @staticmethod def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout: if isinstance(timeout, tuple): @@ -322,14 +200,3 @@ async def aclose(self) -> None: if self._owns_client and not self._closed: await self._client.aclose() self._closed = True - - def _set_retries_enabled(self, enabled: bool) -> None: - """Override retry eligibility for a borrowed, non-owned client. - - Internal coordination hook, not public API: only a caller that - actually owns this adapter's transport (AsyncMlb, wiring up its v1.1 - adapter to share the v1 adapter's client) should call this. Standalone - use never needs it; retry eligibility already follows client - ownership by default. - """ - self._retries_enabled = enabled diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 08a98d2e..afa87feb 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -36,6 +36,7 @@ httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") from mlbstatsapi import Mlb # noqa: E402 +from mlbstatsapi._async_transport import MlbAsyncRetryTransport # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.attendances import Attendance # noqa: E402 @@ -387,29 +388,23 @@ def request(self) -> httpx.Request: async def async_mlb(handler: _Handler): """Yield an AsyncMlb whose own client talks to ``handler``, then close it. - AsyncMlb builds its adapter and client through the production path; only - the transport is swapped. Teardown closes the adapter's client directly - rather than calling AsyncMlb.aclose(), so the lifecycle tests that replace - aclose with a mock still get their real client closed. + AsyncMlb builds its client, its retry transport and its adapters through + the production path; only the innermost network transport is swapped. + Teardown closes the client directly rather than calling AsyncMlb.aclose(), + so the lifecycle tests that replace aclose with a mock still get their real + client closed. """ - real_async_client = httpx.AsyncClient - - def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: - return real_async_client( - transport=httpx.MockTransport(handler), **client_kwargs - ) - with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr( - "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient", - mock_transport_client, + "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport", + lambda **kwargs: httpx.MockTransport(handler), ) mlb = AsyncMlb() try: yield mlb finally: - await mlb._mlb_adapter_v1._client.aclose() + await mlb._client.aclose() def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict, str]: @@ -495,7 +490,7 @@ async def scenario(): def test_context_exit_closes_the_owned_client(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client async with mlb: await mlb.get_team(133) @@ -508,7 +503,7 @@ async def scenario(): def test_context_exit_closes_the_owned_client_when_the_body_raises(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client with pytest.raises(ValueError, match="boom"): async with mlb: @@ -528,7 +523,7 @@ def test_cleanup_failure_does_not_replace_the_original_exception(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - mlb._mlb_adapter_v1.aclose = AsyncMock( + mlb.aclose = AsyncMock( side_effect=RuntimeError("cleanup failed") ) @@ -548,7 +543,7 @@ def test_cancellation_is_preserved_through_cleanup(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client async def worker(): async with mlb: @@ -583,42 +578,47 @@ async def scenario(): asyncio.run(scenario()) -def test_v1_and_v1_1_adapters_share_one_client(): - """One client is shared by both adapters, mirroring Mlb's shared Session.""" +def test_v1_and_v1_1_adapters_share_the_client_this_client_owns(): + """One client is shared by both adapters and owned by AsyncMlb itself, + mirroring Mlb's shared Session.""" async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - assert mlb._mlb_adapter_v1._client is mlb._mlb_adapter_v1_1._client - # Only v1 tracks close-ownership of the shared client; v1.1 must - # never double-close it. - assert mlb._mlb_adapter_v1._owns_client is True + assert mlb._mlb_adapter_v1._client is mlb._client + assert mlb._mlb_adapter_v1_1._client is mlb._client + # Close-ownership lives on AsyncMlb, so neither adapter can close + # the shared client out from under the other. + assert mlb._owns_client is True + assert mlb._mlb_adapter_v1._owns_client is False assert mlb._mlb_adapter_v1_1._owns_client is False asyncio.run(scenario()) -def test_v1_1_adapter_retries_when_the_shared_client_is_library_owned(): - """Retry eligibility follows the shared client's ownership, not which - adapter version issues the request (matching Mlb, which configures - retries once on the shared Session).""" +def test_both_api_versions_retry_because_the_shared_client_carries_the_policy(): + """Retries belong to the shared client's transport, not to an adapter, so + the two versions cannot disagree about them (matching Mlb, which mounts + one retry policy on the shared Session).""" async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - assert mlb._mlb_adapter_v1._retries_enabled is True - assert mlb._mlb_adapter_v1_1._retries_enabled is True + assert isinstance(mlb._client._transport, MlbAsyncRetryTransport) asyncio.run(scenario()) -def test_v1_1_adapter_does_not_retry_with_a_caller_injected_client(): +def test_caller_injected_client_keeps_its_own_transport(): + """The library mounts nothing on a client it did not create, so an + injected client retries exactly as much as its caller configured.""" handler = _Handler(_json(TEAM_PAYLOAD)) - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) async def scenario(): try: async with AsyncMlb(client=client) as mlb: - assert mlb._mlb_adapter_v1._retries_enabled is False - assert mlb._mlb_adapter_v1_1._retries_enabled is False + assert mlb._client is client + assert mlb._client._transport is transport finally: await client.aclose() diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index f2ef0f58..90737323 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -41,6 +41,10 @@ MlbTimeoutError, MlbTransportError, ) +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402 @@ -54,11 +58,13 @@ BASE_URL = "https://statsapi.mlb.com/api/v1/" -SLEEP_TARGET = "mlbstatsapi.async_mlb_dataadapter.asyncio.sleep" +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" # Patched only while a test adapter is constructed, so the adapter creates its -# own library-owned client the way production does, over a MockTransport. -CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" +# own library-owned client the way production does. Only the innermost network +# transport is swapped, so the library retry transport under test is the real +# one, wrapping a MockTransport instead of a socket. +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" # Matches tests/test_mlb_session.py, so both adapters assert the same contract. MOCKED_PACKAGE_VERSION = "9.8.7" @@ -117,26 +123,23 @@ def _owned_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: """Build an adapter that owns its client, so retries are active. The adapter still builds its own client through the production path — only - the transport is swapped for a MockTransport — so ownership, headers, and - retry behavior are exactly what the library does at runtime, and no client - is constructed and then discarded. Call this from inside a run_async() - scenario; run_async() closes what it creates. + the innermost network transport is swapped for a MockTransport — so + ownership, headers, and retry behavior are exactly what the library does at + runtime, and no client is constructed and then discarded. Call this from + inside a run_async() scenario; run_async() closes what it creates. """ - real_async_client = httpx.AsyncClient - - def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: - return real_async_client( - transport=httpx.MockTransport(handler), - **client_kwargs, - ) - - with patch(CLIENT_TARGET, mock_transport_client): + with patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)): adapter = AsyncMlbDataAdapter(**kwargs) _ADAPTERS_TO_CLOSE.append(adapter) return adapter +def _retry_policy_of(adapter: AsyncMlbDataAdapter): + """Read the retry policy the adapter's client actually uses.""" + return adapter._client._transport._retry_policy + + def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: """Build an adapter with a caller-supplied client, so retries are bypassed.""" client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) @@ -145,34 +148,43 @@ def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: def test_retry_policy_matches_library_default(): adapter = AsyncMlbDataAdapter() - assert_library_retry_policy(adapter._retry_policy) + assert_library_retry_policy(_retry_policy_of(adapter)) -def test_set_retries_enabled_does_not_change_client_ownership(): - """The private coordination hook AsyncMlb uses for its v1.1 adapter only - overrides retry eligibility; it must never grant close-ownership over a - client this adapter did not create.""" - handler = _ScriptedHandler(_response(200)) - adapter = _injected_adapter(handler) - assert adapter._retries_enabled is False +def test_library_created_client_mounts_the_retry_transport(): + """Retries are configured onto the client at creation, the way the sync + side mounts them onto a library-created Session.""" + client = create_library_async_client() + + assert isinstance(client._transport, MlbAsyncRetryTransport) - adapter._set_retries_enabled(True) - assert adapter._retries_enabled is True +def test_injected_client_transport_is_left_alone(): + """The library mounts nothing on a client it did not create, so an + injected client keeps exactly the retry behavior its caller gave it.""" + transport = httpx.MockTransport(_ScriptedHandler(_response(200))) + client = httpx.AsyncClient(transport=transport) + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._client._transport is transport assert adapter._owns_client is False -def test_set_retries_enabled_true_makes_an_injected_client_retry(): - """An injected client normally gets zero retries; overriding the flag - must actually change retry behavior, not just the stored value.""" +def test_mounting_the_retry_transport_makes_an_injected_client_retry(): + """The supported way for a caller to opt their own client into library + retry behavior, mirroring the sync create_retry_policy() recipe.""" handler = _ScriptedHandler(_response(503), _response(200)) async def scenario(): - adapter = _injected_adapter(handler) - adapter._set_retries_enabled(True) - - with patch(SLEEP_TARGET, new_callable=AsyncMock): - return await adapter.get(endpoint="sports") + client = httpx.AsyncClient( + transport=MlbAsyncRetryTransport(httpx.MockTransport(handler)), + ) + adapter = AsyncMlbDataAdapter(client=client) + try: + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + finally: + await client.aclose() result = run_async(scenario()) assert result.status_code == 200 @@ -765,7 +777,7 @@ def test_connect_timeout_spends_the_connect_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.connect = 1 + _retry_policy_of(adapter).connect = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbTimeoutError): await adapter.get(endpoint="sports") @@ -825,7 +837,7 @@ def test_generic_failures_spend_the_total_retry_budget(failure, expected_excepti async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.total = 1 + _retry_policy_of(adapter).total = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(expected_exception) as exc_info: await adapter.get(endpoint="sports") @@ -849,7 +861,7 @@ def test_connect_error_spends_the_connect_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.connect = 1 + _retry_policy_of(adapter).connect = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbTransportError): await adapter.get(endpoint="sports") @@ -864,7 +876,7 @@ def test_retryable_status_spends_the_status_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.status = 1 + _retry_policy_of(adapter).status = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbHttpError) as exc_info: await adapter.get(endpoint="sports") @@ -921,9 +933,9 @@ async def scenario(): def test_retry_sleep_is_async_and_non_blocking(): """A real (unmocked) backoff wait must yield the event loop. - If _sleep_before_retry ever used a blocking call (e.g. time.sleep) - instead of `await asyncio.sleep(...)`, the whole event loop would - freeze for the wait's duration and the concurrently running marker + If the retry transport's backoff ever used a blocking call (e.g. + time.sleep) instead of `await asyncio.sleep(...)`, the whole event loop + would freeze for the wait's duration and the concurrently running marker task below would make zero progress during it. """ handler = _ScriptedHandler(_response(500), _response(500), _response(200)) @@ -931,7 +943,7 @@ def test_retry_sleep_is_async_and_non_blocking(): async def scenario(): adapter = _owned_adapter(handler) # Small but real backoff so the test stays fast without mocking sleep. - adapter._retry_policy.backoff_factor = 0.05 + _retry_policy_of(adapter).backoff_factor = 0.05 marker_ticks = 0 From ab019e76daae1feb403170065179934a3a402339 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:35:47 -0700 Subject: [PATCH 10/13] docs: tighten README and add async examples --- README.md | 869 +++++++++++++----------------------------------------- 1 file changed, 205 insertions(+), 664 deletions(-) diff --git a/README.md b/README.md index 141379d5..6be3f0a2 100644 --- a/README.md +++ b/README.md @@ -9,481 +9,344 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -
+
-### *Copyright Notice* -This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. +### *Copyright Notice* -###### This is an educational project - Not for commercial use. +This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. +###### This is an educational project - Not for commercial use. ![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) ## Getting Started -*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+. - -All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience. +`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more. -For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more. +Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names. +Version 1.1.0 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without changes. -
+[Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) -### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/) +## Installation -
+### Synchronous client -## Installation ```bash python3 -m pip install python-mlb-statsapi ``` +### Async support + +Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`: + +```bash +python3 -m pip install "python-mlb-statsapi[async]" +``` + +The async extra installs HTTPX. Python 3.10 or newer is required. + ### Python support | Claim | Value | | --- | --- | -| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| Minimum Python version | `>=3.10` | | CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | -The minimum declared Python version is 3.10 and the CI-validated versions are -3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are -excluded from the required test matrix and are not claimed as supported. - ## Quick Start -```python ->>> import mlbstatsapi ->>> mlb = mlbstatsapi.Mlb() - ->>> mlb.get_people_id("Ty France") -[664034] ->>> player = mlb.get_person(664034) ->>> print(player.full_name) -Ty France +### Sync ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} ->>> mlb.get_player_stats(664034, stats, groups, **params) -{'hitting': {'season': Stat, 'seasonAdvanced': Stat }} +```python +from mlbstatsapi import Mlb ->>> mlb.get_team_id("Seattle Mariners") -[136] +with Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) ->>> team = mlb.get_team(136) ->>> print(team.name, team.franchise_name) -Seattle Mariners Seattle +print(player.full_name) +print(team.name) ``` -## HTTP Sessions, Timeouts, Retries, and Error Behavior +### Async -Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract. +```python +import asyncio + +from mlbstatsapi import AsyncMlb -The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default. -For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md). +async def main(): + async with AsyncMlb() as mlb: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) -### Upgrading to version 1.0 + print(player.full_name) + print(team.name) -`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`. -```text -Mlb() now uses strict HTTP handling by default -Final non-404 4xx responses raise MlbHttpError -404 keeps endpoint-specific None / [] / {} behavior -Final 5xx still raises MlbHttpError -Timeouts still raise MlbTimeoutError -Transport failures still raise MlbTransportError -Successful invalid JSON still raises MlbDecodeError +asyncio.run(main()) ``` -Recommended version 1.0 usage: +### Concurrent async requests + +`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is caller-controlled. ```python -import mlbstatsapi +import asyncio -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.MlbHttpError as exc: - print(exc.status_code) - print(exc.reason) - print(exc.url) -``` +from mlbstatsapi import AsyncMlb -Temporary compatibility opt-out while migrating: -```python -import mlbstatsapi +async def main(): + async with AsyncMlb() as mlb: + player, team = await asyncio.gather( + mlb.get_person(664034), + mlb.get_team(136), + ) -with mlbstatsapi.Mlb(strict_http=False) as mlb: - player = mlb.get_person(664034) -``` + print(player.full_name) + print(team.name) -`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples. -### Recommended context-manager usage +asyncio.run(main()) +``` -Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: +`AsyncMlb` does not create hidden background tasks or automatic request fanout. -```python -import mlbstatsapi +## Sync or Async? -with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) - team = mlb.get_team(136) -``` +| | `Mlb` | `AsyncMlb` | +| --- | --- | --- | +| HTTP library | Requests | HTTPX | +| Context manager | `with Mlb()` | `async with AsyncMlb()` | +| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | +| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | -One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection. +Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. -Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner. +Async endpoint coverage is expanding in v1.1.0. See the [public API contract](docs/public-api.md) for the current supported async methods. -### Compatibility mode +## HTTP Behavior -Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures. +Both clients use explicit timeouts, bounded retries for temporary failures, structured exceptions, and pooled HTTP connections. -The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error: +Library-created HTTP resources are configured and closed by the library. Caller-injected Requests Sessions or HTTPX clients remain caller-owned and are not closed or reconfigured by the library. -```python -import warnings -import mlbstatsapi +`strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. -warnings.filterwarnings( - "error", - category=mlbstatsapi.MlbHttpCompatibilityWarning, -) -``` +The main transport exceptions are: -Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning. +- `MlbHttpError` +- `MlbTimeoutError` +- `MlbTransportError` +- `MlbDecodeError` -### Custom timeouts +Example: -Every request uses an explicit timeout. The defaults are: +```python +from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError -```text -Connection timeout: 3.05 seconds -Read timeout: 30 seconds +try: + with Mlb() as mlb: + player = mlb.get_person(664034) +except MlbTimeoutError: + print("The MLB API timed out") +except MlbHttpError as exc: + print(exc.status_code, exc.reason) ``` -The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request. +For retry policy, timeouts, compatibility mode, custom Sessions, ownership rules, and migration guidance, see the [HTTP transport documentation](docs/http-transport.md). -Use a scalar to apply the same value to both connect and read phases: +For the supported 1.x API surface and async endpoint list, see the [public API contract](docs/public-api.md). -```python -import mlbstatsapi +## Working with Pydantic Models -with mlbstatsapi.Mlb(timeout=10) as mlb: - player = mlb.get_person(664034) -``` +All returned model objects use Pydantic. -Or provide separate connection and read timeouts: +### Convert to a dictionary ```python -import mlbstatsapi +from mlbstatsapi import Mlb -with mlbstatsapi.Mlb( - timeout=(5.0, 60.0), -) as mlb: +with Mlb() as mlb: player = mlb.get_person(664034) -``` -```text -5.0 seconds: connection timeout -60.0 seconds: read timeout +print(player.model_dump(exclude_none=True)) ``` -### Injecting a custom Session - -Advanced callers may inject a caller-owned Session: +### Convert to JSON ```python -import requests -import mlbstatsapi - -session = requests.Session() -session.headers.update({ - "User-Agent": "my-baseball-project/1.0", -}) - -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() +print(player.model_dump_json(indent=2)) ``` -Ownership rules: +### Snake case fields -```text -Library-created Session - The library configures and closes it -Caller-injected Session - The caller configures and closes it -``` +MLB response names are converted to Python-style field names: -`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration. +```python +print(player.full_name) # not fullName +print(player.primary_position) # not primaryPosition +print(player.bat_side) # not batSide +``` -### Reusing the retry policy on a caller-managed Session +## Examples -`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior: +### Find a player or team ```python -import requests -import mlbstatsapi +from mlbstatsapi import Mlb -session = requests.Session() -adapter = requests.adapters.HTTPAdapter( - max_retries=mlbstatsapi.create_retry_policy(), -) -session.mount("https://", adapter) -session.mount("http://", adapter) +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + team_id = mlb.get_team_id("Seattle Mariners")[0] -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() + player = mlb.get_person(player_id) + team = mlb.get_team(team_id) + +print(player.full_name) +print(team.name) ``` -* The caller mounts the adapters -* The caller closes the injected Session -* The library never reconfigures an injected Session +### Schedule -### Versioned User-Agent +Sync: -A Session created by the library sends a package-specific User-Agent: +```python +from mlbstatsapi import Mlb -```text -python-mlb-statsapi/ +with Mlb() as mlb: + schedule = mlb.get_schedule(date="2022-10-13") ``` -For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version. +Async: -Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it. +```python +import asyncio -### Structured exception handling +from mlbstatsapi import AsyncMlb -```python -import mlbstatsapi -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.MlbTimeoutError: - print("The MLB API timed out") -except mlbstatsapi.MlbTransportError: - print("The request could not reach the MLB API") -except mlbstatsapi.MlbHttpError as exc: - print(exc.method) - print(exc.status_code) - print(exc.reason) - print(exc.url) - print(exc.response_data) - print(exc.body_excerpt) -except mlbstatsapi.MlbDecodeError: - print("The MLB API returned invalid JSON") -``` +async def main(): + async with AsyncMlb() as mlb: + schedule = await mlb.get_schedule(date="2022-10-13") + return schedule -* `MlbTimeoutError` represents connection and read timeouts -* `MlbTransportError` represents other request transport failures -* `MlbHttpError` represents an unexpected final HTTP response -* `MlbDecodeError` represents invalid JSON in a successful response -`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise. +schedule = asyncio.run(main()) +``` -### Backward-compatible exception handling +### Game data -All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible: +Sync: ```python -import mlbstatsapi +from mlbstatsapi import Mlb -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.TheMlbStatsApiException: - print("The MLB request failed") +with Mlb() as mlb: + game = mlb.get_game(662242) + play_by_play = mlb.get_game_play_by_play(662242) + line_score = mlb.get_game_line_score(662242) + box_score = mlb.get_game_box_score(662242) ``` -### Default retry behavior +Async: -Library-created Sessions automatically retry temporary GET failures for: +```python +import asyncio -```text -429 -500 -502 -503 -504 -``` +from mlbstatsapi import AsyncMlb -```text -Initial request: 1 -Maximum retries: 3 -Maximum total attempts: 4 -Backoff factor: 0.5 -Retry-After respected: yes -``` -Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior. +async def main(): + async with AsyncMlb() as mlb: + game, play_by_play, line_score, box_score = await asyncio.gather( + mlb.get_game(662242), + mlb.get_game_play_by_play(662242), + mlb.get_game_line_score(662242), + mlb.get_game_box_score(662242), + ) -### Existing 404 compatibility + return game, play_by_play, line_score, box_score -Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce: -```text -None -[] -{} +results = asyncio.run(main()) ``` -Not every 404 raises `MlbHttpError`, and the strict default does not change that. +### Player stats -### HTTP behavior at a glance +The higher-level stats helpers remain on the synchronous `Mlb` client in v1.1.0. -| Final response | Default 1.0 behavior | Explicit compatibility mode | -| -------------- | -------------------- | --------------------------- | -| Successful 2xx | Normal result | Normal result | -| Non-404 4xx | `MlbHttpError` | Warning and historical empty result | -| 404 | Existing endpoint behavior | Existing endpoint behavior | -| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries | -| Final 5xx | `MlbHttpError` | `MlbHttpError` | +```python +from mlbstatsapi import Mlb -See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary. +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + stats = mlb.get_player_stats( + player_id, + stats=["season", "career"], + groups=["hitting"], + season=2022, + ) -## Working with Pydantic Models +season = stats["hitting"]["season"] +for split in season.splits: + print(split.stat.model_dump(exclude_none=True)) +``` -All returned objects are Pydantic models, giving you access to powerful serialization and validation features. +### Team roster -### Convert to Dictionary ```python ->>> player = mlb.get_person(664034) ->>> player.model_dump() -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} +from mlbstatsapi import Mlb -# Exclude None values ->>> player.model_dump(exclude_none=True) -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} +with Mlb() as mlb: + players = mlb.get_team_roster(136) -# Include only specific fields ->>> player.model_dump(include={'id', 'full_name', 'primary_position'}) -{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)} +for player in players: + print(f"#{player.jersey_number} {player.person.full_name}") ``` -### Convert to JSON -```python ->>> player = mlb.get_person(664034) ->>> player.model_dump_json() -'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}' - -# Pretty print with indentation ->>> print(player.model_dump_json(indent=2)) -{ - "id": 664034, - "full_name": "Ty France", - "link": "/api/v1/people/664034", - ... -} -``` +The same roster endpoint is also available through `AsyncMlb`: -### Access Fields with Snake Case Names ```python ->>> player = mlb.get_person(664034) ->>> player.full_name # Not fullName -'Ty France' ->>> player.primary_position # Not primaryPosition -Position(code='3', name='First Base', ...) ->>> player.bat_side # Not batSide -CodeDesc(code='R', description='Right') +players = await mlb.get_team_roster(136) ``` ## Documentation -### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) -* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname -* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id -* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport -### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) -* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year -### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) -* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award -### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) -* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name -* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id -* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport -* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season -* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season -### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) -* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups -* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups -* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args -* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game -### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) -* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. -### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) -* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) -* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id -* `Mlb.get_venues(self, **params)` - Return all Venues -### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) -* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id -* `Mlb.get_sports(self, **params)` - Return all Sports -* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule -### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) -* `Mlb.get_division(self, division_id: int, **params)` - Return a Division -* `Mlb.get_divisions(self, **params)` - Return all Divisions -* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name -### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) -* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id -* `Mlb.get_leagues(self, **params)` - Return all Leagues -* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) -### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) -* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season -* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons -### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) -* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates -* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates -### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) -* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id -* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game -* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game -* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game - +- [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) - endpoint and model documentation +- [Public API contract](docs/public-api.md) - supported package API and async endpoint coverage +- [HTTP transport](docs/http-transport.md) - retries, timeouts, errors, ownership, and compatibility behavior +- [Release notes](docs/releases/) - release-specific changes and migration notes ## Contributing -Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help. +Contributions, bug fixes, tests, and documentation improvements are welcome. -### Getting Started +### Setup -1. Fork the repository -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git` -3. Install dependencies: `poetry install` -4. Create a branch: `git checkout -b feat/your-feature` +```bash +git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git +cd python-mlb-statsapi +poetry install -E async +``` -### Development +### Tests Offline tests are deterministic and should run before every pull request: ```bash -poetry run pytest \ - tests/ \ - --ignore=tests/external_tests +poetry run pytest tests/ --ignore=tests/external_tests ``` -External tests contact the live MLB API. They require internet access and are separate from normal offline CI: +External tests contact the live MLB API and are kept separate from normal offline CI: ```bash -poetry run pytest \ - tests/external_tests/ +poetry run pytest tests/external_tests/ ``` -These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. - Full local validation: ```bash @@ -494,348 +357,26 @@ python3 scripts/validate_release.py poetry run twine check dist/* ``` -`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. - -Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. +Live tests may fail when the MLB service is unavailable or when MLB changes undocumented payloads. -### Pull Request Guidelines +### Pull requests - Run offline tests before submitting a PR -- Use the [PR template](.github/pull_request_template.md) when creating your pull request -- Follow the branch naming convention: - - `feat/` - New features - - `fix/` - Bug fixes - - `docs/` - Documentation updates - - `refactor/` - Code improvements +- Use the [PR template](.github/pull_request_template.md) +- Keep changes focused and reviewable -### Reporting Issues +Suggested branch prefixes: -Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with: +- `feat/` - new features +- `fix/` - bug fixes +- `docs/` - documentation +- `refactor/` - code improvements -- A clear description of the problem or feature -- Steps to reproduce (for bugs) -- Expected vs actual behavior -- Python version and package version - - -## Examples +### Reporting issues -Let's show some examples of getting stat objects from the API. What is baseball without stats, right? +Found a bug or have a feature request? [Open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) and include: -### Player Stats -Get the Id(s) of the players you want stats for and set stat types and groups. -```python ->>> mlb = mlbstatsapi.Mlb() ->>> player_id = mlb.get_people_id("Ty France")[0] ->>> stats = ['season', 'career'] ->>> groups = ['hitting', 'pitching'] ->>> params = {'season': 2022} -``` - -Use player id with stat types and groups to return a stats dictionary -```python ->>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params) ->>> season_hitting_stat = stat_dict['hitting']['season'] ->>> career_pitching_stat = stat_dict['pitching']['career'] -``` - -Print season hitting stats using Pydantic's `model_dump()` -```python ->>> for split in season_hitting_stat.splits: -... print(split.stat.model_dump(exclude_none=True)) -{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...} -``` - -Or access individual fields directly -```python ->>> for split in season_hitting_stat.splits: -... print(f"Games: {split.stat.games_played}") -... print(f"Home Runs: {split.stat.home_runs}") -... print(f"Batting Avg: {split.stat.avg}") -Games: 140 -Home Runs: 20 -Batting Avg: .274 -``` - -### Team Stats -Get the Team Id(s) -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id('Seattle Mariners')[0] -``` - -Set the stat types and groups -```python ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} -``` - -Use team id and the stat types and groups to return season hitting stats -```python ->>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params) ->>> season_hitting = stats['hitting']['season'] ->>> advanced_hitting = stats['hitting']['seasonAdvanced'] -``` - -Print stats as JSON -```python ->>> for split in season_hitting.splits: -... print(split.stat.model_dump_json(indent=2, exclude_none=True)) -{ - "games_played": 162, - "groundouts": 1273, - "runs": 690, - "doubles": 229, - ... -} -``` - -### Expected Stats -```python ->>> player_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['expectedStatistics'] ->>> group = ['hitting'] ->>> params = {'season': 2022} - ->>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params) ->>> expected = stats['hitting']['expectedStatistics'] ->>> for split in expected.splits: -... print(f"Expected AVG: {split.stat.avg}") -... print(f"Expected SLG: {split.stat.slg}") -Expected AVG: .259 -Expected SLG: .394 -``` - -### vsPlayer Stats -Get pitcher and batter player Ids -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0] -``` - -Set stat type, stat groups, and params -```python ->>> stats = ['vsPlayer'] ->>> group = ['hitting'] ->>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022} -``` - -Get stats -```python ->>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params) ->>> vs_player = stats['hitting']['vsPlayer'] ->>> for split in vs_player.splits: -... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}") -Games: 2, Hits: 2 -``` - -### Hot/Cold Zones -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['hotColdZones'] ->>> hitting_group = ['hitting'] ->>> params = {'season': 2022} - ->>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params) ->>> zones = hotcoldzones['stats']['hotColdZones'] - ->>> for split in zones.splits: -... print(f"Stat: {split.stat.name}") -... for zone in split.stat.zones: -... print(f" Zone {zone.zone}: {zone.value}") -Stat: battingAverage - Zone 01: .226 - Zone 02: .400 - ... -``` - -### Schedule Examples -Get a schedule for a given date -```python ->>> mlb = mlbstatsapi.Mlb() ->>> schedule = mlb.get_schedule(date='2022-10-13') ->>> dates = schedule.dates - ->>> for date in dates: -... for game in date.games: -... print(f"Game: {game.game_pk}") -... print(f"Status: {game.status.detailed_state}") -... print(f"Home: {game.teams.home.team.name}") -... print(f"Away: {game.teams.away.team.name}") -``` - -### Game Examples -Get a Game for a given game id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> game = mlb.get_game(662242) -``` - -Get the weather for a game -```python ->>> weather = game.game_data.weather ->>> print(f"Condition: {weather.condition}") ->>> print(f"Temperature: {weather.temp}") ->>> print(f"Wind: {weather.wind}") -``` - -Get the current status of a game -```python ->>> linescore = game.live_data.linescore ->>> home_info = game.game_data.teams.home ->>> away_info = game.game_data.teams.away ->>> home_status = linescore.teams.home ->>> away_status = linescore.teams.away - ->>> print(f"Home: {home_info.franchise_name} {home_info.club_name}") ->>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}") ->>> print(f"Away: {away_info.franchise_name} {away_info.club_name}") ->>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}") ->>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}") -``` - -Get play by play, line score, and box score objects -```python ->>> play_by_play = game.live_data.plays ->>> line_score = game.live_data.linescore ->>> box_score = game.live_data.boxscore -``` - -#### Play by Play -Get only the play by play for a given game id -```python ->>> playbyplay = mlb.get_game_play_by_play(662242) -``` - -#### Line Score -Get only the line score for a given game id -```python ->>> linescore = mlb.get_game_line_score(662242) -``` - -#### Box Score -Get only the box score for a given game id -```python ->>> boxscore = mlb.get_game_box_score(662242) -``` - -### Gamepace Examples -Get pace of game metrics for a specific season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> gamepace = mlb.get_gamepace(season=2021) ->>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}") -``` - -### People Examples -Get all Players for a given sport id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_people(sport_id=1) ->>> for player in players: -... print(f"{player.id}: {player.full_name}") -``` - -Get a player id -```python ->>> player_id = mlb.get_people_id("Ty France") ->>> print(player_id[0]) -664034 -``` - -### Team Examples -Get a Team -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id("Seattle Mariners")[0] ->>> team = mlb.get_team(team_id) ->>> print(f"{team.id}: {team.name}") ->>> print(f"Venue: {team.venue.name}") -``` - -Get a Player Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_team_roster(136) ->>> for player in players: -... print(f"#{player.jersey_number} {player.person.full_name}") -``` - -Get a Coach Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> coaches = mlb.get_team_coaches(136) ->>> for coach in coaches: -... print(f"{coach.person.full_name}: {coach.title}") -``` - -### Draft Examples -Get a draft for a year -```python ->>> mlb = mlbstatsapi.Mlb() ->>> draft = mlb.get_draft('2019') -``` - -Get Players from Draft -```python ->>> draftpicks = draft[0].picks ->>> for pick in draftpicks: -... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") -``` - -### Award Examples -Get awards for a given award id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108') ->>> for recipient in retired_numbers.awards: -... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") -``` - -### Venue Examples -Get a Venue -```python ->>> mlb = mlbstatsapi.Mlb() ->>> venue_id = mlb.get_venue_id('PNC Park')[0] ->>> venue = mlb.get_venue(venue_id) ->>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}") -``` - -### Division Examples -Get a division -```python ->>> mlb = mlbstatsapi.Mlb() ->>> division = mlb.get_division(200) ->>> print(division.name) -American League West -``` - -### League Examples -Get a league -```python ->>> mlb = mlbstatsapi.Mlb() ->>> league = mlb.get_league(103) ->>> print(league.name) -American League -``` - -### Season Examples -Get a Season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> season = mlb.get_season(2018) ->>> print(f"Season: {season.season_id}") ->>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}") -``` - -### Standings Examples -Get Standings -```python ->>> mlb = mlbstatsapi.Mlb() ->>> standings = mlb.get_standings(103, 2018) ->>> for record in standings: -... print(f"Division: {record.division.name}") -... for team in record.team_records: -... print(f" {team.team.name}: {team.wins}-{team.losses}") -``` +- A clear description +- Steps to reproduce when reporting a bug +- Expected and actual behavior +- Python and package versions From 962d0ae1d0928b6bbee0a143aba9b9dc3f72692e Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:43:56 -0700 Subject: [PATCH 11/13] revert: keep README work on the dedicated docs branch --- README.md | 869 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 664 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index 6be3f0a2..141379d5 100644 --- a/README.md +++ b/README.md @@ -9,344 +9,481 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -
- -### *Copyright Notice* +
+### *Copyright Notice* This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. -###### This is an educational project - Not for commercial use. +###### This is an educational project - Not for commercial use. + ![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) ## Getting Started -`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more. - -Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names. +*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+. -Version 1.1.0 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without changes. +All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience. -[Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) +For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more. -## Installation - -### Synchronous client -```bash -python3 -m pip install python-mlb-statsapi -``` +
-### Async support +### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/) -Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`: +
+## Installation ```bash -python3 -m pip install "python-mlb-statsapi[async]" +python3 -m pip install python-mlb-statsapi ``` -The async extra installs HTTPX. Python 3.10 or newer is required. - ### Python support | Claim | Value | | --- | --- | -| Minimum Python version | `>=3.10` | +| Minimum declared Python version (`Requires-Python`) | `>=3.10` | | CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | +The minimum declared Python version is 3.10 and the CI-validated versions are +3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are +excluded from the required test matrix and are not claimed as supported. + ## Quick Start +```python +>>> import mlbstatsapi +>>> mlb = mlbstatsapi.Mlb() -### Sync +>>> mlb.get_people_id("Ty France") +[664034] -```python -from mlbstatsapi import Mlb +>>> player = mlb.get_person(664034) +>>> print(player.full_name) +Ty France -with Mlb() as mlb: - player = mlb.get_person(664034) - team = mlb.get_team(136) +>>> stats = ['season', 'seasonAdvanced'] +>>> groups = ['hitting'] +>>> params = {'season': 2022} +>>> mlb.get_player_stats(664034, stats, groups, **params) +{'hitting': {'season': Stat, 'seasonAdvanced': Stat }} -print(player.full_name) -print(team.name) -``` +>>> mlb.get_team_id("Seattle Mariners") +[136] -### Async +>>> team = mlb.get_team(136) +>>> print(team.name, team.franchise_name) +Seattle Mariners Seattle +``` -```python -import asyncio +## HTTP Sessions, Timeouts, Retries, and Error Behavior -from mlbstatsapi import AsyncMlb +Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract. +The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default. -async def main(): - async with AsyncMlb() as mlb: - player = await mlb.get_person(664034) - team = await mlb.get_team(136) +For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md). - print(player.full_name) - print(team.name) +### Upgrading to version 1.0 +`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`. -asyncio.run(main()) +```text +Mlb() now uses strict HTTP handling by default +Final non-404 4xx responses raise MlbHttpError +404 keeps endpoint-specific None / [] / {} behavior +Final 5xx still raises MlbHttpError +Timeouts still raise MlbTimeoutError +Transport failures still raise MlbTransportError +Successful invalid JSON still raises MlbDecodeError ``` -### Concurrent async requests - -`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is caller-controlled. +Recommended version 1.0 usage: ```python -import asyncio +import mlbstatsapi -from mlbstatsapi import AsyncMlb +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.MlbHttpError as exc: + print(exc.status_code) + print(exc.reason) + print(exc.url) +``` +Temporary compatibility opt-out while migrating: -async def main(): - async with AsyncMlb() as mlb: - player, team = await asyncio.gather( - mlb.get_person(664034), - mlb.get_team(136), - ) +```python +import mlbstatsapi - print(player.full_name) - print(team.name) +with mlbstatsapi.Mlb(strict_http=False) as mlb: + player = mlb.get_person(664034) +``` +`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples. -asyncio.run(main()) -``` +### Recommended context-manager usage -`AsyncMlb` does not create hidden background tasks or automatic request fanout. +Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: -## Sync or Async? +```python +import mlbstatsapi -| | `Mlb` | `AsyncMlb` | -| --- | --- | --- | -| HTTP library | Requests | HTTPX | -| Context manager | `with Mlb()` | `async with AsyncMlb()` | -| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | -| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) +``` -Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. +One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection. -Async endpoint coverage is expanding in v1.1.0. See the [public API contract](docs/public-api.md) for the current supported async methods. +Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner. -## HTTP Behavior +### Compatibility mode -Both clients use explicit timeouts, bounded retries for temporary failures, structured exceptions, and pooled HTTP connections. +Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures. -Library-created HTTP resources are configured and closed by the library. Caller-injected Requests Sessions or HTTPX clients remain caller-owned and are not closed or reconfigured by the library. +The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error: -`strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. +```python +import warnings +import mlbstatsapi -The main transport exceptions are: +warnings.filterwarnings( + "error", + category=mlbstatsapi.MlbHttpCompatibilityWarning, +) +``` -- `MlbHttpError` -- `MlbTimeoutError` -- `MlbTransportError` -- `MlbDecodeError` +Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning. -Example: +### Custom timeouts -```python -from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError +Every request uses an explicit timeout. The defaults are: -try: - with Mlb() as mlb: - player = mlb.get_person(664034) -except MlbTimeoutError: - print("The MLB API timed out") -except MlbHttpError as exc: - print(exc.status_code, exc.reason) +```text +Connection timeout: 3.05 seconds +Read timeout: 30 seconds ``` -For retry policy, timeouts, compatibility mode, custom Sessions, ownership rules, and migration guidance, see the [HTTP transport documentation](docs/http-transport.md). +The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request. -For the supported 1.x API surface and async endpoint list, see the [public API contract](docs/public-api.md). +Use a scalar to apply the same value to both connect and read phases: -## Working with Pydantic Models +```python +import mlbstatsapi -All returned model objects use Pydantic. +with mlbstatsapi.Mlb(timeout=10) as mlb: + player = mlb.get_person(664034) +``` -### Convert to a dictionary +Or provide separate connection and read timeouts: ```python -from mlbstatsapi import Mlb +import mlbstatsapi -with Mlb() as mlb: +with mlbstatsapi.Mlb( + timeout=(5.0, 60.0), +) as mlb: player = mlb.get_person(664034) +``` -print(player.model_dump(exclude_none=True)) +```text +5.0 seconds: connection timeout +60.0 seconds: read timeout ``` -### Convert to JSON +### Injecting a custom Session + +Advanced callers may inject a caller-owned Session: ```python -print(player.model_dump_json(indent=2)) -``` +import requests +import mlbstatsapi + +session = requests.Session() +session.headers.update({ + "User-Agent": "my-baseball-project/1.0", +}) -### Snake case fields +try: + with mlbstatsapi.Mlb(session=session) as mlb: + player = mlb.get_person(664034) +finally: + session.close() +``` -MLB response names are converted to Python-style field names: +Ownership rules: -```python -print(player.full_name) # not fullName -print(player.primary_position) # not primaryPosition -print(player.bat_side) # not batSide +```text +Library-created Session + The library configures and closes it +Caller-injected Session + The caller configures and closes it ``` -## Examples +`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration. -### Find a player or team +### Reusing the retry policy on a caller-managed Session -```python -from mlbstatsapi import Mlb +`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior: -with Mlb() as mlb: - player_id = mlb.get_people_id("Ty France")[0] - team_id = mlb.get_team_id("Seattle Mariners")[0] +```python +import requests +import mlbstatsapi - player = mlb.get_person(player_id) - team = mlb.get_team(team_id) +session = requests.Session() +adapter = requests.adapters.HTTPAdapter( + max_retries=mlbstatsapi.create_retry_policy(), +) +session.mount("https://", adapter) +session.mount("http://", adapter) -print(player.full_name) -print(team.name) +try: + with mlbstatsapi.Mlb(session=session) as mlb: + player = mlb.get_person(664034) +finally: + session.close() ``` -### Schedule +* The caller mounts the adapters +* The caller closes the injected Session +* The library never reconfigures an injected Session -Sync: +### Versioned User-Agent -```python -from mlbstatsapi import Mlb +A Session created by the library sends a package-specific User-Agent: -with Mlb() as mlb: - schedule = mlb.get_schedule(date="2022-10-13") +```text +python-mlb-statsapi/ ``` -Async: +For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version. -```python -import asyncio +Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it. -from mlbstatsapi import AsyncMlb +### Structured exception handling +```python +import mlbstatsapi -async def main(): - async with AsyncMlb() as mlb: - schedule = await mlb.get_schedule(date="2022-10-13") - return schedule +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.MlbTimeoutError: + print("The MLB API timed out") +except mlbstatsapi.MlbTransportError: + print("The request could not reach the MLB API") +except mlbstatsapi.MlbHttpError as exc: + print(exc.method) + print(exc.status_code) + print(exc.reason) + print(exc.url) + print(exc.response_data) + print(exc.body_excerpt) +except mlbstatsapi.MlbDecodeError: + print("The MLB API returned invalid JSON") +``` +* `MlbTimeoutError` represents connection and read timeouts +* `MlbTransportError` represents other request transport failures +* `MlbHttpError` represents an unexpected final HTTP response +* `MlbDecodeError` represents invalid JSON in a successful response -schedule = asyncio.run(main()) -``` +`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise. -### Game data +### Backward-compatible exception handling -Sync: +All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible: ```python -from mlbstatsapi import Mlb +import mlbstatsapi -with Mlb() as mlb: - game = mlb.get_game(662242) - play_by_play = mlb.get_game_play_by_play(662242) - line_score = mlb.get_game_line_score(662242) - box_score = mlb.get_game_box_score(662242) +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.TheMlbStatsApiException: + print("The MLB request failed") ``` -Async: +### Default retry behavior -```python -import asyncio +Library-created Sessions automatically retry temporary GET failures for: -from mlbstatsapi import AsyncMlb +```text +429 +500 +502 +503 +504 +``` +```text +Initial request: 1 +Maximum retries: 3 +Maximum total attempts: 4 +Backoff factor: 0.5 +Retry-After respected: yes +``` -async def main(): - async with AsyncMlb() as mlb: - game, play_by_play, line_score, box_score = await asyncio.gather( - mlb.get_game(662242), - mlb.get_game_play_by_play(662242), - mlb.get_game_line_score(662242), - mlb.get_game_box_score(662242), - ) +Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior. - return game, play_by_play, line_score, box_score +### Existing 404 compatibility +Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce: -results = asyncio.run(main()) +```text +None +[] +{} ``` -### Player stats +Not every 404 raises `MlbHttpError`, and the strict default does not change that. -The higher-level stats helpers remain on the synchronous `Mlb` client in v1.1.0. +### HTTP behavior at a glance -```python -from mlbstatsapi import Mlb +| Final response | Default 1.0 behavior | Explicit compatibility mode | +| -------------- | -------------------- | --------------------------- | +| Successful 2xx | Normal result | Normal result | +| Non-404 4xx | `MlbHttpError` | Warning and historical empty result | +| 404 | Existing endpoint behavior | Existing endpoint behavior | +| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries | +| Final 5xx | `MlbHttpError` | `MlbHttpError` | -with Mlb() as mlb: - player_id = mlb.get_people_id("Ty France")[0] - stats = mlb.get_player_stats( - player_id, - stats=["season", "career"], - groups=["hitting"], - season=2022, - ) +See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary. -season = stats["hitting"]["season"] -for split in season.splits: - print(split.stat.model_dump(exclude_none=True)) -``` +## Working with Pydantic Models -### Team roster +All returned objects are Pydantic models, giving you access to powerful serialization and validation features. +### Convert to Dictionary ```python -from mlbstatsapi import Mlb +>>> player = mlb.get_person(664034) +>>> player.model_dump() +{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} -with Mlb() as mlb: - players = mlb.get_team_roster(136) +# Exclude None values +>>> player.model_dump(exclude_none=True) +{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} -for player in players: - print(f"#{player.jersey_number} {player.person.full_name}") +# Include only specific fields +>>> player.model_dump(include={'id', 'full_name', 'primary_position'}) +{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)} ``` -The same roster endpoint is also available through `AsyncMlb`: +### Convert to JSON +```python +>>> player = mlb.get_person(664034) +>>> player.model_dump_json() +'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}' + +# Pretty print with indentation +>>> print(player.model_dump_json(indent=2)) +{ + "id": 664034, + "full_name": "Ty France", + "link": "/api/v1/people/664034", + ... +} +``` +### Access Fields with Snake Case Names ```python -players = await mlb.get_team_roster(136) +>>> player = mlb.get_person(664034) +>>> player.full_name # Not fullName +'Ty France' +>>> player.primary_position # Not primaryPosition +Position(code='3', name='First Base', ...) +>>> player.bat_side # Not batSide +CodeDesc(code='R', description='Right') ``` ## Documentation -- [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) - endpoint and model documentation -- [Public API contract](docs/public-api.md) - supported package API and async endpoint coverage -- [HTTP transport](docs/http-transport.md) - retries, timeouts, errors, ownership, and compatibility behavior -- [Release notes](docs/releases/) - release-specific changes and migration notes +### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) +* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname +* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id +* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport +### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) +* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year +### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) +* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award +### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) +* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name +* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id +* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport +* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season +* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season +### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) +* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups +* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups +* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args +* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game +### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) +* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. +### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) +* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) +* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id +* `Mlb.get_venues(self, **params)` - Return all Venues +### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) +* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id +* `Mlb.get_sports(self, **params)` - Return all Sports +* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name +### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) +* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule +### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) +* `Mlb.get_division(self, division_id: int, **params)` - Return a Division +* `Mlb.get_divisions(self, **params)` - Return all Divisions +* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name +### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) +* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id +* `Mlb.get_leagues(self, **params)` - Return all Leagues +* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) +### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) +* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season +* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons +### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) +* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings +### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) +* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates +* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates +### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) +* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id +* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game +* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game +* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game + ## Contributing -Contributions, bug fixes, tests, and documentation improvements are welcome. +Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help. -### Setup +### Getting Started -```bash -git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git -cd python-mlb-statsapi -poetry install -E async -``` +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git` +3. Install dependencies: `poetry install` +4. Create a branch: `git checkout -b feat/your-feature` -### Tests +### Development Offline tests are deterministic and should run before every pull request: ```bash -poetry run pytest tests/ --ignore=tests/external_tests +poetry run pytest \ + tests/ \ + --ignore=tests/external_tests ``` -External tests contact the live MLB API and are kept separate from normal offline CI: +External tests contact the live MLB API. They require internet access and are separate from normal offline CI: ```bash -poetry run pytest tests/external_tests/ +poetry run pytest \ + tests/external_tests/ ``` +These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. + Full local validation: ```bash @@ -357,26 +494,348 @@ python3 scripts/validate_release.py poetry run twine check dist/* ``` -Live tests may fail when the MLB service is unavailable or when MLB changes undocumented payloads. +`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. + +Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. -### Pull requests +### Pull Request Guidelines - Run offline tests before submitting a PR -- Use the [PR template](.github/pull_request_template.md) -- Keep changes focused and reviewable +- Use the [PR template](.github/pull_request_template.md) when creating your pull request +- Follow the branch naming convention: + - `feat/` - New features + - `fix/` - Bug fixes + - `docs/` - Documentation updates + - `refactor/` - Code improvements -Suggested branch prefixes: +### Reporting Issues -- `feat/` - new features -- `fix/` - bug fixes -- `docs/` - documentation -- `refactor/` - code improvements +Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with: -### Reporting issues +- A clear description of the problem or feature +- Steps to reproduce (for bugs) +- Expected vs actual behavior +- Python version and package version + + +## Examples -Found a bug or have a feature request? [Open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) and include: +Let's show some examples of getting stat objects from the API. What is baseball without stats, right? -- A clear description -- Steps to reproduce when reporting a bug -- Expected and actual behavior -- Python and package versions +### Player Stats +Get the Id(s) of the players you want stats for and set stat types and groups. +```python +>>> mlb = mlbstatsapi.Mlb() +>>> player_id = mlb.get_people_id("Ty France")[0] +>>> stats = ['season', 'career'] +>>> groups = ['hitting', 'pitching'] +>>> params = {'season': 2022} +``` + +Use player id with stat types and groups to return a stats dictionary +```python +>>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params) +>>> season_hitting_stat = stat_dict['hitting']['season'] +>>> career_pitching_stat = stat_dict['pitching']['career'] +``` + +Print season hitting stats using Pydantic's `model_dump()` +```python +>>> for split in season_hitting_stat.splits: +... print(split.stat.model_dump(exclude_none=True)) +{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...} +``` + +Or access individual fields directly +```python +>>> for split in season_hitting_stat.splits: +... print(f"Games: {split.stat.games_played}") +... print(f"Home Runs: {split.stat.home_runs}") +... print(f"Batting Avg: {split.stat.avg}") +Games: 140 +Home Runs: 20 +Batting Avg: .274 +``` + +### Team Stats +Get the Team Id(s) +```python +>>> mlb = mlbstatsapi.Mlb() +>>> team_id = mlb.get_team_id('Seattle Mariners')[0] +``` + +Set the stat types and groups +```python +>>> stats = ['season', 'seasonAdvanced'] +>>> groups = ['hitting'] +>>> params = {'season': 2022} +``` + +Use team id and the stat types and groups to return season hitting stats +```python +>>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params) +>>> season_hitting = stats['hitting']['season'] +>>> advanced_hitting = stats['hitting']['seasonAdvanced'] +``` + +Print stats as JSON +```python +>>> for split in season_hitting.splits: +... print(split.stat.model_dump_json(indent=2, exclude_none=True)) +{ + "games_played": 162, + "groundouts": 1273, + "runs": 690, + "doubles": 229, + ... +} +``` + +### Expected Stats +```python +>>> player_id = mlb.get_people_id('Ty France')[0] +>>> stats = ['expectedStatistics'] +>>> group = ['hitting'] +>>> params = {'season': 2022} + +>>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params) +>>> expected = stats['hitting']['expectedStatistics'] +>>> for split in expected.splits: +... print(f"Expected AVG: {split.stat.avg}") +... print(f"Expected SLG: {split.stat.slg}") +Expected AVG: .259 +Expected SLG: .394 +``` + +### vsPlayer Stats +Get pitcher and batter player Ids +```python +>>> ty_france_id = mlb.get_people_id('Ty France')[0] +>>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0] +``` + +Set stat type, stat groups, and params +```python +>>> stats = ['vsPlayer'] +>>> group = ['hitting'] +>>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022} +``` + +Get stats +```python +>>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params) +>>> vs_player = stats['hitting']['vsPlayer'] +>>> for split in vs_player.splits: +... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}") +Games: 2, Hits: 2 +``` + +### Hot/Cold Zones +```python +>>> ty_france_id = mlb.get_people_id('Ty France')[0] +>>> stats = ['hotColdZones'] +>>> hitting_group = ['hitting'] +>>> params = {'season': 2022} + +>>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params) +>>> zones = hotcoldzones['stats']['hotColdZones'] + +>>> for split in zones.splits: +... print(f"Stat: {split.stat.name}") +... for zone in split.stat.zones: +... print(f" Zone {zone.zone}: {zone.value}") +Stat: battingAverage + Zone 01: .226 + Zone 02: .400 + ... +``` + +### Schedule Examples +Get a schedule for a given date +```python +>>> mlb = mlbstatsapi.Mlb() +>>> schedule = mlb.get_schedule(date='2022-10-13') +>>> dates = schedule.dates + +>>> for date in dates: +... for game in date.games: +... print(f"Game: {game.game_pk}") +... print(f"Status: {game.status.detailed_state}") +... print(f"Home: {game.teams.home.team.name}") +... print(f"Away: {game.teams.away.team.name}") +``` + +### Game Examples +Get a Game for a given game id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> game = mlb.get_game(662242) +``` + +Get the weather for a game +```python +>>> weather = game.game_data.weather +>>> print(f"Condition: {weather.condition}") +>>> print(f"Temperature: {weather.temp}") +>>> print(f"Wind: {weather.wind}") +``` + +Get the current status of a game +```python +>>> linescore = game.live_data.linescore +>>> home_info = game.game_data.teams.home +>>> away_info = game.game_data.teams.away +>>> home_status = linescore.teams.home +>>> away_status = linescore.teams.away + +>>> print(f"Home: {home_info.franchise_name} {home_info.club_name}") +>>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}") +>>> print(f"Away: {away_info.franchise_name} {away_info.club_name}") +>>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}") +>>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}") +``` + +Get play by play, line score, and box score objects +```python +>>> play_by_play = game.live_data.plays +>>> line_score = game.live_data.linescore +>>> box_score = game.live_data.boxscore +``` + +#### Play by Play +Get only the play by play for a given game id +```python +>>> playbyplay = mlb.get_game_play_by_play(662242) +``` + +#### Line Score +Get only the line score for a given game id +```python +>>> linescore = mlb.get_game_line_score(662242) +``` + +#### Box Score +Get only the box score for a given game id +```python +>>> boxscore = mlb.get_game_box_score(662242) +``` + +### Gamepace Examples +Get pace of game metrics for a specific season +```python +>>> mlb = mlbstatsapi.Mlb() +>>> gamepace = mlb.get_gamepace(season=2021) +>>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}") +``` + +### People Examples +Get all Players for a given sport id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> players = mlb.get_people(sport_id=1) +>>> for player in players: +... print(f"{player.id}: {player.full_name}") +``` + +Get a player id +```python +>>> player_id = mlb.get_people_id("Ty France") +>>> print(player_id[0]) +664034 +``` + +### Team Examples +Get a Team +```python +>>> mlb = mlbstatsapi.Mlb() +>>> team_id = mlb.get_team_id("Seattle Mariners")[0] +>>> team = mlb.get_team(team_id) +>>> print(f"{team.id}: {team.name}") +>>> print(f"Venue: {team.venue.name}") +``` + +Get a Player Roster +```python +>>> mlb = mlbstatsapi.Mlb() +>>> players = mlb.get_team_roster(136) +>>> for player in players: +... print(f"#{player.jersey_number} {player.person.full_name}") +``` + +Get a Coach Roster +```python +>>> mlb = mlbstatsapi.Mlb() +>>> coaches = mlb.get_team_coaches(136) +>>> for coach in coaches: +... print(f"{coach.person.full_name}: {coach.title}") +``` + +### Draft Examples +Get a draft for a year +```python +>>> mlb = mlbstatsapi.Mlb() +>>> draft = mlb.get_draft('2019') +``` + +Get Players from Draft +```python +>>> draftpicks = draft[0].picks +>>> for pick in draftpicks: +... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") +``` + +### Award Examples +Get awards for a given award id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108') +>>> for recipient in retired_numbers.awards: +... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") +``` + +### Venue Examples +Get a Venue +```python +>>> mlb = mlbstatsapi.Mlb() +>>> venue_id = mlb.get_venue_id('PNC Park')[0] +>>> venue = mlb.get_venue(venue_id) +>>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}") +``` + +### Division Examples +Get a division +```python +>>> mlb = mlbstatsapi.Mlb() +>>> division = mlb.get_division(200) +>>> print(division.name) +American League West +``` + +### League Examples +Get a league +```python +>>> mlb = mlbstatsapi.Mlb() +>>> league = mlb.get_league(103) +>>> print(league.name) +American League +``` + +### Season Examples +Get a Season +```python +>>> mlb = mlbstatsapi.Mlb() +>>> season = mlb.get_season(2018) +>>> print(f"Season: {season.season_id}") +>>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}") +``` + +### Standings Examples +Get Standings +```python +>>> mlb = mlbstatsapi.Mlb() +>>> standings = mlb.get_standings(103, 2018) +>>> for record in standings: +... print(f"Division: {record.division.name}") +... for team in record.team_records: +... print(f" {team.team.name}: {team.wins}-{team.losses}") +``` From 7d2f0ecd37eddd0bff6230c5f4e86cbaea27f32c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 15:11:09 -0700 Subject: [PATCH 12/13] 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 6a3d6a75..94310e4c 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 00000000..af05d62c --- /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 94a479e5..fee87d04 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 b19c5308..f4ef944b 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 00000000..97526e58 --- /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 afa87feb..c6137b3a 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 b3cae605..7aba78f8 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 17461364..2f22bcef 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 13/13] 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 94310e4c..260094c2 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 00000000..d04b3a59 --- /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 39fc0d6b..3179d4e0 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 fee87d04..839dbf63 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 f4ef944b..9ca1d429 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 00000000..e036a4e3 --- /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 fcd1b418..61c27f15 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 c6137b3a..87c68b3d 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 7aba78f8..d909bad3 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 2f22bcef..38540ce6 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.