Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 119 additions & 3 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,31 @@ 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:

```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(
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions mlbstatsapi/_async_transport.py
Original file line number Diff line number Diff line change
@@ -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(),
)
15 changes: 15 additions & 0 deletions mlbstatsapi/_helpers/id_lookup.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions mlbstatsapi/_parsers/attendance.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions mlbstatsapi/_parsers/awards.py
Original file line number Diff line number Diff line change
@@ -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"]]
21 changes: 21 additions & 0 deletions mlbstatsapi/_parsers/divisions.py
Original file line number Diff line number Diff line change
@@ -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]
16 changes: 16 additions & 0 deletions mlbstatsapi/_parsers/draft.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading