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/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index e5a6cf7e..7cde2d79 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -32,20 +32,29 @@ # 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}") + 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)) 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/async_mlb.py b/mlbstatsapi/async_mlb.py new file mode 100644 index 00000000..12cac734 --- /dev/null +++ b/mlbstatsapi/async_mlb.py @@ -0,0 +1,174 @@ +# mlbstatsapi/async_mlb.py + +from __future__ import annotations + +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 +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, + 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, + ) + + 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, + sport_id: int = 1, + **params, + ) -> list[Person]: + """Return every player for a sport id. + + 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=f"sports/{sport_id}/players", + 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: + + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + team_id=team_id, + **params, + ) + + if params is None: + return None + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_schedule(mlb_data.data) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py new file mode 100644 index 00000000..cafb2637 --- /dev/null +++ b/tests/test_async_mlb.py @@ -0,0 +1,443 @@ +"""Focused offline tests for the AsyncMlb client (issue #303). + +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. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# 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 + + +TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} +PERSON_PAYLOAD = { + "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] +} +SCHEDULE_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-07", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [], + } + ], +} + +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" +) + +# 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 _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) + + +class _Handler: + """Serve a canned response and record the requests that arrive.""" + + 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] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if isinstance(self._responses, httpx.Response): + return self._responses + return self._responses[request.url.path.split("/api/v1/", 1)[-1]] + + @property + def request(self) -> httpx.Request: + """The single request the call made. + + 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] + + +@asynccontextmanager +async def async_mlb(handler: _Handler): + """Yield an AsyncMlb whose own client talks to ``handler``, then close it. + + AsyncMlb builds its adapter and client through the production path; only + the transport is swapped. Teardown closes the adapter's client directly + rather than calling AsyncMlb.aclose(), so the lifecycle tests that replace + aclose with a mock still get their real client closed. + """ + real_async_client = httpx.AsyncClient + + def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: + return real_async_client( + transport=httpx.MockTransport(handler), **client_kwargs + ) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient", + mock_transport_client, + ) + mlb = AsyncMlb() + + try: + yield mlb + finally: + await mlb._mlb_adapter_v1._client.aclose() + + +def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: + """Return the endpoint and params ``Mlb`` builds for a call. + + The adapter is stubbed, so this reaches no network; it just reads back what + the synchronous client asked for. + """ + 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 + + return call.kwargs["endpoint"], call.kwargs["ep_params"] + + +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 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()} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +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 + + assert RootAsyncMlb is AsyncMlb + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +def test_aenter_returns_self(): + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb as entered: + assert entered is mlb + + asyncio.run(scenario()) + + +def test_context_exit_closes_the_owned_client(): + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client + + async with mlb: + await mlb.get_team(133) + + assert client.is_closed + + asyncio.run(scenario()) + + +def test_context_exit_closes_the_owned_client_when_the_body_raises(): + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client + + with pytest.raises(ValueError, match="boom"): + async with mlb: + raise ValueError("boom") + + assert client.is_closed + + asyncio.run(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. + """ + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) + + with pytest.raises(ValueError, match="original"): + async with mlb: + raise ValueError("original") + + with pytest.raises(RuntimeError, match="cleanup failed"): + async with mlb: + pass + + asyncio.run(scenario()) + + +def test_cancellation_is_preserved_through_cleanup(): + """Cleanup must not swallow a cancellation that arrived from outside.""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client + + 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 + + assert client.is_closed + + asyncio.run(scenario()) + + +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(): + try: + async with AsyncMlb(client=client) as mlb: + await mlb.get_team(133) + + assert client.is_closed is False + finally: + await client.aclose() + + asyncio.run(scenario()) + + +def test_aclose_is_idempotent(): + """Closing more than once, however the caller mixes the forms, is safe.""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb: + await mlb.get_team(133) + + await mlb.aclose() + await mlb.aclose() + + asyncio.run(scenario()) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +def test_get_team_requests_the_team_endpoint_and_parses_the_result(): + handler = _Handler(_json(TEAM_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team(133, season="2022") + + team = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_team", 133, season="2022") + assert team == EXPECTED_TEAM + + +@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(): + async with async_mlb(handler) as mlb: + return await mlb.get_team(1) + + assert asyncio.run(scenario()) is None + + +def test_get_person_requests_the_person_endpoint_and_parses_the_result(): + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_person(660271, hydrate="currentTeam") + + person = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_person", 660271, hydrate="currentTeam") + assert person == EXPECTED_PERSON + + +@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(): + async with async_mlb(handler) as mlb: + return await mlb.get_person(1) + + assert asyncio.run(scenario()) is None + + +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(): + 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 + ) + + schedule = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, + "get_schedule", + start_date="2021-08-01", + end_date="2021-08-11", + team_id=133, + ) + assert schedule == Schedule(**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(): + async with async_mlb(handler) as mlb: + return await mlb.get_schedule() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +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(): + async with async_mlb(handler) as mlb: + return await mlb.get_teams(11, season="2021") + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_teams", 11, season="2021") + + +def test_get_people_request_matches_the_sync_client(): + """get_people reads sports/{sport_id}/players, like Mlb.get_people. + + 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(): + async with async_mlb(handler) as mlb: + return await mlb.get_people(11, season="2021") + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_people", 11, season="2021") + + +# --------------------------------------------------------------------------- +# Parity and concurrency +# --------------------------------------------------------------------------- + + +def test_public_signatures_match_the_sync_client(): + """Argument names, kinds, and defaults must not drift from Mlb's.""" + import inspect + + for name in ("get_team", "get_teams", "get_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"AsyncMlb.{name} drifted from Mlb.{name}" + + +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), + } + ) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await asyncio.gather(mlb.get_team(133), mlb.get_person(660271)) + + team, person = asyncio.run(scenario()) + + assert team == EXPECTED_TEAM + assert person == EXPECTED_PERSON 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 # ---------------------------------------------------------------------------