From f68349ba1369346e491f3470986e78ca24d46fe4 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 17:27:20 -0700 Subject: [PATCH 1/8] feat: add initial AsyncMlb client vertical slice - Expose AsyncMlb lazily from the package root - Add async team, people, and schedule endpoint methods - Support async context management and resource cleanup - Preserve original exceptions and cancellations during cleanup - Add offline lifecycle tests and live API endpoint coverage --- mlbstatsapi/__init__.py | 23 ++- mlbstatsapi/async_mlb.py | 164 ++++++++++++++++++ .../async_mlb/test_async_mlb.py | 60 +++++++ tests/test_async_mlb.py | 115 ++++++++++++ 4 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 mlbstatsapi/async_mlb.py create mode 100644 tests/external_tests/async_mlb/test_async_mlb.py create mode 100644 tests/test_async_mlb.py diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index e5a6cf7e..1eb2b924 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -32,20 +32,25 @@ # first access keeps async functionality discoverable from the package root # while the missing-dependency error surfaces only when async is actually # requested. See docs/public-api.md. -_LAZY_ASYNC_EXPORTS = ("AsyncMlbDataAdapter",) +_LAZY_ASYNC_EXPORTS = ( + "AsyncMlb", + "AsyncMlbDataAdapter", +) def __getattr__(name: str): - if name in _LAZY_ASYNC_EXPORTS: + if name == "AsyncMlb": + from .async_mlb import AsyncMlb + + globals()["AsyncMlb"] = AsyncMlb + return AsyncMlb + + if name == "AsyncMlbDataAdapter": from .async_mlb_dataadapter import AsyncMlbDataAdapter - # Cache on the module so later attribute access is an ordinary lookup. globals()["AsyncMlbDataAdapter"] = AsyncMlbDataAdapter return AsyncMlbDataAdapter - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def __dir__() -> list[str]: - # Keeps the lazy async names discoverable without importing HTTPX. - return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py new file mode 100644 index 00000000..d5a944e6 --- /dev/null +++ b/mlbstatsapi/async_mlb.py @@ -0,0 +1,164 @@ +# mlbstatsapi/async_mlb.py + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ._parsers.people import parse_person, parse_people +from ._parsers.schedules import parse_schedule +from ._parsers.teams import parse_team, parse_teams +from .async_mlb_dataadapter import AsyncMlbDataAdapter +from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType +from .models.people import Person +from .models.schedules import Schedule +from .models.teams import Team + +if TYPE_CHECKING: + import httpx + + +class AsyncMlb: + """Asynchronous client for the MLB Stats API.""" + + def __init__( + self, + hostname: str = "statsapi.mlb.com", + logger: logging.Logger | None = None, + timeout: TimeoutType = DEFAULT_TIMEOUT, + client: "httpx.AsyncClient | None" = None, + *, + strict_http: bool = True, + ): + self._logger = logger or logging.getLogger(__name__) + + self._mlb_adapter_v1 = AsyncMlbDataAdapter( + hostname=hostname, + ver="v1", + logger=self._logger, + timeout=timeout, + client=client, + strict_http=strict_http, + ) + + async def aclose(self) -> None: + """Close library-owned async resources.""" + await self._mlb_adapter_v1.aclose() + + async def __aenter__(self) -> "AsyncMlb": + return self + + async def __aexit__( + self, + exc_type, + exc, + traceback, + ) -> None: + try: + await self.aclose() + except BaseException: + # Cleanup must not replace an exception or cancellation that + # already occurred inside the async context. + if exc is None: + raise + + self._logger.exception( + "AsyncMlb cleanup failed while preserving the original exception" + ) + + async def get_team( + self, + team_id: int, + **params, + ) -> Team | None: + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_team(mlb_data.data) + + async def get_teams( + self, + **params, + ) -> list[Team]: + mlb_data = await self._mlb_adapter_v1.get( + endpoint="teams", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_teams(mlb_data.data) + + async def get_person( + self, + player_id: int, + **params, + ) -> Person | None: + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{player_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_person(mlb_data.data) + + async def get_people( + self, + person_ids: Union[str, List[int]], + **params, + ) -> list[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_schedule( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + team_id: int = None, + **params, + ) -> Schedule | None: + + 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 + elif "gamePks" not in params: + return None + + if team_id: + params['teamId'] = team_id + + 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 None + + return parse_schedule(mlb_data.data) diff --git a/tests/external_tests/async_mlb/test_async_mlb.py b/tests/external_tests/async_mlb/test_async_mlb.py new file mode 100644 index 00000000..691585ac --- /dev/null +++ b/tests/external_tests/async_mlb/test_async_mlb.py @@ -0,0 +1,60 @@ +import asyncio + +from mlbstatsapi import AsyncMlb +from mlbstatsapi.models.people import Person +from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.teams import Team + + +def test_async_get_team(): + async def scenario(): + async with AsyncMlb() as mlb: + team = await mlb.get_team(133) + + assert isinstance(team, Team) + assert team.id == 133 + + asyncio.run(scenario()) + +def test_async_get_teams(): + async def scenario(): + async with AsyncMlb() as mlb: + teams = await mlb.get_teams() + + assert isinstance(teams, list) + assert all(isinstance(team, Team) for team in teams) + + +def test_async_get_person(): + async def scenario(): + async with AsyncMlb() as mlb: + person = await mlb.get_person(664034) + + assert isinstance(person, Person) + assert person.id == 664034 + + asyncio.run(scenario()) + +def test_async_get_people(): + async def scenario(): + + player_ids_l = [605151,592450] + + async with AsyncMlb() as mlb: + people = await mlb.get_people(player_ids_l) + + assert isinstance(people, list) + assert all(isinstance(person, Person) for person in people) + + asyncio.run(scenario()) + + +def test_async_get_schedule(): + async def scenario(): + async with AsyncMlb() as mlb: + schedule = await mlb.get_schedule(date="2022-10-07") + + assert isinstance(schedule, Schedule) + assert schedule.dates + + asyncio.run(scenario()) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py new file mode 100644 index 00000000..d5228869 --- /dev/null +++ b/tests/test_async_mlb.py @@ -0,0 +1,115 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from mlbstatsapi.async_mlb import AsyncMlb + + +def run_async(coro): + return asyncio.run(coro) + + +def test_async_mlb_context_manager_returns_self(): + async def scenario(): + mlb = AsyncMlb() + + async with mlb as entered: + assert entered is mlb + + run_async(scenario()) + + +def test_async_mlb_aclose_delegates_to_adapter(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + await mlb.aclose() + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_context_manager_closes_on_normal_exit(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + async with mlb: + pass + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_context_manager_closes_when_body_raises(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + with pytest.raises(ValueError, match="boom"): + async with mlb: + raise ValueError("boom") + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_preserves_original_exception_if_cleanup_fails(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) + + with pytest.raises(ValueError, match="original"): + async with mlb: + raise ValueError("original") + + run_async(scenario()) + + +def test_async_mlb_cleanup_failure_raises_when_no_original_exception(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) + + with pytest.raises(RuntimeError, match="cleanup failed"): + async with mlb: + pass + + run_async(scenario()) + + +def test_async_mlb_preserves_cancellation_during_cleanup(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + async def worker(): + async with mlb: + await asyncio.sleep(60) + + task = asyncio.create_task(worker()) + + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) From aef77fa04ce15ca8077fba3f5e2a36d261aeddff Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 19:18:21 -0700 Subject: [PATCH 2/8] refactor(async): extract schedule parameter construction - Move schedule request parameter building into a shared parser helper - Reuse the helper in AsyncMlb.get_schedule - Preserve existing handling for dates, game IDs, teams, and sports - Rename the live async test module to identify it as an external test --- mlbstatsapi/_parsers/schedules.py | 23 +++++++++++++++++++ mlbstatsapi/async_mlb.py | 23 +++++++++---------- ...est_async_mlb.py => test_ext_async_mlb.py} | 0 3 files changed, 34 insertions(+), 12 deletions(-) rename tests/external_tests/async_mlb/{test_async_mlb.py => test_ext_async_mlb.py} (100%) diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 39fc0d6b..1126df21 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -7,3 +7,26 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) + +def build_schedule_params( + date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + sport_id: int = 1, + team_id: int | None = None, + **params, +) -> dict | None: + 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 + elif "gamePks" not in params: + return None + + if team_id: + params["teamId"] = team_id + + params["sportId"] = sport_id + + return params diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index d5a944e6..cc7f30a1 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from ._parsers.people import parse_person, parse_people -from ._parsers.schedules import parse_schedule +from ._parsers.schedules import parse_schedule, build_schedule_params from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType @@ -139,18 +139,17 @@ async def get_schedule( **params, ) -> Schedule | None: - 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 - elif "gamePks" not in params: - return None - - if team_id: - params['teamId'] = team_id + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + team_id=team_id, + **params, + ) - params['sportId'] = sport_id + if not params: + return None mlb_data = await self._mlb_adapter_v1.get( endpoint="schedule", diff --git a/tests/external_tests/async_mlb/test_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py similarity index 100% rename from tests/external_tests/async_mlb/test_async_mlb.py rename to tests/external_tests/async_mlb/test_ext_async_mlb.py From b3071ef99af2ed3f0d5ed9987fa34f46466652c1 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 19:31:48 -0700 Subject: [PATCH 3/8] fix(async): preserve lazy exports and schedule parameters - Include lazy async client exports in package introspection - Distinguish missing schedule parameters from valid parameter dictionaries --- mlbstatsapi/__init__.py | 5 +++++ mlbstatsapi/async_mlb.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index 1eb2b924..35ba4784 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -54,3 +54,8 @@ def __getattr__(name: str): raise AttributeError( f"module {__name__!r} has no attribute {name!r}" ) + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index cc7f30a1..6a88e5b1 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -148,7 +148,7 @@ async def get_schedule( **params, ) - if not params: + if params is None: return None mlb_data = await self._mlb_adapter_v1.get( From 3150f9b016672ce661f6255a0abb7afcaf1d17fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:31:50 +0000 Subject: [PATCH 4/8] fix(async): repair package root and align AsyncMlb with the sync API The package root carried a stray closing paren after __dir__, which made `import mlbstatsapi` a SyntaxError and took down all 17 offline test modules at collection, not just the async ones. The lazy AsyncMlb and AsyncMlbDataAdapter exports are unchanged. Two endpoints on the vertical slice had drifted from the synchronous client they port: * get_teams took no sport_id at all, so it never sent the sportId query parameter that Mlb.get_teams always sends. * get_people was really get_persons: it posted personIds to `people` rather than reading `sports/{sport_id}/players`. Its annotations also referenced Union and List, neither imported, which stayed latent only because annotations are deferred in this module. Both now match Mlb in argument names, defaults, endpoint, parameter construction, return type, and empty-result behavior. get_schedule and the shared build_schedule_params helper are unchanged; they already reproduce the sync logic exactly. The async surface no longer offers a personIds lookup. Adding a get_persons port is deliberately left out of this slice. Tests: * tests/test_async_mlb.py grows from 7 tests to 48. Endpoint tests drive the real adapter over httpx.MockTransport instead of mocking the adapter away, so a method that stopped issuing HTTP would fail rather than pass against a mock. Adds the package-root import, client ownership, idempotent cleanup, per-endpoint request and result behavior, a parametrized parity check against Mlb, a signature-drift guard, concurrency on a shared client, and absence of hidden fan-out or background tasks. The transport-contract matrix stays in #302. * The module now guards its import with pytest.importorskip("httpx"), so a sync-only install skips it instead of erroring at collection. * test_async_get_teams defined a scenario but never ran it. It now executes, and it and test_async_get_people assert the result is non-empty, since `all()` over an empty list proves nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013La4LovbUtWoZSrD3iQQKu --- mlbstatsapi/__init__.py | 1 - mlbstatsapi/async_mlb.py | 18 +- .../async_mlb/test_ext_async_mlb.py | 9 +- tests/test_async_mlb.py | 711 +++++++++++++++++- 4 files changed, 728 insertions(+), 11 deletions(-) diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index 35ba4784..7cde2d79 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -58,4 +58,3 @@ def __getattr__(name: str): def __dir__() -> list[str]: return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) - ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 6a88e5b1..080e4393 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -83,8 +83,16 @@ async def get_team( async def get_teams( self, + sport_id: int = 1, **params, ) -> list[Team]: + """Return every Team for a sport id. + + Async counterpart of ``Mlb.get_teams``; see that method for the + supported keyword parameters. + """ + params["sportId"] = sport_id + mlb_data = await self._mlb_adapter_v1.get( endpoint="teams", ep_params=params, @@ -112,14 +120,16 @@ async def get_person( async def get_people( self, - person_ids: Union[str, List[int]], + sport_id: int = 1, **params, ) -> list[Person]: + """Return every player for a sport id. - params['personIds'] = person_ids - + Async counterpart of ``Mlb.get_people``, which reads the + ``sports/{sport_id}/players`` endpoint rather than ``people``. + """ mlb_data = await self._mlb_adapter_v1.get( - endpoint="people", + endpoint=f"sports/{sport_id}/players", ep_params=params, ) diff --git a/tests/external_tests/async_mlb/test_ext_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py index 691585ac..9ba91612 100644 --- a/tests/external_tests/async_mlb/test_ext_async_mlb.py +++ b/tests/external_tests/async_mlb/test_ext_async_mlb.py @@ -22,8 +22,11 @@ async def scenario(): teams = await mlb.get_teams() assert isinstance(teams, list) + assert teams assert all(isinstance(team, Team) for team in teams) + asyncio.run(scenario()) + def test_async_get_person(): async def scenario(): @@ -37,13 +40,11 @@ async def scenario(): def test_async_get_people(): async def scenario(): - - player_ids_l = [605151,592450] - async with AsyncMlb() as mlb: - people = await mlb.get_people(player_ids_l) + people = await mlb.get_people() assert isinstance(people, list) + assert people assert all(isinstance(person, Person) for person in people) asyncio.run(scenario()) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index d5228869..5aa1219f 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1,13 +1,182 @@ +"""Focused offline tests for the AsyncMlb client (issue #303). + +Covers what the vertical slice actually promises: the package-root import, the +async context-manager and cleanup contract, and — for each endpoint on the +client — the request it builds and the parsed value it returns. + +AsyncMlb is deliberately thin: HTTP behavior belongs to AsyncMlbDataAdapter and +is asserted in tests/test_async_mlb_dataadapter.py, with the exhaustive +transport-contract matrix in #302. Nothing here re-tests retries, status +mapping, timeouts, or exception translation. What is tested here instead is +that the client hands the adapter the right endpoint and params, hands the +response to the shared parsers, and adds nothing of its own between the two. + +The endpoint tests therefore drive the real adapter over an +``httpx.MockTransport`` rather than mocking the adapter away, so an endpoint +that stopped producing a real HTTP request would fail rather than pass against +a mock. Request construction is additionally pinned to the synchronous client +in test_request_construction_matches_sync_client, because the async surface is +only correct insofar as it matches ``Mlb``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + import asyncio from unittest.mock import AsyncMock, patch import pytest -from mlbstatsapi.async_mlb import AsyncMlb +# The endpoint tests drive the real HTTPX-backed adapter, so a sync-only +# install has nothing here to run. Skipping at collection keeps +# ``pytest tests/`` working without the ``async`` extra instead of erroring on +# the import, matching tests/test_async_mlb_dataadapter.py. +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.teams import Team # noqa: E402 + + +# Patched only while a client is constructed, so AsyncMlb builds its own +# adapter and client through the production path and only the transport is +# swapped. Mirrors tests/test_async_mlb_dataadapter.py. +CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" + +# Failure guard for the concurrency test: a request that should never wait is +# bounded so a serializing regression fails fast instead of hanging CI. +BLOCKED_REQUEST_TIMEOUT = 10 + +TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} +TEAMS_PAYLOAD = { + "teams": [ + {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}, + {"id": 134, "link": "/api/v1/teams/134", "name": "Team 134"}, + ] +} +PERSON_PAYLOAD = { + "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] +} +PEOPLE_PAYLOAD = { + "people": [ + {"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}, + {"id": 605151, "link": "/api/v1/people/605151", "fullName": "Person 605151"}, + ] +} +SCHEDULE_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-07", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [], + } + ], +} + + +# Clients built by _owned_client(); run_async() closes them inside the same +# event loop that used them, so no AsyncClient is left open by a test. +_CLIENTS_TO_CLOSE: list[AsyncMlb] = [] def run_async(coro): - return asyncio.run(coro) + async def runner(): + try: + return await coro + finally: + while _CLIENTS_TO_CLOSE: + await _CLIENTS_TO_CLOSE.pop().aclose() + + return asyncio.run(runner()) + + +class _RecordingHandler: + """Serve one response per endpoint path and record every request seen.""" + + def __init__(self, responses: dict[str, httpx.Response] | httpx.Response): + self._responses = responses + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if isinstance(self._responses, httpx.Response): + return self._responses + # Keyed by the path after ``/api/v1/``, e.g. "teams/133". + endpoint = request.url.path.split("/api/v1/", 1)[-1] + return self._responses[endpoint] + + @property + def call_count(self) -> int: + return len(self.requests) + + def params_for(self, endpoint: str) -> dict[str, str]: + for request in self.requests: + if request.url.path.endswith(endpoint): + return dict(request.url.params) + raise AssertionError(f"no request was made to {endpoint!r}") + + +def _owned_client(handler, **kwargs) -> AsyncMlb: + """Build an AsyncMlb that owns its client, over a MockTransport. + + 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): + mlb = AsyncMlb(**kwargs) + + _CLIENTS_TO_CLOSE.append(mlb) + return mlb + + +def _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) + + +# --------------------------------------------------------------------------- +# Package-root import +# --------------------------------------------------------------------------- + + +def test_async_mlb_is_importable_from_the_package_root(): + """AsyncMlb is reachable as ``from mlbstatsapi import AsyncMlb``. + + It resolves through the package-root lazy __getattr__, so this also proves + the lazy async export still works and is the same class the module exposes. + """ + from mlbstatsapi import AsyncMlb as RootAsyncMlb + + assert RootAsyncMlb is AsyncMlb + + +def test_async_mlb_is_advertised_by_package_dir(): + """dir(mlbstatsapi) advertises the lazily exported async names.""" + import mlbstatsapi + + assert "AsyncMlb" in dir(mlbstatsapi) + assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + + +# --------------------------------------------------------------------------- +# Lifecycle: context manager, cleanup, cancellation, ownership +# --------------------------------------------------------------------------- def test_async_mlb_context_manager_returns_self(): @@ -17,6 +186,8 @@ async def scenario(): async with mlb as entered: assert entered is mlb + return mlb + run_async(scenario()) @@ -113,3 +284,539 @@ async def worker(): mlb._mlb_adapter_v1.aclose.assert_awaited_once() run_async(scenario()) + + +def test_async_mlb_closes_the_client_it_owns(): + """Exiting the context manager really closes the underlying HTTPX client.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + client = mlb._mlb_adapter_v1._client + + async with mlb: + await mlb.get_team(133) + + assert client.is_closed + + run_async(scenario()) + + +def test_async_mlb_leaves_a_caller_injected_client_open(): + """A client the caller supplied is the caller's to close, not the library's. + + AsyncMlb must pass ownership through to the adapter unchanged: closing an + injected client would break a caller reusing it for its own requests after + the ``async with`` block. + """ + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + async def scenario(): + mlb = AsyncMlb(client=client) + + assert mlb._mlb_adapter_v1._owns_client is False + + async with mlb: + await mlb.get_team(133) + + assert client.is_closed is False + + # Still usable afterwards, which is the point of injecting it. + await client.get("https://statsapi.mlb.com/api/v1/teams/133") + + await client.aclose() + + run_async(scenario()) + + +def test_async_mlb_cleanup_is_idempotent(): + """Repeated cleanup is safe, however the caller mixes the two forms.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + + async with mlb: + await mlb.get_team(133) + + # Already closed by __aexit__; neither of these may raise. + await mlb.aclose() + await mlb.aclose() + + async with mlb: + pass + + run_async(scenario()) + + +# --------------------------------------------------------------------------- +# Endpoints: request construction and parsed results +# --------------------------------------------------------------------------- + + +def test_get_team_requests_the_team_endpoint_and_parses_the_result(): + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(133, season="2022") + + team = run_async(scenario()) + + assert handler.call_count == 1 + assert handler.requests[0].url.path == "/api/v1/teams/133" + assert handler.params_for("teams/133") == {"season": "2022"} + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + + +def test_get_team_returns_none_for_an_unknown_team(): + """A 404 is the adapter's empty result, which the client turns into None.""" + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(1) + + assert run_async(scenario()) is None + + +def test_get_team_returns_none_for_an_empty_payload(): + """An empty 200 body has no team to parse, so there is no Team to return.""" + handler = _RecordingHandler(_json({"teams": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(133) + + assert run_async(scenario()) is None + + +def test_get_teams_sends_sport_id_and_parses_every_team(): + """get_teams defaults to sportId=1 and promotes sport_id into the query.""" + handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams() + + teams = run_async(scenario()) + + assert handler.params_for("teams") == {"sportId": "1"} + assert teams == [ + Team(id=133, link="/api/v1/teams/133", name="Athletics"), + Team(id=134, link="/api/v1/teams/134", name="Team 134"), + ] + + +def test_get_teams_passes_an_explicit_sport_id_and_extra_params(): + handler = _RecordingHandler(_json({"teams": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams(11, season="2021") + + teams = run_async(scenario()) + + assert handler.params_for("teams") == {"sportId": "11", "season": "2021"} + assert teams == [] + + +def test_get_teams_returns_empty_list_for_an_unknown_sport(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams(999) + + assert run_async(scenario()) == [] + + +def test_get_person_requests_the_people_endpoint_and_parses_the_result(): + handler = _RecordingHandler(_json(PERSON_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(660271, hydrate="currentTeam") + + person = run_async(scenario()) + + assert handler.call_count == 1 + assert handler.requests[0].url.path == "/api/v1/people/660271" + assert handler.params_for("people/660271") == {"hydrate": "currentTeam"} + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + + +def test_get_person_returns_none_for_an_unknown_person(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(1) + + assert run_async(scenario()) is None + + +def test_get_person_returns_none_for_an_empty_payload(): + handler = _RecordingHandler(_json({"people": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(660271) + + assert run_async(scenario()) is None + + +def test_get_people_requests_the_sport_players_endpoint(): + """get_people reads sports/{sport_id}/players, exactly like Mlb.get_people. + + The sport id goes in the path, not the query, which is what separates this + endpoint from get_persons' ``people?personIds=`` form. + """ + handler = _RecordingHandler(_json(PEOPLE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people() + + people = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/sports/1/players" + assert handler.params_for("sports/1/players") == {} + assert people == [ + Person(id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani"), + Person(id=605151, link="/api/v1/people/605151", full_name="Person 605151"), + ] + + +def test_get_people_passes_an_explicit_sport_id_and_extra_params(): + handler = _RecordingHandler(_json({"people": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people(11, season="2021") + + people = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/sports/11/players" + assert handler.params_for("sports/11/players") == {"season": "2021"} + assert people == [] + + +def test_get_people_returns_empty_list_for_an_unknown_sport(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people(999) + + assert run_async(scenario()) == [] + + +def test_get_schedule_sends_date_and_sport_id_and_parses_the_result(): + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-10-07") + + schedule = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/schedule" + assert handler.params_for("schedule") == {"date": "2022-10-07", "sportId": "1"} + assert isinstance(schedule, Schedule) + assert schedule == Schedule(**SCHEDULE_PAYLOAD) + + +def test_get_schedule_sends_a_date_range_and_team_id(): + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule( + start_date="2021-08-01", + end_date="2021-08-11", + team_id=133, + ) + + run_async(scenario()) + + assert handler.params_for("schedule") == { + "startDate": "2021-08-01", + "endDate": "2021-08-11", + "teamId": "133", + "sportId": "1", + } + + +def test_get_schedule_allows_game_pks_without_a_date(): + """gamePks is the one way to ask for a schedule with no date at all.""" + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(gamePks=531493) + + run_async(scenario()) + + assert handler.params_for("schedule") == {"gamePks": "531493", "sportId": "1"} + + +def test_get_schedule_without_dates_or_game_pks_makes_no_request(): + """An unanswerable schedule request returns None without touching the API.""" + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule() + + assert run_async(scenario()) is None + assert handler.call_count == 0 + + +def test_get_schedule_returns_none_for_an_unknown_schedule(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-10-07") + + assert run_async(scenario()) is None + + +def test_get_schedule_returns_none_when_no_games_are_scheduled(): + """An empty ``dates`` list is a valid 200 that parses to no Schedule.""" + handler = _RecordingHandler( + _json( + { + "totalItems": 0, + "totalEvents": 0, + "totalGames": 0, + "totalGamesInProgress": 0, + "dates": [], + } + ) + ) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-12-25") + + assert run_async(scenario()) is None + + +# --------------------------------------------------------------------------- +# Parity with the synchronous client +# --------------------------------------------------------------------------- + + +SYNC_PARITY_CASES = [ + ("get_team", (133,), {}), + ("get_team", (133,), {"season": "2022"}), + ("get_teams", (), {}), + ("get_teams", (11,), {"season": "2021"}), + ("get_person", (660271,), {}), + ("get_people", (), {}), + ("get_people", (11,), {"season": "2021"}), + ("get_schedule", (), {"date": "2022-10-07"}), + ("get_schedule", (), {"start_date": "2021-08-01", "end_date": "2021-08-11"}), + ("get_schedule", (), {"team_id": 133, "date": "2022-10-07"}), + ("get_schedule", (), {"gamePks": 531493}), + ("get_schedule", (), {}), +] + + +@pytest.mark.parametrize("method, args, kwargs", SYNC_PARITY_CASES) +def test_request_construction_matches_sync_client(method, args, kwargs): + """AsyncMlb asks the adapter for exactly what Mlb asks for. + + The async surface is a port of the sync one, so a drift in endpoint, + parameter name, or default belongs in this test rather than in a live + failure. Both adapters are stubbed, so nothing here reaches the network. + """ + from unittest.mock import MagicMock + + from mlbstatsapi import Mlb + from mlbstatsapi.mlb_dataadapter import MlbResult + + empty = MlbResult(status_code=200, message=None, data={}) + + sync_mlb = Mlb() + sync_mlb._mlb_adapter_v1.get = MagicMock(return_value=empty) + sync_result = getattr(sync_mlb, method)(*args, **kwargs) + + async def scenario(): + async_mlb = AsyncMlb() + async_mlb._mlb_adapter_v1.get = AsyncMock(return_value=empty) + result = await getattr(async_mlb, method)(*args, **kwargs) + return result, async_mlb._mlb_adapter_v1.get.call_args + + async_result, async_call = run_async(scenario()) + + assert async_call == sync_mlb._mlb_adapter_v1.get.call_args + assert async_result == sync_result + + +def test_signatures_match_the_sync_client(): + """Names, kinds, and defaults are identical to the sync client's.""" + import inspect + + from mlbstatsapi import Mlb + + for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): + sync_params = inspect.signature(getattr(Mlb, name)).parameters + async_params = inspect.signature(getattr(AsyncMlb, name)).parameters + + assert [ + (p.name, p.kind, p.default) for p in sync_params.values() + ] == [ + (p.name, p.kind, p.default) for p in async_params.values() + ], f"{name} drifted from Mlb.{name}" + + +# --------------------------------------------------------------------------- +# Concurrency and the absence of hidden work +# --------------------------------------------------------------------------- + + +def test_concurrent_endpoint_calls_share_one_client_without_crossing_results(): + """Two endpoints on one client keep their own request and their own result. + + Sharing an AsyncClient is the reason AsyncMlb exists; a client that mixed + up two in-flight responses would be worse than useless. + """ + handler = _RecordingHandler( + { + "teams/133": _json(TEAM_PAYLOAD), + "people/660271": _json(PERSON_PAYLOAD), + "schedule": _json(SCHEDULE_PAYLOAD), + } + ) + + async def scenario(): + mlb = _owned_client(handler) + return await asyncio.gather( + mlb.get_team(133), + mlb.get_person(660271), + mlb.get_schedule(date="2022-10-07"), + ) + + team, person, schedule = run_async(scenario()) + + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + assert schedule == Schedule(**SCHEDULE_PAYLOAD) + assert handler.call_count == 3 + + +def test_one_endpoint_call_does_not_block_another_on_the_same_client(): + """A slow endpoint must not serialize the rest of the client. + + Without real concurrency the fast call could not finish while the slow one + is still waiting, so the gate would never open and the test would hit its + timeout instead of passing. + """ + fast_call_completed = asyncio.Event() + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("teams/133"): + await asyncio.wait_for( + fast_call_completed.wait(), + timeout=BLOCKED_REQUEST_TIMEOUT, + ) + return _json(TEAM_PAYLOAD) + return _json(PERSON_PAYLOAD) + + async def scenario(): + mlb = _owned_client(handler) + + slow = asyncio.ensure_future(mlb.get_team(133)) + await asyncio.sleep(0) + + person = await mlb.get_person(660271) + fast_call_completed.set() + + return await slow, person + + team, person = run_async(scenario()) + + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + + +def test_an_endpoint_call_issues_exactly_one_request(): + """No hidden fan-out: one call to one endpoint is one HTTP request. + + A successful call must not prefetch, hydrate, or otherwise widen itself + into extra traffic behind the caller's back. + """ + handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + await mlb.get_teams() + + run_async(scenario()) + + assert handler.call_count == 1 + assert [request.url.path for request in handler.requests] == ["/api/v1/teams"] + + +def test_endpoint_calls_leave_no_background_tasks_behind(): + """No hidden background work: nothing outlives the awaited call. + + A stray task would keep running after the client is closed and surface as + an unpredictable warning or error somewhere else entirely. + """ + handler = _RecordingHandler( + { + "teams": _json(TEAMS_PAYLOAD), + "teams/133": _json(TEAM_PAYLOAD), + "people/660271": _json(PERSON_PAYLOAD), + "sports/1/players": _json(PEOPLE_PAYLOAD), + "schedule": _json(SCHEDULE_PAYLOAD), + } + ) + + async def scenario(): + mlb = _owned_client(handler) + + before = asyncio.all_tasks() + + # Every endpoint on the client, so none of them may leak a task. + await mlb.get_team(133) + await mlb.get_teams() + await mlb.get_person(660271) + await mlb.get_people() + await mlb.get_schedule(date="2022-10-07") + await mlb.aclose() + + # Let anything that was scheduled get a chance to appear. + await asyncio.sleep(0) + + assert asyncio.all_tasks() - before == set() + + run_async(scenario()) + + +def test_construction_starts_no_work(): + """Building a client is inert: no request, no task, until an endpoint is called.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + before = asyncio.all_tasks() + + _owned_client(handler) + + await asyncio.sleep(0) + + assert handler.call_count == 0 + assert asyncio.all_tasks() - before == set() + + run_async(scenario()) From 3a6925658350f866718e108a6f0ea8304d558860 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:13:18 +0000 Subject: [PATCH 5/8] test: focus the AsyncMlb suite and fix its resource cleanup The suite had grown to 48 tests over 838 lines, well past what the #303 vertical slice is worth. It is now 20 tests over 443 lines, covering the public import, the lifecycle contract, per-endpoint request construction and parsed results, signature parity, and one concurrency case. What went, and why: * Exhaustive schedule argument combinations collapse to one representative date-range-with-team case plus the no-selector case. * Six empty-result permutations become two parametrized tests over the two ways an endpoint comes back with nothing: 404 and an empty 200. * The twelve-case request-construction matrix is gone. Each endpoint test now derives its expected endpoint and params from Mlb itself through assert_matches_sync(), so drift is still caught where the endpoint is asserted rather than in a separate matrix. * The asyncio.all_tasks() tests asserted an implementation detail and are dropped. The standalone fan-out test is dropped too: _Handler asserts the request count when a test reads .request, so every endpoint test rules out fan-out on its own. * Status mapping and transport behavior belong to the adapter suite and the #302 matrix; payload parsing belongs to tests/parsers/. Neither is re-asserted here. Cleanup is also correct now, which it was not before. Tests built a bare AsyncMlb, which eagerly opens an HTTPX client, then replaced the adapter's aclose with a mock, so the client was never closed; the sync parity test leaked a requests Session per case. Eighteen tests leaked. Rather than a global registry, teardown is a local async_mlb() context manager that closes the adapter's client in its finally. It closes the client directly instead of calling AsyncMlb.aclose(), because the one test that mocks aclose would otherwise still leak. Verified with an instrumented run that counts unclosed clients and sessions: eighteen leaking tests before, zero after. Seven mutations of the client -- dropped sportId, get_people reverting to the people endpoint, a lost schedule short-circuit, a no-op aclose, aclose closing an injected client, a swallowed cleanup failure, and a drifted default -- all still fail, so the smaller suite protects what the larger one did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013La4LovbUtWoZSrD3iQQKu --- tests/test_async_mlb.py | 853 +++++++++++----------------------------- 1 file changed, 237 insertions(+), 616 deletions(-) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 5aa1219f..cafb2637 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1,22 +1,21 @@ """Focused offline tests for the AsyncMlb client (issue #303). -Covers what the vertical slice actually promises: the package-root import, the -async context-manager and cleanup contract, and — for each endpoint on the -client — the request it builds and the parsed value it returns. - -AsyncMlb is deliberately thin: HTTP behavior belongs to AsyncMlbDataAdapter and -is asserted in tests/test_async_mlb_dataadapter.py, with the exhaustive -transport-contract matrix in #302. Nothing here re-tests retries, status -mapping, timeouts, or exception translation. What is tested here instead is -that the client hands the adapter the right endpoint and params, hands the -response to the shared parsers, and adds nothing of its own between the two. - -The endpoint tests therefore drive the real adapter over an -``httpx.MockTransport`` rather than mocking the adapter away, so an endpoint -that stopped producing a real HTTP request would fail rather than pass against -a mock. Request construction is additionally pinned to the synchronous client -in test_request_construction_matches_sync_client, because the async surface is -only correct insofar as it matches ``Mlb``. +AsyncMlb is deliberately thin: it builds a request, hands it to +AsyncMlbDataAdapter, and hands the response to a shared parser. So this module +asserts only what the client itself is responsible for — the package-root +import, the async lifecycle contract, and, per endpoint, the request built and +the value parsed back. + +Everything below the client belongs to other modules and is not retested here: +HTTP status mapping, retries, timeouts and exception translation live in +tests/test_async_mlb_dataadapter.py and the #302 transport matrix, and payload +parsing lives in tests/parsers/. + +Endpoint tests drive the real adapter over an ``httpx.MockTransport`` rather +than mocking the adapter away, so a method that stopped issuing a request would +fail rather than pass against a mock. Where an endpoint exists to mirror one on +the synchronous client, the expected request is derived from ``Mlb`` itself +rather than hardcoded, so drift shows up here instead of in production. These tests must not contact the live MLB API. """ @@ -24,47 +23,30 @@ from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, patch +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock import pytest -# The endpoint tests drive the real HTTPX-backed adapter, so a sync-only -# install has nothing here to run. Skipping at collection keeps -# ``pytest tests/`` working without the ``async`` extra instead of erroring on -# the import, matching tests/test_async_mlb_dataadapter.py. +# These tests drive the real HTTPX-backed adapter, so a sync-only install has +# nothing here to run. Skipping at collection keeps ``pytest tests/`` working +# without the ``async`` extra instead of erroring on the import. The +# optional-dependency contract itself is asserted in +# tests/test_async_optional_dependency.py. httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") +from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.people import Person # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 -# Patched only while a client is constructed, so AsyncMlb builds its own -# adapter and client through the production path and only the transport is -# swapped. Mirrors tests/test_async_mlb_dataadapter.py. -CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" - -# Failure guard for the concurrency test: a request that should never wait is -# bounded so a serializing regression fails fast instead of hanging CI. -BLOCKED_REQUEST_TIMEOUT = 10 - TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} -TEAMS_PAYLOAD = { - "teams": [ - {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}, - {"id": 134, "link": "/api/v1/teams/134", "name": "Team 134"}, - ] -} PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } -PEOPLE_PAYLOAD = { - "people": [ - {"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}, - {"id": 605151, "link": "/api/v1/people/605151", "fullName": "Person 605151"}, - ] -} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -82,27 +64,28 @@ ], } +EXPECTED_TEAM = Team(id=133, link="/api/v1/teams/133", name="Athletics") +EXPECTED_PERSON = Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" +) -# Clients built by _owned_client(); run_async() closes them inside the same -# event loop that used them, so no AsyncClient is left open by a test. -_CLIENTS_TO_CLOSE: list[AsyncMlb] = [] +# The two ways an endpoint legitimately comes back with nothing to parse. +NO_RESULT_RESPONSES = { + "404": httpx.Response(404, json={}), + "empty 200": httpx.Response(200, json={}), +} -def run_async(coro): - async def runner(): - try: - return await coro - finally: - while _CLIENTS_TO_CLOSE: - await _CLIENTS_TO_CLOSE.pop().aclose() - - return asyncio.run(runner()) +def _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) -class _RecordingHandler: - """Serve one response per endpoint path and record every request seen.""" +class _Handler: + """Serve a canned response and record the requests that arrive.""" - def __init__(self, responses: dict[str, httpx.Response] | httpx.Response): + def __init__(self, responses: httpx.Response | dict[str, httpx.Response]): + # A bare Response answers any path; a dict is keyed by endpoint, + # e.g. {"teams/133": ...}. self._responses = responses self.requests: list[httpx.Request] = [] @@ -110,713 +93,351 @@ def __call__(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) if isinstance(self._responses, httpx.Response): return self._responses - # Keyed by the path after ``/api/v1/``, e.g. "teams/133". - endpoint = request.url.path.split("/api/v1/", 1)[-1] - return self._responses[endpoint] + return self._responses[request.url.path.split("/api/v1/", 1)[-1]] @property - def call_count(self) -> int: - return len(self.requests) + def request(self) -> httpx.Request: + """The single request the call made. - def params_for(self, endpoint: str) -> dict[str, str]: - for request in self.requests: - if request.url.path.endswith(endpoint): - return dict(request.url.params) - raise AssertionError(f"no request was made to {endpoint!r}") + Asserting the count here means every test using it also rules out a + client that quietly fanned one call out into several. + """ + assert len(self.requests) == 1, f"expected 1 request, got {len(self.requests)}" + return self.requests[0] -def _owned_client(handler, **kwargs) -> AsyncMlb: - """Build an AsyncMlb that owns its client, over a MockTransport. +@asynccontextmanager +async def async_mlb(handler: _Handler): + """Yield an AsyncMlb whose own client talks to ``handler``, then close it. - Call this from inside a run_async() scenario; run_async() closes what it - creates. + 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. """ real_async_client = httpx.AsyncClient def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: return real_async_client( - transport=httpx.MockTransport(handler), - **client_kwargs, + transport=httpx.MockTransport(handler), **client_kwargs ) - with patch(CLIENT_TARGET, mock_transport_client): - mlb = AsyncMlb(**kwargs) - - _CLIENTS_TO_CLOSE.append(mlb) - return mlb - - -def _json(payload: dict) -> httpx.Response: - return httpx.Response(200, json=payload) - + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient", + mock_transport_client, + ) + mlb = AsyncMlb() -# --------------------------------------------------------------------------- -# Package-root import -# --------------------------------------------------------------------------- + try: + yield mlb + finally: + await mlb._mlb_adapter_v1._client.aclose() -def test_async_mlb_is_importable_from_the_package_root(): - """AsyncMlb is reachable as ``from mlbstatsapi import AsyncMlb``. +def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: + """Return the endpoint and params ``Mlb`` builds for a call. - It resolves through the package-root lazy __getattr__, so this also proves - the lazy async export still works and is the same class the module exposes. + The adapter is stubbed, so this reaches no network; it just reads back what + the synchronous client asked for. """ - from mlbstatsapi import AsyncMlb as RootAsyncMlb + with Mlb() as sync_mlb: + sync_mlb._mlb_adapter_v1.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 - assert RootAsyncMlb is AsyncMlb + return call.kwargs["endpoint"], call.kwargs["ep_params"] -def test_async_mlb_is_advertised_by_package_dir(): - """dir(mlbstatsapi) advertises the lazily exported async names.""" - import mlbstatsapi +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 "AsyncMlb" in dir(mlbstatsapi) - assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + 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()} # --------------------------------------------------------------------------- -# Lifecycle: context manager, cleanup, cancellation, ownership +# Public API # --------------------------------------------------------------------------- -def test_async_mlb_context_manager_returns_self(): - async def scenario(): - mlb = AsyncMlb() - - async with mlb as entered: - assert entered is mlb - - return mlb - - run_async(scenario()) - - -def test_async_mlb_aclose_delegates_to_adapter(): - async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() +def test_async_mlb_is_importable_from_the_package_root(): + """AsyncMlb resolves through the package root's lazy async export.""" + from mlbstatsapi import AsyncMlb as RootAsyncMlb - await mlb.aclose() + assert RootAsyncMlb is AsyncMlb - mlb._mlb_adapter_v1.aclose.assert_awaited_once() - run_async(scenario()) +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- -def test_async_mlb_context_manager_closes_on_normal_exit(): +def test_aenter_returns_self(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb as entered: + assert entered is mlb - async with mlb: - pass + asyncio.run(scenario()) - mlb._mlb_adapter_v1.aclose.assert_awaited_once() - run_async(scenario()) - - -def test_async_mlb_context_manager_closes_when_body_raises(): +def test_context_exit_closes_the_owned_client(): async def scenario(): - mlb = AsyncMlb() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - mlb._mlb_adapter_v1.aclose = AsyncMock() - - with pytest.raises(ValueError, match="boom"): async with mlb: - raise ValueError("boom") + await mlb.get_team(133) - mlb._mlb_adapter_v1.aclose.assert_awaited_once() + assert client.is_closed - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_preserves_original_exception_if_cleanup_fails(): +def test_context_exit_closes_the_owned_client_when_the_body_raises(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock( - side_effect=RuntimeError("cleanup failed") - ) - - with pytest.raises(ValueError, match="original"): - async with mlb: - raise ValueError("original") + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - run_async(scenario()) + with pytest.raises(ValueError, match="boom"): + async with mlb: + raise ValueError("boom") + assert client.is_closed -def test_async_mlb_cleanup_failure_raises_when_no_original_exception(): - async def scenario(): - mlb = AsyncMlb() + asyncio.run(scenario()) - mlb._mlb_adapter_v1.aclose = AsyncMock( - side_effect=RuntimeError("cleanup failed") - ) - with pytest.raises(RuntimeError, match="cleanup failed"): - async with mlb: - pass - - run_async(scenario()) +def test_cleanup_failure_does_not_replace_the_original_exception(): + """A failure while closing must not mask what actually went wrong. + With no original exception to protect, the cleanup failure is the only + thing to report and does surface. + """ -def test_async_mlb_preserves_cancellation_during_cleanup(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() - - async def worker(): - async with mlb: - await asyncio.sleep(60) - - task = asyncio.create_task(worker()) - - await asyncio.sleep(0) - task.cancel() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) - with pytest.raises(asyncio.CancelledError): - await task + with pytest.raises(ValueError, match="original"): + async with mlb: + raise ValueError("original") - mlb._mlb_adapter_v1.aclose.assert_awaited_once() + with pytest.raises(RuntimeError, match="cleanup failed"): + async with mlb: + pass - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_closes_the_client_it_owns(): - """Exiting the context manager really closes the underlying HTTPX client.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) +def test_cancellation_is_preserved_through_cleanup(): + """Cleanup must not swallow a cancellation that arrived from outside.""" async def scenario(): - mlb = _owned_client(handler) - client = mlb._mlb_adapter_v1._client + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - async with mlb: - await mlb.get_team(133) + async def worker(): + async with mlb: + await asyncio.sleep(60) - assert client.is_closed + task = asyncio.create_task(worker()) + await asyncio.sleep(0) + task.cancel() - run_async(scenario()) + with pytest.raises(asyncio.CancelledError): + await task + assert client.is_closed -def test_async_mlb_leaves_a_caller_injected_client_open(): - """A client the caller supplied is the caller's to close, not the library's. + asyncio.run(scenario()) - AsyncMlb must pass ownership through to the adapter unchanged: closing an - injected client would break a caller reusing it for its own requests after - the ``async with`` block. - """ - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + +def test_caller_injected_client_is_left_open(): + """A client the caller supplied is the caller's to close, not the library's.""" + handler = _Handler(_json(TEAM_PAYLOAD)) client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) async def scenario(): - mlb = AsyncMlb(client=client) - - assert mlb._mlb_adapter_v1._owns_client is False - - async with mlb: - await mlb.get_team(133) - - assert client.is_closed is False - - # Still usable afterwards, which is the point of injecting it. - await client.get("https://statsapi.mlb.com/api/v1/teams/133") + try: + async with AsyncMlb(client=client) as mlb: + await mlb.get_team(133) - await client.aclose() + assert client.is_closed is False + finally: + await client.aclose() - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_cleanup_is_idempotent(): - """Repeated cleanup is safe, however the caller mixes the two forms.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) +def test_aclose_is_idempotent(): + """Closing more than once, however the caller mixes the forms, is safe.""" async def scenario(): - mlb = _owned_client(handler) - - async with mlb: - await mlb.get_team(133) - - # Already closed by __aexit__; neither of these may raise. - await mlb.aclose() - await mlb.aclose() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb: + await mlb.get_team(133) - async with mlb: - pass + await mlb.aclose() + await mlb.aclose() - run_async(scenario()) + asyncio.run(scenario()) # --------------------------------------------------------------------------- -# Endpoints: request construction and parsed results +# Endpoints # --------------------------------------------------------------------------- def test_get_team_requests_the_team_endpoint_and_parses_the_result(): - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(133, season="2022") - - team = run_async(scenario()) - - assert handler.call_count == 1 - assert handler.requests[0].url.path == "/api/v1/teams/133" - assert handler.params_for("teams/133") == {"season": "2022"} - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - - -def test_get_team_returns_none_for_an_unknown_team(): - """A 404 is the adapter's empty result, which the client turns into None.""" - handler = _RecordingHandler(httpx.Response(404, json={})) + handler = _Handler(_json(TEAM_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(1) - - assert run_async(scenario()) is None + async with async_mlb(handler) as mlb: + return await mlb.get_team(133, season="2022") + team = asyncio.run(scenario()) -def test_get_team_returns_none_for_an_empty_payload(): - """An empty 200 body has no team to parse, so there is no Team to return.""" - handler = _RecordingHandler(_json({"teams": []})) + assert_matches_sync(handler.request, "get_team", 133, season="2022") + assert team == EXPECTED_TEAM - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(133) - assert run_async(scenario()) is None - - -def test_get_teams_sends_sport_id_and_parses_every_team(): - """get_teams defaults to sportId=1 and promotes sport_id into the query.""" - handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_returns_none_when_there_is_no_team(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams() - - teams = run_async(scenario()) + async with async_mlb(handler) as mlb: + return await mlb.get_team(1) - assert handler.params_for("teams") == {"sportId": "1"} - assert teams == [ - Team(id=133, link="/api/v1/teams/133", name="Athletics"), - Team(id=134, link="/api/v1/teams/134", name="Team 134"), - ] + assert asyncio.run(scenario()) is None -def test_get_teams_passes_an_explicit_sport_id_and_extra_params(): - handler = _RecordingHandler(_json({"teams": []})) +def test_get_person_requests_the_person_endpoint_and_parses_the_result(): + handler = _Handler(_json(PERSON_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams(11, season="2021") + async with async_mlb(handler) as mlb: + return await mlb.get_person(660271, hydrate="currentTeam") - teams = run_async(scenario()) + person = asyncio.run(scenario()) - assert handler.params_for("teams") == {"sportId": "11", "season": "2021"} - assert teams == [] + assert_matches_sync(handler.request, "get_person", 660271, hydrate="currentTeam") + assert person == EXPECTED_PERSON -def test_get_teams_returns_empty_list_for_an_unknown_sport(): - handler = _RecordingHandler(httpx.Response(404, json={})) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_person_returns_none_when_there_is_no_person(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams(999) + async with async_mlb(handler) as mlb: + return await mlb.get_person(1) - assert run_async(scenario()) == [] + assert asyncio.run(scenario()) is None -def test_get_person_requests_the_people_endpoint_and_parses_the_result(): - handler = _RecordingHandler(_json(PERSON_PAYLOAD)) +def test_get_schedule_requests_the_schedule_endpoint_and_parses_the_result(): + """A date range with a team is representative of the schedule params.""" + handler = _Handler(_json(SCHEDULE_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(660271, hydrate="currentTeam") + async with async_mlb(handler) as mlb: + return await mlb.get_schedule( + start_date="2021-08-01", end_date="2021-08-11", team_id=133 + ) - person = run_async(scenario()) + schedule = asyncio.run(scenario()) - assert handler.call_count == 1 - assert handler.requests[0].url.path == "/api/v1/people/660271" - assert handler.params_for("people/660271") == {"hydrate": "currentTeam"} - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + assert_matches_sync( + handler.request, + "get_schedule", + start_date="2021-08-01", + end_date="2021-08-11", + team_id=133, ) - - -def test_get_person_returns_none_for_an_unknown_person(): - handler = _RecordingHandler(httpx.Response(404, json={})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(1) - - assert run_async(scenario()) is None - - -def test_get_person_returns_none_for_an_empty_payload(): - handler = _RecordingHandler(_json({"people": []})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(660271) - - assert run_async(scenario()) is None - - -def test_get_people_requests_the_sport_players_endpoint(): - """get_people reads sports/{sport_id}/players, exactly like Mlb.get_people. - - The sport id goes in the path, not the query, which is what separates this - endpoint from get_persons' ``people?personIds=`` form. - """ - handler = _RecordingHandler(_json(PEOPLE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people() - - people = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/sports/1/players" - assert handler.params_for("sports/1/players") == {} - assert people == [ - Person(id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani"), - Person(id=605151, link="/api/v1/people/605151", full_name="Person 605151"), - ] - - -def test_get_people_passes_an_explicit_sport_id_and_extra_params(): - handler = _RecordingHandler(_json({"people": []})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people(11, season="2021") - - people = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/sports/11/players" - assert handler.params_for("sports/11/players") == {"season": "2021"} - assert people == [] - - -def test_get_people_returns_empty_list_for_an_unknown_sport(): - handler = _RecordingHandler(httpx.Response(404, json={})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people(999) - - assert run_async(scenario()) == [] - - -def test_get_schedule_sends_date_and_sport_id_and_parses_the_result(): - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-10-07") - - schedule = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/schedule" - assert handler.params_for("schedule") == {"date": "2022-10-07", "sportId": "1"} - assert isinstance(schedule, Schedule) assert schedule == Schedule(**SCHEDULE_PAYLOAD) -def test_get_schedule_sends_a_date_range_and_team_id(): - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule( - start_date="2021-08-01", - end_date="2021-08-11", - team_id=133, - ) - - run_async(scenario()) - - assert handler.params_for("schedule") == { - "startDate": "2021-08-01", - "endDate": "2021-08-11", - "teamId": "133", - "sportId": "1", - } - - -def test_get_schedule_allows_game_pks_without_a_date(): - """gamePks is the one way to ask for a schedule with no date at all.""" - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) +def test_get_schedule_without_a_selector_returns_none_without_requesting(): + """No date and no gamePks is unanswerable, so nothing is sent.""" + handler = _Handler(_json(SCHEDULE_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(gamePks=531493) - - run_async(scenario()) + async with async_mlb(handler) as mlb: + return await mlb.get_schedule() - assert handler.params_for("schedule") == {"gamePks": "531493", "sportId": "1"} + assert asyncio.run(scenario()) is None + assert handler.requests == [] -def test_get_schedule_without_dates_or_game_pks_makes_no_request(): - """An unanswerable schedule request returns None without touching the API.""" - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) +def test_get_teams_request_matches_the_sync_client(): + """get_teams promotes sport_id into sportId exactly as Mlb.get_teams does.""" + handler = _Handler(_json({"teams": []})) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule() + async with async_mlb(handler) as mlb: + return await mlb.get_teams(11, season="2021") - assert run_async(scenario()) is None - assert handler.call_count == 0 + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_teams", 11, season="2021") -def test_get_schedule_returns_none_for_an_unknown_schedule(): - handler = _RecordingHandler(httpx.Response(404, json={})) +def test_get_people_request_matches_the_sync_client(): + """get_people reads sports/{sport_id}/players, like Mlb.get_people. - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-10-07") - - assert run_async(scenario()) is None - - -def test_get_schedule_returns_none_when_no_games_are_scheduled(): - """An empty ``dates`` list is a valid 200 that parses to no Schedule.""" - handler = _RecordingHandler( - _json( - { - "totalItems": 0, - "totalEvents": 0, - "totalGames": 0, - "totalGamesInProgress": 0, - "dates": [], - } - ) - ) + The sport id belongs in the path, not the query; sending it as personIds + against ``people`` would be the get_persons endpoint instead. + """ + handler = _Handler(_json({"people": []})) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-12-25") + async with async_mlb(handler) as mlb: + return await mlb.get_people(11, season="2021") - assert run_async(scenario()) is None + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_people", 11, season="2021") # --------------------------------------------------------------------------- -# Parity with the synchronous client +# Parity and concurrency # --------------------------------------------------------------------------- -SYNC_PARITY_CASES = [ - ("get_team", (133,), {}), - ("get_team", (133,), {"season": "2022"}), - ("get_teams", (), {}), - ("get_teams", (11,), {"season": "2021"}), - ("get_person", (660271,), {}), - ("get_people", (), {}), - ("get_people", (11,), {"season": "2021"}), - ("get_schedule", (), {"date": "2022-10-07"}), - ("get_schedule", (), {"start_date": "2021-08-01", "end_date": "2021-08-11"}), - ("get_schedule", (), {"team_id": 133, "date": "2022-10-07"}), - ("get_schedule", (), {"gamePks": 531493}), - ("get_schedule", (), {}), -] - - -@pytest.mark.parametrize("method, args, kwargs", SYNC_PARITY_CASES) -def test_request_construction_matches_sync_client(method, args, kwargs): - """AsyncMlb asks the adapter for exactly what Mlb asks for. - - The async surface is a port of the sync one, so a drift in endpoint, - parameter name, or default belongs in this test rather than in a live - failure. Both adapters are stubbed, so nothing here reaches the network. - """ - from unittest.mock import MagicMock - - from mlbstatsapi import Mlb - from mlbstatsapi.mlb_dataadapter import MlbResult - - empty = MlbResult(status_code=200, message=None, data={}) - - sync_mlb = Mlb() - sync_mlb._mlb_adapter_v1.get = MagicMock(return_value=empty) - sync_result = getattr(sync_mlb, method)(*args, **kwargs) - - async def scenario(): - async_mlb = AsyncMlb() - async_mlb._mlb_adapter_v1.get = AsyncMock(return_value=empty) - result = await getattr(async_mlb, method)(*args, **kwargs) - return result, async_mlb._mlb_adapter_v1.get.call_args - - async_result, async_call = run_async(scenario()) - - assert async_call == sync_mlb._mlb_adapter_v1.get.call_args - assert async_result == sync_result - - -def test_signatures_match_the_sync_client(): - """Names, kinds, and defaults are identical to the sync client's.""" +def test_public_signatures_match_the_sync_client(): + """Argument names, kinds, and defaults must not drift from Mlb's.""" import inspect - from mlbstatsapi import Mlb - for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters - assert [ - (p.name, p.kind, p.default) for p in sync_params.values() - ] == [ + assert [(p.name, p.kind, p.default) for p in sync_params.values()] == [ (p.name, p.kind, p.default) for p in async_params.values() - ], f"{name} drifted from Mlb.{name}" + ], f"AsyncMlb.{name} drifted from Mlb.{name}" -# --------------------------------------------------------------------------- -# Concurrency and the absence of hidden work -# --------------------------------------------------------------------------- - - -def test_concurrent_endpoint_calls_share_one_client_without_crossing_results(): - """Two endpoints on one client keep their own request and their own result. - - Sharing an AsyncClient is the reason AsyncMlb exists; a client that mixed - up two in-flight responses would be worse than useless. - """ - handler = _RecordingHandler( +def test_concurrent_calls_on_one_client_do_not_cross_results(): + """Sharing one client is the point of AsyncMlb; results must stay distinct.""" + handler = _Handler( { "teams/133": _json(TEAM_PAYLOAD), "people/660271": _json(PERSON_PAYLOAD), - "schedule": _json(SCHEDULE_PAYLOAD), } ) async def scenario(): - mlb = _owned_client(handler) - return await asyncio.gather( - mlb.get_team(133), - mlb.get_person(660271), - mlb.get_schedule(date="2022-10-07"), - ) - - team, person, schedule = run_async(scenario()) - - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" - ) - assert schedule == Schedule(**SCHEDULE_PAYLOAD) - assert handler.call_count == 3 - - -def test_one_endpoint_call_does_not_block_another_on_the_same_client(): - """A slow endpoint must not serialize the rest of the client. - - Without real concurrency the fast call could not finish while the slow one - is still waiting, so the gate would never open and the test would hit its - timeout instead of passing. - """ - fast_call_completed = asyncio.Event() - - async def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("teams/133"): - await asyncio.wait_for( - fast_call_completed.wait(), - timeout=BLOCKED_REQUEST_TIMEOUT, - ) - return _json(TEAM_PAYLOAD) - return _json(PERSON_PAYLOAD) - - async def scenario(): - mlb = _owned_client(handler) - - slow = asyncio.ensure_future(mlb.get_team(133)) - await asyncio.sleep(0) - - person = await mlb.get_person(660271) - fast_call_completed.set() - - return await slow, person - - team, person = run_async(scenario()) - - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" - ) - - -def test_an_endpoint_call_issues_exactly_one_request(): - """No hidden fan-out: one call to one endpoint is one HTTP request. - - A successful call must not prefetch, hydrate, or otherwise widen itself - into extra traffic behind the caller's back. - """ - handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - await mlb.get_teams() - - run_async(scenario()) - - assert handler.call_count == 1 - assert [request.url.path for request in handler.requests] == ["/api/v1/teams"] - - -def test_endpoint_calls_leave_no_background_tasks_behind(): - """No hidden background work: nothing outlives the awaited call. - - A stray task would keep running after the client is closed and surface as - an unpredictable warning or error somewhere else entirely. - """ - handler = _RecordingHandler( - { - "teams": _json(TEAMS_PAYLOAD), - "teams/133": _json(TEAM_PAYLOAD), - "people/660271": _json(PERSON_PAYLOAD), - "sports/1/players": _json(PEOPLE_PAYLOAD), - "schedule": _json(SCHEDULE_PAYLOAD), - } - ) - - async def scenario(): - mlb = _owned_client(handler) - - before = asyncio.all_tasks() - - # Every endpoint on the client, so none of them may leak a task. - await mlb.get_team(133) - await mlb.get_teams() - await mlb.get_person(660271) - await mlb.get_people() - await mlb.get_schedule(date="2022-10-07") - await mlb.aclose() - - # Let anything that was scheduled get a chance to appear. - await asyncio.sleep(0) - - assert asyncio.all_tasks() - before == set() - - run_async(scenario()) - - -def test_construction_starts_no_work(): - """Building a client is inert: no request, no task, until an endpoint is called.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) - - async def scenario(): - before = asyncio.all_tasks() - - _owned_client(handler) - - await asyncio.sleep(0) + async with async_mlb(handler) as mlb: + return await asyncio.gather(mlb.get_team(133), mlb.get_person(660271)) - assert handler.call_count == 0 - assert asyncio.all_tasks() - before == set() + team, person = asyncio.run(scenario()) - run_async(scenario()) + assert team == EXPECTED_TEAM + assert person == EXPECTED_PERSON From 28029ab75ec5832c565855bf20a351bf00bb36cf Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 04:29:01 -0700 Subject: [PATCH 6/8] refactor(async): move schedule params to helper --- mlbstatsapi/_helpers/__init__.py | 0 mlbstatsapi/_helpers/schedule.py | 22 ++++++++++++++++++++++ mlbstatsapi/_parsers/schedules.py | 23 ----------------------- mlbstatsapi/async_mlb.py | 3 ++- 4 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 mlbstatsapi/_helpers/__init__.py create mode 100644 mlbstatsapi/_helpers/schedule.py diff --git a/mlbstatsapi/_helpers/__init__.py b/mlbstatsapi/_helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mlbstatsapi/_helpers/schedule.py b/mlbstatsapi/_helpers/schedule.py new file mode 100644 index 00000000..0d6b4a5b --- /dev/null +++ b/mlbstatsapi/_helpers/schedule.py @@ -0,0 +1,22 @@ +def build_schedule_params( + date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + sport_id: int = 1, + team_id: int | None = None, + **params, +) -> dict | None: + 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 + elif "gamePks" not in params: + return None + + if team_id: + params["teamId"] = team_id + + params["sportId"] = sport_id + + return params diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 1126df21..39fc0d6b 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -7,26 +7,3 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) - -def build_schedule_params( - date: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - sport_id: int = 1, - team_id: int | None = None, - **params, -) -> dict | None: - 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 - elif "gamePks" not in params: - return None - - if team_id: - params["teamId"] = team_id - - params["sportId"] = sport_id - - return params diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 080e4393..12cac734 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,8 +5,9 @@ import logging from typing import TYPE_CHECKING +from ._helpers.schedule import build_schedule_params from ._parsers.people import parse_person, parse_people -from ._parsers.schedules import parse_schedule, build_schedule_params +from ._parsers.schedules import parse_schedule from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType From 90eceb5ef568e9f31f25ed285bdcc48b150e1531 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 07:55:34 -0700 Subject: [PATCH 7/8] test: freeze AsyncMlb public API contract --- docs/public-api.md | 87 +++++++++++++++++++++++++++++++------- tests/test_public_api.py | 90 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 157 insertions(+), 20 deletions(-) diff --git a/docs/public-api.md b/docs/public-api.md index 923fe084..0e44a0dc 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -4,9 +4,10 @@ This document is the authoritative public API contract for the `python-mlb-statsapi` **1.x** series. It defines which package-root symbols, constructor signatures, exception and -warning relationships, Session ownership rules, and `Mlb` endpoint methods are -supported after version 1.0. Maintainers should use this document when deciding -whether a change is a patch, a minor release, or a major release. +warning relationships, resource ownership rules, and `Mlb` and `AsyncMlb` +endpoint methods are supported after version 1.0. Maintainers should use this +document when deciding whether a change is a patch, a minor release, or a major +release. This package is an unofficial wrapper for the MLB Stats API and is not affiliated with Major League Baseball. @@ -78,9 +79,10 @@ from mlbstatsapi import ( ) ``` -The symbols above are available in every install. `AsyncMlbDataAdapter` is -equally public, but it resolves only when the optional `async` extra is -installed; see [Optional async support](#optional-async-support). +The symbols above are available in every install. `AsyncMlb` and +`AsyncMlbDataAdapter` are equally public, but they resolve only when the +optional `async` extra is installed; see +[Optional async support](#optional-async-support). ### Classification of package-root symbols @@ -91,6 +93,7 @@ resolving it needs an optional dependency. | Symbol | Status | Availability | | --- | --- | --- | | `Mlb` | Public and stable in 1.x | Always available | +| `AsyncMlb` | Public and stable in 1.x | Requires the optional `async` extra | | `MlbDataAdapter` | Public and stable in 1.x | Always available | | `AsyncMlbDataAdapter` | Public and stable in 1.x | Requires the optional `async` extra | | `MlbResult` | Public and stable in 1.x | Always available | @@ -104,10 +107,11 @@ resolving it needs an optional dependency. | `return_splits` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | | `get_stat_attributes` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | -`AsyncMlbDataAdapter` is supported 1.x API on the same terms as the synchronous -symbols: it will not be removed or renamed during the series, and its documented -behavior stays compatible. Only its availability is conditional, because its -HTTP dependency ships with the `async` extra. See +`AsyncMlb` and `AsyncMlbDataAdapter` are supported 1.x API on the same terms as +the synchronous symbols: they will not be removed or renamed during the +series, and their documented behavior stays compatible. Only their +availability is conditional, because their HTTP dependency ships with the +`async` extra. See [Optional async support](#optional-async-support). No package-root symbol is marked deprecated in version 1.0. Deprecation requires @@ -150,9 +154,9 @@ accidental submodule names (for example, a documented deprecation period). ## Optional async support -`AsyncMlbDataAdapter` is a public package-root symbol, like `MlbDataAdapter`, -and appears in the classification table above. Its HTTP dependency is optional -and installed with the `async` extra: +`AsyncMlb` and `AsyncMlbDataAdapter` are public package-root symbols, like +`Mlb` and `MlbDataAdapter`, and appear in the classification table above. Their +HTTP dependency is optional and installed with the `async` extra: ```bash pip install "python-mlb-statsapi[async]" @@ -161,7 +165,7 @@ pip install "python-mlb-statsapi[async]" With the extra installed: ```python -from mlbstatsapi import AsyncMlbDataAdapter +from mlbstatsapi import AsyncMlb, AsyncMlbDataAdapter ``` Async symbols are resolved on first access, so the optional dependency is not @@ -228,6 +232,61 @@ Session. Most endpoint methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. Standalone `MlbDataAdapter(ver="v1")` and `MlbDataAdapter(ver="v1.1")` remain supported. +## AsyncMlb public client + +`AsyncMlb` is the public asynchronous client and requires the optional `async` +extra. + +### Constructor + +```text +AsyncMlb( + hostname="statsapi.mlb.com", + logger=None, + timeout=(3.05, 30.0), + client=None, + *, + strict_http=True, +) +``` + +Parameter order and default values above are part of the API. +`strict_http` is keyword-only. + +### Lifecycle + +* `async with AsyncMlb(...) as mlb` returns the `AsyncMlb` instance itself +* `AsyncMlb.__aexit__` awaits cleanup +* Explicit cleanup with `await mlb.aclose()` is supported +* Repeated `aclose()` calls are safe +* Library-owned async clients are closed +* Caller-injected async clients remain caller-owned and open + +### Concurrency + +One `AsyncMlb` instance supports concurrent in-flight requests on the same +event loop. Concurrency is caller-controlled. Cross-event-loop use is not +promised. + +### Endpoint methods + +The currently supported awaitable endpoint methods are: + +```text +get_team(team_id: int, **params) +get_teams(sport_id: int = 1, **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, +) +``` + ## Low-level adapter `MlbDataAdapter` is the public low-level HTTP adapter. diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2575c3a9..b76164dc 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -2,7 +2,8 @@ These tests freeze the supported package-root symbols, constructor signatures, exception and warning inheritance, Session ownership guarantees, and the -explicit ``Mlb`` public-method manifest documented in ``docs/public-api.md``. +explicit ``Mlb`` and ``AsyncMlb`` public-method manifests documented in +``docs/public-api.md``. The package-root surface is split across two manifests because "public API" and "available without optional dependencies" are different questions. Everything in @@ -75,6 +76,7 @@ # ``async`` extra (HTTPX). These are public and stable exactly like the symbols # above; only their availability is conditional. See docs/public-api.md. OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = ( + "AsyncMlb", "AsyncMlbDataAdapter", ) @@ -131,7 +133,11 @@ def _async_extra_installed() -> bool: def _normalize_annotation(annotation: Any) -> str: - rendered = inspect.formatannotation(annotation) + rendered = ( + annotation + if isinstance(annotation, str) + else inspect.formatannotation(annotation) + ) for legacy, pep604 in LEGACY_UNION_RENDERINGS.items(): rendered = rendered.replace(legacy, pep604) return rendered @@ -224,6 +230,22 @@ def _normalize_signature(fn: Any) -> str: "get_stats": "(stats: list, groups: list, **params: dict)", } +# Explicit inventory of public methods defined directly on AsyncMlb. +# Only currently supported async endpoints belong here. +ASYNC_MLB_PUBLIC_METHOD_MANIFEST: dict[str, str] = { + "aclose": "()", + "__aenter__": "()", + "__aexit__": "(exc_type, exc, traceback)", + "get_team": "(team_id: int, **params)", + "get_teams": "(sport_id: int=1, **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)" + ), +} + # --------------------------------------------------------------------------- # Package-root symbols @@ -254,10 +276,11 @@ def test_supported_package_root_api_is_the_union_of_both_manifests() -> None: assert len(SUPPORTED_PACKAGE_ROOT_API) == len(set(SUPPORTED_PACKAGE_ROOT_API)) -def test_async_data_adapter_is_part_of_the_supported_api() -> None: - """The async adapter is supported 1.x API, not merely an optional add-on.""" - assert "AsyncMlbDataAdapter" in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS - assert "AsyncMlbDataAdapter" in SUPPORTED_PACKAGE_ROOT_API +def test_async_symbols_are_part_of_the_supported_api() -> None: + """Async symbols are supported 1.x API, not merely optional add-ons.""" + for name in ("AsyncMlb", "AsyncMlbDataAdapter"): + assert name in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS + assert name in SUPPORTED_PACKAGE_ROOT_API def test_supported_package_root_symbols_are_importable_from_package() -> None: @@ -396,6 +419,26 @@ def test_mlb_constructor_parameter_order_and_defaults() -> None: assert parameters["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY +@requires_async_extra +def test_async_mlb_constructor_parameter_order_and_defaults() -> None: + async_mlb = mlbstatsapi.AsyncMlb + parameters = inspect.signature(async_mlb.__init__).parameters + + assert _parameter_names(async_mlb.__init__) == [ + "hostname", + "logger", + "timeout", + "client", + "strict_http", + ] + assert parameters["hostname"].default == "statsapi.mlb.com" + assert parameters["logger"].default is None + assert parameters["timeout"].default == (3.05, 30.0) + assert parameters["client"].default is None + assert parameters["strict_http"].default is True + assert parameters["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + + def test_mlb_data_adapter_constructor_parameter_order_and_defaults() -> None: parameters = inspect.signature(MlbDataAdapter.__init__).parameters @@ -483,6 +526,41 @@ def test_mlb_public_endpoint_count() -> None: assert len(MLB_PUBLIC_METHOD_MANIFEST) == 43 +# --------------------------------------------------------------------------- +# AsyncMlb public method manifest +# --------------------------------------------------------------------------- + + +def test_async_mlb_public_method_manifest_has_unique_names() -> None: + assert len(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) == len( + set(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) + ) + + +@requires_async_extra +def test_async_mlb_public_method_manifest_matches_class_dict() -> None: + async_mlb = mlbstatsapi.AsyncMlb + discovered = { + name + for name, obj in async_mlb.__dict__.items() + if inspect.isfunction(obj) + and (not name.startswith("_") or name in ("__aenter__", "__aexit__")) + and name != "__init__" + } + assert discovered == set(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) + + +@requires_async_extra +@pytest.mark.parametrize( + "method_name, expected", ASYNC_MLB_PUBLIC_METHOD_MANIFEST.items() +) +def test_async_mlb_public_method_signature(method_name: str, expected: str) -> None: + method = getattr(mlbstatsapi.AsyncMlb, method_name) + assert inspect.iscoroutinefunction(method), method_name + actual = _normalize_signature(method) + assert actual == expected, f"{method_name}: {actual} != {expected}" + + # --------------------------------------------------------------------------- # Exception and warning inheritance # --------------------------------------------------------------------------- From 9bfcfe842ba68d0cc5dc1ccb0225e2065d5ac037 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 08:02:14 -0700 Subject: [PATCH 8/8] test: remove live AsyncMlb tests from issue 303 --- .../async_mlb/test_ext_async_mlb.py | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 tests/external_tests/async_mlb/test_ext_async_mlb.py diff --git a/tests/external_tests/async_mlb/test_ext_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py deleted file mode 100644 index 9ba91612..00000000 --- a/tests/external_tests/async_mlb/test_ext_async_mlb.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncio - -from mlbstatsapi import AsyncMlb -from mlbstatsapi.models.people import Person -from mlbstatsapi.models.schedules import Schedule -from mlbstatsapi.models.teams import Team - - -def test_async_get_team(): - async def scenario(): - async with AsyncMlb() as mlb: - team = await mlb.get_team(133) - - assert isinstance(team, Team) - assert team.id == 133 - - asyncio.run(scenario()) - -def test_async_get_teams(): - async def scenario(): - async with AsyncMlb() as mlb: - teams = await mlb.get_teams() - - assert isinstance(teams, list) - assert teams - assert all(isinstance(team, Team) for team in teams) - - asyncio.run(scenario()) - - -def test_async_get_person(): - async def scenario(): - async with AsyncMlb() as mlb: - person = await mlb.get_person(664034) - - assert isinstance(person, Person) - assert person.id == 664034 - - asyncio.run(scenario()) - -def test_async_get_people(): - async def scenario(): - async with AsyncMlb() as mlb: - people = await mlb.get_people() - - assert isinstance(people, list) - assert people - assert all(isinstance(person, Person) for person in people) - - asyncio.run(scenario()) - - -def test_async_get_schedule(): - async def scenario(): - async with AsyncMlb() as mlb: - schedule = await mlb.get_schedule(date="2022-10-07") - - assert isinstance(schedule, Schedule) - assert schedule.dates - - asyncio.run(scenario())