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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,37 @@ 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).

### 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 |
Status and availability are separate questions. Every symbol below is public and
covered by the stability policy above; the availability column records whether
resolving it needs an optional dependency.

| Symbol | Status | Availability |
| --- | --- | --- |
| `Mlb` | Public and stable in 1.x | Always available |
| `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 |
| `create_retry_policy` | Public and stable in 1.x | Always available |
| `TheMlbStatsApiException` | Public and stable in 1.x | Always available |
| `MlbTransportError` | Public and stable in 1.x | Always available |
| `MlbTimeoutError` | Public and stable in 1.x | Always available |
| `MlbHttpError` | Public and stable in 1.x | Always available |
| `MlbDecodeError` | Public and stable in 1.x | Always available |
| `MlbHttpCompatibilityWarning` | Public and stable in 1.x | Always available |
| `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
[Optional async support](#optional-async-support).

No package-root symbol is marked deprecated in version 1.0. Deprecation requires
a documented replacement, a warning strategy, a removal timeline, and a
Expand Down Expand Up @@ -133,6 +148,37 @@ surface.
A future focused issue may introduce `__all__` after deciding how to treat the
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:

```bash
pip install "python-mlb-statsapi[async]"
```

With the extra installed:

```python
from mlbstatsapi import AsyncMlbDataAdapter
```

Async symbols are resolved on first access, so the optional dependency is not
imported by `import mlbstatsapi`. A synchronous-only install is unaffected:

* `import mlbstatsapi` succeeds without the `async` extra
* every package-root symbol marked "Always available" above stays importable
* nothing in the synchronous surface changes

Requesting async functionality without the extra raises `ImportError` naming
the install command above. That failure happens only when async functionality
is requested — importing the package, or any supported synchronous symbol,
never triggers it.

The async HTTP library is an implementation detail. It is not re-exported from
the package root, and its types are not part of the public API.

## Primary client

`Mlb` is the primary synchronous client.
Expand Down
24 changes: 24 additions & 0 deletions mlbstatsapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,27 @@
return_splits,
get_stat_attributes
)

# Async symbols are resolved lazily. HTTPX is an optional dependency installed
# with the ``async`` extra, so importing the async adapter eagerly here would
# make ``import mlbstatsapi`` fail for every sync-only install. Resolving on
# 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",)


def __getattr__(name: str):
if name in _LAZY_ASYNC_EXPORTS:
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))
43 changes: 43 additions & 0 deletions mlbstatsapi/_async_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Private optional-dependency boundary for async support.

HTTPX ships only with the ``async`` extra, so a sync-only install must be able
to ``import mlbstatsapi`` and use ``Mlb`` / ``MlbDataAdapter`` without it. Every
async entry point routes its HTTPX import through :func:`import_httpx`, so a
missing optional dependency produces one actionable install message instead of a
bare ``ModuleNotFoundError`` naming a library the user never asked for. Import
failures that are not a missing ``httpx`` are left alone.

HTTPX itself stays an implementation detail: nothing here re-exports it.
"""

from types import ModuleType

ASYNC_EXTRA_REQUIREMENT = 'python-mlb-statsapi[async]'

MISSING_HTTPX_MESSAGE = (
"Async support requires the optional HTTPX dependency, which is not "
"installed. Install it with:\n\n"
f' pip install "{ASYNC_EXTRA_REQUIREMENT}"\n'
)


def import_httpx() -> ModuleType:
"""Return the ``httpx`` module, or raise an actionable ``ImportError``.

Only a genuinely missing top-level ``httpx`` is translated into the install
message. An installed-but-broken HTTPX fails on some other module (a
missing transitive dependency, for example), and telling that user to
install the extra would send them chasing the wrong problem, so those
failures propagate unchanged.

The original failure is preserved as the exception cause so a broken async
install stays diagnosable.
"""
try:
import httpx
except ModuleNotFoundError as exc:
if exc.name != "httpx":
raise
raise ImportError(MISSING_HTTPX_MESSAGE) from exc

return httpx
127 changes: 127 additions & 0 deletions mlbstatsapi/_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import inspect
import warnings
from typing import Protocol

from .exceptions import MlbHttpError
from .warnings import MlbHttpCompatibilityWarning


HTTP_ERROR_BODY_EXCERPT_LIMIT = 500


class _ResponseLike(Protocol):
content: bytes
text: str

def json(self) -> object:
...


def _is_mlbstatsapi_module(module_name: str) -> bool:
"""Return True when module_name belongs to this package."""
return module_name == "mlbstatsapi" or module_name.startswith("mlbstatsapi.")


def _compatibility_warning_stacklevel() -> int:
"""Return a warnings.warn stacklevel for the first non-package caller."""
frame = inspect.currentframe()
stacklevel = 1

try:
frame = frame.f_back

while frame is not None:
module_name = frame.f_globals.get("__name__", "")

if not _is_mlbstatsapi_module(module_name):
return stacklevel

stacklevel += 1
frame = frame.f_back
finally:
del frame

return 1


def _warn_http_compatibility(
*,
status_code: int,
url: str,
) -> None:
warnings.warn(
(
f"HTTP {status_code} for {url} was suppressed because "
"strict_http=False explicitly selected compatibility mode, so the "
"historical empty result was returned. Strict HTTP behavior is the "
"default in version 1.0. Remove strict_http=False or pass "
"strict_http=True to raise MlbHttpError."
),
MlbHttpCompatibilityWarning,
stacklevel=_compatibility_warning_stacklevel(),
)


def _extract_error_response_data(
response: _ResponseLike,
) -> dict | list | None:
"""Best-effort JSON extraction from an error response."""
try:
if not response.content:
return None

data = response.json()
except Exception:
return None

if isinstance(data, (dict, list)):
return data

return None


def _extract_error_body_excerpt(
response: _ResponseLike,
) -> str | None:
"""Best-effort bounded text excerpt from an error response."""
try:
if not response.content:
return None

text = response.text
except Exception:
return None

if not text:
return None

return text[:HTTP_ERROR_BODY_EXCERPT_LIMIT]


def _build_http_error(
response: _ResponseLike,
*,
status_code: int,
reason: str,
url: str | None,
method: str,
) -> MlbHttpError:
"""Build MlbHttpError from transport-neutral response context."""
try:
response_data = _extract_error_response_data(response)
except Exception:
response_data = None

try:
body_excerpt = _extract_error_body_excerpt(response)
except Exception:
body_excerpt = None

return MlbHttpError(
status_code=status_code,
reason=reason,
url=url,
method=method,
response_data=response_data,
body_excerpt=body_excerpt,
)
Loading
Loading