diff --git a/docs/public-api.md b/docs/public-api.md index 0e44a0dc..260094c2 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -268,6 +268,22 @@ 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. `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 The currently supported awaitable endpoint methods are: @@ -275,6 +291,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 +303,98 @@ 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) +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_draft(year_id: int, **params) +get_awards(award_id: str, **params) +get_homerun_derby(game_id, **params) +get_team_stats(team_id: int, stats: list, groups: list, **params) +get_players_stats_for_game(person_id: int, game_id: int, **params) +get_player_stats(person_id: int, stats: list, groups: list, **params) +get_stats(stats: list, groups: list, **params) +get_persons(person_ids: str | list[int], **params) +get_scheduled_games_by_date( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, +) +get_gamepace(season: str, sport_id=1, **params) +get_team_id(team_name: str, search_key: str = 'name', **params) +get_people_id( + fullname: str, + sport_id: int = 1, + search_key: str = 'fullName', + **params, +) +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 +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`. + +The four stat methods return the same nested `dict` their sync counterparts +do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}}` +— and return `{}` on a 400–499 response, on a body with no `stats`, and on a +`stats` entry carrying no splits. Note that an unrecognized value in `stats` or +`groups` is not rejected; it produces the same empty `{}`. Valid values are +listed at `https://statsapi.mlb.com/api/v1/statTypes` and +`https://statsapi.mlb.com/api/v1/statGroups`. + +`AsyncMlb` now covers every endpoint method `Mlb` exposes. The only public +name that differs is lifecycle: `Mlb.close()` is spelled `AsyncMlb.aclose()`. + +`get_scheduled_games_by_date` inherits the same documented quirk as +`Mlb.get_scheduled_games_by_date`: it is annotated `list[ScheduleGames]` but +returns `None` when no `date`, `start_date`/`end_date` pair, or `gamePks` was +given to select with. This is preserved for parity, not introduced by the +async port. + +`get_gamepace` sends the same request on both clients but builds it +differently. `Mlb` embeds the season in the endpoint string +(`gamePace?season=2021`) and relies on Requests merging that query with the +rest of the parameters. HTTPX replaces a URL's existing query rather than +merging into it, so `AsyncMlb` passes the season as an ordinary parameter. +Callers see no difference; this matters only if you are reading the two +implementations side by side. + ## Low-level adapter `MlbDataAdapter` is the public low-level HTTP adapter. @@ -520,9 +628,17 @@ 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 + 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/_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/_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/_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/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/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/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/_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/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/_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/_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/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/_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/_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/_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/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/_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 12cac734..839dbf63 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,15 +5,49 @@ 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 +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.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.schedules import parse_schedule +from ._parsers.roster import parse_roster_coaches, parse_roster_players +from ._parsers.schedules import parse_schedule, parse_scheduled_games +from ._parsers.seasons import parse_season, parse_seasons +from ._parsers.sports import parse_sport, parse_sports +from ._parsers.standings import parse_standings +from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_team, parse_teams +from ._parsers.venues import parse_venue, parse_venues from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType -from .models.people import Person -from .models.schedules import Schedule +from .models.attendances import Attendance +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.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, ScheduleGames +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 @@ -33,18 +67,42 @@ def __init__( ): self._logger = logger or logging.getLogger(__name__) + # 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( + hostname=hostname, + ver="v1.1", + logger=self._logger, + timeout=timeout, + client=self._client, strict_http=strict_http, ) 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 @@ -72,6 +130,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 +201,71 @@ async def get_teams( sport_id: int = 1, **params, ) -> list[Team]: - """Return every Team for a sport id. + """ + return the all Teams - Async counterpart of ``Mlb.get_teams``; see that method for the - supported keyword parameters. + 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 +279,220 @@ 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, + **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 +508,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", @@ -139,6 +553,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, @@ -149,7 +617,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 +795,1672 @@ async def get_schedule( return None 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, + **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_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, + **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_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, + **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) + + 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, + 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) + + 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_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, + 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) + + 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) + + 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) + + async def get_team_stats( + self, + team_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns a split stat data for a team + + Async counterpart of ``Mlb.get_team_stats``. + + Parameters + ---------- + team_id : int + the team id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return team stats for a particular season, season=2018 + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_team_stats(133, ["season"], ["pitching"]) + {'pitching': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_players_stats_for_game( + self, + person_id: int, + game_id: int, + **params, + ) -> dict: + """ + Insert personId and gamePk to view stats for individual player based on a specific game. + + Fielding, Hitting, & Pitching gameLog Statistics as well as vsPlayer stats. + + Async counterpart of ``Mlb.get_players_stats_for_game``. + + Parameters + ---------- + person_id : int + the person id + game_id : int + the game id + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_players_stats_for_game(663728, 715757) + ... print(stats["stats"]["gameLog"]) + ... print(stats["hitting"]["playLog"]) + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats/game/{game_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_player_stats( + self, + person_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns stat data for a player + + Async counterpart of ``Mlb.get_player_stats``. + + Parameters + ---------- + person_id : int + the person id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return player stats for a particular season, season=2018 + eventType : str + Notes for individual events for playLog, playLog can be filered by individual events. + List of eventTypes can be found at https://statsapi.mlb.com/api/v1/eventTypes + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_stats : Get stats + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_player_stats(647351, ["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_stats( + self, + stats: list, + groups: list, + **params, + ) -> dict: + """ + return a stat dictionary + + Async counterpart of ``Mlb.get_stats``. + + Parameters + ---------- + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return stats for a particular season, season=2018 + teamId : int + Insert teamId to return statistics for a given team. Default to "Qualified" playerPool. + For a list of all teamIds : AsyncMlb.get_leagues() + leagueId : int + Insert leagueId to return statistics for a given league. Default to "Qualified" playerPool + For a list of all leagueIds : AsyncMlb.get_leagues() + gameType : str + Insert gameType to return statistics for a given sport or league based on gameType. Default to "Qualified" playerPool + Find available gameType at https://statsapi.mlb.com/api/v1/gameTypes + sportIds : int + Insert sportId to return statistics for a given sport. + For a list of all sportIds : AsyncMlb.get_sports() + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get player stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_stats(["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_persons( + self, + person_ids: str | list[int], + **params, + ) -> list[Person]: + """ + This endpoint returns statistical data and biographical information + for players, umpires, and coaches based on playerId. + + Async counterpart of ``Mlb.get_persons``. + + Parameters + ---------- + person_ids : str, list[int] + Insert personId(s) to return biographical information for a + specific player. Format '605151,592450' or [605151,592450] + + Other Parameters + ---------------- + hydrate : str + Insert hydration(s) to return statistical or biographical data + for a specific player(s). + Format stats(group=["statGroup1","statGroup2"], + type=["statType1","statType2"]). + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Person + returns a list of Person + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + AsyncMlb.get_people_id : Return person id from name. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... people = await mlb.get_persons("605151,592450") + [Person, Person] + """ + params["personIds"] = person_ids + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="people", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_people(mlb_data.data) + + async def get_scheduled_games_by_date( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, + ) -> list[ScheduleGames]: + """ + return game ids for a specific date and game status + + Async counterpart of ``Mlb.get_scheduled_games_by_date``. + + Parameters + ---------- + date : str + start date, 'yyyy-mm-dd' + start_date : str + Start date, 'yyyy-mm-dd' + end_date : str + end date, 'yyyy-mm-dd' + sport_id : int + sport id of schedule, defaults to 1 + + Other Parameters + ---------------- + leagueId : int, str + Insert leagueId to return all schedules based on a particular + scheduleType for a specific league. Usage: 1 or '1,11' + gamePks : int, str + Insert gamePks to return all schedules based on a particular + scheduleType for specific games. Usage: 531493 or '531493,531497' + venueIds : int + Insert venueId to return all schedules based on a particular + scheduleType for a specific venueId. + gameTypes : str + Insert gameTypes to return schedule information for all games in + particular gameTypes. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + Returns + ------- + list of ScheduleGames + returns a list of matching games + + See Also + -------- + AsyncMlb.get_game_ids : return a list of game ids + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... games = await mlb.get_scheduled_games_by_date("2022-10-13") + [ScheduleGames, ScheduleGames] + """ + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + **params, + ) + + # Mirrors Mlb.get_scheduled_games_by_date, which returns None -- not + # the empty list its annotation promises -- when no date selector was + # given. Preserved for parity, not introduced here. + if params is None: + return None + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_scheduled_games(mlb_data.data) + + async def get_gamepace( + self, + season: str, + sport_id=1, + **params, + ) -> GamePace | None: + """ + Get pace of game metrics for specific sport, league or team. + + Async counterpart of ``Mlb.get_gamepace``. + + Parameters + ---------- + season : str + Insert year to return a directory of pace of game metrics for a + given season. + sport_id : int + Insert a sportId to return a directory of pace of game metrics + for a specific sport, defaults to 1 + + Other Parameters + ---------------- + teamIds : int + Insert a teamIds to return directory of pace of game metrics for + a given team. Format '110' or '110,147' + leagueId : int + Insert leagueIds to return a directory of pace of game metrics + for a given league. Format '103' or '103,104' + leagueListId : str + Insert a unique League List Identifier to return a directory of + pace of game metrics for a specific league listId. + gameType : str + Insert gameType(s) to return a directory of pace of game metrics + for a specific gameType. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + orgType : str + Insert a orgType to return a directory of pace of game metrics + based on team, league or sport. + Available values : T- TEAM, L- LEAGUE, S- SPORT + includeChildren : bool + Insert includeChildren to return a directory of pace of game + metrics for all child teams in a given parent sport. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + GamePace + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... gamepace = await mlb.get_gamepace("2021") + GamePace + """ + # Mlb.get_gamepace embeds the season in the endpoint string + # ("gamePace?season=2021") and lets Requests merge that query with + # ep_params. HTTPX does not merge -- passing params replaces a query + # already present on the URL -- so copying that idiom here would drop + # the season silently. Passing it as a param produces the identical + # request on both clients. + params["season"] = season + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="gamePace", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_gamepace(mlb_data.data) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 65f29749..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,17 +47,16 @@ def __init__( self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None - 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 @@ -92,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 @@ -173,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._owns_client 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._owns_client 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._owns_client 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._owns_client 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._owns_client 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._owns_client 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): diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ace2c570..9ca1d429 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,9 +23,24 @@ 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 +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 +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.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 ( DEFAULT_TIMEOUT, @@ -291,16 +306,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]: """ @@ -488,16 +494,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]: """ @@ -573,13 +570,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 +614,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, @@ -885,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]: """ @@ -952,8 +930,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]: """ @@ -997,8 +974,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]: """ @@ -1040,8 +1016,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]: """ @@ -1085,8 +1060,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, @@ -1139,14 +1113,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]: """ @@ -1209,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]: """ @@ -1248,11 +1211,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]: """ @@ -1296,12 +1259,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]: @@ -1333,16 +1291,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]: """ @@ -1381,9 +1330,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 +1363,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]: @@ -1456,17 +1398,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]: """ @@ -1503,9 +1435,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 +1476,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]: @@ -1585,16 +1510,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]: """ @@ -1626,9 +1542,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 +1581,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]: @@ -1706,17 +1615,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: """ @@ -1759,9 +1659,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]: """ @@ -1816,13 +1714,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): """ @@ -1893,14 +1785,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, @@ -1955,8 +1841,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 @@ -1968,8 +1854,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]: """ @@ -2016,13 +1901,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]: """ @@ -2056,14 +1935,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]: """ @@ -2095,10 +1968,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: @@ -2146,12 +2018,7 @@ def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> d if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> dict: """ @@ -2162,9 +2029,9 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> Parameters ---------- person_id : int - the team id - game_id : list - list of stat types + the person id + game_id : int + the game id Returns ------- @@ -2182,20 +2049,16 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> >>> mlb = Mlb() >>> player_id = 663728 >>> game_id = 715757 - >>> stats = mlb.get_player_stats_for_game(person_id=person_id, game_id=game_id) + >>> stats = mlb.get_players_stats_for_game(person_id=person_id, game_id=game_id) >>> print(stats['stats']['gameLog']) >>> print(stats['hitting']['playLog']) """ - mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}') + mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}', + ep_params=params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_player_stats(self, person_id: int, stats: list, groups: list, **params) -> dict: """ @@ -2244,12 +2107,7 @@ def get_player_stats(self, person_id: int, stats: list, groups: list, **params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_stats(self, stats: list, groups: list, **params: dict) -> dict: """ @@ -2303,11 +2161,6 @@ def get_stats(self, stats: list, groups: list, **params: dict) -> dict: if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) # This is to test pypi, please delete later diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 9e39cf6a..949b9919 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -1,9 +1,20 @@ import asyncio from mlbstatsapi import AsyncMlb -from mlbstatsapi.models.people import Person +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.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 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(): @@ -17,6 +28,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 +70,223 @@ 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()) + + +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()) + + +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()) + + +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()) + + +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()) + + +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()) + + +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/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/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_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_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_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/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_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/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/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_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/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/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/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_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/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 cafb2637..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 @@ -36,17 +37,273 @@ 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.people import Person # 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.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 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.stats import Stat # 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"}]} 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", + } + ] +} +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, + }, +} +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]} +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, + }, +} +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, @@ -68,8 +325,142 @@ 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", +) +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. +SCHEDULED_GAME = { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", +} + +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [SCHEDULED_GAME], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + NO_RESULT_RESPONSES = { "404": httpx.Response(404, json={}), "empty 200": httpx.Response(200, json={}), @@ -110,54 +501,83 @@ 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]: - """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" - 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"], ver + + +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: - """Assert an observed request is the one ``Mlb`` would have made.""" - endpoint, params = sync_request_for(method, *args, **kwargs) + """Assert an observed request is the one ``Mlb`` would have made. - 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()} + 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) + + 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) # --------------------------------------------------------------------------- @@ -189,7 +609,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) @@ -202,7 +622,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: @@ -222,7 +642,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") ) @@ -242,7 +662,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: @@ -277,6 +697,53 @@ async def scenario(): asyncio.run(scenario()) +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._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_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 isinstance(mlb._client._transport, MlbAsyncRetryTransport) + + asyncio.run(scenario()) + + +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)) + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + + async def scenario(): + try: + async with AsyncMlb(client=client) as mlb: + assert mlb._client is client + assert mlb._client._transport is transport + finally: + await client.aclose() + + asyncio.run(scenario()) + + def test_aclose_is_idempotent(): """Closing more than once, however the caller mixes the forms, is safe.""" @@ -406,16 +873,879 @@ async def scenario(): assert_matches_sync(handler.request, "get_people", 11, season="2021") -# --------------------------------------------------------------------------- -# Parity and concurrency -# --------------------------------------------------------------------------- +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) -def test_public_signatures_match_the_sync_client(): - """Argument names, kinds, and defaults must not drift from Mlb's.""" - import inspect + 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()) == [] + + +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) + + +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") - for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): + 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 + + +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()) == [] + + +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 + + +def test_get_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_stats(["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_stats", ["season"], ["hitting"]) + assert list(stats) == ["hitting"] + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_player_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_player_stats(660271, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_player_stats", 660271, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_team_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_stats(133, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_team_stats", 133, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game(660271, 715757) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_players_stats_for_game", 660271, 715757 + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_forwards_extra_params(): + """The signature accepts **params, so they have to reach the query string.""" + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game( + 660271, 715757, eventType="single" + ) + + asyncio.run(scenario()) + + assert handler.request.url.params["eventType"] == "single" + + +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoints_return_an_empty_mapping_when_there_are_no_stats( + method, args, label +): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await getattr(mlb, method)(*args) + + assert asyncio.run(scenario()) == {} + + +def test_get_persons_request_matches_the_sync_client(): + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("660271,605151") + + people = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", "660271,605151") + assert people == [EXPECTED_PERSON] + + +def test_get_persons_accepts_a_list_of_ids(): + """The signature allows a list as well as a comma-delimited string.""" + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons([660271, 605151]) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", [660271, 605151]) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_returns_an_empty_list_when_there_are_no_people(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("1") + + assert asyncio.run(scenario()) == [] + + +def test_get_scheduled_games_by_date_request_matches_the_sync_client(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + games = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_scheduled_games_by_date", "2022-10-13" + ) + assert [game.game_pk for game in games] == [715757] + + +def test_get_scheduled_games_by_date_accepts_a_date_range(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date( + start_date="2022-10-13", end_date="2022-10-14" + ) + + asyncio.run(scenario()) + + assert_matches_sync( + handler.request, + "get_scheduled_games_by_date", + start_date="2022-10-13", + end_date="2022-10-14", + ) + + +def test_get_scheduled_games_by_date_accepts_game_pks_without_a_date(): + """gamePks is its own selector; no date is required alongside it.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date(gamePks=715757) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_scheduled_games_by_date", gamePks=715757) + + +def test_get_scheduled_games_by_date_without_a_selector_returns_none_without_requesting(): + """Mirrors Mlb, which returns None rather than [] when nothing selects a date.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_returns_an_empty_list_when_there_are_no_games(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + assert asyncio.run(scenario()) == [] + + +def test_get_gamepace_request_matches_the_sync_client(): + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + gamepace = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_gamepace", "2021") + assert isinstance(gamepace, GamePace) + assert gamepace.sports[0].season == "2021" + + +def test_get_gamepace_puts_the_season_in_the_query_string(): + """The season rides in the endpoint string rather than in ep_params.""" + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + asyncio.run(scenario()) + + assert handler.request.url.path == "/api/v1/gamePace" + assert handler.request.url.params["season"] == "2021" + assert handler.request.url.params["sportId"] == "1" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_returns_none_when_there_is_no_pace_data(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + assert asyncio.run(scenario()) is None + + +def test_get_team_id_request_matches_the_sync_client(): + handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) + + 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") + + +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 +# --------------------------------------------------------------------------- + + +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_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", + "get_awards", + "get_homerun_derby", + "get_stats", + "get_player_stats", + "get_team_stats", + "get_players_stats_for_game", + "get_persons", + "get_scheduled_games_by_date", + "get_gamepace", + "get_game", + "get_game_play_by_play", + "get_game_line_score", + "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_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 28605b87..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,7 +148,47 @@ 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_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) + + +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_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(): + 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 + assert handler.call_count == 2 def test_200_succeeds_with_no_retry(): @@ -734,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") @@ -794,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") @@ -818,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") @@ -833,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") @@ -890,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)) @@ -900,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 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_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 b76164dc..d909bad3 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -238,12 +238,58 @@ 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)", + "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_draft": "(year_id: int, **params)", + "get_awards": "(award_id: str, **params)", + "get_homerun_derby": "(game_id, **params)", + "get_team_stats": "(team_id: int, stats: list, groups: list, **params)", + "get_players_stats_for_game": "(person_id: int, game_id: int, **params)", + "get_player_stats": "(person_id: int, stats: list, groups: list, **params)", + "get_stats": "(stats: list, groups: list, **params)", + "get_persons": "(person_ids: str | list[int], **params)", + "get_scheduled_games_by_date": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), + "get_gamepace": "(season: str, sport_id=1, **params)", + "get_team_id": "(team_name: str, search_key: str='name', **params)", + "get_people_id": ( + "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" + ), + "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)" + ), } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 813e0507..38540ce6 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -42,15 +42,270 @@ MlbTimeoutError, MlbTransportError, ) -from mlbstatsapi.models.people import Person # 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.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 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.stats import Stat # 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"}]} 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", + } + ] +} +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, + }, +} +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]} +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, + }, +} +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, @@ -88,6 +343,120 @@ }, } +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [ + { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", + } + ], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": { + "id": 108, + "name": "Los Angeles Angels", + "link": "/api/v1/teams/108", + }, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + # The canned transport failures, per client. Each pair is the closest # equivalent the two libraries offer, so the public exception is the only # thing being compared. @@ -315,6 +684,423 @@ 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", {}) + + +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"}) + + +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"}) + + +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", {}) + + +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", {}) + + +def test_get_stats_success_parity(): + """A successful stats response parses to the same split mapping on both clients.""" + result = call_both("get_stats", ["season"], ["hitting"], payload=STATS_PAYLOAD) + + assert list(result.sync) == ["hitting"], "sync get_stats did not key by group" + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_player_stats_success_parity(): + """A successful player stats response parses the same on both clients.""" + result = call_both( + "get_player_stats", 660271, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_team_stats_success_parity(): + """A successful team stats response parses the same on both clients.""" + result = call_both( + "get_team_stats", 133, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/teams/133/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_players_stats_for_game_success_parity(): + """A successful per-game stats response parses the same on both clients.""" + result = call_both( + "get_players_stats_for_game", 660271, 715757, payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {}, + ) + + +def test_get_players_stats_for_game_forwards_params_on_both_clients(): + """Regression coverage: **params used to be accepted and silently dropped. + + ``get_players_stats_for_game`` advertises ``**params`` but never passed + ``ep_params`` to the adapter, so every caller-supplied keyword vanished + before the request was built. Both clients now forward them. + """ + result = call_both( + "get_players_stats_for_game", + 660271, + 715757, + eventType="single", + payload=STATS_PAYLOAD, + ) + + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {"eventType": "single"}, + ) + + +def test_get_persons_success_parity(): + """A successful people response parses to the same Person list on both clients.""" + result = call_both("get_persons", "660271", payload=PERSON_PAYLOAD) + + assert result.sync == [Person(**PERSON_PAYLOAD["people"][0])] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/people", {"personIds": "660271"}) + + +def test_get_scheduled_games_by_date_success_parity(): + """A successful schedule response parses to the same game list on both clients.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", payload=SCHEDULED_GAMES_PAYLOAD + ) + + assert [game.game_pk for game in result.sync] == [715757] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/schedule", + {"date": "2022-10-13", "sportId": "1"}, + ) + + +def test_get_gamepace_success_parity(): + """A successful gamePace response parses to the same GamePace on both clients. + + The season is the part that matters here. Mlb embeds it in the endpoint + string and relies on Requests merging that query with ep_params; HTTPX + replaces rather than merges, so AsyncMlb passes it as a param instead. + Asserting one shared request signature pins that the two routes converge. + """ + result = call_both("get_gamepace", "2021", payload=GAMEPACE_PAYLOAD) + + assert isinstance(result.sync, GamePace), "sync get_gamepace did not return a GamePace" + assert result.sync.sports[0].season == "2021" + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/gamePace", + {"season": "2021", "sportId": "1"}, + ) + + +def test_get_team_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "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", {}) + + +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 # --------------------------------------------------------------------------- @@ -355,6 +1141,308 @@ 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}" + ) + + +@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}" + ) + + +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 == [] + + +@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}" + ) + + +@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}" + ) + + +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoint_no_result_parity(method, args, label): + """Every no-result response returns an empty mapping on either client.""" + result = call_both(method, *args, **NO_RESULT_RESPONSES[label]) + + assert result.sync == {}, f"sync {method} returned {result.sync!r} for {label}" + assert result.asynchronous == {}, ( + f"async {method} returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_persons", "1", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_persons returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_persons returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", **NO_RESULT_RESPONSES[label] + ) + + assert result.sync == [], ( + f"sync get_scheduled_games_by_date returned {result.sync!r} for {label}" + ) + assert result.asynchronous == [], ( + f"async get_scheduled_games_by_date returned {result.asynchronous!r} for {label}" + ) + + +def test_get_scheduled_games_by_date_without_a_selector_parity(): + """Both clients return None -- not [] -- when nothing selects a date. + + The annotation promises list[ScheduleGames]. Mlb returns a bare None here + and AsyncMlb preserves that rather than quietly correcting it, so the two + stay interchangeable. + """ + assert call_sync("get_scheduled_games_by_date") is None + assert call_async("get_scheduled_games_by_date") is None + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_gamepace", "2021", **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_gamepace returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_gamepace returned {result.asynchronous!r} for {label}" + ) + + +def test_get_homerun_derby_malformed_error_body_parity(): + """Regression coverage: the bare-None-instead-of-return-None bug fix. + + 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 + + +@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) # ---------------------------------------------------------------------------