diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..235268a --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,476 @@ +# Public API Contract (1.x) + +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. + +This package is an unofficial wrapper for the MLB Stats API and is not +affiliated with Major League Baseball. + +Related documents: + +* [HTTP transport](http-transport.md) — timeouts, retries, strict mode, and Session details +* Issue #286 — define the stable 1.0 public API +* Issue #282 — parent 1.0 release tracking + +## Stability policy + +During the 1.x series: + +* Existing supported package-root symbols will not be removed or renamed +* Required constructor parameters will not be added without compatibility handling +* Positional and keyword-only parameter boundaries are part of the API +* Structured exception inheritance will remain compatible +* Documented Session ownership behavior will remain compatible +* Documented endpoint-level 404 return shapes will remain compatible + +The following may still evolve in a compatible way: + +* New optional parameters +* New endpoint methods +* New model fields +* New exception subclasses under `TheMlbStatsApiException` +* New documented public helpers +* Bug fixes +* Additional supported Python versions + +Semantic versioning expectations after 1.0: + +| Change | Typical release | +| --- | --- | +| Bug fix that preserves documented contracts | patch | +| Compatible addition (optional arg, new endpoint, new model field) | minor | +| Removal or rename of a supported symbol | major | +| Breaking change to a documented constructor signature | major | +| Breaking change to documented exception inheritance | major | +| Breaking change to documented Session ownership | major | +| Breaking change to a documented 404 return shape | major | + +## Package-root imports + +Supported symbols are importable as: + +```python +import mlbstatsapi +from mlbstatsapi import Mlb +``` + +and via: + +```python +from mlbstatsapi import ( + Mlb, + MlbDataAdapter, + MlbResult, + create_retry_policy, + TheMlbStatsApiException, + MlbTransportError, + MlbTimeoutError, + MlbHttpError, + MlbDecodeError, + MlbHttpCompatibilityWarning, + return_splits, + get_stat_attributes, +) +``` + +### Classification of package-root symbols + +| Symbol | Status | +| --- | --- | +| `Mlb` | Public and stable in 1.x | +| `MlbDataAdapter` | Public and stable in 1.x | +| `MlbResult` | Public and stable in 1.x | +| `create_retry_policy` | Public and stable in 1.x | +| `TheMlbStatsApiException` | Public and stable in 1.x | +| `MlbTransportError` | Public and stable in 1.x | +| `MlbTimeoutError` | Public and stable in 1.x | +| `MlbHttpError` | Public and stable in 1.x | +| `MlbDecodeError` | Public and stable in 1.x | +| `MlbHttpCompatibilityWarning` | Public and stable in 1.x | +| `return_splits` | Public legacy helper, stable in 1.x but not preferred for new code | +| `get_stat_attributes` | Public legacy helper, stable in 1.x but not preferred for new code | + +No package-root symbol is marked deprecated in version 1.0. Deprecation requires +a documented replacement, a warning strategy, a removal timeline, and a +separate focused issue. + +### Accidentally exposed submodule names + +Python attaches imported submodules to the package namespace. The following +names may appear via `dir(mlbstatsapi)` and `from mlbstatsapi import *`, but +they are **not** part of the supported public API: + +| Name | Where exposed | Recommended 1.0 status | Follow-up | +| --- | --- | --- | --- | +| `exceptions` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | +| `warnings` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | +| `mlb_api` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | +| `mlb_dataadapter` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | +| `mlb_module` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | +| `models` | package attribute / star import | Accidentally exposed | Yes — dedicated cleanup issue | + +These names are not documented as public imports. Prefer the explicit +package-root symbols above. Do not remove them from star imports in a patch +release without an approved issue; wildcard callers may currently receive them. + +### Why `__all__` is not defined + +Version 1.0 intentionally omits `__all__`. + +Without `__all__`, `from mlbstatsapi import *` currently includes both the +supported symbols and the accidentally exposed submodule names listed above. + +Adding `__all__` that lists only the supported symbols would silently change +wildcard-import behavior by removing those submodule names. Adding them to +`__all__` would incorrectly promote accidental exposure into the supported +surface. + +A future focused issue may introduce `__all__` after deciding how to treat the +accidental submodule names (for example, a documented deprecation period). + +## Primary client + +`Mlb` is the primary synchronous client. + +### Constructor + +```text +Mlb( + hostname="statsapi.mlb.com", + logger=None, + timeout=(3.05, 30.0), + session=None, + *, + strict_http=True, +) +``` + +Stable constructor rules: + +* Parameter order above is part of the API +* Default values above are part of the API +* `strict_http` is keyword-only +* Version 1.0 defaults `strict_http` to `True` +* Pass `strict_http=False` for the historical empty-result compatibility path + on final non-404 4xx responses + +Private attributes such as `_session`, `_owns_session`, `_mlb_adapter_v1`, and +`_mlb_adapter_v1_1` are **not** public API. + +### Context-manager behavior + +```python +with mlbstatsapi.Mlb() as mlb: + person = mlb.get_person(664034) +``` + +* `Mlb.__enter__` returns `self` +* `Mlb.__exit__` calls `close()` +* Repeated `close()` calls are safe +* Library-owned Sessions are closed +* Caller-injected Sessions are not closed + +### API versions used by `Mlb` + +`Mlb` constructs internal adapters for both `v1` and `v1.1` that share one +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. + +## Low-level adapter + +`MlbDataAdapter` is the public low-level HTTP adapter. + +### Constructor + +```text +MlbDataAdapter( + hostname="statsapi.mlb.com", + ver="v1", + logger=None, + timeout=(3.05, 30.0), + session=None, + *, + strict_http=True, +) +``` + +Stable constructor rules: + +* Parameter order above is part of the API +* Default values above are part of the API +* `strict_http` is keyword-only +* Version 1.0 defaults `strict_http` to `True` +* `ver` supports the library's documented API versions, including `v1` and + `v1.1` + +`MlbDataAdapter` exposes `get()` and `close()`. It does not implement the +context-manager protocol in version 1.0; callers should call `close()` +explicitly when they own a standalone adapter. + +## Result object + +```text +MlbResult( + status_code, + message, + data=None, +) +``` + +Stable public attributes: + +* `status_code` — coerced to `int` +* `message` — coerced to `str` +* `data` — a dictionary; defaults to `{}` when `data` is omitted or `None` + +Stable behaviors already covered by offline tests: + +* Caller-provided dictionaries are not mutated +* Each instance gets an independent `data` dictionary +* A top-level `"copyright"` key is removed from the stored `data` copy + +## Retry policy + +```text +create_retry_policy() +``` + +Stable factory contract: + +* Takes no arguments +* Returns `urllib3.util.retry.Retry` +* Each call returns a new instance +* Callers may mount the returned policy on their own `requests.Session` +* The library does not automatically modify injected Sessions + +Current numeric configuration (also asserted by the HTTP contract tests and +treated as stable for 1.x unless a future major release documents otherwise): + +```text +total=3 +connect=3 +read=2 +status=3 +backoff_factor=0.5 +status_forcelist={429, 500, 502, 503, 504} +allowed_methods={"GET"} +respect_retry_after_header=True +raise_on_status=False +``` + +## Exception hierarchy + +```text +Exception +└── TheMlbStatsApiException + ├── MlbTransportError + │ └── MlbTimeoutError + ├── MlbHttpError + └── MlbDecodeError +``` + +Supported catch patterns: + +* Broad package failures: `except TheMlbStatsApiException` +* Transport failures: `except MlbTransportError` +* Timeouts: `except MlbTimeoutError` +* HTTP failures: `except MlbHttpError` +* JSON decode failures: `except MlbDecodeError` + +### `MlbHttpError` stable attributes + +* `status_code` +* `reason` +* `url` +* `method` +* `response_data` +* `body_excerpt` + +Exact `str(exc)` formatting beyond the currently tested +`"{status_code}: {reason}"` shape for `MlbHttpError` is not frozen as a broader +string-formatting promise for every exception type. + +## Compatibility warning + +```text +issubclass(MlbHttpCompatibilityWarning, FutureWarning) +``` + +remains true. + +`MlbHttpCompatibilityWarning` is emitted when `strict_http=False` suppresses a +final non-404 4xx response that strict mode would raise. Warning message text +may be refined for clarity within 1.x as long as the warning class and +filtering behavior remain compatible. See [HTTP transport](http-transport.md). + +## Session ownership + +These ownership rules are stable public API: + +```text +Library-created Session + Owned by the library + Receives library retry adapters + Receives the package User-Agent + Closed by Mlb.close(), adapter.close(), or Mlb context-manager exit + +Caller-injected Session + Owned by the caller + Existing headers remain untouched + Existing adapters remain untouched + Not closed by the library +``` + +## Python support + +| Claim | Value | +| --- | --- | +| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| Actively validated CI versions on this release branch | 3.10, 3.11, 3.12 | +| Later Python versions | May work, but are not claimed as CI-validated unless added to the matrix | + +Version 1.0 does not add an upper Python bound. Absence of Python 3.13 (or +newer) CI coverage should be tracked under the release-validator / CI issue +stream rather than silently claimed here. + +## Mlb endpoint methods + +The following public methods are defined directly on `Mlb`. Newly exposed +methods require an intentional update to the public API tests. + +Lifecycle and context managers: + +| Method | Signature notes | +| --- | --- | +| `close` | no parameters | +| `__enter__` | returns `self` | +| `__exit__` | closes only library-owned Sessions | + +Endpoint methods (parameter order and defaults are part of the API): + +| Method | Parameters | Top-level return shape | 404 / client-empty shape | +| --- | --- | --- | --- | +| `get_people` | `sport_id=1, **params` | `list[Person]` | `[]` | +| `get_person` | `player_id, **params` | `Person \| None` | `None` | +| `get_persons` | `person_ids, **params` | `list[Person]` | `[]` | +| `get_people_id` | `fullname, sport_id=1, search_key='fullName', **params` | `list[int]` | `[]` | +| `get_teams` | `sport_id=1, **params` | `list[Team]` | `[]` | +| `get_team` | `team_id, **params` | `Team \| None` | `None` | +| `get_team_id` | `team_name, search_key='name', **params` | `list[int]` | `[]` | +| `get_team_roster` | `team_id, **params` | `list[Player]` | `[]` | +| `get_team_coaches` | `team_id, **params` | `list[Coach]` | `[]` | +| `get_schedule` | `date=None, start_date=None, end_date=None, sport_id=1, team_id=None, **params` | `Schedule \| None` | `None` | +| `get_scheduled_games_by_date` | `date=None, start_date=None, end_date=None, sport_id=1, **params` | `list[ScheduleGames]` | `[]` | +| `get_game` | `game_id, **params` | `Game \| None` | `None` (uses `v1.1`) | +| `get_game_play_by_play` | `game_id, **params` | `Plays \| None` | `None` | +| `get_game_line_score` | `game_id, **params` | `Linescore \| None` | see notes | +| `get_game_box_score` | `game_id, **params` | `BoxScore \| None` | `None` | +| `get_game_ids` | `date=None, start_date=None, end_date=None, sport_id=1, **params` | `list[int]` | `[]` | +| `get_gamepace` | `season, sport_id=1, **params` | `GamePace \| None` | `None` | +| `get_venue` | `venue_id, **params` | annotated `Venue \| None` | returns `[]` today; see notes | +| `get_venues` | `**params` | `list[Venue]` | `[]` | +| `get_venue_id` | `venue_name, search_key='name', **params` | `list[int]` | `[]` | +| `get_sport` | `sport_id, **params` | `Sport \| None` | `None` | +| `get_sports` | `**params` | `list[Sport]` | `[]` | +| `get_sport_id` | `sport_name, search_key='name', **params` | `list[int]` | `[]` | +| `get_league` | `league_id, **params` | `League \| None` | `None` | +| `get_leagues` | `**params` | `list[League]` | `[]` | +| `get_league_id` | `league_name, search_key='name', **params` | `list[int]` | `[]` | +| `get_division` | `division_id, **params` | `Division \| None` | `None` | +| `get_divisions` | `**params` | `list[Division]` | `[]` | +| `get_division_id` | `division_name, search_key='name', **params` | `list[int]` | `[]` | +| `get_season` | `season_id, sport_id=1, **params` | annotated `Season`; may return `None` | `None` | +| `get_seasons` | `sport_id=1, **params` | `list[Season]` | `[]` | +| `get_standings` | `league_id, season, **params` | `list[Standings]` | `[]` | +| `get_attendance` | `team_id=None, league_id=None, league_list_id=None, **params` | `Attendance \| None` | `None` | +| `get_draft` | `year_id, **params` | `list[Round]` | `[]` | +| `get_awards` | `award_id, **params` | `list[Award]` | `[]` | +| `get_homerun_derby` | `game_id, **params` | `HomeRunDerby \| None` | see notes | +| `get_team_stats` | `team_id, stats, groups, **params` | `dict` | `{}` | +| `get_players_stats_for_game` | `person_id, game_id, **params` | `dict` | `{}` | +| `get_player_stats` | `person_id, stats, groups, **params` | `dict` | `{}` | +| `get_stats` | `stats, groups, **params` | `dict` | `{}` | + +Notes and known conflicts (documented, not redesigned by this contract): + +* Under the version 1.0 strict default, final non-404 4xx responses raise + `MlbHttpError` before endpoint empty-shape logic runs. The empty shapes above + remain the documented domain-level not-found / empty results for **404** + responses (and for compatibility mode where applicable). +* `get_game_line_score` does not currently short-circuit on a 400–499 status + the same way as sibling game helpers; missing linescore data falls through to + an implicit `None`. +* `get_venue` is annotated to return `Venue | None` but currently returns `[]` + on 400–499 statuses. Treat the implementation shape as the observed behavior + until a focused fix lands. +* `get_homerun_derby` currently executes a bare `None` expression on 400–499 + instead of `return None`, so execution may continue. A focused bugfix is + recommended. +* Nested Pydantic model fields are not frozen by this contract. + +## Return-contract boundaries + +Version 1.0 guarantees endpoint method availability, parameter order and +defaults, top-level return types or shapes listed above, and documented 404 +empty shapes. + +Version 1.0 does **not** guarantee: + +* Every nested model field +* Every upstream JSON property +* Undocumented behavior caused by malformed upstream data +* Exact log messages +* Exact exception string formatting beyond documented attributes +* The availability or stability of the unofficial MLB Stats API itself + +## Internal APIs + +The following are outside the 1.0 stability promise: + +* Private names beginning with an underscore +* Internal adapter helpers such as `_configure_library_session`, + `_build_http_error`, and `_warn_http_compatibility` +* Private `Mlb` attributes such as `_session` or `_mlb_adapter_v1` +* Exact log messages +* Exact exception string formatting beyond documented attributes +* Undocumented upstream MLB response fields +* The availability or stability of the unofficial MLB API itself +* Every symbol located in `mlbstatsapi.models` unless separately documented +* Accidentally exposed package-root submodule names listed above + +Do not treat every Pydantic model field as permanently frozen. + +## Legacy helpers + +`return_splits` and `get_stat_attributes` remain importable from the package +root and are stable in 1.x for existing callers. + +They are not the preferred entry point for new application code. Prefer the +`Mlb` statistics endpoint methods. These helpers are **not** deprecated in +version 1.0. + +## Deprecation policy + +No new deprecations are introduced by the version 1.0 public API audit. + +A future deprecation must include: + +1. A documented replacement +2. A warning strategy +3. A removal timeline +4. A separate focused issue + +## Semantic-versioning expectations + +After 1.0.0: + +* Preserve supported package-root imports across minor and patch releases +* Prefer additive changes for new endpoints and optional parameters +* Use a major version for removals, renames, or incompatible contract changes +* Update this document when the supported surface intentionally changes diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index 3a046df..bb3c21c 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -1,3 +1,15 @@ +"""python-mlb-statsapi public package root. + +Supported package-root symbols for the 1.x series are documented in +``docs/public-api.md``. + +``__all__`` is intentionally omitted in version 1.0. Adding it today would +change ``from mlbstatsapi import *`` by excluding submodule names that appear +in the package namespace as an import side effect. Those submodules are not +part of the supported public API; cleaning them up requires a separate +focused issue. See ``docs/public-api.md``. +""" + from .mlb_api import Mlb from .mlb_dataadapter import MlbDataAdapter, MlbResult, create_retry_policy from .exceptions import ( diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ce55eaf..0f3384f 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -1044,7 +1044,7 @@ def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]: Examples -------- >>> mlb = Mlb() - >>> mlb.get_game_line_scrore(662242) + >>> mlb.get_game_line_score(662242) Linescore """ @@ -1684,10 +1684,10 @@ def get_divisions(self, **params) -> List[Division]: return divisions - def get_division_id(self, division_name: str, - search_key: str = 'name', **params) -> List[Division]: + def get_division_id(self, division_name: str, + search_key: str = 'name', **params) -> List[int]: """ - return divsion id + return division id Parameters ---------- diff --git a/scripts/validate_release.py b/scripts/validate_release.py index ff45dfc..58befb3 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -58,10 +58,13 @@ MlbDecodeError, MlbHttpCompatibilityWarning, MlbHttpError, + MlbResult, MlbTimeoutError, MlbTransportError, TheMlbStatsApiException, create_retry_policy, + get_stat_attributes, + return_splits, ) expected_version = sys.argv[1] @@ -76,7 +79,29 @@ f"installed metadata reports {installed_version}, expected {expected_version}" ) +supported_symbols = ( + "Mlb", + "MlbDataAdapter", + "MlbDecodeError", + "MlbHttpCompatibilityWarning", + "MlbHttpError", + "MlbResult", + "MlbTimeoutError", + "MlbTransportError", + "TheMlbStatsApiException", + "create_retry_policy", + "get_stat_attributes", + "return_splits", +) +for name in supported_symbols: + assert hasattr(mlbstatsapi, name), name + assert getattr(mlbstatsapi, name) is not None + +# Version 1.0 intentionally omits __all__; adding it would narrow star imports. +assert getattr(mlbstatsapi, "__all__", None) is None + assert callable(create_retry_policy) +assert inspect.signature(create_retry_policy).parameters == {} retry_policy = create_retry_policy() assert isinstance(retry_policy, Retry), type(retry_policy) assert create_retry_policy() is not retry_policy, ( @@ -84,19 +109,60 @@ ) assert issubclass(MlbHttpCompatibilityWarning, FutureWarning) +assert issubclass(TheMlbStatsApiException, Exception) assert issubclass(MlbHttpError, TheMlbStatsApiException) assert issubclass(MlbTimeoutError, MlbTransportError) assert issubclass(MlbTransportError, TheMlbStatsApiException) assert issubclass(MlbDecodeError, TheMlbStatsApiException) -# Compatibility mode is the default in this release. -assert ( - inspect.signature(Mlb.__init__).parameters["strict_http"].default is False -) -assert ( - inspect.signature(MlbDataAdapter.__init__).parameters["strict_http"].default - is False -) +mlb_init = inspect.signature(Mlb.__init__).parameters +adapter_init = inspect.signature(MlbDataAdapter.__init__).parameters +result_init = inspect.signature(MlbResult.__init__).parameters + +assert list(mlb_init) == [ + "self", + "hostname", + "logger", + "timeout", + "session", + "strict_http", +] +assert mlb_init["hostname"].default == "statsapi.mlb.com" +assert mlb_init["logger"].default is None +assert mlb_init["timeout"].default == (3.05, 30.0) +assert mlb_init["session"].default is None +assert mlb_init["strict_http"].default is True +assert mlb_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + +assert list(adapter_init) == [ + "self", + "hostname", + "ver", + "logger", + "timeout", + "session", + "strict_http", +] +assert adapter_init["hostname"].default == "statsapi.mlb.com" +assert adapter_init["ver"].default == "v1" +assert adapter_init["logger"].default is None +assert adapter_init["timeout"].default == (3.05, 30.0) +assert adapter_init["session"].default is None +assert adapter_init["strict_http"].default is True +assert adapter_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + +assert list(result_init) == ["self", "status_code", "message", "data"] +assert result_init["data"].default is None + +result = MlbResult(200, "OK", {"copyright": "x", "ok": True}) +assert result.status_code == 200 +assert result.message == "OK" +assert result.data == {"ok": True} + +assert callable(return_splits) +assert callable(get_stat_attributes) +assert return_splits is mlbstatsapi.return_splits +assert get_stat_attributes is mlbstatsapi.get_stat_attributes # A library-created Session is library-owned, so reading its User-Agent through # the private attribute is acceptable for internal release validation only. @@ -104,8 +170,9 @@ with Mlb() as mlb: user_agent = mlb._session.headers["User-Agent"] assert user_agent == expected_user_agent, user_agent + assert mlb._strict_http is True -# Strict mode is constructible and injected Session headers stay untouched. +# Injected Session headers stay untouched; library close does not close them. session = requests.Session() session.headers.update( { @@ -114,8 +181,8 @@ } ) try: - with Mlb(session=session, strict_http=True): - pass + with Mlb(session=session, strict_http=False) as mlb: + assert mlb._strict_http is False assert session.headers["User-Agent"] == "release-smoke-test/1.0" assert session.headers["X-Release-Test"] == "preserved" finally: diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..e0c1d45 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,518 @@ +"""Contract tests for the version 1.x public API surface. + +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``. + +They must not contact the live MLB API. +""" + +from __future__ import annotations + +import inspect +import warnings +from typing import Any + +import pytest +import requests +from urllib3.util.retry import Retry + +import mlbstatsapi +from mlbstatsapi import ( + Mlb, + MlbDataAdapter, + MlbDecodeError, + MlbHttpCompatibilityWarning, + MlbHttpError, + MlbResult, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, + create_retry_policy, + get_stat_attributes, + return_splits, +) + +from http_contract_support import assert_library_retry_policy + + +# --------------------------------------------------------------------------- +# Package-root manifests +# --------------------------------------------------------------------------- + +# Intentionally supported package-root symbols for the 1.x series. +SUPPORTED_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = ( + "Mlb", + "MlbDataAdapter", + "MlbDecodeError", + "MlbHttpCompatibilityWarning", + "MlbHttpError", + "MlbResult", + "MlbTimeoutError", + "MlbTransportError", + "TheMlbStatsApiException", + "create_retry_policy", + "get_stat_attributes", + "return_splits", +) + +# Legacy helpers remain supported but are not preferred for new code. +LEGACY_PACKAGE_ROOT_HELPERS: tuple[str, ...] = ( + "get_stat_attributes", + "return_splits", +) + +# Submodules that appear on the package namespace as an import side effect. +# They are not part of the supported public API; see docs/public-api.md. +ACCIDENTAL_PACKAGE_ROOT_SUBMODULES: tuple[str, ...] = ( + "exceptions", + "mlb_api", + "mlb_dataadapter", + "mlb_module", + "models", + "warnings", +) + + +def _normalize_signature(fn: Any) -> str: + """Return a stable, readable signature string without the ``self`` parameter.""" + sig = inspect.signature(fn) + parts: list[str] = [] + for name, parameter in sig.parameters.items(): + if name == "self": + continue + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + annotation = "" + if parameter.annotation is not inspect.Parameter.empty: + annotation = f": {inspect.formatannotation(parameter.annotation)}" + parts.append(f"**{name}{annotation}") + continue + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + parts.append(f"*{name}") + continue + piece = name + if parameter.annotation is not inspect.Parameter.empty: + piece += f": {inspect.formatannotation(parameter.annotation)}" + if parameter.default is not inspect.Parameter.empty: + piece += f"={parameter.default!r}" + parts.append(piece) + return "(" + ", ".join(parts) + ")" + + +# Explicit inventory of public methods defined directly on Mlb. +# A newly exposed method must update this manifest intentionally. +MLB_PUBLIC_METHOD_MANIFEST: dict[str, str] = { + "close": "()", + "__enter__": "()", + "__exit__": "(exc_type, exc, traceback)", + "get_people": "(sport_id: int=1, **params)", + "get_person": "(player_id: int, **params)", + "get_persons": "(person_ids: Union[str, List[int]], **params)", + "get_people_id": ( + "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" + ), + "get_teams": "(sport_id: int=1, **params)", + "get_team": "(team_id: int, **params)", + "get_team_id": "(team_name: str, search_key: str='name', **params)", + "get_team_roster": "(team_id: int, **params)", + "get_team_coaches": "(team_id: int, **params)", + "get_schedule": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, team_id: int=None, **params)" + ), + "get_scheduled_games_by_date": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), + "get_game": "(game_id: int, **params)", + "get_game_play_by_play": "(game_id: int, **params)", + "get_game_line_score": "(game_id: int, **params)", + "get_game_box_score": "(game_id: int, **params)", + "get_game_ids": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), + "get_gamepace": "(season: str, sport_id=1, **params)", + "get_venue": "(venue_id: int, **params)", + "get_venues": "(**params)", + "get_venue_id": "(venue_name: str, search_key: str='name', **params)", + "get_sport": "(sport_id: int, **params)", + "get_sports": "(**params)", + "get_sport_id": "(sport_name: str, search_key: str='name', **params)", + "get_league": "(league_id: int, **params)", + "get_leagues": "(**params)", + "get_league_id": "(league_name: str, search_key: str='name', **params)", + "get_division": "(division_id: int, **params)", + "get_divisions": "(**params)", + "get_division_id": "(division_name: str, search_key: str='name', **params)", + "get_season": "(season_id: str, sport_id: int=1, **params)", + "get_seasons": "(sport_id: int=1, **params)", + "get_standings": "(league_id: int, season: str, **params)", + "get_attendance": ( + "(team_id: int=None, league_id: int=None, " + "league_list_id: str=None, **params)" + ), + "get_draft": "(year_id: int, **params)", + "get_awards": "(award_id: str, **params)", + "get_homerun_derby": "(game_id, **params)", + "get_team_stats": "(team_id: int, stats: list, groups: list, **params)", + "get_players_stats_for_game": "(person_id: int, game_id: int, **params)", + "get_player_stats": "(person_id: int, stats: list, groups: list, **params)", + "get_stats": "(stats: list, groups: list, **params: dict)", +} + + +# --------------------------------------------------------------------------- +# Package-root symbols +# --------------------------------------------------------------------------- + + +def test_supported_package_root_symbols_are_unique() -> None: + assert len(SUPPORTED_PACKAGE_ROOT_SYMBOLS) == len(set(SUPPORTED_PACKAGE_ROOT_SYMBOLS)) + + +def test_supported_package_root_symbols_are_importable_from_package() -> None: + for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS: + assert hasattr(mlbstatsapi, name), name + assert getattr(mlbstatsapi, name) is not None + + +@pytest.mark.parametrize("name", SUPPORTED_PACKAGE_ROOT_SYMBOLS) +def test_supported_symbols_are_importable_by_name(name: str) -> None: + namespace: dict[str, Any] = {} + exec(f"from mlbstatsapi import {name}", namespace) + assert name in namespace + assert namespace[name] is getattr(mlbstatsapi, name) + + +def test_package_does_not_define_all_in_version_1_0() -> None: + """``__all__`` is omitted so star-import behavior is not silently narrowed.""" + assert getattr(mlbstatsapi, "__all__", None) is None + + +def test_star_import_includes_supported_symbols() -> None: + namespace: dict[str, Any] = {} + exec("from mlbstatsapi import *", namespace) + for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS: + assert name in namespace, name + + +def test_star_import_currently_includes_accidental_submodules() -> None: + """Document current wildcard behavior without promoting it to supported API.""" + namespace: dict[str, Any] = {} + exec("from mlbstatsapi import *", namespace) + for name in ACCIDENTAL_PACKAGE_ROOT_SUBMODULES: + assert name in namespace, name + + +def test_legacy_helpers_remain_package_root_importable() -> None: + assert return_splits is mlbstatsapi.return_splits + assert get_stat_attributes is mlbstatsapi.get_stat_attributes + assert callable(return_splits) + assert callable(get_stat_attributes) + for name in LEGACY_PACKAGE_ROOT_HELPERS: + assert name in SUPPORTED_PACKAGE_ROOT_SYMBOLS + + +# --------------------------------------------------------------------------- +# Constructor signatures +# --------------------------------------------------------------------------- + + +def _parameter_names(fn: Any) -> list[str]: + return [ + name + for name in inspect.signature(fn).parameters + if name != "self" + ] + + +def test_mlb_constructor_parameter_order_and_defaults() -> None: + parameters = inspect.signature(Mlb.__init__).parameters + + assert _parameter_names(Mlb.__init__) == [ + "hostname", + "logger", + "timeout", + "session", + "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["session"].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 + + assert _parameter_names(MlbDataAdapter.__init__) == [ + "hostname", + "ver", + "logger", + "timeout", + "session", + "strict_http", + ] + assert parameters["hostname"].default == "statsapi.mlb.com" + assert parameters["ver"].default == "v1" + assert parameters["logger"].default is None + assert parameters["timeout"].default == (3.05, 30.0) + assert parameters["session"].default is None + assert parameters["strict_http"].default is True + assert parameters["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + + +def test_mlb_result_constructor_parameter_order_and_defaults() -> None: + parameters = inspect.signature(MlbResult.__init__).parameters + + assert _parameter_names(MlbResult.__init__) == [ + "status_code", + "message", + "data", + ] + assert parameters["status_code"].default is inspect.Parameter.empty + assert parameters["message"].default is inspect.Parameter.empty + assert parameters["data"].default is None + + +def test_strict_http_rejects_positional_argument_for_mlb() -> None: + with pytest.raises(TypeError): + Mlb("statsapi.mlb.com", None, (3.05, 30.0), None, True) # type: ignore[misc] + + +def test_strict_http_rejects_positional_argument_for_adapter() -> None: + with pytest.raises(TypeError): + MlbDataAdapter( + "statsapi.mlb.com", + "v1", + None, + (3.05, 30.0), + None, + True, + ) # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Mlb public method manifest +# --------------------------------------------------------------------------- + + +def test_mlb_public_method_manifest_has_unique_names() -> None: + assert len(MLB_PUBLIC_METHOD_MANIFEST) == len(set(MLB_PUBLIC_METHOD_MANIFEST)) + + +def test_mlb_public_method_manifest_matches_class_dict() -> None: + discovered = { + name + for name, obj in Mlb.__dict__.items() + if inspect.isfunction(obj) + and (not name.startswith("_") or name in ("__enter__", "__exit__")) + and name != "__init__" + } + assert discovered == set(MLB_PUBLIC_METHOD_MANIFEST) + + +@pytest.mark.parametrize("method_name, expected", MLB_PUBLIC_METHOD_MANIFEST.items()) +def test_mlb_public_method_signature(method_name: str, expected: str) -> None: + method = getattr(Mlb, method_name) + actual = _normalize_signature(method) + assert actual == expected, f"{method_name}: {actual} != {expected}" + + +def test_mlb_public_endpoint_count() -> None: + endpoint_methods = [ + name + for name in MLB_PUBLIC_METHOD_MANIFEST + if not name.startswith("_") and name != "close" + ] + assert len(endpoint_methods) == 40 + assert len(MLB_PUBLIC_METHOD_MANIFEST) == 43 + + +# --------------------------------------------------------------------------- +# Exception and warning inheritance +# --------------------------------------------------------------------------- + + +def test_exception_hierarchy() -> None: + assert issubclass(TheMlbStatsApiException, Exception) + assert issubclass(MlbTransportError, TheMlbStatsApiException) + assert issubclass(MlbTimeoutError, MlbTransportError) + assert issubclass(MlbHttpError, TheMlbStatsApiException) + assert issubclass(MlbDecodeError, TheMlbStatsApiException) + + +def test_exceptions_are_publicly_imported() -> None: + assert mlbstatsapi.TheMlbStatsApiException is TheMlbStatsApiException + assert mlbstatsapi.MlbTransportError is MlbTransportError + assert mlbstatsapi.MlbTimeoutError is MlbTimeoutError + assert mlbstatsapi.MlbHttpError is MlbHttpError + assert mlbstatsapi.MlbDecodeError is MlbDecodeError + + +def test_broad_and_specific_exception_catches() -> None: + with pytest.raises(TheMlbStatsApiException): + raise MlbTransportError("transport") + with pytest.raises(MlbTransportError): + raise MlbTimeoutError("timeout") + with pytest.raises(MlbTimeoutError): + raise MlbTimeoutError("timeout") + with pytest.raises(MlbHttpError): + raise MlbHttpError(500, "Internal Server Error", "https://example.test") + with pytest.raises(MlbDecodeError): + raise MlbDecodeError("bad json") + with pytest.raises(TheMlbStatsApiException): + raise MlbDecodeError("bad json") + + +def test_mlb_http_error_stable_attributes() -> None: + exc = MlbHttpError( + status_code=502, + reason="Bad Gateway", + url="https://statsapi.mlb.com/api/v1/sports", + method="get", + response_data={"message": "nope"}, + body_excerpt="nope", + ) + assert exc.status_code == 502 + assert exc.reason == "Bad Gateway" + assert exc.url == "https://statsapi.mlb.com/api/v1/sports" + assert exc.method == "GET" + assert exc.response_data == {"message": "nope"} + assert exc.body_excerpt == "nope" + + +def test_compatibility_warning_inherits_from_future_warning() -> None: + assert issubclass(MlbHttpCompatibilityWarning, FutureWarning) + assert mlbstatsapi.MlbHttpCompatibilityWarning is MlbHttpCompatibilityWarning + + +# --------------------------------------------------------------------------- +# Retry policy +# --------------------------------------------------------------------------- + + +def test_create_retry_policy_contract() -> None: + assert callable(create_retry_policy) + assert inspect.signature(create_retry_policy).parameters == {} + + first = create_retry_policy() + second = create_retry_policy() + + assert isinstance(first, Retry) + assert first is not second + assert_library_retry_policy(first) + assert_library_retry_policy(second) + + +# --------------------------------------------------------------------------- +# MlbResult +# --------------------------------------------------------------------------- + + +def test_mlb_result_public_attributes_and_non_mutation() -> None: + payload = {"copyright": "MLB", "sports": [{"id": 1}]} + result = MlbResult(200, "OK", payload) + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1}]} + assert payload == {"copyright": "MLB", "sports": [{"id": 1}]} + + +def test_mlb_result_default_data_is_empty_dict() -> None: + result = MlbResult(404, "Not Found") + assert result.data == {} + + +# --------------------------------------------------------------------------- +# Context managers and Session ownership +# --------------------------------------------------------------------------- + + +def test_mlb_context_manager_returns_self_and_closes_library_session() -> None: + with Mlb() as mlb: + assert mlb is mlb.__enter__() + session = mlb._session + assert mlb._owns_session is True + assert mlb._closed is True + # Requests marks a closed Session; a second close must remain safe. + mlb.close() + assert mlb._closed is True + # The underlying Session object still exists but was closed by the client. + assert session is mlb._session + + +def test_mlb_context_manager_does_not_close_injected_session() -> None: + session = requests.Session() + session.headers.update( + { + "User-Agent": "public-api-test/1.0", + "X-Public-Api-Test": "preserved", + } + ) + try: + with Mlb(session=session) as mlb: + assert mlb._owns_session is False + assert mlb._session is session + mlb.close() + assert session.headers["User-Agent"] == "public-api-test/1.0" + assert session.headers["X-Public-Api-Test"] == "preserved" + # Injected Sessions remain usable after the client exits. + assert session.headers.get("X-Public-Api-Test") == "preserved" + finally: + session.close() + + +def test_library_created_session_receives_user_agent_and_retries() -> None: + with Mlb() as mlb: + assert "python-mlb-statsapi/" in mlb._session.headers["User-Agent"] + https_adapter = mlb._session.get_adapter("https://example.test") + assert_library_retry_policy(https_adapter.max_retries) + + +def test_injected_session_adapters_remain_untouched() -> None: + session = requests.Session() + original_adapters = dict(session.adapters) + try: + with Mlb(session=session): + assert session.adapters == original_adapters + finally: + session.close() + + +def test_adapter_close_owns_only_library_sessions() -> None: + adapter = MlbDataAdapter() + adapter.close() + adapter.close() + assert adapter._closed is True + + session = requests.Session() + try: + injected = MlbDataAdapter(session=session) + injected.close() + injected.close() + assert injected._owns_session is False + assert session.headers is not None + finally: + session.close() + + +def test_adapter_supports_documented_api_versions() -> None: + for version in ("v1", "v1.1"): + adapter = MlbDataAdapter(ver=version) + try: + assert adapter.url.endswith(f"/api/{version}/") + finally: + adapter.close() + + +def test_compatibility_warning_can_be_filtered_by_public_class() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.warn("probe", MlbHttpCompatibilityWarning) + assert len(caught) == 1 + assert caught[0].category is MlbHttpCompatibilityWarning diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a20f5de..a2e70ed 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -13,7 +13,9 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent README = PROJECT_ROOT / "README.md" TRANSPORT_DOC = PROJECT_ROOT / "docs" / "http-transport.md" +PUBLIC_API_DOC = PROJECT_ROOT / "docs" / "public-api.md" RELEASE_NOTES = PROJECT_ROOT / "docs" / "releases" / "0.9.0.md" +VALIDATE_RELEASE = PROJECT_ROOT / "scripts" / "validate_release.py" PYTHON_BLOCK_PATTERN = re.compile( r"^```python\n(.*?)^```", @@ -45,7 +47,7 @@ def _python_blocks(path: Path) -> list[tuple[int, str]]: def _documented_paths() -> list[Path]: - return [README, TRANSPORT_DOC, RELEASE_NOTES] + return [README, TRANSPORT_DOC, PUBLIC_API_DOC, RELEASE_NOTES] @pytest.mark.parametrize( @@ -104,6 +106,23 @@ def test_documented_user_agent_matches_the_declared_version() -> None: ) +def test_public_api_contract_document_exists() -> None: + assert PUBLIC_API_DOC.is_file() + text = PUBLIC_API_DOC.read_text(encoding="utf-8") + assert text.startswith("# Public API Contract (1.x)") + assert "Stability policy" in text + assert "Session ownership" in text + assert "Python support" in text + + +def test_release_smoke_test_asserts_strict_http_default() -> None: + """The installed-wheel smoke test must match the version 1.0 strict default.""" + text = VALIDATE_RELEASE.read_text(encoding="utf-8") + assert 'mlb_init["strict_http"].default is True' in text + assert 'adapter_init["strict_http"].default is True' in text + assert "Compatibility mode is the default in this release." not in text + + def test_ci_watches_the_current_release_branch() -> None: workflow = PROJECT_ROOT / ".github" / "workflows" / "build-and-test.yml" text = workflow.read_text(encoding="utf-8")