diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index b4efbd66..5f4e7d6c 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -4,11 +4,11 @@ on:
pull_request:
branches:
- main
- - release/1.0.0
+ - "release/**"
push:
branches:
- main
- - release/1.0.0
+ - "release/**"
workflow_dispatch:
permissions:
@@ -27,7 +27,7 @@ jobs:
# first failure, so a single-version incompatibility is easy to isolate.
fail-fast: false
# Python 3.15 is intentionally absent: it is still a prerelease during the
- # 1.0 release work and is not claimed as a supported version.
+ # 1.1 release work and is not claimed as a supported version.
matrix:
python-version:
- "3.10"
@@ -48,13 +48,53 @@ jobs:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install dependencies
- run: poetry install --no-interaction
+ run: poetry install --no-interaction -E async
- name: Run offline tests
run: |
poetry run pytest \
tests/ \
--ignore=tests/external_tests
+ sync-only:
+ name: Sync-only installation
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.14
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.14"
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Install dependencies without async extra
+ run: poetry install --no-interaction --only main
+
+ - name: Verify sync-only installation
+ run: |
+ poetry run python - <<'PY'
+ import importlib.util
+
+ assert importlib.util.find_spec("httpx") is None, (
+ "HTTPX should not be installed without the async extra"
+ )
+
+ import mlbstatsapi
+ from mlbstatsapi import Mlb, MlbDataAdapter
+
+ assert mlbstatsapi.Mlb is Mlb
+ assert mlbstatsapi.MlbDataAdapter is MlbDataAdapter
+
+ print("Sync-only installation verified without HTTPX")
+ PY
+
+
build-package:
name: Build and validate package
needs: offline-tests
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 00000000..37e66f3f
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,45 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
+ plugins: 'code-review@claude-code-plugins'
+ prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+ claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"'
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 00000000..6b15fac7
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr *)'
+
diff --git a/.github/workflows/external-tests.yml b/.github/workflows/external-tests.yml
index cc76258a..5d458715 100644
--- a/.github/workflows/external-tests.yml
+++ b/.github/workflows/external-tests.yml
@@ -26,7 +26,7 @@ jobs:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install dependencies
- run: poetry install --no-interaction
+ run: poetry install --no-interaction -E async
- name: Run external MLB API tests
run: |
poetry run pytest \
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 73cc3890..aaeacb10 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,11 +19,60 @@ Pull requests are the best way to propose changes to the codebase. We actively w
4. Ensure the test suite passes.
5. Issue that pull request!
+## Development
+
+Install dependencies:
+
+```bash
+poetry install -E async
+```
+
+Offline tests are deterministic and should run before every pull request:
+
+```bash
+poetry run pytest \
+ tests/ \
+ --ignore=tests/external_tests
+```
+
+External tests contact the live MLB API. They require internet access and are separate from normal offline CI:
+
+```bash
+poetry run pytest \
+ tests/external_tests/
+```
+
+These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads.
+
+Full local validation:
+
+```bash
+poetry run pytest tests/
+rm -rf dist
+poetry build
+python3 scripts/validate_release.py
+poetry run twine check dist/*
+```
+
+`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. Every response it observes comes from injected fake HTTP clients, so it never contacts the MLB API.
+
+Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases.
+
+## Pull Request Guidelines
+
+- Run offline tests before submitting a PR
+- Use the [PR template](.github/pull_request_template.md) when creating your pull request
+- Follow the branch naming convention:
+ - `feat/` - New features
+ - `fix/` - Bug fixes
+ - `docs/` - Documentation updates
+ - `refactor/` - Code improvements
+
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues)
-We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy!
+We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new).
## Write bug reports with detail, background, and sample code
**Great Bug Reports** tend to have:
@@ -37,7 +86,7 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue]
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
## Use a Consistent Coding Style
-* Adhere to this projects coding style
+* Adhere to this project's coding style
## License
-By contributing, you agree that your contributions will be licensed under its MIT License.
\ No newline at end of file
+By contributing, you agree that your contributions will be licensed under its MIT License.
diff --git a/README.md b/README.md
index 141379d5..34b2c15a 100644
--- a/README.md
+++ b/README.md
@@ -9,833 +9,278 @@


-
+### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Methods](docs/methods.md) | [Examples](docs/examples.md) | [Stats](docs/stats.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/)
-### *Copyright Notice*
-This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt.
-
-###### This is an educational project - Not for commercial use.
+
+`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more.
-
-
-## Getting Started
+Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names.
-*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+.
+Version 1.1 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without code changes for sync users.
-All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience.
+### Copyright Notice
-For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more.
+This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt.
+###### This is an educational project - Not for commercial use.
-
+
-### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/)
+## Installation
-
+### Synchronous client
-## Installation
```bash
python3 -m pip install python-mlb-statsapi
```
-### Python support
-
-| Claim | Value |
-| --- | --- |
-| Minimum declared Python version (`Requires-Python`) | `>=3.10` |
-| CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 |
-
-The minimum declared Python version is 3.10 and the CI-validated versions are
-3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are
-excluded from the required test matrix and are not claimed as supported.
-
-## Quick Start
-```python
->>> import mlbstatsapi
->>> mlb = mlbstatsapi.Mlb()
-
->>> mlb.get_people_id("Ty France")
-[664034]
-
->>> player = mlb.get_person(664034)
->>> print(player.full_name)
-Ty France
-
->>> stats = ['season', 'seasonAdvanced']
->>> groups = ['hitting']
->>> params = {'season': 2022}
->>> mlb.get_player_stats(664034, stats, groups, **params)
-{'hitting': {'season': Stat, 'seasonAdvanced': Stat }}
-
->>> mlb.get_team_id("Seattle Mariners")
-[136]
-
->>> team = mlb.get_team(136)
->>> print(team.name, team.franchise_name)
-Seattle Mariners Seattle
-```
-
-## HTTP Sessions, Timeouts, Retries, and Error Behavior
-
-Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract.
-
-The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default.
-
-For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md).
+### Async support
-### Upgrading to version 1.0
+Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`:
-`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`.
-
-```text
-Mlb() now uses strict HTTP handling by default
-Final non-404 4xx responses raise MlbHttpError
-404 keeps endpoint-specific None / [] / {} behavior
-Final 5xx still raises MlbHttpError
-Timeouts still raise MlbTimeoutError
-Transport failures still raise MlbTransportError
-Successful invalid JSON still raises MlbDecodeError
-```
-
-Recommended version 1.0 usage:
-
-```python
-import mlbstatsapi
-
-try:
- with mlbstatsapi.Mlb() as mlb:
- player = mlb.get_person(664034)
-except mlbstatsapi.MlbHttpError as exc:
- print(exc.status_code)
- print(exc.reason)
- print(exc.url)
+```bash
+python3 -m pip install "python-mlb-statsapi[async]"
```
-Temporary compatibility opt-out while migrating:
-
-```python
-import mlbstatsapi
+The async extra installs HTTPX. Python 3.10 or newer is required.
-with mlbstatsapi.Mlb(strict_http=False) as mlb:
- player = mlb.get_person(664034)
-```
+| Claim | Value |
+| --- | --- |
+| Minimum Python version | `>=3.10` |
+| CI-validated versions | Python 3.10 through 3.14 (`3.10`, `3.11`, `3.12`, `3.13`, `3.14`) |
-`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples.
+See [Python support](docs/public-api.md#python-support) for the complete policy.
-### Recommended context-manager usage
+## Quick Start
-Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception:
+### Sync
```python
-import mlbstatsapi
+from mlbstatsapi import Mlb
-with mlbstatsapi.Mlb() as mlb:
+with Mlb() as mlb:
player = mlb.get_person(664034)
team = mlb.get_team(136)
-```
-
-One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection.
-
-Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner.
-
-### Compatibility mode
-
-Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures.
-
-The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error:
-
-```python
-import warnings
-import mlbstatsapi
-
-warnings.filterwarnings(
- "error",
- category=mlbstatsapi.MlbHttpCompatibilityWarning,
-)
-```
-
-Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning.
-
-### Custom timeouts
-
-Every request uses an explicit timeout. The defaults are:
-```text
-Connection timeout: 3.05 seconds
-Read timeout: 30 seconds
+print(player.full_name)
+print(team.name)
```
-The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request.
-
-Use a scalar to apply the same value to both connect and read phases:
+### Async
```python
-import mlbstatsapi
-
-with mlbstatsapi.Mlb(timeout=10) as mlb:
- player = mlb.get_person(664034)
-```
-
-Or provide separate connection and read timeouts:
-
-```python
-import mlbstatsapi
-
-with mlbstatsapi.Mlb(
- timeout=(5.0, 60.0),
-) as mlb:
- player = mlb.get_person(664034)
-```
+import asyncio
-```text
-5.0 seconds: connection timeout
-60.0 seconds: read timeout
-```
+from mlbstatsapi import AsyncMlb
-### Injecting a custom Session
-Advanced callers may inject a caller-owned Session:
+async def main():
+ async with AsyncMlb() as mlb:
+ player = await mlb.get_person(664034)
+ team = await mlb.get_team(136)
-```python
-import requests
-import mlbstatsapi
+ print(player.full_name)
+ print(team.name)
-session = requests.Session()
-session.headers.update({
- "User-Agent": "my-baseball-project/1.0",
-})
-try:
- with mlbstatsapi.Mlb(session=session) as mlb:
- player = mlb.get_person(664034)
-finally:
- session.close()
+asyncio.run(main())
```
-Ownership rules:
+### Without a context manager
-```text
-Library-created Session
- The library configures and closes it
-Caller-injected Session
- The caller configures and closes it
-```
+Context managers are recommended, but both clients can also be created directly. When doing that, close library-owned HTTP resources explicitly.
-`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration.
-
-### Reusing the retry policy on a caller-managed Session
-
-`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior:
+#### Sync
```python
-import requests
-import mlbstatsapi
-
-session = requests.Session()
-adapter = requests.adapters.HTTPAdapter(
- max_retries=mlbstatsapi.create_retry_policy(),
-)
-session.mount("https://", adapter)
-session.mount("http://", adapter)
+from mlbstatsapi import Mlb
+mlb = Mlb()
try:
- with mlbstatsapi.Mlb(session=session) as mlb:
- player = mlb.get_person(664034)
-finally:
- session.close()
-```
-
-* The caller mounts the adapters
-* The caller closes the injected Session
-* The library never reconfigures an injected Session
-
-### Versioned User-Agent
-
-A Session created by the library sends a package-specific User-Agent:
+ player = mlb.get_person(664034)
+ team = mlb.get_team(136)
-```text
-python-mlb-statsapi/
+ print(player.full_name)
+ print(team.name)
+finally:
+ mlb.close()
```
-For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version.
-
-Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it.
-
-### Structured exception handling
+#### Async
```python
-import mlbstatsapi
+import asyncio
-try:
- with mlbstatsapi.Mlb() as mlb:
- player = mlb.get_person(664034)
-except mlbstatsapi.MlbTimeoutError:
- print("The MLB API timed out")
-except mlbstatsapi.MlbTransportError:
- print("The request could not reach the MLB API")
-except mlbstatsapi.MlbHttpError as exc:
- print(exc.method)
- print(exc.status_code)
- print(exc.reason)
- print(exc.url)
- print(exc.response_data)
- print(exc.body_excerpt)
-except mlbstatsapi.MlbDecodeError:
- print("The MLB API returned invalid JSON")
-```
+from mlbstatsapi import AsyncMlb
-* `MlbTimeoutError` represents connection and read timeouts
-* `MlbTransportError` represents other request transport failures
-* `MlbHttpError` represents an unexpected final HTTP response
-* `MlbDecodeError` represents invalid JSON in a successful response
-`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise.
+async def main():
+ mlb = AsyncMlb()
+ try:
+ player = await mlb.get_person(664034)
+ team = await mlb.get_team(136)
-### Backward-compatible exception handling
+ print(player.full_name)
+ print(team.name)
+ finally:
+ await mlb.aclose()
-All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible:
-```python
-import mlbstatsapi
-
-try:
- with mlbstatsapi.Mlb() as mlb:
- player = mlb.get_person(664034)
-except mlbstatsapi.TheMlbStatsApiException:
- print("The MLB request failed")
-```
-
-### Default retry behavior
-
-Library-created Sessions automatically retry temporary GET failures for:
-
-```text
-429
-500
-502
-503
-504
-```
-
-```text
-Initial request: 1
-Maximum retries: 3
-Maximum total attempts: 4
-Backoff factor: 0.5
-Retry-After respected: yes
+asyncio.run(main())
```
-Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior.
+See [Async usage](docs/async.md) for lifecycle, concurrency, custom HTTPX clients, and the current async endpoint list.
-### Existing 404 compatibility
-
-Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce:
-
-```text
-None
-[]
-{}
-```
+## Sync or Async?
-Not every 404 raises `MlbHttpError`, and the strict default does not change that.
+| | `Mlb` | `AsyncMlb` |
+| --- | --- | --- |
+| HTTP library | Requests | HTTPX |
+| Context manager | `with Mlb()` | `async with AsyncMlb()` |
+| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` |
+| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` |
-### HTTP behavior at a glance
+`AsyncMlb` mirrors the full endpoint surface of `Mlb`. Both clients return the same Pydantic models and follow the same public HTTP/error behavior.
-| Final response | Default 1.0 behavior | Explicit compatibility mode |
-| -------------- | -------------------- | --------------------------- |
-| Successful 2xx | Normal result | Normal result |
-| Non-404 4xx | `MlbHttpError` | Warning and historical empty result |
-| 404 | Existing endpoint behavior | Existing endpoint behavior |
-| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries |
-| Final 5xx | `MlbHttpError` | `MlbHttpError` |
+See the [public API contract](docs/public-api.md#asyncmlb-public-client) for the authoritative method list and signatures.
-See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary.
+## Concurrent Async Requests
-## Working with Pydantic Models
+`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is controlled by the caller.
-All returned objects are Pydantic models, giving you access to powerful serialization and validation features.
-
-### Convert to Dictionary
```python
->>> player = mlb.get_person(664034)
->>> player.model_dump()
-{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...}
-
-# Exclude None values
->>> player.model_dump(exclude_none=True)
-{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...}
-
-# Include only specific fields
->>> player.model_dump(include={'id', 'full_name', 'primary_position'})
-{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)}
-```
-
-### Convert to JSON
-```python
->>> player = mlb.get_person(664034)
->>> player.model_dump_json()
-'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}'
-
-# Pretty print with indentation
->>> print(player.model_dump_json(indent=2))
-{
- "id": 664034,
- "full_name": "Ty France",
- "link": "/api/v1/people/664034",
- ...
-}
-```
-
-### Access Fields with Snake Case Names
-```python
->>> player = mlb.get_person(664034)
->>> player.full_name # Not fullName
-'Ty France'
->>> player.primary_position # Not primaryPosition
-Position(code='3', name='First Base', ...)
->>> player.bat_side # Not batSide
-CodeDesc(code='R', description='Right')
-```
-
-## Documentation
-
-### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People)
-* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname
-* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id
-* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport
-### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round))
-* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year
-### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award)
-* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award
-### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team)
-* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name
-* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id
-* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport
-* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season
-* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season
-### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats)
-* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups
-* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups
-* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args
-* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game
-### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace)
-* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team.
-### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue)
-* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s)
-* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id
-* `Mlb.get_venues(self, **params)` - Return all Venues
-### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport)
-* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id
-* `Mlb.get_sports(self, **params)` - Return all Sports
-* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name
-### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule)
-* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule
-### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division)
-* `Mlb.get_division(self, division_id: int, **params)` - Return a Division
-* `Mlb.get_divisions(self, **params)` - Return all Divisions
-* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name
-### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League)
-* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id
-* `Mlb.get_leagues(self, **params)` - Return all Leagues
-* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s)
-### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season)
-* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season
-* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons
-### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings)
-* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings
-### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule)
-* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates
-* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates
-### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game)
-* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id
-* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game
-* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game
-* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game
+import asyncio
+from mlbstatsapi import AsyncMlb
-## Contributing
-
-Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help.
-
-### Getting Started
-
-1. Fork the repository
-2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git`
-3. Install dependencies: `poetry install`
-4. Create a branch: `git checkout -b feat/your-feature`
-### Development
+async def main():
+ async with AsyncMlb() as mlb:
+ player, team = await asyncio.gather(
+ mlb.get_person(664034),
+ mlb.get_team(136),
+ )
-Offline tests are deterministic and should run before every pull request:
-
-```bash
-poetry run pytest \
- tests/ \
- --ignore=tests/external_tests
-```
+ return player, team
-External tests contact the live MLB API. They require internet access and are separate from normal offline CI:
-```bash
-poetry run pytest \
- tests/external_tests/
-```
-
-These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads.
-
-Full local validation:
-
-```bash
-poetry run pytest tests/
-rm -rf dist
-poetry build
-python3 scripts/validate_release.py
-poetry run twine check dist/*
+player, team = asyncio.run(main())
```
-`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API.
+`AsyncMlb` does not create hidden background tasks or automatic request fanout.
-Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases.
+## Common Methods
-### Pull Request Guidelines
+The examples below assume an initialized `Mlb` client named `mlb`, as shown in [Quick Start](#quick-start).
-- Run offline tests before submitting a PR
-- Use the [PR template](.github/pull_request_template.md) when creating your pull request
-- Follow the branch naming convention:
- - `feat/` - New features
- - `fix/` - Bug fixes
- - `docs/` - Documentation updates
- - `refactor/` - Code improvements
+### Players
-### Reporting Issues
-
-Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with:
-
-- A clear description of the problem or feature
-- Steps to reproduce (for bugs)
-- Expected vs actual behavior
-- Python version and package version
-
-
-## Examples
-
-Let's show some examples of getting stat objects from the API. What is baseball without stats, right?
-
-### Player Stats
-Get the Id(s) of the players you want stats for and set stat types and groups.
```python
->>> mlb = mlbstatsapi.Mlb()
->>> player_id = mlb.get_people_id("Ty France")[0]
->>> stats = ['season', 'career']
->>> groups = ['hitting', 'pitching']
->>> params = {'season': 2022}
+player = mlb.get_person(664034)
+players = mlb.get_people()
+player_ids = mlb.get_people_id("Ty France")
```
-Use player id with stat types and groups to return a stats dictionary
-```python
->>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params)
->>> season_hitting_stat = stat_dict['hitting']['season']
->>> career_pitching_stat = stat_dict['pitching']['career']
-```
+### Teams
-Print season hitting stats using Pydantic's `model_dump()`
```python
->>> for split in season_hitting_stat.splits:
-... print(split.stat.model_dump(exclude_none=True))
-{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...}
+team = mlb.get_team(136)
+teams = mlb.get_teams()
+team_ids = mlb.get_team_id("Seattle Mariners")
```
-Or access individual fields directly
-```python
->>> for split in season_hitting_stat.splits:
-... print(f"Games: {split.stat.games_played}")
-... print(f"Home Runs: {split.stat.home_runs}")
-... print(f"Batting Avg: {split.stat.avg}")
-Games: 140
-Home Runs: 20
-Batting Avg: .274
-```
+### Stats
-### Team Stats
-Get the Team Id(s)
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> team_id = mlb.get_team_id('Seattle Mariners')[0]
-```
+The stats API has several entry points and returns a nested `stats[group][type]` structure. See the dedicated [Stats Guide](docs/stats.md) for `get_player_stats()`, `get_team_stats()`, `get_stats()`, and `get_players_stats_for_game()` examples using both `Mlb` and `AsyncMlb`.
-Set the stat types and groups
-```python
->>> stats = ['season', 'seasonAdvanced']
->>> groups = ['hitting']
->>> params = {'season': 2022}
-```
+### Schedule
-Use team id and the stat types and groups to return season hitting stats
```python
->>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params)
->>> season_hitting = stats['hitting']['season']
->>> advanced_hitting = stats['hitting']['seasonAdvanced']
+schedule = mlb.get_schedule(date="2022-10-13")
```
-Print stats as JSON
-```python
->>> for split in season_hitting.splits:
-... print(split.stat.model_dump_json(indent=2, exclude_none=True))
-{
- "games_played": 162,
- "groundouts": 1273,
- "runs": 690,
- "doubles": 229,
- ...
-}
-```
+See the [method reference](docs/methods.md) for the full method documentation that previously lived in the README. Longer runnable examples live in [docs/examples.md](docs/examples.md).
-### Expected Stats
-```python
->>> player_id = mlb.get_people_id('Ty France')[0]
->>> stats = ['expectedStatistics']
->>> group = ['hitting']
->>> params = {'season': 2022}
-
->>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params)
->>> expected = stats['hitting']['expectedStatistics']
->>> for split in expected.splits:
-... print(f"Expected AVG: {split.stat.avg}")
-... print(f"Expected SLG: {split.stat.slg}")
-Expected AVG: .259
-Expected SLG: .394
-```
+## HTTP and Error Behavior
-### vsPlayer Stats
-Get pitcher and batter player Ids
-```python
->>> ty_france_id = mlb.get_people_id('Ty France')[0]
->>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0]
-```
+Both clients use explicit timeouts, structured exceptions, and pooled HTTP connections. `strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved.
-Set stat type, stat groups, and params
-```python
->>> stats = ['vsPlayer']
->>> group = ['hitting']
->>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022}
-```
+Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.1.0`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract.
-Get stats
-```python
->>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params)
->>> vs_player = stats['hitting']['vsPlayer']
->>> for split in vs_player.splits:
-... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}")
-Games: 2, Hits: 2
-```
+The main transport exceptions are:
-### Hot/Cold Zones
-```python
->>> ty_france_id = mlb.get_people_id('Ty France')[0]
->>> stats = ['hotColdZones']
->>> hitting_group = ['hitting']
->>> params = {'season': 2022}
-
->>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params)
->>> zones = hotcoldzones['stats']['hotColdZones']
-
->>> for split in zones.splits:
-... print(f"Stat: {split.stat.name}")
-... for zone in split.stat.zones:
-... print(f" Zone {zone.zone}: {zone.value}")
-Stat: battingAverage
- Zone 01: .226
- Zone 02: .400
- ...
-```
+* `MlbHttpError`
+* `MlbTimeoutError`
+* `MlbTransportError`
+* `MlbDecodeError`
-### Schedule Examples
-Get a schedule for a given date
```python
->>> mlb = mlbstatsapi.Mlb()
->>> schedule = mlb.get_schedule(date='2022-10-13')
->>> dates = schedule.dates
-
->>> for date in dates:
-... for game in date.games:
-... print(f"Game: {game.game_pk}")
-... print(f"Status: {game.status.detailed_state}")
-... print(f"Home: {game.teams.home.team.name}")
-... print(f"Away: {game.teams.away.team.name}")
-```
+from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError
-### Game Examples
-Get a Game for a given game id
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> game = mlb.get_game(662242)
+try:
+ with Mlb() as mlb:
+ player = mlb.get_person(664034)
+except MlbTimeoutError:
+ print("The MLB API timed out")
+except MlbHttpError as exc:
+ print(exc.status_code, exc.reason)
```
-Get the weather for a game
-```python
->>> weather = game.game_data.weather
->>> print(f"Condition: {weather.condition}")
->>> print(f"Temperature: {weather.temp}")
->>> print(f"Wind: {weather.wind}")
-```
+For timeouts, retries, compatibility mode, ownership rules, and transport details, see [docs/http-transport.md](docs/http-transport.md).
-Get the current status of a game
-```python
->>> linescore = game.live_data.linescore
->>> home_info = game.game_data.teams.home
->>> away_info = game.game_data.teams.away
->>> home_status = linescore.teams.home
->>> away_status = linescore.teams.away
-
->>> print(f"Home: {home_info.franchise_name} {home_info.club_name}")
->>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}")
->>> print(f"Away: {away_info.franchise_name} {away_info.club_name}")
->>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}")
->>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}")
-```
+## Working with Models
-Get play by play, line score, and box score objects
-```python
->>> play_by_play = game.live_data.plays
->>> line_score = game.live_data.linescore
->>> box_score = game.live_data.boxscore
-```
+Every returned model object uses Pydantic and Python-style `snake_case` fields:
-#### Play by Play
-Get only the play by play for a given game id
```python
->>> playbyplay = mlb.get_game_play_by_play(662242)
-```
+from mlbstatsapi import Mlb
-#### Line Score
-Get only the line score for a given game id
-```python
->>> linescore = mlb.get_game_line_score(662242)
-```
-
-#### Box Score
-Get only the box score for a given game id
-```python
->>> boxscore = mlb.get_game_box_score(662242)
-```
-
-### Gamepace Examples
-Get pace of game metrics for a specific season
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> gamepace = mlb.get_gamepace(season=2021)
->>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}")
-```
+with Mlb() as mlb:
+ player = mlb.get_person(664034)
-### People Examples
-Get all Players for a given sport id
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> players = mlb.get_people(sport_id=1)
->>> for player in players:
-... print(f"{player.id}: {player.full_name}")
+print(player.full_name) # not fullName
+print(player.model_dump(exclude_none=True))
+print(player.model_dump_json(indent=2))
```
-Get a player id
-```python
->>> player_id = mlb.get_people_id("Ty France")
->>> print(player_id[0])
-664034
-```
+## Documentation
-### Team Examples
-Get a Team
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> team_id = mlb.get_team_id("Seattle Mariners")[0]
->>> team = mlb.get_team(team_id)
->>> print(f"{team.id}: {team.name}")
->>> print(f"Venue: {team.venue.name}")
-```
+| Document | Contents |
+| --- | --- |
+| [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | Endpoint reference, return objects, and model documentation |
+| [Method reference](docs/methods.md) | Method signatures and short descriptions from the original README reference |
+| [Usage examples](docs/examples.md) | Extended synchronous examples |
+| [Stats guide](docs/stats.md) | Player, team, general, and per-game stat queries with sync and async examples |
+| [Async usage](docs/async.md) | Async installation, lifecycle, concurrency, and examples |
+| [HTTP transport](docs/http-transport.md) | Timeouts, retries, strict HTTP, exceptions, and ownership |
+| [Public API contract](docs/public-api.md) | Supported symbols, signatures, endpoint methods, and stability policy |
+| [Release notes](docs/releases/) | Release-specific changes and migration notes |
-Get a Player Roster
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> players = mlb.get_team_roster(136)
->>> for player in players:
-... print(f"#{player.jersey_number} {player.person.full_name}")
-```
+## Contributing
-Get a Coach Roster
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> coaches = mlb.get_team_coaches(136)
->>> for coach in coaches:
-... print(f"{coach.person.full_name}: {coach.title}")
-```
+Contributions, bug fixes, tests, and documentation improvements are welcome.
-### Draft Examples
-Get a draft for a year
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> draft = mlb.get_draft('2019')
+```bash
+git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git
+cd python-mlb-statsapi
+poetry install -E async
```
-Get Players from Draft
-```python
->>> draftpicks = draft[0].picks
->>> for pick in draftpicks:
-... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}")
-```
+Run the deterministic offline suite before a pull request:
-### Award Examples
-Get awards for a given award id
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108')
->>> for recipient in retired_numbers.awards:
-... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})")
+```bash
+poetry run pytest tests/ --ignore=tests/external_tests
```
-### Venue Examples
-Get a Venue
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> venue_id = mlb.get_venue_id('PNC Park')[0]
->>> venue = mlb.get_venue(venue_id)
->>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}")
-```
+External tests contact the live MLB API and are separate from normal offline CI:
-### Division Examples
-Get a division
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> division = mlb.get_division(200)
->>> print(division.name)
-American League West
+```bash
+poetry run pytest tests/external_tests/
```
-### League Examples
-Get a league
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> league = mlb.get_league(103)
->>> print(league.name)
-American League
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development and pull request workflow.
-### Season Examples
-Get a Season
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> season = mlb.get_season(2018)
->>> print(f"Season: {season.season_id}")
->>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}")
-```
+## License
-### Standings Examples
-Get Standings
-```python
->>> mlb = mlbstatsapi.Mlb()
->>> standings = mlb.get_standings(103, 2018)
->>> for record in standings:
-... print(f"Division: {record.division.name}")
-... for team in record.team_records:
-... print(f" {team.team.name}: {team.wins}-{team.losses}")
-```
+Released under the [MIT License](LICENSE).
diff --git a/docs/async.md b/docs/async.md
new file mode 100644
index 00000000..0103a069
--- /dev/null
+++ b/docs/async.md
@@ -0,0 +1,231 @@
+# Async Usage
+
+`AsyncMlb` is the public asynchronous client for `python-mlb-statsapi` 1.1.
+It requires the optional `async` extra.
+
+## Installation
+
+```bash
+python3 -m pip install "python-mlb-statsapi[async]"
+```
+
+A synchronous-only install remains unchanged and does not require HTTPX.
+
+## Quick start
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ player = await mlb.get_person(664034)
+ team = await mlb.get_team(136)
+
+ print(player.full_name)
+ print(team.name)
+
+
+asyncio.run(main())
+```
+
+Use `async with` when possible so library-owned HTTP resources are closed when
+the block exits.
+
+## Without a context manager
+
+If a context manager is not practical, create `AsyncMlb` directly and call
+`await mlb.aclose()` when finished:
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ mlb = AsyncMlb()
+ try:
+ player = await mlb.get_person(664034)
+ team = await mlb.get_team(136)
+
+ print(player.full_name)
+ print(team.name)
+ finally:
+ await mlb.aclose()
+
+
+asyncio.run(main())
+```
+
+Repeated `aclose()` calls are safe.
+
+## Concurrent requests
+
+One `AsyncMlb` instance supports concurrent in-flight requests on the same
+event loop. Concurrency is controlled by the caller.
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ player, team = await asyncio.gather(
+ mlb.get_person(664034),
+ mlb.get_team(136),
+ )
+
+ return player, team
+
+
+player, team = asyncio.run(main())
+```
+
+`AsyncMlb` does not create hidden background tasks or automatic request fanout.
+Cross-event-loop use of the same client is not promised.
+
+## Supported endpoints
+
+`AsyncMlb` mirrors the endpoint surface exposed by `Mlb`. Its endpoint methods
+are asynchronous and return the same parsed Pydantic model types while following
+the same public HTTP/error behavior as their synchronous counterparts.
+
+For the authoritative method list and signatures, see the
+[public API contract](public-api.md#asyncmlb-public-client).
+
+## Error handling
+
+The public exception hierarchy is shared with the synchronous client:
+
+```python
+from mlbstatsapi import (
+ AsyncMlb,
+ MlbDecodeError,
+ MlbHttpError,
+ MlbTimeoutError,
+ MlbTransportError,
+)
+
+
+async def get_player():
+ try:
+ async with AsyncMlb() as mlb:
+ return await mlb.get_person(664034)
+ except MlbTimeoutError:
+ print("The MLB API timed out")
+ except MlbTransportError:
+ print("The request could not reach the MLB API")
+ except MlbHttpError as exc:
+ print(exc.status_code, exc.reason)
+ except MlbDecodeError:
+ print("The MLB API returned invalid JSON")
+```
+
+`strict_http=True` is the default. Existing endpoint-specific 404 behavior is
+preserved. See the [HTTP transport documentation](http-transport.md) for the
+complete status, timeout, retry, and compatibility-mode contract.
+
+## Custom HTTPX client
+
+Advanced callers may inject their own `httpx.AsyncClient`:
+
+```python
+import httpx
+
+from mlbstatsapi import AsyncMlb
+
+
+async def get_person_with_custom_client(client: httpx.AsyncClient, person_id: int):
+ async with AsyncMlb(client=client) as mlb:
+ return await mlb.get_person(person_id)
+```
+
+`async with` and `await` are only valid inside an `async def`, so this is
+written as a plain, reusable function rather than a top-level script. Call it
+however your application already enters async code — `asyncio.run(...)`, a
+web framework's request handler, an existing event loop, and so on. Nothing
+here requires restructuring your application around a `main()` entry point;
+`get_person_with_custom_client()` itself has no opinion on how it is invoked.
+
+Below are a few ways to invoke it, depending on how your application already
+enters async code.
+
+**Script entry point**
+
+```python
+import asyncio
+
+
+async def main():
+ async with httpx.AsyncClient() as client:
+ return await get_person_with_custom_client(client, 664034)
+
+
+asyncio.run(main())
+```
+
+**Inside an application that already runs on an event loop** — a web
+framework's request handler, a worker task, and so on — just `await` it
+directly with a client your application already owns:
+
+```python
+async def handle_request(client: httpx.AsyncClient, person_id: int):
+ return await get_person_with_custom_client(client, person_id)
+```
+
+**FastAPI (or another ASGI framework)**
+
+```python
+from fastapi import FastAPI
+
+app = FastAPI()
+http_client = httpx.AsyncClient()
+
+
+@app.get("/players/{person_id}")
+async def read_player(person_id: int):
+ return await get_person_with_custom_client(http_client, person_id)
+```
+
+**Interactively, with no wrapper at all** — Jupyter/IPython and the
+`python -m asyncio` REPL both support top-level `await`:
+
+```pycon
+>>> import httpx
+>>> client = httpx.AsyncClient()
+>>> player = await get_person_with_custom_client(client, 664034)
+>>> await client.aclose()
+```
+
+An injected client remains caller-owned and is not closed by `AsyncMlb`. In a
+real application the client is typically created once, reused across calls,
+and closed by whatever code owns its lifecycle — the examples above show a
+few ways to run this, not the required shape of your application.
+
+## Environment proxies
+
+A library-created client (the default — no `client=` passed) honors
+`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the environment,
+the same variables a plain `httpx.AsyncClient()` discovers on its own.
+
+An injected client keeps whatever proxy configuration its caller gave it —
+`httpx.AsyncClient()` reads those variables itself by default, or a caller
+may pass `trust_env=False` or an explicit `proxy=`/`mounts=` to opt out or
+override. The library does not add or remove proxy configuration on an
+injected client.
+
+See [HTTP transport: async client environment
+proxies](http-transport.md#async-client-environment-proxies) for the full
+behavior.
+
+## Documentation boundaries
+
+- [README](../README.md) — installation and quick-start examples
+- [Usage examples](examples.md) — longer synchronous examples
+- [Public API contract](public-api.md) — supported symbols, signatures, and endpoint coverage
+- [HTTP transport](http-transport.md) — timeouts, retries, errors, and compatibility behavior
diff --git a/docs/examples.md b/docs/examples.md
new file mode 100644
index 00000000..442dfb77
--- /dev/null
+++ b/docs/examples.md
@@ -0,0 +1,157 @@
+# Usage Examples
+
+This document collects the longer usage examples that previously lived in the README. The README keeps a short quick start; this guide is the extended tour.
+
+Every example in this file uses the synchronous `Mlb` client. Async usage is documented separately in [async.md](async.md). Stats have their own detailed [stats guide](stats.md) with both sync and async examples.
+
+For return-object structure and endpoint details see the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki). For the supported method list, parameters, and return shapes see the [public API contract](public-api.md). For transport behavior see the [HTTP transport documentation](http-transport.md).
+
+## Without a context manager
+
+A context manager is recommended, but `Mlb` can also be created directly. Call `mlb.close()` when finished so library-owned HTTP resources are released.
+
+```python
+from mlbstatsapi import Mlb
+
+mlb = Mlb()
+try:
+ player = mlb.get_person(664034)
+ team = mlb.get_team(136)
+
+ print(player.full_name)
+ print(team.name)
+finally:
+ mlb.close()
+```
+
+## Working with Pydantic Models
+
+All returned objects are Pydantic models, giving you access to serialization and validation helpers.
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ player = mlb.get_person(664034)
+
+print(player.full_name)
+print(player.model_dump(exclude_none=True))
+print(player.model_dump_json(indent=2))
+```
+
+## Players and teams
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ player_id = mlb.get_people_id("Ty France")[0]
+ team_id = mlb.get_team_id("Seattle Mariners")[0]
+
+ player = mlb.get_person(player_id)
+ team = mlb.get_team(team_id)
+
+print(player.full_name)
+print(team.name)
+```
+
+## Stats
+
+Player, team, general, and per-game stat examples live in the dedicated [Stats Guide](stats.md). It also explains the nested `stats[group][type]` return structure and includes matching `Mlb` and `AsyncMlb` examples.
+
+## Schedule
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ schedule = mlb.get_schedule(date="2022-10-13")
+
+for date in schedule.dates:
+ for game in date.games:
+ print(game.game_pk, game.status.detailed_state)
+```
+
+## Game data
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ game = mlb.get_game(662242)
+ play_by_play = mlb.get_game_play_by_play(662242)
+ line_score = mlb.get_game_line_score(662242)
+ box_score = mlb.get_game_box_score(662242)
+```
+
+## Rosters
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ players = mlb.get_team_roster(136)
+ coaches = mlb.get_team_coaches(136)
+
+for player in players:
+ print(f"#{player.jersey_number} {player.person.full_name}")
+
+for coach in coaches:
+ print(f"{coach.person.full_name}: {coach.title}")
+```
+
+## Draft
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ draft = mlb.get_draft("2019")
+
+for pick in draft[0].picks:
+ print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}")
+```
+
+## Awards
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ retired_numbers = mlb.get_awards(award_id="RETIREDUNI_108")
+
+for recipient in retired_numbers.awards:
+ print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})")
+```
+
+## Venue, division, league, and season
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ venue_id = mlb.get_venue_id("PNC Park")[0]
+ venue = mlb.get_venue(venue_id)
+ division = mlb.get_division(200)
+ league = mlb.get_league(103)
+ season = mlb.get_season(2018)
+
+print(venue.name)
+print(division.name)
+print(league.name)
+print(season.season_id)
+```
+
+## Standings
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ standings = mlb.get_standings(103, 2018)
+
+for record in standings:
+ print(f"Division: {record.division.name}")
+ for team in record.team_records:
+ print(f" {team.team.name}: {team.wins}-{team.losses}")
+```
diff --git a/docs/http-transport.md b/docs/http-transport.md
index 4b246953..5760613c 100644
--- a/docs/http-transport.md
+++ b/docs/http-transport.md
@@ -1,18 +1,19 @@
# HTTP Transport
This document describes the HTTP transport behavior of the current release,
-version 1.0.0.
+version 1.1.0.
Version 0.8.0 introduced shared Sessions, explicit timeouts, bounded retries,
and structured exceptions. Version 0.9.0 introduced configurable strict
behavior and compatibility warnings. Version 1.0.0 makes strict handling the
default and defines the stable public contract.
-The public client remains synchronous. Ordinary usage does not need to
-configure sessions or retries.
+Version 1.1.0 adds the optional asynchronous `AsyncMlb` and
+`AsyncMlbDataAdapter` clients while preserving the existing synchronous API.
+Ordinary usage does not need to configure sessions, clients, or retries.
-See [the 1.0.0 release notes](releases/1.0.0.md) for a shorter summary of what
-changed. For the authoritative public API boundary see
+See [the 1.1.0 release notes](releases/1.1.0.md) for a shorter summary of what
+changed in the current release. For the authoritative public API boundary see
[the public API contract](public-api.md).
## Public transport API
@@ -21,6 +22,8 @@ Everything this document describes is reachable from the package root:
```python
from mlbstatsapi import (
+ AsyncMlb,
+ AsyncMlbDataAdapter,
Mlb,
MlbDataAdapter,
MlbDecodeError,
@@ -33,6 +36,9 @@ from mlbstatsapi import (
)
```
+The async symbols require the optional `async` installation extra. The
+synchronous symbols remain available without HTTPX.
+
Names that are not exported from `mlbstatsapi` are internal and may change
without a deprecation cycle. See [public-api.md](public-api.md) for the
complete stability classification.
@@ -48,8 +54,10 @@ mlb = mlbstatsapi.Mlb()
player = mlb.get_person(664034)
```
-In version 1.0.0 that construction uses strict HTTP handling by default. The
-client remains synchronous. Async support is not part of version 1.0.0.
+Version 1.0.0 made strict HTTP handling the default for this construction.
+That synchronous behavior is unchanged in 1.1.0, and existing synchronous
+users require no code changes. Version 1.1.0 also provides the optional
+`AsyncMlb` client; see [Async usage](async.md).
## Context manager
@@ -189,7 +197,7 @@ to the library's tested retry policy.
## User-Agent
-Library-created Sessions send a package-specific User-Agent:
+Library-created Sessions and async clients send a package-specific User-Agent:
```text
python-mlb-statsapi/
@@ -198,7 +206,7 @@ python-mlb-statsapi/
With the package version currently declared in project metadata that resolves to:
```text
-python-mlb-statsapi/1.0.1
+python-mlb-statsapi/1.1.0
```
The version comes from the installed package metadata, so it always matches
@@ -207,10 +215,10 @@ the installed release without a separately maintained version string.
Notes:
* The header helps identify package traffic while debugging
-* Other Requests default headers such as `Accept-Encoding`, `Accept`, and `Connection` remain intact
+* Other transport default headers remain intact
* Only `User-Agent` is set; the full header mapping is never replaced
-* Caller-injected Sessions are never modified
-* Applications using an injected Session may set their own User-Agent
+* Caller-injected Sessions and HTTPX clients are never modified
+* Applications using an injected Session or client may set their own User-Agent
* The header contains no machine identifiers, installation identifiers, hostnames, or user tracking data
* This is not telemetry and sends no analytics
@@ -243,12 +251,12 @@ finally:
## Default retry policy
-Library-created Sessions mount a bounded retry policy for GET requests
-automatically.
+Library-created Sessions and async clients use a bounded retry policy for GET
+requests automatically.
-Caller-injected Sessions are never automatically reconfigured. Retry settings
-on an injected Session remain under the caller's control unless the caller
-opts in.
+Caller-injected Sessions and HTTPX clients are never automatically
+reconfigured. Retry settings on injected Sessions and clients remain under
+the caller's control.
```text
Initial request: 1
@@ -386,9 +394,10 @@ is an explicit compatibility opt-out. It:
* Does not alter timeout, transport, or decode failures
* Runs only after retry exhaustion
-Compatibility mode is a temporary migration path and an explicit request for
-historical 0.9 behavior. It is not the recommended long-term 1.0
-configuration.
+`strict_http=False` is a compatibility mode for users migrating from pre-1.0
+behavior. It will remain available throughout the 1.x release series and may
+be removed in 2.0. New code should use the default `strict_http=True` behavior
+and handle `MlbHttpError`.
## Compatibility warnings
@@ -637,7 +646,7 @@ Notes:
* `MlbTimeoutError` is a subtype of `MlbTransportError`
* All new errors inherit from `TheMlbStatsApiException`
* Existing broad exception handling remains valid
-* Original Requests or JSON decoding failures are preserved through exception chaining
+* Original Requests, HTTPX, or JSON decoding failures are preserved through exception chaining
## HTTP exception attributes
@@ -710,8 +719,32 @@ bodies.
The client has no default response cache.
-## No async support
+## Async client environment proxies
+
+Library-created `AsyncMlb` / `AsyncMlbDataAdapter` clients honor
+`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` (any case), the same
+environment variables HTTPX itself discovers for a plain `httpx.AsyncClient()`.
+
+```text
+Library-created async client
+ Reads HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY from the environment
+ Routes matching requests through the proxy
+ Applies the library retry policy to proxied and direct requests alike
+
+Caller-injected async client
+ Keeps exactly whatever transport and mounts its caller configured
+ The library never reads proxy environment variables for it
+```
+
+This mirrors [Session ownership](#session-ownership) on the sync side: the
+library only ever configures a client it created itself. See
+[async.md](async.md#custom-httpx-client) for injecting a client, including one
+configured with its own proxy settings.
-The client remains synchronous.
+## Scope of this document
-Async support is not part of version 1.0.0.
+The retry, timeout, User-Agent, strict-HTTP, and error-handling contract applies
+to both `Mlb` and `AsyncMlb`. Session-specific sections describe the
+synchronous Requests transport; `AsyncMlb` uses a caller-owned or
+library-created HTTPX client with the corresponding ownership rules. See
+[async.md](async.md) for async lifecycle, concurrency, and client injection.
diff --git a/docs/methods.md b/docs/methods.md
new file mode 100644
index 00000000..3c11b2d3
--- /dev/null
+++ b/docs/methods.md
@@ -0,0 +1,221 @@
+# Method Reference
+
+This page contains the method reference that previously lived in the README.
+
+For detailed return-object and model documentation, follow the linked Wiki pages. For the stable 1.x public API contract and current async endpoint coverage, see [public-api.md](public-api.md).
+
+**Jump to:** [People](#people-person-players-coaches) · [Teams](#teams) · [Stats](#stats) · [Games](#games) · [Schedules](#schedules) · [Venues](#venues) · [Sports](#sports) · [Leagues](#leagues) · [Divisions](#divisions) · [Seasons](#seasons) · [Standings](#standings) · [Draft](#draft) · [Awards](#awards) · [Gamepace](#gamepace)
+
+## People, Person, Players, Coaches
+
+[Wiki: People](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People)
+
+| Method | Description |
+| --- | --- |
+| `get_people_id()` | Return person ID(s) from a full name |
+| `get_person()` | Return a person from an ID |
+| `get_people()` | Return all players for a sport |
+
+```text
+Mlb.get_people_id(fullname: str, sport_id: int = 1, search_key: str = 'fullName', **params)
+Mlb.get_person(player_id: int, **params)
+Mlb.get_people(sport_id: int = 1, **params)
+```
+
+## Teams
+
+[Wiki: Team](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team)
+
+| Method | Description |
+| --- | --- |
+| `get_team_id()` | Return team ID(s) from a name |
+| `get_team()` | Return a team from a team ID |
+| `get_teams()` | Return all teams for a sport |
+| `get_team_coaches()` | Return the coaching roster for a team |
+| `get_team_roster()` | Return the player roster for a team |
+
+```text
+Mlb.get_team_id(team_name: str, search_key: str = 'name', **params)
+Mlb.get_team(team_id: int, **params)
+Mlb.get_teams(sport_id: int = 1, **params)
+Mlb.get_team_coaches(team_id: int, **params)
+Mlb.get_team_roster(team_id: int, **params)
+```
+
+## Stats
+
+[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) · [Stats Guide](stats.md)
+
+| Method | Description |
+| --- | --- |
+| `get_player_stats()` | Return stats for a player |
+| `get_team_stats()` | Return stats for a team |
+| `get_stats()` | Return stats by stat type and group |
+| `get_players_stats_for_game()` | Return player stats for a game |
+
+```text
+Mlb.get_player_stats(person_id: int, stats: list, groups: list, **params)
+Mlb.get_team_stats(team_id: int, stats: list, groups: list, **params)
+Mlb.get_stats(stats: list, groups: list, **params: dict)
+Mlb.get_players_stats_for_game(person_id: int, game_id: int, **params)
+```
+
+The [Stats Guide](stats.md) includes runnable `Mlb` and `AsyncMlb` examples and explains the nested `stats[group][type]` return structure.
+
+## Games
+
+[Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game)
+
+| Method | Description |
+| --- | --- |
+| `get_game()` | Return a game for a game ID |
+| `get_game_play_by_play()` | Return play-by-play data for a game |
+| `get_game_line_score()` | Return a linescore for a game |
+| `get_game_box_score()` | Return a boxscore for a game |
+
+```text
+Mlb.get_game(game_id: int, **params)
+Mlb.get_game_play_by_play(game_id: int, **params)
+Mlb.get_game_line_score(game_id: int, **params)
+Mlb.get_game_box_score(game_id: int, **params)
+```
+
+## Schedules
+
+[Wiki: Schedule](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule)
+
+| Method | Description |
+| --- | --- |
+| `get_schedule()` | Return a schedule from a date or date range |
+| `get_scheduled_games_by_date()` | Return scheduled games from dates |
+
+```text
+Mlb.get_schedule(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)
+Mlb.get_scheduled_games_by_date(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params)
+```
+
+## Venues
+
+[Wiki: Venue](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue)
+
+| Method | Description |
+| --- | --- |
+| `get_venue_id()` | Return venue ID(s) from a name |
+| `get_venue()` | Return a venue from an ID |
+| `get_venues()` | Return all venues |
+
+```text
+Mlb.get_venue_id(venue_name: str, search_key: str = 'name', **params)
+Mlb.get_venue(venue_id: int, **params)
+Mlb.get_venues(**params)
+```
+
+## Sports
+
+[Wiki: Sport](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport)
+
+| Method | Description |
+| --- | --- |
+| `get_sport()` | Return a sport from an ID |
+| `get_sports()` | Return all sports |
+| `get_sport_id()` | Return sport ID(s) from a name |
+
+```text
+Mlb.get_sport(sport_id: int, **params)
+Mlb.get_sports(**params)
+Mlb.get_sport_id(sport_name: str, search_key: str = 'name', **params)
+```
+
+## Leagues
+
+[Wiki: League](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League)
+
+| Method | Description |
+| --- | --- |
+| `get_league()` | Return a league from an ID |
+| `get_leagues()` | Return all leagues |
+| `get_league_id()` | Return league ID(s) from a name |
+
+```text
+Mlb.get_league(league_id: int, **params)
+Mlb.get_leagues(**params)
+Mlb.get_league_id(league_name: str, search_key: str = 'name', **params)
+```
+
+## Divisions
+
+[Wiki: Division](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division)
+
+| Method | Description |
+| --- | --- |
+| `get_division()` | Return a division from an ID |
+| `get_divisions()` | Return all divisions |
+| `get_division_id()` | Return division ID(s) from a name |
+
+```text
+Mlb.get_division(division_id: int, **params)
+Mlb.get_divisions(**params)
+Mlb.get_division_id(division_name: str, search_key: str = 'name', **params)
+```
+
+## Seasons
+
+[Wiki: Season](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season)
+
+| Method | Description |
+| --- | --- |
+| `get_season()` | Return a season |
+| `get_seasons()` | Return all seasons |
+
+```text
+Mlb.get_season(season_id: str, sport_id: int = 1, **params)
+Mlb.get_seasons(sport_id: int = 1, **params)
+```
+
+## Standings
+
+[Wiki: Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings)
+
+| Method | Description |
+| --- | --- |
+| `get_standings()` | Return standings for a league and season |
+
+```text
+Mlb.get_standings(league_id: int, season: str, **params)
+```
+
+## Draft
+
+[Wiki: Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round))
+
+| Method | Description |
+| --- | --- |
+| `get_draft()` | Return a draft for a given year |
+
+```text
+Mlb.get_draft(year_id: int, **params)
+```
+
+## Awards
+
+[Wiki: Award](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award)
+
+| Method | Description |
+| --- | --- |
+| `get_awards()` | Return award recipients for an award |
+
+```text
+Mlb.get_awards(award_id: str, **params)
+```
+
+## Gamepace
+
+[Wiki: Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace)
+
+| Method | Description |
+| --- | --- |
+| `get_gamepace()` | Return pace-of-game metrics for a sport, league, or team |
+
+```text
+Mlb.get_gamepace(season: str, sport_id=1, **params)
+```
diff --git a/docs/public-api.md b/docs/public-api.md
index 12ee2176..1e04e090 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.
@@ -28,6 +29,11 @@ During the 1.x series:
* Documented Session ownership behavior will remain compatible
* Documented endpoint-level 404 return shapes will remain compatible
+`strict_http=False` is a compatibility mode for users migrating from pre-1.0
+behavior. It will remain available throughout the 1.x release series and may
+be removed in 2.0. New code should use the default `strict_http=True` behavior
+and handle `MlbHttpError`.
+
The following may still evolve in a compatible way:
* New optional parameters
@@ -78,22 +84,40 @@ from mlbstatsapi import (
)
```
+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
-| 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 |
+| `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 |
+| `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 |
+
+`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
a documented replacement, a warning strategy, a removal timeline, and a
@@ -133,6 +157,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
+
+`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]"
+```
+
+With the extra installed:
+
+```python
+from mlbstatsapi import AsyncMlb, 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.
@@ -182,6 +237,169 @@ 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.
+
+### API versions used by `AsyncMlb`
+
+`AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share
+one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint
+methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. `AsyncMlb`
+owns the shared client, exactly as `Mlb` owns the shared `Session`: it creates
+one when the caller passes none, closes only a client it created, and hands
+the same client to both adapters.
+
+Retries are a property of that client, not of either adapter. A
+library-created client is built with the library retry transport mounted on
+it, the way a library-created `Session` is built with the library retry
+adapters mounted on it, so both API versions retry identically without either
+adapter holding retry state. A caller-injected client keeps whatever transport
+its caller mounted.
+
+### Endpoint methods
+
+The currently supported awaitable endpoint methods are:
+
+```text
+get_team(team_id: int, **params)
+get_teams(sport_id: int = 1, **params)
+get_team_roster(team_id: int, **params)
+get_team_coaches(team_id: int, **params)
+get_person(player_id: int, **params)
+get_people(sport_id: int = 1, **params)
+get_schedule(
+ date: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ sport_id: int = 1,
+ team_id: int = None,
+ **params,
+)
+get_sport(sport_id: int, **params)
+get_sports(**params)
+get_league(league_id: int, **params)
+get_leagues(**params)
+get_division(division_id: int, **params)
+get_divisions(**params)
+get_season(season_id: str, sport_id: int = 1, **params)
+get_seasons(sport_id: int = 1, **params)
+get_venue(venue_id: int, **params)
+get_venues(**params)
+get_standings(league_id: int, season: str, **params)
+get_attendance(
+ team_id: int = None,
+ league_id: int = None,
+ league_list_id: str = None,
+ **params,
+)
+get_draft(year_id: int, **params)
+get_awards(award_id: str, **params)
+get_homerun_derby(game_id, **params)
+get_team_stats(team_id: int, stats: list, groups: list, **params)
+get_players_stats_for_game(person_id: int, game_id: int, **params)
+get_player_stats(person_id: int, stats: list, groups: list, **params)
+get_stats(stats: list, groups: list, **params)
+get_persons(person_ids: str | list[int], **params)
+get_scheduled_games_by_date(
+ date: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ sport_id: int = 1,
+ **params,
+)
+get_gamepace(season: str, sport_id=1, **params)
+get_team_id(team_name: str, search_key: str = 'name', **params)
+get_people_id(
+ fullname: str,
+ sport_id: int = 1,
+ search_key: str = 'fullName',
+ **params,
+)
+get_sport_id(sport_name: str, search_key: str = 'name', **params)
+get_league_id(league_name: str, search_key: str = 'name', **params)
+get_division_id(division_name: str, search_key: str = 'name', **params)
+get_venue_id(venue_name: str, search_key: str = 'name', **params)
+get_game(game_id: int, **params)
+get_game_play_by_play(game_id: int, **params)
+get_game_line_score(game_id: int, **params)
+get_game_box_score(game_id: int, **params)
+get_game_ids(
+ date: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ sport_id: int = 1,
+ **params,
+)
+```
+
+`get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is
+annotated `Venue | None` but returns `[]` (not `None`) on a 400–499 response,
+matching the sync behavior noted above. This is preserved for parity, not
+introduced by the async port.
+
+`get_game_line_score` inherits the same documented quirk as
+`Mlb.get_game_line_score`: it does not short-circuit on a 400–499 status the
+way its sibling game helpers do; missing linescore data falls through to an
+implicit `None`.
+
+The four stat methods return the same nested `dict` their sync counterparts
+do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}}`
+— and return `{}` on a 400–499 response, on a body with no `stats`, and on a
+`stats` entry carrying no splits. Note that an unrecognized value in `stats` or
+`groups` is not rejected; it produces the same empty `{}`. Valid values are
+listed at `https://statsapi.mlb.com/api/v1/statTypes` and
+`https://statsapi.mlb.com/api/v1/statGroups`.
+
+`AsyncMlb` now covers every endpoint method `Mlb` exposes. The only public
+name that differs is lifecycle: `Mlb.close()` is spelled `AsyncMlb.aclose()`.
+
+`get_scheduled_games_by_date` inherits the same documented quirk as
+`Mlb.get_scheduled_games_by_date`: it is annotated `list[ScheduleGames]` but
+returns `None` when no `date`, `start_date`/`end_date` pair, or `gamePks` was
+given to select with. This is preserved for parity, not introduced by the
+async port.
+
+`get_gamepace` sends the same request on both clients but builds it
+differently. `Mlb` embeds the season in the endpoint string
+(`gamePace?season=2021`) and relies on Requests merging that query with the
+rest of the parameters. HTTPX replaces a URL's existing query rather than
+merging into it, so `AsyncMlb` passes the season as an ordinary parameter.
+Callers see no difference; this matters only if you are reading the two
+implementations side by side.
+
## Low-level adapter
`MlbDataAdapter` is the public low-level HTTP adapter.
@@ -415,9 +633,17 @@ Notes and known conflicts (documented, not redesigned by this contract):
* `get_venue` is annotated to return `Venue | None` but currently returns `[]`
on 400–499 statuses. Treat the implementation shape as the observed behavior
until a focused fix lands.
-* `get_homerun_derby` currently executes a bare `None` expression on 400–499
- instead of `return None`, so execution may continue. A focused bugfix is
- recommended.
+* `get_homerun_derby` previously executed a bare `None` expression on
+ 400–499 instead of `return None`, so a 4xx response whose body happened to
+ contain a truthy `status` key would have continued into
+ `HomeRunDerby(**data)` and raised instead of returning `None`. Fixed to
+ `return None` while porting the endpoint to `AsyncMlb` (issue #305).
+* `get_attendance`'s "at least one of `team_id`/`league_id`/`league_list_id`"
+ guard previously used `any(required_args)`, which iterates dict keys
+ (always truthy) rather than values, so the guard never actually fired. This
+ was fixed to `any(required_args.values())` while porting the endpoint to
+ `AsyncMlb` (issue #305); calling either client with no identifier now
+ returns `None` without making a request, as already documented above.
* Nested Pydantic model fields are not frozen by this contract.
## Return-contract boundaries
diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md
new file mode 100644
index 00000000..de51ffee
--- /dev/null
+++ b/docs/releases/1.1.0.md
@@ -0,0 +1,69 @@
+# python-mlb-statsapi 1.1.0
+
+Version 1.1.0 adds first-class asynchronous access to the MLB Stats API while
+preserving the existing synchronous API. Applications upgrading from 1.0.x
+that use `Mlb` or `MlbDataAdapter` require no code changes.
+
+## Async support
+
+Install the optional `async` extra to add HTTPX, the asynchronous transport
+dependency:
+
+```bash
+python3 -m pip install "python-mlb-statsapi[async]"
+```
+
+The extra provides the public `AsyncMlb` and `AsyncMlbDataAdapter` classes.
+`AsyncMlb` covers the full endpoint surface exposed by `Mlb`. Sync and async
+endpoints share the same parsing functions and return matching public Pydantic
+models, values, and endpoint-specific empty-result shapes.
+
+```python
+from mlbstatsapi import AsyncMlb
+
+
+async def get_player(person_id: int):
+ async with AsyncMlb() as mlb:
+ return await mlb.get_person(person_id)
+```
+
+`async with AsyncMlb(...)` returns the client and closes library-owned HTTPX
+resources when the block exits. Directly constructed clients support explicit
+`await mlb.aclose()`, and repeated `aclose()` calls are safe. An injected
+`httpx.AsyncClient` remains caller-owned and is never closed or reconfigured by
+`AsyncMlb`.
+
+One `AsyncMlb` instance supports caller-controlled concurrent requests on the
+same event loop. It does not create hidden request fanout or background tasks,
+and cross-event-loop use is not promised. Caller cancellation propagates
+without blocking unrelated concurrent requests.
+
+Library-created HTTPX clients honor `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`,
+and `NO_PROXY` from the environment while retaining the library's bounded
+retry policy. Injected clients keep their caller-provided proxy and transport
+configuration.
+
+## HTTP compatibility
+
+`strict_http=True` remains the default for both synchronous and asynchronous
+clients. New code should use `strict_http=True` and handle `MlbHttpError`.
+
+`strict_http=False` remains supported throughout the 1.x release series and
+may be removed in 2.0. It continues to provide the documented compatibility
+path for final non-404 4xx responses; it is not removed or deprecated in
+1.1.0.
+
+The base installation remains synchronous-only and does not require HTTPX.
+Existing 1.0.x synchronous users require zero code changes for 1.1.0.
+
+## Python and release validation
+
+python-mlb-statsapi requires Python >=3.10. CI validates Python 3.10 through 3.14
+(`3.10`, `3.11`, `3.12`, `3.13`, and `3.14`) for the deterministic offline sync
+and async suites.
+
+Release validation now checks both wheel and source-distribution installs in
+separate clean environments. Each artifact retains its existing synchronous
+smoke validation and is also installed with the `async` extra to verify the
+public async imports, lifecycle, ownership, strict/compatibility behavior, and
+versioned User-Agent without contacting the live MLB API.
diff --git a/docs/stats.md b/docs/stats.md
new file mode 100644
index 00000000..0a301842
--- /dev/null
+++ b/docs/stats.md
@@ -0,0 +1,272 @@
+# Stats Guide
+
+The stats methods return MLB statistics grouped by **stat group** and then by **stat type**. Both `Mlb` and `AsyncMlb` return the same structure.
+
+## Stats methods
+
+| Method | Use |
+| --- | --- |
+| `get_player_stats()` | Stats for one player |
+| `get_team_stats()` | Stats for one team |
+| `get_stats()` | General stats query across the Stats API |
+| `get_players_stats_for_game()` | Stats for one player in one game |
+
+The synchronous and asynchronous signatures match. With `AsyncMlb`, await the method call.
+
+## Understanding the return value
+
+The four stats methods return a nested dictionary:
+
+```text
+stats[group][type] -> Stat
+```
+
+For example:
+
+```python
+stats = mlb.get_player_stats(
+ 664034,
+ stats=["season"],
+ groups=["hitting"],
+ season=2022,
+)
+
+season_hitting = stats["hitting"]["season"]
+```
+
+`season_hitting` is a `Stat` model. Its `splits` field contains the returned stat splits.
+
+```python
+for split in season_hitting.splits:
+ print(split.stat.model_dump(exclude_none=True))
+```
+
+A query can request multiple groups and stat types at once:
+
+```python
+stats = mlb.get_player_stats(
+ 664034,
+ stats=["season", "career"],
+ groups=["hitting", "fielding"],
+ season=2022,
+)
+
+for group_name, group_stats in stats.items():
+ for stat_type, stat in group_stats.items():
+ print(group_name, stat_type, stat.total_splits)
+```
+
+If the API response contains no usable stats, these methods return `{}`.
+
+## Player stats
+
+Use `get_player_stats()` when you know the MLB person ID and want one or more stat types for that player.
+
+### Sync
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ stats = mlb.get_player_stats(
+ 664034,
+ stats=["season", "career"],
+ groups=["hitting"],
+ season=2022,
+ )
+
+season = stats["hitting"]["season"]
+for split in season.splits:
+ print(split.stat.model_dump(exclude_none=True))
+```
+
+### Async
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ stats = await mlb.get_player_stats(
+ 664034,
+ stats=["season", "career"],
+ groups=["hitting"],
+ season=2022,
+ )
+
+ season = stats["hitting"]["season"]
+ for split in season.splits:
+ print(split.stat.model_dump(exclude_none=True))
+
+
+asyncio.run(main())
+```
+
+## Team stats
+
+Use `get_team_stats()` for stat data scoped to one team.
+
+### Sync
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ stats = mlb.get_team_stats(
+ 136,
+ stats=["season", "seasonAdvanced"],
+ groups=["hitting"],
+ season=2022,
+ )
+
+for stat_type, stat in stats["hitting"].items():
+ print(stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+```
+
+### Async
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ stats = await mlb.get_team_stats(
+ 136,
+ stats=["season", "seasonAdvanced"],
+ groups=["hitting"],
+ season=2022,
+ )
+
+ for stat_type, stat in stats["hitting"].items():
+ print(stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+
+
+asyncio.run(main())
+```
+
+## General stats queries
+
+`get_stats()` queries the general `/stats` endpoint. Additional keyword arguments can narrow the request by season, team, league, game type, sport, and other Stats API parameters.
+
+### Sync
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ stats = mlb.get_stats(
+ stats=["season"],
+ groups=["hitting"],
+ season=2022,
+ sportIds=1,
+ )
+
+for group_name, group_stats in stats.items():
+ for stat_type, stat in group_stats.items():
+ print(group_name, stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+```
+
+### Async
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ stats = await mlb.get_stats(
+ stats=["season"],
+ groups=["hitting"],
+ season=2022,
+ sportIds=1,
+ )
+
+ for group_name, group_stats in stats.items():
+ for stat_type, stat in group_stats.items():
+ print(group_name, stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+
+
+asyncio.run(main())
+```
+
+## Player stats for a game
+
+Use `get_players_stats_for_game()` when you have both the player's MLB person ID and the game's `gamePk`.
+
+### Sync
+
+```python
+from mlbstatsapi import Mlb
+
+with Mlb() as mlb:
+ stats = mlb.get_players_stats_for_game(
+ person_id=663728,
+ game_id=715757,
+ )
+
+for group_name, group_stats in stats.items():
+ for stat_type, stat in group_stats.items():
+ print(group_name, stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+```
+
+### Async
+
+```python
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+
+
+async def main():
+ async with AsyncMlb() as mlb:
+ stats = await mlb.get_players_stats_for_game(
+ person_id=663728,
+ game_id=715757,
+ )
+
+ for group_name, group_stats in stats.items():
+ for stat_type, stat in group_stats.items():
+ print(group_name, stat_type)
+ for split in stat.splits:
+ print(split.stat.model_dump(exclude_none=True))
+
+
+asyncio.run(main())
+```
+
+## Finding valid stat types and groups
+
+The MLB Stats API publishes the available values directly:
+
+- Stat types:
+- Stat groups:
+- Event types:
+- Game types:
+
+Common stat groups include `hitting`, `pitching`, and `fielding`. Available stat types depend on the group and endpoint. Examples include `season`, `career`, `seasonAdvanced`, `gameLog`, and `playLog`.
+
+## Related documentation
+
+- [Method reference](methods.md)
+- [General usage examples](examples.md)
+- [Async usage](async.md)
+- [Public API contract](public-api.md)
+- [Stats model Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats)
diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py
index bb3c21cf..7cde2d79 100644
--- a/mlbstatsapi/__init__.py
+++ b/mlbstatsapi/__init__.py
@@ -25,3 +25,36 @@
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 = (
+ "AsyncMlb",
+ "AsyncMlbDataAdapter",
+)
+
+
+def __getattr__(name: str):
+ if name == "AsyncMlb":
+ from .async_mlb import AsyncMlb
+
+ globals()["AsyncMlb"] = AsyncMlb
+ return AsyncMlb
+
+ if name == "AsyncMlbDataAdapter":
+ from .async_mlb_dataadapter import AsyncMlbDataAdapter
+
+ globals()["AsyncMlbDataAdapter"] = AsyncMlbDataAdapter
+ return AsyncMlbDataAdapter
+
+ 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_support.py b/mlbstatsapi/_async_support.py
new file mode 100644
index 00000000..88b7fb4e
--- /dev/null
+++ b/mlbstatsapi/_async_support.py
@@ -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
diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py
new file mode 100644
index 00000000..64a954ff
--- /dev/null
+++ b/mlbstatsapi/_async_transport.py
@@ -0,0 +1,209 @@
+"""Retry-aware HTTPX transport for the async client.
+
+The synchronous side does not implement retries. It *configures* them: ``Mlb``
+mounts an ``HTTPAdapter`` carrying the library ``Retry`` policy onto the
+Session it creates, and from that point on every ``session.get()`` retries
+without any caller — ``MlbDataAdapter`` included — knowing retries exist.
+
+HTTPX has the same seam. ``AsyncClient(transport=...)`` accepts any
+``AsyncBaseTransport``, which is the position ``HTTPAdapter`` occupies in
+Requests. Putting the retry loop there instead of inside
+``AsyncMlbDataAdapter`` gives the async side the sync structure:
+
+* Adapters call ``client.get()`` and are unaware of retries.
+* The retry policy travels with the client, so two adapters sharing one client
+ share one policy by construction. Neither adapter holds retry state, so
+ neither can disagree with the other about it.
+* A caller-injected client keeps whatever transport its caller mounted, so
+ "the library does not touch an injected client" needs no flag to enforce.
+
+A caller who wants library retry behavior on a client they own mounts this
+transport themselves, mirroring the documented sync recipe for
+``create_retry_policy()``.
+"""
+
+import asyncio
+
+from ._async_support import import_httpx
+from ._env_proxies import environment_proxy_map
+from .mlb_dataadapter import _build_user_agent, create_retry_policy
+
+httpx = import_httpx()
+
+
+class MlbAsyncRetryTransport(httpx.AsyncBaseTransport):
+ """Wrap an HTTPX transport with the library's bounded retry policy.
+
+ Failures spend the same retry budget the sync policy spends:
+
+ ReadTimeout -> read budget
+ ConnectTimeout -> connect budget
+ ConnectError -> connect budget
+ other TimeoutException -> total budget
+ other RequestError -> total budget
+ retryable HTTP status -> status budget
+
+ Exhausting a budget re-raises the underlying HTTPX exception. Translating
+ those into the library's public exception types stays with the adapter, so
+ this class satisfies the transport contract HTTPX documents: transports
+ raise HTTPX errors.
+ """
+
+ def __init__(
+ self,
+ inner: httpx.AsyncBaseTransport | None = None,
+ *,
+ retry_policy=None,
+ ):
+ self._inner = inner if inner is not None else httpx.AsyncHTTPTransport()
+ self._retry_policy = (
+ retry_policy if retry_policy is not None else create_retry_policy()
+ )
+
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ policy = self._retry_policy
+
+ attempt = 0
+ while True:
+ attempt += 1
+ try:
+ response = await self._inner.handle_async_request(request)
+
+ except httpx.ReadTimeout:
+ if attempt > policy.read:
+ raise
+ await self._backoff(attempt=attempt, response=None)
+ continue
+
+ except httpx.ConnectTimeout:
+ # Caught before httpx.TimeoutException: a connect timeout is a
+ # timeout for the caller, but it spends the connect budget so
+ # the retry accounting matches the sync policy.
+ if attempt > policy.connect:
+ raise
+ await self._backoff(attempt=attempt, response=None)
+ continue
+
+ except httpx.ConnectError:
+ if attempt > policy.connect:
+ raise
+ await self._backoff(attempt=attempt, response=None)
+ continue
+
+ except httpx.TimeoutException:
+ if attempt > policy.total:
+ raise
+ await self._backoff(attempt=attempt, response=None)
+ continue
+
+ except httpx.RequestError:
+ if attempt > policy.total:
+ raise
+ await self._backoff(attempt=attempt, response=None)
+ continue
+
+ if (
+ response.status_code not in policy.status_forcelist
+ or attempt > policy.status
+ ):
+ return response
+
+ # The response is discarded, so release it before another attempt
+ # rather than leaving a connection checked out of the pool.
+ delay = self._delay_for(attempt=attempt, response=response)
+ await response.aclose()
+ if delay > 0:
+ await asyncio.sleep(delay)
+
+ async def _backoff(
+ self,
+ *,
+ attempt: int,
+ response: httpx.Response | None,
+ ) -> None:
+ delay = self._delay_for(attempt=attempt, response=response)
+ if delay > 0:
+ await asyncio.sleep(delay)
+
+ def _delay_for(
+ self,
+ *,
+ attempt: int,
+ response: httpx.Response | None,
+ ) -> float:
+ policy = self._retry_policy
+
+ if policy.respect_retry_after_header and response is not None:
+ retry_after = policy.get_retry_after(response)
+ if retry_after:
+ return retry_after
+
+ # Mirrors urllib3's Retry.get_backoff_time(): no delay before the
+ # first retry, exponential thereafter, capped at backoff_max.
+ if attempt <= 1:
+ return 0.0
+
+ return min(
+ policy.backoff_factor * (2 ** (attempt - 1)),
+ policy.backoff_max,
+ )
+
+ async def aclose(self) -> None:
+ await self._inner.aclose()
+
+
+def create_library_async_client() -> httpx.AsyncClient:
+ """Build the async client the library creates and owns.
+
+ The counterpart of ``_configure_library_session()`` on the sync side:
+ library defaults are applied here, at creation, and only to clients the
+ library creates. Passing headers to the constructor replaces just the
+ User-Agent, so HTTPX's other default headers survive.
+
+ HTTPX only builds its own environment-proxy mounts when the caller leaves
+ ``transport=None`` (``allow_env_proxies = trust_env and transport is
+ None`` in ``httpx.Client.__init__``). Passing ``transport=`` here, which
+ is required to install the retry transport, would otherwise silently
+ disable ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY``
+ support for every library-created async client (issue #324). This
+ rebuilds that discovery from the stdlib (see ``_env_proxies.py``) and
+ passes it through HTTPX's public ``mounts=`` argument instead, wrapping
+ every proxy transport in the same retry transport the direct path uses,
+ so a request routed through a proxy still gets library retries.
+
+ Environment discovery always runs here, matching the ``trust_env=True``
+ default a caller gets from a plain ``httpx.AsyncClient()``. Neither
+ ``AsyncMlb`` nor ``AsyncMlbDataAdapter`` exposes a ``trust_env`` toggle;
+ a caller who needs one injects their own client instead, the same way
+ they would opt into any other HTTPX-level setting this factory does not
+ surface.
+
+ One retry policy instance is shared by the direct transport and every
+ proxy transport, mirroring the sync side sharing one Session across the
+ v1 and v1.1 adapters: retries are a property of the client, not of any
+ one transport within it.
+ """
+ retry_policy = create_retry_policy()
+ direct = MlbAsyncRetryTransport(
+ httpx.AsyncHTTPTransport(), retry_policy=retry_policy
+ )
+
+ mounts: dict[str, httpx.AsyncBaseTransport | None] = {}
+ for pattern, proxy in environment_proxy_map().items():
+ if proxy is None:
+ # None tells HTTPX to fall back to client._transport for this
+ # pattern (see AsyncClient._transport_for_url), i.e. bypass the
+ # proxy rather than route through a second transport instance.
+ # aclose() also skips a None mount, so this never gets closed
+ # twice via both the direct transport and a mount entry.
+ mounts[pattern] = None
+ else:
+ mounts[pattern] = MlbAsyncRetryTransport(
+ httpx.AsyncHTTPTransport(proxy=proxy), retry_policy=retry_policy
+ )
+
+ return httpx.AsyncClient(
+ headers={"User-Agent": _build_user_agent()},
+ transport=direct,
+ mounts=mounts,
+ )
diff --git a/mlbstatsapi/_env_proxies.py b/mlbstatsapi/_env_proxies.py
new file mode 100644
index 00000000..70d411d4
--- /dev/null
+++ b/mlbstatsapi/_env_proxies.py
@@ -0,0 +1,81 @@
+"""Build an HTTPX-compatible proxy mount map from the environment.
+
+HTTPX only discovers ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` /
+``NO_PROXY`` for itself when it builds its own transport, which happens only
+when the caller does not pass ``transport=`` (see ``allow_env_proxies =
+trust_env and transport is None`` in ``httpx.Client.__init__``). The async
+retry transport (``_async_transport.py``) always passes ``transport=``, so
+that discovery never runs, and environment proxy support silently disappears
+for library-created async clients (issue #324).
+
+This module reimplements that discovery from the stdlib and hands the result
+to HTTPX's public ``mounts=`` argument instead, so the library stays off
+HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here
+intentionally mirrors that private function's semantics, verified against
+installed httpx 0.28.1. The differential test in
+``tests/test_async_env_proxies.py`` (``test_matches_stock_httpx_env_proxy_resolution``)
+is the drift alarm: it resolves the same URLs against a stock
+``httpx.AsyncClient()`` and against this module's output on every run, so a
+future httpx release changing ``NO_PROXY`` or proxy semantics fails that test
+instead of silently diverging.
+
+No httpx import here: environment variables in, a plain ``dict`` out.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+from urllib.request import getproxies
+
+
+def environment_proxy_map(*, trust_env: bool = True) -> dict[str, str | None]:
+ """Return an HTTPX ``mounts=``-shaped map of proxies from the environment.
+
+ Keys are URL patterns such as ``"https://"`` or ``"all://*mlb.com"``; a
+ ``None`` value means "bypass the proxy for this pattern" and is meaningful
+ only when a broader pattern (from ``ALL_PROXY``) would otherwise match.
+ """
+ if not trust_env:
+ return {}
+
+ proxy_info = getproxies()
+ mounts: dict[str, str | None] = {}
+
+ for scheme in ("http", "https", "all"):
+ value = proxy_info.get(scheme)
+ if value:
+ mounts[f"{scheme}://"] = value if "://" in value else f"http://{value}"
+
+ no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")]
+ for hostname in no_proxy_hosts:
+ if hostname == "*":
+ return {}
+ elif hostname:
+ if "://" in hostname:
+ mounts[hostname] = None
+ elif _is_ipv4(hostname):
+ mounts[f"all://{hostname}"] = None
+ elif _is_ipv6(hostname):
+ mounts[f"all://[{hostname}]"] = None
+ elif hostname.lower() == "localhost":
+ mounts[f"all://{hostname}"] = None
+ else:
+ mounts[f"all://*{hostname}"] = None
+
+ return mounts
+
+
+def _is_ipv4(hostname: str) -> bool:
+ try:
+ ipaddress.IPv4Address(hostname.split("/")[0])
+ except ValueError:
+ return False
+ return True
+
+
+def _is_ipv6(hostname: str) -> bool:
+ try:
+ ipaddress.IPv6Address(hostname.split("/")[0])
+ except ValueError:
+ return False
+ return True
diff --git a/mlbstatsapi/_helpers/__init__.py b/mlbstatsapi/_helpers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/mlbstatsapi/_helpers/id_lookup.py b/mlbstatsapi/_helpers/id_lookup.py
new file mode 100644
index 00000000..c663c3f4
--- /dev/null
+++ b/mlbstatsapi/_helpers/id_lookup.py
@@ -0,0 +1,15 @@
+def find_ids_by_key(items: list[dict], search_key: str, value: str) -> list[int]:
+ """Return the ids of items whose ``search_key`` value case-insensitively matches ``value``.
+
+ Shared by every ``Mlb``/``AsyncMlb`` ``get_*_id`` name-lookup helper. An
+ item missing ``search_key`` or ``id`` is silently skipped, matching the
+ historical per-endpoint behavior.
+ """
+ ids = []
+ for item in items:
+ try:
+ if item[search_key].lower() == value.lower():
+ ids.append(item["id"])
+ except KeyError:
+ continue
+ return ids
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/_http.py b/mlbstatsapi/_http.py
new file mode 100644
index 00000000..c8f3feba
--- /dev/null
+++ b/mlbstatsapi/_http.py
@@ -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,
+ )
\ No newline at end of file
diff --git a/mlbstatsapi/_parsers/__init__.py b/mlbstatsapi/_parsers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/mlbstatsapi/_parsers/attendance.py b/mlbstatsapi/_parsers/attendance.py
new file mode 100644
index 00000000..275cb1b7
--- /dev/null
+++ b/mlbstatsapi/_parsers/attendance.py
@@ -0,0 +1,8 @@
+from mlbstatsapi.models.attendances import Attendance
+
+
+def parse_attendance(data: dict) -> Attendance | None:
+ """Parse an Attendance from an MLB /attendance response body."""
+ if not data or not data.get("records"):
+ return None
+ return Attendance(**data)
diff --git a/mlbstatsapi/_parsers/awards.py b/mlbstatsapi/_parsers/awards.py
new file mode 100644
index 00000000..f800b4ba
--- /dev/null
+++ b/mlbstatsapi/_parsers/awards.py
@@ -0,0 +1,8 @@
+from mlbstatsapi.models.awards import Award
+
+
+def parse_awards(data: dict) -> list[Award]:
+ """Parse Award models from an MLB /awards/{id}/recipients response body."""
+ if not data or not data.get("awards"):
+ return []
+ return [Award(**award) for award in data["awards"]]
diff --git a/mlbstatsapi/_parsers/divisions.py b/mlbstatsapi/_parsers/divisions.py
new file mode 100644
index 00000000..7a791aff
--- /dev/null
+++ b/mlbstatsapi/_parsers/divisions.py
@@ -0,0 +1,21 @@
+from mlbstatsapi.models.divisions import Division
+
+
+def parse_divisions(data: dict) -> list[Division]:
+ """Parse Division models from an MLB /divisions response body.
+
+ Expects the full response, e.g. ``{"divisions": [...]}``, not the inner list.
+ """
+ if not data or not data.get("divisions"):
+ return []
+ return [Division(**division) for division in data["divisions"]]
+
+
+def parse_division(data: dict) -> Division | None:
+ """Parse a Division from a single division payload."""
+ divisions = parse_divisions(data)
+
+ if not divisions:
+ return None
+
+ return divisions[0]
diff --git a/mlbstatsapi/_parsers/draft.py b/mlbstatsapi/_parsers/draft.py
new file mode 100644
index 00000000..466f181e
--- /dev/null
+++ b/mlbstatsapi/_parsers/draft.py
@@ -0,0 +1,16 @@
+from mlbstatsapi.models.drafts import Round
+
+
+def parse_draft(data: dict) -> list[Round]:
+ """Parse Round models from an MLB /draft/{year} response body.
+
+ Expects the full response, e.g. ``{"drafts": {"rounds": [...]}}``.
+ """
+ if not data or not data.get("drafts"):
+ return []
+
+ rounds = data["drafts"].get("rounds")
+ if not rounds:
+ return []
+
+ return [Round(**round_data) for round_data in rounds]
diff --git a/mlbstatsapi/_parsers/gamepace.py b/mlbstatsapi/_parsers/gamepace.py
new file mode 100644
index 00000000..d04b3a59
--- /dev/null
+++ b/mlbstatsapi/_parsers/gamepace.py
@@ -0,0 +1,17 @@
+from mlbstatsapi.models.gamepace import GamePace
+
+
+def parse_gamepace(data: dict) -> GamePace | None:
+ """Parse a GamePace from an MLB /gamePace response body.
+
+ The endpoint keys its metrics by whichever of ``teams``, ``leagues`` or
+ ``sports`` the caller's ``orgType`` selected, so a body carrying none of
+ them has nothing to build from.
+ """
+ if not data:
+ return None
+
+ if not (data.get("teams") or data.get("leagues") or data.get("sports")):
+ return None
+
+ return GamePace(**data)
diff --git a/mlbstatsapi/_parsers/games.py b/mlbstatsapi/_parsers/games.py
new file mode 100644
index 00000000..de22e912
--- /dev/null
+++ b/mlbstatsapi/_parsers/games.py
@@ -0,0 +1,40 @@
+from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays
+
+
+def parse_game(data: dict, game_id: int) -> Game | None:
+ """Parse a Game from an MLB /game/{id}/feed/live response body."""
+ if not data or data.get("gamePk") != game_id:
+ return None
+ return Game(**data)
+
+
+def parse_plays(data: dict) -> Plays | None:
+ """Parse Plays from an MLB /game/{id}/playByPlay response body."""
+ if not data or not data.get("allPlays"):
+ return None
+ return Plays(**data)
+
+
+def parse_linescore(data: dict) -> Linescore | None:
+ """Parse a Linescore from an MLB /game/{id}/linescore response body."""
+ if not data or not data.get("teams"):
+ return None
+ return Linescore(**data)
+
+
+def parse_boxscore(data: dict) -> BoxScore | None:
+ """Parse a BoxScore from an MLB /game/{id}/boxscore response body."""
+ if not data or not data.get("teams"):
+ return None
+ return BoxScore(**data)
+
+
+def parse_game_ids(data: dict) -> list[int]:
+ """Parse gamePks out of an MLB /schedule response body."""
+ if not data or not data.get("dates"):
+ return []
+ return [
+ game["gamePk"]
+ for date in data["dates"]
+ for game in date["games"]
+ ]
diff --git a/mlbstatsapi/_parsers/homerunderby.py b/mlbstatsapi/_parsers/homerunderby.py
new file mode 100644
index 00000000..2167a526
--- /dev/null
+++ b/mlbstatsapi/_parsers/homerunderby.py
@@ -0,0 +1,8 @@
+from mlbstatsapi.models.homerunderby import HomeRunDerby
+
+
+def parse_homerun_derby(data: dict) -> HomeRunDerby | None:
+ """Parse a HomeRunDerby from an MLB /homeRunDerby/{gamePk} response body."""
+ if not data or not data.get("status"):
+ return None
+ return HomeRunDerby(**data)
diff --git a/mlbstatsapi/_parsers/leagues.py b/mlbstatsapi/_parsers/leagues.py
new file mode 100644
index 00000000..c866dfd4
--- /dev/null
+++ b/mlbstatsapi/_parsers/leagues.py
@@ -0,0 +1,21 @@
+from mlbstatsapi.models.leagues import League
+
+
+def parse_leagues(data: dict) -> list[League]:
+ """Parse League models from an MLB /leagues response body.
+
+ Expects the full response, e.g. ``{"leagues": [...]}``, not the inner list.
+ """
+ if not data or not data.get("leagues"):
+ return []
+ return [League(**league) for league in data["leagues"]]
+
+
+def parse_league(data: dict) -> League | None:
+ """Parse a League from a single league payload."""
+ leagues = parse_leagues(data)
+
+ if not leagues:
+ return None
+
+ return leagues[0]
diff --git a/mlbstatsapi/_parsers/people.py b/mlbstatsapi/_parsers/people.py
new file mode 100644
index 00000000..eb7e9709
--- /dev/null
+++ b/mlbstatsapi/_parsers/people.py
@@ -0,0 +1,20 @@
+from mlbstatsapi.models.people import Person
+
+
+def parse_people(data: dict) -> list[Person]:
+ """Parse Person models from an MLB /people response body."""
+ if not data or not data.get("people"):
+ return []
+ return [Person(**person) for person in data["people"]]
+
+
+def parse_person(data: dict) -> Person | None:
+ """Parse a Person from a single person payload."""
+
+ people = parse_people(data)
+
+ if not people:
+ return None
+
+ return people[0]
+
diff --git a/mlbstatsapi/_parsers/roster.py b/mlbstatsapi/_parsers/roster.py
new file mode 100644
index 00000000..9ee36dbf
--- /dev/null
+++ b/mlbstatsapi/_parsers/roster.py
@@ -0,0 +1,24 @@
+from mlbstatsapi import mlb_module
+from mlbstatsapi.models.people import Coach, Player
+
+
+def parse_roster_players(data: dict) -> list[Player]:
+ """Parse Player models from an MLB /teams/{id}/roster response body."""
+ if not data or not data.get("roster"):
+ return []
+ return [
+ Player(**mlb_module.merge_keys(player, ["person"])) for player in data["roster"]
+ ]
+
+
+def parse_roster_coaches(data: dict) -> list[Coach]:
+ """Parse Coach models from an MLB /teams/{id}/coaches response body.
+
+ The coaches endpoint reuses the same ``roster`` envelope as the player
+ roster endpoint.
+ """
+ if not data or not data.get("roster"):
+ return []
+ return [
+ Coach(**mlb_module.merge_keys(coach, ["person"])) for coach in data["roster"]
+ ]
diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py
new file mode 100644
index 00000000..3179d4e0
--- /dev/null
+++ b/mlbstatsapi/_parsers/schedules.py
@@ -0,0 +1,25 @@
+from mlbstatsapi.models.schedules import Schedule, ScheduleGames
+
+
+def parse_schedule(data: dict) -> Schedule | None:
+ """Parse a Schedule from an MLB /schedule response body."""
+ if not data or not data.get("dates"):
+ return None
+
+ return Schedule(**data)
+
+
+def parse_scheduled_games(data: dict) -> list[ScheduleGames]:
+ """Parse the games out of an MLB /schedule response body, flattened.
+
+ The response nests games under one entry per date; this returns them as a
+ single list, dropping the date grouping.
+ """
+ if not data or not data.get("dates"):
+ return []
+
+ return [
+ ScheduleGames(**game)
+ for date in data["dates"]
+ for game in date["games"]
+ ]
diff --git a/mlbstatsapi/_parsers/seasons.py b/mlbstatsapi/_parsers/seasons.py
new file mode 100644
index 00000000..4bb1ca8e
--- /dev/null
+++ b/mlbstatsapi/_parsers/seasons.py
@@ -0,0 +1,21 @@
+from mlbstatsapi.models.seasons import Season
+
+
+def parse_seasons(data: dict) -> list[Season]:
+ """Parse Season models from an MLB /seasons response body.
+
+ Expects the full response, e.g. ``{"seasons": [...]}``, not the inner list.
+ """
+ if not data or not data.get("seasons"):
+ return []
+ return [Season(**season) for season in data["seasons"]]
+
+
+def parse_season(data: dict) -> Season | None:
+ """Parse a Season from a single season payload."""
+ seasons = parse_seasons(data)
+
+ if not seasons:
+ return None
+
+ return seasons[0]
diff --git a/mlbstatsapi/_parsers/sports.py b/mlbstatsapi/_parsers/sports.py
new file mode 100644
index 00000000..4872aa48
--- /dev/null
+++ b/mlbstatsapi/_parsers/sports.py
@@ -0,0 +1,21 @@
+from mlbstatsapi.models.sports import Sport
+
+
+def parse_sports(data: dict) -> list[Sport]:
+ """Parse Sport models from an MLB /sports response body.
+
+ Expects the full response, e.g. ``{"sports": [...]}``, not the inner list.
+ """
+ if not data or not data.get("sports"):
+ return []
+ return [Sport(**sport) for sport in data["sports"]]
+
+
+def parse_sport(data: dict) -> Sport | None:
+ """Parse a Sport from a single sport payload."""
+ sports = parse_sports(data)
+
+ if not sports:
+ return None
+
+ return sports[0]
diff --git a/mlbstatsapi/_parsers/standings.py b/mlbstatsapi/_parsers/standings.py
new file mode 100644
index 00000000..ff22603c
--- /dev/null
+++ b/mlbstatsapi/_parsers/standings.py
@@ -0,0 +1,8 @@
+from mlbstatsapi.models.standings import Standings
+
+
+def parse_standings(data: dict) -> list[Standings]:
+ """Parse Standings models from an MLB /standings response body."""
+ if not data or not data.get("records"):
+ return []
+ return [Standings(**standing) for standing in data["records"]]
diff --git a/mlbstatsapi/_parsers/stats.py b/mlbstatsapi/_parsers/stats.py
new file mode 100644
index 00000000..af05d62c
--- /dev/null
+++ b/mlbstatsapi/_parsers/stats.py
@@ -0,0 +1,16 @@
+from mlbstatsapi import mlb_module
+
+
+def parse_split_stats(data: dict) -> dict:
+ """Parse split stat data from an MLB stats response body.
+
+ Shared by every stats endpoint -- ``/stats``, ``/people/{id}/stats``,
+ ``/teams/{id}/stats``, and ``/people/{id}/stats/game/{game_id}`` -- all of
+ which return the same ``stats`` envelope.
+
+ Returns a dict keyed by stat group, then by stat type, or ``{}`` when the
+ response carries no stats.
+ """
+ if not data or not data.get("stats"):
+ return {}
+ return mlb_module.create_split_data(data["stats"])
diff --git a/mlbstatsapi/_parsers/teams.py b/mlbstatsapi/_parsers/teams.py
new file mode 100644
index 00000000..772bcbe0
--- /dev/null
+++ b/mlbstatsapi/_parsers/teams.py
@@ -0,0 +1,22 @@
+from mlbstatsapi.models.teams import Team
+
+
+def parse_teams(data: dict) -> list[Team]:
+ """Parse Team models from an MLB /teams response body.
+
+ Expects the full response, e.g. ``{"teams": [...]}``, not the inner list.
+ """
+ if not data or not data.get("teams"):
+ return []
+ return [Team(**team) for team in data["teams"]]
+
+
+def parse_team(data: dict) -> Team | None:
+ """Parse a Team from a single team payload."""
+ teams = parse_teams(data)
+
+ if not teams:
+ return None
+
+ return teams[0]
+
diff --git a/mlbstatsapi/_parsers/venues.py b/mlbstatsapi/_parsers/venues.py
new file mode 100644
index 00000000..7004b19c
--- /dev/null
+++ b/mlbstatsapi/_parsers/venues.py
@@ -0,0 +1,21 @@
+from mlbstatsapi.models.venues import Venue
+
+
+def parse_venues(data: dict) -> list[Venue]:
+ """Parse Venue models from an MLB /venues response body.
+
+ Expects the full response, e.g. ``{"venues": [...]}``, not the inner list.
+ """
+ if not data or not data.get("venues"):
+ return []
+ return [Venue(**venue) for venue in data["venues"]]
+
+
+def parse_venue(data: dict) -> Venue | None:
+ """Parse a Venue from a single venue payload."""
+ venues = parse_venues(data)
+
+ if not venues:
+ return None
+
+ return venues[0]
diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py
new file mode 100644
index 00000000..839dbf63
--- /dev/null
+++ b/mlbstatsapi/async_mlb.py
@@ -0,0 +1,2466 @@
+# mlbstatsapi/async_mlb.py
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from ._async_transport import create_library_async_client
+from ._helpers.id_lookup import find_ids_by_key
+from ._helpers.schedule import build_schedule_params
+from ._parsers.attendance import parse_attendance
+from ._parsers.awards import parse_awards
+from ._parsers.divisions import parse_division, parse_divisions
+from ._parsers.draft import parse_draft
+from ._parsers.games import (
+ parse_boxscore,
+ parse_game,
+ parse_game_ids,
+ parse_linescore,
+ parse_plays,
+)
+from ._parsers.gamepace import parse_gamepace
+from ._parsers.homerunderby import parse_homerun_derby
+from ._parsers.leagues import parse_league, parse_leagues
+from ._parsers.people import parse_person, parse_people
+from ._parsers.roster import parse_roster_coaches, parse_roster_players
+from ._parsers.schedules import parse_schedule, parse_scheduled_games
+from ._parsers.seasons import parse_season, parse_seasons
+from ._parsers.sports import parse_sport, parse_sports
+from ._parsers.standings import parse_standings
+from ._parsers.stats import parse_split_stats
+from ._parsers.teams import parse_team, parse_teams
+from ._parsers.venues import parse_venue, parse_venues
+from .async_mlb_dataadapter import AsyncMlbDataAdapter
+from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType
+from .models.attendances import Attendance
+from .models.awards import Award
+from .models.divisions import Division
+from .models.drafts import Round
+from .models.game import BoxScore, Game, Linescore, Plays
+from .models.gamepace import GamePace
+from .models.homerunderby import HomeRunDerby
+from .models.leagues import League
+from .models.people import Coach, Person, Player
+from .models.schedules import Schedule, ScheduleGames
+from .models.seasons import Season
+from .models.sports import Sport
+from .models.standings import Standings
+from .models.teams import Team
+from .models.venues import Venue
+
+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__)
+
+ # One client is shared by the v1 and v1.1 adapters, and this client
+ # owns it, mirroring Mlb's shared-Session pattern. The library closes
+ # only clients it creates; caller-injected clients remain caller-owned.
+ # The versioned User-Agent and the retry transport are applied only to
+ # library-created clients.
+ self._owns_client = client is None
+ if client is None:
+ self._client = create_library_async_client()
+ else:
+ self._client = client
+ self._closed = False
+ self._mlb_adapter_v1 = AsyncMlbDataAdapter(
+ hostname=hostname,
+ ver="v1",
+ logger=self._logger,
+ timeout=timeout,
+ client=self._client,
+ strict_http=strict_http,
+ )
+ self._mlb_adapter_v1_1 = AsyncMlbDataAdapter(
+ hostname=hostname,
+ ver="v1.1",
+ logger=self._logger,
+ timeout=timeout,
+ client=self._client,
+ strict_http=strict_http,
+ )
+
+ async def aclose(self) -> None:
+ """Close the HTTP client when this client owns it.
+
+ Safe to call more than once. Caller-injected clients are left alone.
+ """
+ if self._owns_client and not self._closed:
+ await self._client.aclose()
+ self._closed = True
+
+ 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:
+ """
+ Returns a team based on teamId.
+
+ Async counterpart of ``Mlb.get_team``.
+
+ Parameters
+ ----------
+ team_id : int
+ Insert teamId to return a directory of team information for a
+ particular club.
+
+ Other Parameters
+ ----------------
+ season : int
+ Insert year to return a directory of team information for a
+ particular club in a specific season.
+ sportId : int
+ Insert a sportId to return a directory of team information for a
+ particular club in a sport.
+ hydrate : str
+ Insert Hydration(s) to return data for any available team
+ hydration. Format "league,venue"
+ Available Hydrations:
+ previousSchedule
+ nextSchedule
+ venue
+ social
+ deviceProperties
+ game(promotions)
+ game(atBatPromotions)
+ game(tickets)
+ game(atBatTickets)
+ game(sponsorships)
+ league
+ person
+ sport
+ division
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Team
+ returns a Team from team id
+
+ See Also
+ --------
+ AsyncMlb.get_teams : Return a list of Teams from sport id.
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... team = await mlb.get_team(133)
+ Team
+ """
+ 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 the all Teams
+
+ Async counterpart of ``Mlb.get_teams``.
+
+ Parameters
+ ----------
+ sport_id : int
+ Insert sportId to return team information for a particular sportId
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return team information for a particular season.
+ leagueIds : int
+ Insert leagueId to return team information for particular league.
+ activeStatus : str
+ Insert activeStatus to populate a teams based on active/inactive
+ status for a given season. There are three status types: Y, N, B
+ allStarStatuses : str
+ Insert allStarStatuses to populate a teams based on Allstar status
+ for a given season. There are two status types: Y and N
+ sportIds : str
+ Insert sportId to return team information for a particular sportId
+ Usage: '1' or '1,11,12'
+ gameType : str
+ Insert gameType to return team information for a particular
+ gameType. For a list of all gameTypes:
+ https://statsapi.mlb.com/api/v1/gameTypes
+ hydrate : str
+ Insert Hydration(s) to return data for any available team
+ hydration. Format "league,venue"
+ Available Hydrations:
+ previousSchedule
+ nextSchedule
+ venue
+ social
+ deviceProperties
+ game(promotions)
+ game(atBatPromotions)
+ game(tickets)
+ game(atBatTickets)
+ game(sponsorships)
+ league
+ person
+ sport
+ division
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ list of Teams
+ returns a list of teams
+
+ See Also
+ --------
+ AsyncMlb.get_team : Return a Team from id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... teams = await mlb.get_teams()
+ [Team, Team, Team]
+ """
+ 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_team_id(
+ self,
+ team_name: str,
+ search_key: str = "name",
+ **params,
+ ) -> list[int]:
+ """
+ return a team Id
+
+ Async counterpart of ``Mlb.get_team_id``.
+
+ Parameters
+ ----------
+ team_name : str
+ Teams name
+
+ search_key : str
+ search key search json for matching team_name
+
+ Other Parameters
+ ----------------
+ sportId : int
+ sport id number for team search
+
+ Returns
+ -------
+ list of ints
+ returns a list of matching team ids
+
+ See Also
+ --------
+ AsyncMlb.get_teams : Return a list of Teams from sport id.
+ AsyncMlb.get_team : Return a Team from id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_team_id("Athletics")
+ [133]
+ """
+ params["fields"] = "teams,id,name"
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="teams",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return find_ids_by_key(mlb_data.data.get("teams") or [], search_key, team_name)
+
+ async def get_team_roster(
+ self,
+ team_id: int,
+ **params,
+ ) -> list[Player]:
+ """
+ return the team player roster
+
+ Async counterpart of ``Mlb.get_team_roster``.
+
+ Parameters
+ ----------
+ team_id : int
+ teamId to return a directory of players based on roster status for
+ a particular club.
+
+ Other Parameters
+ ----------------
+ rosterType : str
+ Insert teamId to return a directory of players based on roster
+ status for a particular club. rosterType's include 40Man,
+ fullSeason, fullRoster, nonRosterInvitees, active, allTime,
+ depthChart, gameday, and coach.
+ season : str
+ Insert year to return a directory of players based on roster
+ status for a particular club in a specific season.
+ date : str
+ Insert date to return a directory of players based on roster
+ status for a particular club on a specific date.
+ hydrate : str
+ Insert Hydration(s) to return data for any available team
+ hydration. The hydration for Teams contains "person" which has
+ subhydrations Format "person(subHydration1, subHydrations2)"
+ Available Hydrations:
+ "person"
+ Hydrations Available Through Person
+ hydrations
+ awards
+ currentTeam
+ team
+ rosterEntries
+ relatives
+ transactions
+ social
+ education
+ stats
+ draft
+ mixedFeed
+ articles
+ video
+ xrefId
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ list of players
+
+ See Also
+ --------
+ AsyncMlb.get_team : Return a Team from id
+ AsyncMlb.get_team_coaches : Return a list of Coaches from team id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... roster = await mlb.get_team_roster(133)
+ [Player, Player, Player]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"teams/{team_id}/roster",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_roster_players(mlb_data.data)
+
+ async def get_team_coaches(
+ self,
+ team_id: int,
+ **params,
+ ) -> list[Coach]:
+ """
+ Return a directory of coaches for a particular team.
+
+ Async counterpart of ``Mlb.get_team_coaches``.
+
+ Parameters
+ ----------
+ team_id : int
+ Insert teamId to return a directory of coaches for a given team.
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return a directory of players based on roster status for a particular club in a specific season.
+ date : str
+ Insert date to return a directory of players based on roster status for a particular club on a specific date.
+ fields : str
+ Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ list of Coaches
+ returns a list of Coaches
+
+ See Also
+ --------
+ AsyncMlb.get_team : Return a Team from id
+ AsyncMlb.get_team_roster : Return a list of Players from team id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... coaches = await mlb.get_team_coaches(133)
+ [Coach, Coach, Coach]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"teams/{team_id}/coaches",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_roster_coaches(mlb_data.data)
+
+ async def get_person(
+ self,
+ player_id: int,
+ **params,
+ ) -> Person | None:
+ """
+ This endpoint returns statistical data and biographical information
+ for a player,coach or umpire based on playerId.
+
+ Async counterpart of ``Mlb.get_person``.
+
+ Parameters
+ ----------
+ player_id : int
+ Insert personId for a specific player, coach or umpire based on
+ playerId.
+
+ Returns
+ -------
+ Person
+ Returns a Person
+
+ See Also
+ --------
+ AsyncMlb.get_people : Return a list of People from sport id.
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... person = await mlb.get_person(660271)
+ Person
+ """
+ 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 the all players for sportid
+
+ Async counterpart of ``Mlb.get_people``, which reads the
+ ``sports/{sport_id}/players`` endpoint rather than ``people``.
+
+ Parameters
+ ----------
+ sport_id : int
+ Insert a sportId to return player information for a particular
+ sport.
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return player information for a particular season.
+ gameType : str
+ Insert gameType to return player information for a particular
+ gameType.
+
+ Returns
+ -------
+ list
+ Returns a list of People
+
+ See Also
+ --------
+ AsyncMlb.get_person : Return Person from id.
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... people = await mlb.get_people()
+ [Person, Person, Person]
+ """
+ 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_people_id(
+ self,
+ fullname: str,
+ sport_id: int = 1,
+ search_key: str = "fullName",
+ **params,
+ ) -> list[int]:
+ """
+ Returns specific player information based on players fullname
+
+ Async counterpart of ``Mlb.get_people_id``.
+
+ Parameters
+ ----------
+ fullname : str
+ Person full name
+ sport_id : int
+ Insert sportId to return player information for particular sport.
+
+ Other Parameters
+ ----------------
+ season : int
+ Insert year to return player information for a particular season.
+ gameType : str
+ Insert gameType to return player information for a particular
+ gameType.
+
+ Returns
+ -------
+ list of int
+ Returns a list of person ids
+
+ See Also
+ --------
+ AsyncMlb.get_people : Return a list of People from sport id.
+ AsyncMlb.get_person : Return Person from id.
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_people_id("Ty France")
+ [664034]
+ """
+ params["fields"] = "people,id,fullName"
+
+ 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 find_ids_by_key(mlb_data.data.get("people") or [], search_key, fullname)
+
+ 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:
+ """
+ return the schedule created from the included params.
+
+ Async counterpart of ``Mlb.get_schedule``.
+
+ Calling get_schedule without startDate or endDate results in a schedule returned
+ for todays date. Calling with startDate and endDate as the same date returns a
+ schedule for just that desired date. Different results in the schedule for multiple
+ days.
+
+ Parameters
+ ----------
+ date : str
+ Date
+ start_date : str "yyyy-mm-dd"
+ Start date
+ end_date : str "yyyy-mm-dd"
+ End date
+ sport_id : int
+ sport id of schedule defaults to 1
+ team_id : int
+ get schedule for team with team_id
+
+ Other Parameters
+ ----------------
+ leagueId : int,str
+ Insert leagueId to return all schedules based on a particular
+ scheduleType for a specific league. Usage: 1 or '1,11
+ gamePks : int,str
+ Insert gamePks to return all schedules based on a particular
+ scheduleType for specific games. Usage: 531493 or '531493,531497'
+ venueIds : int
+ Insert venueId to return all schedules based on a particular
+ scheduleType for a specific venueId.
+ gameTypes : str
+ Insert gameTypes to return schedule information for all games in
+ particular gameTypes. For a list of all gameTypes:
+ https://statsapi.mlb.com/api/v1/gameTypes
+
+ scheduleType : str
+ Insert one or mutliple of the three available scheduleTypes to
+ return data for a particular schedule. Format "games,events,xref"
+ eventTypes : str
+ Insert one or mutliple of the three available eventTypes to
+ return data for a particular schedule. Format "primary,secondary"
+ There are two different schedule eventTypes:
+ primary- returns calendar/schedule pages.
+ secondary returns ticket pages.
+ hydrate : str
+ Insert Hydration(s) to return data for any available schedule
+ hydration. The hydrations for schedule contain "venue" and "team"
+ which have subhydrations.
+ Format "team(subHydration1, subHydrations2)"
+ Available Hydrations:
+ tickets
+ game(content)
+ game(content(all))
+ game(content(media(all)))
+ game(content(editorial(all)))
+ game(content(highlights(all)))
+ game(content(editorial(preview)))
+ game(content(editorial(recap)))
+ game(content(editorial(articles)))
+ game(content(editorial(wrap)))
+ game(content(media(epg)))
+ game(content(media(milestones)))
+ game(content(highlights(scoreboard)))
+ game(content(highlights(scoreboardPreview)))
+ game(content(highlights(highlights)))
+ game(content(highlights(gamecenter)))
+ game(content(highlights(milestone)))
+ game(content(highlights(live)))
+ game(content(media(featured)))
+ game(content(summary))
+ game(content(gamenotes))
+ game(tickets)
+ game(atBatTickets)
+ game(promotions)
+ game(atBatPromotions)
+ game(sponsorships)
+ lineup
+ linescore
+ linescore(matchup)
+ linescore(runners)
+ linescore(defense)
+ decisions
+ scoringplays
+ broadcasts
+ broadcasts(all)
+ radioBroadcasts
+ metadata
+ game(seriesSummary)
+ seriesStatus
+ event(performers)
+ event(promotions)
+ event(timezone)
+ event(tickets)
+ event(venue)
+ event(designations)
+ event(game)
+ event(status)
+ weather
+ officials
+ probablePitcher
+ venue
+ relatedVenues
+ parentVenues
+ residentVenues
+ relatedVenues(venue)
+ parentVenues(venue)
+ residentVenues(venue)
+ location
+ social
+ relatedApplications
+ timezone
+ menu
+ metadata
+ performers
+ images
+ schedule
+ nextSchedule
+ previousSchedule
+ ticketManagement
+ xrefId
+ team
+ previousSchedule
+ nextSchedule
+ venue
+ springVenue
+ social
+ deviceProperties
+ game(promotions)
+ game(promotions)
+ game(atBatPromotions)
+ game(tickets)
+ game(atBatTickets)
+ game(sponsorships)
+ league
+ videos
+ person
+ sport
+ standings
+ division
+ xref
+
+ Returns
+ -------
+ Schedule
+ returns the Schedule for the dates
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... schedule = await mlb.get_schedule(start_date="2021-08-01", end_date="2021-08-11")
+ Schedule
+ """
+ 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)
+
+ async def get_game(
+ self,
+ game_id: int,
+ **params,
+ ) -> Game | None:
+ """
+ Return the game for a specific game id
+ Gumbo Live Feed for a specific gamePk.
+
+ Async counterpart of ``Mlb.get_game``. Uses the ``v1.1`` live feed
+ endpoint, like the sync client.
+
+ Parameters
+ ----------
+ game_id : int
+ Insert gamePk to return the GUMBO live feed for a specific game.
+
+ Other Parameters
+ ----------------
+ timecode : str
+ Use this parameter to return a snapshot of the data at the
+ specified time. Format: YYYYMMDD_HHMMSS.
+ Return timecodes from timecodes endpoint
+ https://statsapi.mlb.com/api/v1.1/game/534196/feed/live/timestamps
+ hydrate : str
+ Insert hydration(s) to return putout credits or defensive
+ positioning data for all plays in a particular game.
+ Format 'credits,alignment,flags'
+ Available Hydrations:
+ credits
+ alignment
+ flags
+ officials
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Game
+
+ See Also
+ --------
+ AsyncMlb.get_game_play_by_play : return play by play data for a game
+ AsyncMlb.get_game_line_score : return a linescore for a game
+ AsyncMlb.get_game_box_score : return a boxscore for a game
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... game = await mlb.get_game(662242)
+ Game
+ """
+ mlb_data = await self._mlb_adapter_v1_1.get(
+ endpoint=f"game/{game_id}/feed/live",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_game(mlb_data.data, game_id)
+
+ async def get_game_play_by_play(
+ self,
+ game_id: int,
+ **params,
+ ) -> Plays | None:
+ """
+ return the playbyplay of a game for a specific game id
+
+ Async counterpart of ``Mlb.get_game_play_by_play``.
+
+ Parameters
+ ----------
+ game_id : int
+ Game id number
+
+ Other Parameters
+ ----------------
+ timecode : int
+ Use this parameter to return a snapshot of the data at the
+ specified time. Format: YYYYMMDD_HHMMSS
+ fields :
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Plays
+
+ See Also
+ --------
+ AsyncMlb.get_game_line_score : return a linescore for a game
+ AsyncMlb.get_game_box_score : return a boxscore for a game
+ AsyncMlb.get_game : return a specific game from game id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... plays = await mlb.get_game_play_by_play(662242)
+ Plays
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"game/{game_id}/playByPlay",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_plays(mlb_data.data)
+
+ async def get_game_line_score(
+ self,
+ game_id: int,
+ **params,
+ ) -> Linescore | None:
+ """
+ return the Linescore of a game for a specific game id
+
+ Async counterpart of ``Mlb.get_game_line_score``.
+
+ Parameters
+ ----------
+ game_id : int
+ Game id number
+
+ Other Parameters
+ ----------------
+ timecode : int
+ Use this parameter to return a snapshot of the data at the
+ specified time. Format: YYYYMMDD_HHMMSS
+ fields :
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Linescore
+
+ See Also
+ --------
+ AsyncMlb.get_game_play_by_play : return play by play data for a game
+ AsyncMlb.get_game_box_score : return a boxscore for a game
+ AsyncMlb.get_game : return a specific game from game id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... linescore = await mlb.get_game_line_score(662242)
+ Linescore
+ """
+ # Documented quirk: unlike its sibling game helpers, this does not
+ # short-circuit on a 400-499 status; missing linescore data falls
+ # through to an implicit None below. See docs/public-api.md.
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"game/{game_id}/linescore",
+ ep_params=params,
+ )
+
+ return parse_linescore(mlb_data.data)
+
+ async def get_game_box_score(
+ self,
+ game_id: int,
+ **params,
+ ) -> BoxScore | None:
+ """
+ return the boxscore of a game for a specific game id
+
+ Async counterpart of ``Mlb.get_game_box_score``.
+
+ Parameters
+ ----------
+ game_id : int
+ Game id number
+
+ Other Parameters
+ ----------------
+ timecode : int
+ Use this parameter to return a snapshot of the data at the
+ specified time. Format: YYYYMMDD_HHMMSS
+ fields :
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ BoxScore
+
+ See Also
+ --------
+ AsyncMlb.get_game_play_by_play : return play by play data for a game
+ AsyncMlb.get_game_line_score : return a linescore for a game
+ AsyncMlb.get_game : return a specific game from game id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... boxscore = await mlb.get_game_box_score(662242)
+ BoxScore
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"game/{game_id}/boxscore",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_boxscore(mlb_data.data)
+
+ async def get_game_ids(
+ self,
+ date: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ sport_id: int = 1,
+ **params,
+ ) -> list[int]:
+ """
+ return game ids for a specific date and game status
+
+ Async counterpart of ``Mlb.get_game_ids``.
+
+ Parameters
+ ----------
+ date : str
+ date, 'yyyy-mm-dd'
+ start_date : str
+ start date, 'yyyy-mm-dd'
+ end_date : str
+ end date, 'yyyy-mm-dd'
+ spord_id : int
+ spord id of schedule defaults to 1
+
+ Returns
+ -------
+ list of ints
+ returns a list of matching game ids
+
+ See Also
+ --------
+ AsyncMlb.get_game_play_by_play : return play by play data for a game
+ AsyncMlb.get_game_line_score : return a linescore for a game
+ AsyncMlb.get_game : return a specific game from game id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_game_ids(date="2022-09-26")
+ """
+ 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
+ else:
+ return None
+
+ 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 []
+
+ return parse_game_ids(mlb_data.data)
+
+ async def get_sport(
+ self,
+ sport_id: int,
+ **params,
+ ) -> Sport | None:
+ """
+ return sport object from sport_id
+
+ Async counterpart of ``Mlb.get_sport``.
+
+ Parameters
+ ----------
+ sport_id : int
+ Insert a sportId to return a directory of sport(s).
+ For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports
+
+ Other Parameters
+ ----------------
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Sport
+
+ See Also
+ --------
+ AsyncMlb.get_sports : return a list of sports
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... sport = await mlb.get_sport(1)
+ Sport
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"sports/{sport_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_sport(mlb_data.data)
+
+ async def get_sports(
+ self,
+ **params,
+ ) -> list[Sport]:
+ """
+ return all sports
+
+ Async counterpart of ``Mlb.get_sports``.
+
+ Returns
+ -------
+ list of Sports
+ returns a list of sport objects
+
+ Other Parameters
+ ----------------
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ See Also
+ --------
+ AsyncMlb.get_sport : return a sport from id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... sports = await mlb.get_sports()
+ [Sport, Sport, Sport]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="sports",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_sports(mlb_data.data)
+
+ async def get_sport_id(
+ self,
+ sport_name: str,
+ search_key: str = "name",
+ **params,
+ ) -> list[int]:
+ """
+ return sport id
+
+ Async counterpart of ``Mlb.get_sport_id``.
+
+ Parameters
+ ----------
+ sport_name : str
+ Sport name
+ search_key : str
+ search key name
+
+ Returns
+ -------
+ list of ints
+ returns a list of sport ids
+
+ See Also
+ --------
+ AsyncMlb.get_sports : return a list of sports
+ AsyncMlb.get_sport : return a sport from id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_sport_id("Major League Baseball")
+ [1]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="sports",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return find_ids_by_key(mlb_data.data.get("sports") or [], search_key, sport_name)
+
+ async def get_league(
+ self,
+ league_id: int,
+ **params,
+ ) -> League | None:
+ """
+ return league
+
+ Async counterpart of ``Mlb.get_league``.
+
+ Parameters
+ ----------
+ league_id : int
+ leagueId to return league information for a specific league
+
+ Other Parameters
+ ----------------
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ League
+
+ See Also
+ --------
+ AsyncMlb.get_leagues : return a list of Leagues
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... league = await mlb.get_league(103)
+ League
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"leagues/{league_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_league(mlb_data.data)
+
+ async def get_leagues(
+ self,
+ **params,
+ ) -> list[League]:
+ """
+ return all leagues
+
+ Async counterpart of ``Mlb.get_leagues``.
+
+ Returns
+ -------
+ list of Leagues
+
+ Other Parameters
+ ----------------
+ leagueId : str
+ leagueId(s) to return league information for specific leagues.
+ Format '103,104'
+ sportId : int
+ Insert sportId to return league information for a specific sport.
+ For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports
+ seasons : str
+ Insert year(s) to return league information for a specific season.
+ Format '2017,2018'
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ See Also
+ --------
+ AsyncMlb.get_league : return a League from league id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... leagues = await mlb.get_leagues()
+ [League, League, League]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="leagues",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_leagues(mlb_data.data)
+
+ async def get_league_id(
+ self,
+ league_name: str,
+ search_key: str = "name",
+ **params,
+ ) -> list[int]:
+ """
+ return league id
+
+ Async counterpart of ``Mlb.get_league_id``.
+
+ Parameters
+ ----------
+ league_name : str
+ League name
+
+ Returns
+ -------
+ list of ints
+
+ See Also
+ --------
+ AsyncMlb.get_league : return a League from league id
+ AsyncMlb.get_leagues : return a list of Leagues
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_league_id('American League')
+ [103]
+ """
+ params["fields"] = "leagues,id,name"
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="leagues",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return find_ids_by_key(mlb_data.data.get("leagues") or [], search_key, league_name)
+
+ async def get_division(
+ self,
+ division_id: int,
+ **params,
+ ) -> Division | None:
+ """
+ Returns a division based on divisionId,
+
+ Async counterpart of ``Mlb.get_division``.
+
+ Parameters
+ ----------
+ division_id : int
+ divisionId to return a directory of division(s) for a specific division.
+
+ Returns
+ -------
+ Division
+ returns a Division
+
+ See Also
+ --------
+ AsyncMlb.get_divisions : return a list of Divisions
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... division = await mlb.get_division(200)
+ Division
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"divisions/{division_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_division(mlb_data.data)
+
+ async def get_divisions(
+ self,
+ **params,
+ ) -> list[Division]:
+ """
+ return all divisons
+
+ Async counterpart of ``Mlb.get_divisions``.
+
+ Other Parameters
+ ----------------
+ divisionId : str
+ Insert divisionId(s) to return a directory of division(s) for a
+ specific division. Format '200,201'
+ leagueId : int
+ Insert leagueId to return a directory of division(s) for all
+ divisions in a specific league.
+ sportId : int
+ Insert a sportId to return a directory of division(s) for all
+ divisions in a specific sport.
+
+ Returns
+ -------
+ list of Divisions
+ returns a list of all divisions
+
+ See Also
+ --------
+ AsyncMlb.get_division : return a Division from id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... divisions = await mlb.get_divisions()
+ [Division, Division, Division]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="divisions",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_divisions(mlb_data.data)
+
+ async def get_division_id(
+ self,
+ division_name: str,
+ search_key: str = "name",
+ **params,
+ ) -> list[int]:
+ """
+ return division id
+
+ Async counterpart of ``Mlb.get_division_id``.
+
+ Parameters
+ ----------
+ division_name : str
+ Division name
+ search_key : str
+ search key name
+
+ Returns
+ -------
+ list of ints
+ returns a matching list of division ids
+
+ See Also
+ --------
+ AsyncMlb.get_division : return a Division from id
+ AsyncMlb.get_divisions : return a list of Divisions
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_division_id('American League West')
+ [200]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="divisions",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return find_ids_by_key(mlb_data.data.get("divisions") or [], search_key, division_name)
+
+ async def get_season(
+ self,
+ season_id: str,
+ sport_id: int = 1,
+ **params,
+ ) -> Season | None:
+ """
+ return a season object for seasonid and sportid
+
+ Async counterpart of ``Mlb.get_season``.
+
+ Parameters
+ ----------
+ sport_id : int
+ Insert a sportId to return a directory of seasons for a specific sport.
+ season_id : str
+ Insert year to return season information for a particular season.
+
+ Other Parameters
+ ----------------
+ withGameTypeDates : bool, optional
+ Insert a withGameTypeDates to return season information for all gameTypes.
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Season
+ returns a season object
+
+ See Also
+ --------
+ AsyncMlb.get_seasons : return a list of seasons
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... season = await mlb.get_season(season_id="2021", sport_id=1)
+ Season
+ """
+ if sport_id is not None:
+ params["sportId"] = sport_id
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"seasons/{season_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_season(mlb_data.data)
+
+ async def get_seasons(
+ self,
+ sport_id: int = 1,
+ **params,
+ ) -> list[Season]:
+ """
+ return a season object for sportid
+
+ Async counterpart of ``Mlb.get_seasons``.
+
+ Parameters
+ ----------
+ sport_id : int
+ Insert a sportId to return a directory of seasons for a specific
+ sport.
+
+ Other Parameters
+ ----------------
+ divisionId : int, optional
+ Insert divisionId to return a directory of seasons for a specific
+ division.
+ leagueId : int, optional
+ Insert leagueId to return a directory of seasons in a specific
+ league.
+ withGameTypeDates : bool, optional
+ Insert a withGameTypeDates to return season information for all
+ gameTypes.
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ Season
+ returns a season object
+
+ See Also
+ --------
+ AsyncMlb.get_season : return a Season from season id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... seasons = await mlb.get_seasons(1)
+ [Season, Season, Season, Season]
+ """
+ if sport_id is not None:
+ params["sportId"] = sport_id
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="seasons/all",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_seasons(mlb_data.data)
+
+ async def get_venue(
+ self,
+ venue_id: int,
+ **params,
+ ) -> Venue | None:
+ """
+ returns venue directorial information for all available venues in the Stats API.
+
+ Async counterpart of ``Mlb.get_venue``.
+
+ Parameters
+ ----------
+ venue_id : int
+ venueId to return venue directorial information based venueId.
+
+ Other Parameters
+ ----------------
+ fields : str
+ Comma delimited list of specific fields to be returned.
+
+ Returns
+ -------
+ Venue
+
+ See Also
+ --------
+ AsyncMlb.get_venues : return a list of Venues
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... venue = await mlb.get_venue(31)
+ Venue
+ """
+ params["hydrate"] = ["location", "fieldInfo", "timezone"]
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"venues/{venue_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ # Documented quirk: this returns [] rather than None here, unlike
+ # every other single-resource endpoint, matching Mlb.get_venue.
+ # See docs/public-api.md.
+ return []
+
+ return parse_venue(mlb_data.data)
+
+ async def get_venues(
+ self,
+ **params,
+ ) -> list[Venue]:
+ """
+ return all venues
+
+ Async counterpart of ``Mlb.get_venues``.
+
+ Returns
+ -------
+ list of Venues
+ returns a list of Venues
+
+ Other Parameters
+ ----------------
+ venueIds : int, List[int]
+ Insert venueId to return venue directorial information based
+ venueId.
+ sportIds : int, List[int]
+ Insert sportIds to return venue directorial information based a
+ given sport(s). For a list of all sports:
+ https://statsapi.mlb.com/api/v1/sports
+ season : int
+ Insert year to return venue directorial information for a given
+ season.
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ See Also
+ --------
+ AsyncMlb.get_venue : return a Venue
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... venues = await mlb.get_venues()
+ [Venue, Venue, Venue]
+ """
+ params["hydrate"] = ["location", "fieldInfo", "timezone"]
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="venues",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_venues(mlb_data.data)
+
+ async def get_venue_id(
+ self,
+ venue_name: str,
+ search_key: str = "name",
+ **params,
+ ) -> list[int]:
+ """
+ return venue id
+
+ Async counterpart of ``Mlb.get_venue_id``.
+
+ Parameters
+ ----------
+ venue_name : str
+ venue name
+
+ Returns
+ -------
+ list of ints
+ returns a list of matching venue ints
+
+ See Also
+ --------
+ AsyncMlb.get_venue : return a Venue
+ AsyncMlb.get_venues : return a list of Venues
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... ids = await mlb.get_venue_id('PNC Park')
+ [31]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="venues",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return find_ids_by_key(mlb_data.data.get("venues") or [], search_key, venue_name)
+
+ async def get_standings(
+ self,
+ league_id: int,
+ season: str,
+ **params,
+ ) -> list[Standings]:
+ """
+ return a list of standings for league_id and season
+
+ Async counterpart of ``Mlb.get_standings``.
+
+ Parameters
+ ----------
+ league_id : str
+ Insert leagueId to return all standings based on a particular
+ standingType for a specific league.
+ season : str
+ Insert year to return all standings based on a particular year.
+
+ Other Parameters
+ ----------------
+ standingsTypes : str
+ Insert standingType to return all standings based on a particular
+ year.
+ Description of all standingTypes:
+ regularSeason - Regular Season Standings
+ wildCard - Wild card standings
+ divisionLeaders - Division Leader standings
+ wildCardWithLeaders - Wild card standings with Division
+ Leaders firstHalf - First half standings. Only valid for
+ leagues with a split season
+ (Mexican League).
+ secondHalf - Second half standings. Only valid for leagues
+ with a split season (Mexican League).
+ springTraining - Spring Training Standings
+ postseason - Postseason Standings
+ byDivision - Standings by Division
+ byConference - Standings by Conference
+ byLeague - Standings by League
+ Find standingTypes at https://statsapi.mlb.com/api/v1/standingsTypes
+ date : str
+ Insert date to return standing information for on a particular
+ date. Format: MM/DD/YYYY
+ hydrate : str
+ Insert Hydration(s) to return data for any available standings
+ hydration. Format "team,league"
+ Available Hydrations:
+ team
+ league
+ division
+ sport
+ conference
+ record(conference)
+ record(division)
+ fields : str
+ Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ list of Standings
+ returns a list of Standings
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... standings = await mlb.get_standings(103, "2022")
+ [Standings, Standings, Standings]
+ """
+ if league_id is not None:
+ params["leagueId"] = league_id
+
+ if season is not None:
+ params["season"] = season
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="standings",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_standings(mlb_data.data)
+
+ async def get_attendance(
+ self,
+ team_id: int = None,
+ league_id: int = None,
+ league_list_id: str = None,
+ **params,
+ ) -> Attendance | None:
+ """
+ returns attendance data based on teamId, leagueId, or leagueListId.
+
+ Async counterpart of ``Mlb.get_attendance``.
+
+ Required Parameters (at least one)
+ ----------
+ team_id : int
+ Insert a teamId to return directory of attendnace for a given team
+ league_id : int
+ Insert leagueId(s) to return a directory of attendanace for a
+ specific league. Format '103,104'
+ league_list_id : str
+ Insert a unique League List Identifier to return a directory of
+ attendanace for a specific league listId.
+ Available values : milb_full, milb_short, milb_complex, milb_all,
+ milb_all_nomex, milb_all_domestic, milb_noncomp,
+ milb_noncomp_nomex, milb_domcomp, milb_intcomp, win_noabl,
+ win_caribbean, win_all, abl, mlb, mlb_hist, mlb_milb,
+ mlb_milb_hist, mlb_milb_win, baseball_all
+
+ Parameters
+ ----------
+ season : int
+ Insert year(s) to return a directory of attendance for a given
+ season. Season year number format yyyy
+ date : str 'yyyy-mm-dd'
+ Insert date to return information for attendance on a particular
+ date. Format: MM/DD/YYYY
+ gametype : str
+ Insert gameType(s) a directory of attendance for a given gameType.
+ For a list of all gameTypes:
+ https://statsapi.mlb.com/api/v1/gameTypes
+
+ Returns
+ -------
+ Attendance
+
+ See Also
+ --------
+ AsyncMlb.get_leagues : return a list of Leagues
+ AsyncMlb.get_venues : return a list of Venues
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... attendance = await mlb.get_attendance(team_id=133, season=2022)
+ Attendance
+ """
+ required_args = {"teamId": team_id, "leagueId": league_id, "leagueListId": league_list_id}
+
+ if not any(required_args.values()):
+ return None
+
+ for arg_name, arg_value in required_args.items():
+ if arg_value:
+ params[arg_name] = arg_value
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="attendance",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_attendance(mlb_data.data)
+
+ async def get_draft(
+ self,
+ year_id: int,
+ **params,
+ ) -> list[Round]:
+ """
+ return a draft object for year_id
+
+ Async counterpart of ``Mlb.get_draft``.
+
+ Parameters
+ ----------
+ year_id : int
+ Insert a year_id to return a directory of seasons for a specific sport.
+
+ Other Parameters
+ ----------------
+ round : str
+ Insert a round to return biographical and financial data for a specific round in a Rule 4 draft.
+ name : str
+ Insert the first letter of a draftees last name to return their Rule 4 biographical and financial data.
+ school : str
+ Insert the first letter of a draftees school to return their Rule 4 biographical and financial data.
+ state : str
+ Insert state to return a list of Rule 4 draftees from that given state
+ country : str
+ Insert state to return a list of Rule 4 draftees from that given state
+ position : str
+ Insert the position to return Rule 4 biographical and financial data for a players drafted at that position.
+ teamId : int
+ Insert teamId to return Rule 4 biographical and financial data for all picks made by a specific team.
+ playerId : int
+ Insert MLB playerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft.
+ bisPlayerId : int
+ Insert bisPlayerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft.
+
+ Returns
+ -------
+ list of DraftPicks
+ returns a list of DraftPicks
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... rounds = await mlb.get_draft(2019)
+ [Round, Round, Round]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"draft/{year_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_draft(mlb_data.data)
+
+ async def get_awards(
+ self,
+ award_id: str,
+ **params,
+ ) -> list[Award]:
+ """
+ return a list of awards for award_id
+
+ Async counterpart of ``Mlb.get_awards``.
+
+ Parameters
+ ----------
+ award_id : str
+ Insert a awardId to return a directory of players for a given award.
+
+ Other Parameters
+ ----------------
+ sportId : int
+ Insert a sportId to return a directory of players for a given award in a specific sport.
+ leagueId : int, List[int]
+ Insert leagueId(s) to return a directory of players for a given award in a specific league. Format '103,104'
+ season : int, List[int]
+ Insert year(s) to return a directory of players for a given award in a given season. Format '2016,2017'
+
+ Returns
+ -------
+ list of Awards
+ returns a list of awards
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... awards = await mlb.get_awards("ALMVP")
+ [Award, Award, Award]
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"awards/{award_id}/recipients?",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return []
+
+ return parse_awards(mlb_data.data)
+
+ async def get_homerun_derby(
+ self,
+ game_id,
+ **params,
+ ) -> HomeRunDerby | None:
+ """
+ The homerun derby endpoint on the Stats API allows for users to
+ request information from the MLB database pertaining to the
+ homerun derby. This is endpoint contains Statcast trajectory,
+ launchSpeed, launchAngle, & hit coordinates data. Also a timeRemaning
+ string is added to track the progress of the derby in real time.
+
+ Async counterpart of ``Mlb.get_homerun_derby``.
+
+ Parameters
+ ----------
+ game_id : int
+ Insert gamePk to return HomerunDerby data for a specific gamePk.
+
+ Other Parameters
+ ----------------
+ fields : str
+ Format: Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ HomeRunDerby object
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... derby = await mlb.get_homerun_derby(511101)
+ HomeRunDerby
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"homeRunDerby/{game_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_homerun_derby(mlb_data.data)
+
+ async def get_team_stats(
+ self,
+ team_id: int,
+ stats: list,
+ groups: list,
+ **params,
+ ) -> dict:
+ """
+ returns a split stat data for a team
+
+ Async counterpart of ``Mlb.get_team_stats``.
+
+ Parameters
+ ----------
+ team_id : int
+ the team id
+ stats : list
+ list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes
+ groups : list
+ list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return team stats for a particular season, season=2018
+
+ Returns
+ -------
+ dict
+ returns a dict of stats
+
+ See Also
+ --------
+ AsyncMlb.get_player_stats : Get stats for a player
+ AsyncMlb.get_stats : Get stats
+ AsyncMlb.get_players_stats_for_game : Get player stats for a game
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... stats = await mlb.get_team_stats(133, ["season"], ["pitching"])
+ {'pitching': {'season': Stat}}
+ """
+ params["stats"] = stats
+ params["group"] = groups
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"teams/{team_id}/stats",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return {}
+
+ return parse_split_stats(mlb_data.data)
+
+ async def get_players_stats_for_game(
+ self,
+ person_id: int,
+ game_id: int,
+ **params,
+ ) -> dict:
+ """
+ Insert personId and gamePk to view stats for individual player based on a specific game.
+
+ Fielding, Hitting, & Pitching gameLog Statistics as well as vsPlayer stats.
+
+ Async counterpart of ``Mlb.get_players_stats_for_game``.
+
+ Parameters
+ ----------
+ person_id : int
+ the person id
+ game_id : int
+ the game id
+
+ Returns
+ -------
+ dict
+ returns a dict of stats
+
+ See Also
+ --------
+ AsyncMlb.get_team_stats : Get team stats
+ AsyncMlb.get_player_stats : Get stats for a player
+ AsyncMlb.get_stats : Get stats
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... stats = await mlb.get_players_stats_for_game(663728, 715757)
+ ... print(stats["stats"]["gameLog"])
+ ... print(stats["hitting"]["playLog"])
+ """
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"people/{person_id}/stats/game/{game_id}",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return {}
+
+ return parse_split_stats(mlb_data.data)
+
+ async def get_player_stats(
+ self,
+ person_id: int,
+ stats: list,
+ groups: list,
+ **params,
+ ) -> dict:
+ """
+ returns stat data for a player
+
+ Async counterpart of ``Mlb.get_player_stats``.
+
+ Parameters
+ ----------
+ person_id : int
+ the person id
+ stats : list
+ list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes
+ groups : list
+ list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return player stats for a particular season, season=2018
+ eventType : str
+ Notes for individual events for playLog, playLog can be filered by individual events.
+ List of eventTypes can be found at https://statsapi.mlb.com/api/v1/eventTypes
+
+ Returns
+ -------
+ dict
+ returns a dict of stats
+
+ See Also
+ --------
+ AsyncMlb.get_stats : Get stats
+ AsyncMlb.get_team_stats : Get team stats
+ AsyncMlb.get_players_stats_for_game : Get player stats for a game
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... stats = await mlb.get_player_stats(647351, ["season"], ["hitting"])
+ {'hitting': {'season': Stat}}
+ """
+ params["stats"] = stats
+ params["group"] = groups
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint=f"people/{person_id}/stats",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return {}
+
+ return parse_split_stats(mlb_data.data)
+
+ async def get_stats(
+ self,
+ stats: list,
+ groups: list,
+ **params,
+ ) -> dict:
+ """
+ return a stat dictionary
+
+ Async counterpart of ``Mlb.get_stats``.
+
+ Parameters
+ ----------
+ stats : list
+ list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes
+ groups : list
+ list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups
+
+ Other Parameters
+ ----------------
+ season : str
+ Insert year to return stats for a particular season, season=2018
+ teamId : int
+ Insert teamId to return statistics for a given team. Default to "Qualified" playerPool.
+ For a list of all teamIds : AsyncMlb.get_leagues()
+ leagueId : int
+ Insert leagueId to return statistics for a given league. Default to "Qualified" playerPool
+ For a list of all leagueIds : AsyncMlb.get_leagues()
+ gameType : str
+ Insert gameType to return statistics for a given sport or league based on gameType. Default to "Qualified" playerPool
+ Find available gameType at https://statsapi.mlb.com/api/v1/gameTypes
+ sportIds : int
+ Insert sportId to return statistics for a given sport.
+ For a list of all sportIds : AsyncMlb.get_sports()
+
+ Returns
+ -------
+ dict
+ returns a dict of stats
+
+ See Also
+ --------
+ AsyncMlb.get_team_stats : Get team stats
+ AsyncMlb.get_player_stats : Get player stats
+ AsyncMlb.get_players_stats_for_game : Get player stats for a game
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... stats = await mlb.get_stats(["season"], ["hitting"])
+ {'hitting': {'season': Stat}}
+ """
+ params["stats"] = stats
+ params["group"] = groups
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="stats",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return {}
+
+ return parse_split_stats(mlb_data.data)
+
+ async def get_persons(
+ self,
+ person_ids: str | list[int],
+ **params,
+ ) -> list[Person]:
+ """
+ This endpoint returns statistical data and biographical information
+ for players, umpires, and coaches based on playerId.
+
+ Async counterpart of ``Mlb.get_persons``.
+
+ Parameters
+ ----------
+ person_ids : str, list[int]
+ Insert personId(s) to return biographical information for a
+ specific player. Format '605151,592450' or [605151,592450]
+
+ Other Parameters
+ ----------------
+ hydrate : str
+ Insert hydration(s) to return statistical or biographical data
+ for a specific player(s).
+ Format stats(group=["statGroup1","statGroup2"],
+ type=["statType1","statType2"]).
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ list of Person
+ returns a list of Person
+
+ See Also
+ --------
+ AsyncMlb.get_people : Return a list of People from sport id.
+ AsyncMlb.get_people_id : Return person id from name.
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... people = await mlb.get_persons("605151,592450")
+ [Person, 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_scheduled_games_by_date(
+ self,
+ date: str = None,
+ start_date: str = None,
+ end_date: str = None,
+ sport_id: int = 1,
+ **params,
+ ) -> list[ScheduleGames]:
+ """
+ return game ids for a specific date and game status
+
+ Async counterpart of ``Mlb.get_scheduled_games_by_date``.
+
+ Parameters
+ ----------
+ date : str
+ start date, 'yyyy-mm-dd'
+ start_date : str
+ Start date, 'yyyy-mm-dd'
+ end_date : str
+ end date, 'yyyy-mm-dd'
+ sport_id : int
+ sport id of schedule, defaults to 1
+
+ Other Parameters
+ ----------------
+ leagueId : int, str
+ Insert leagueId to return all schedules based on a particular
+ scheduleType for a specific league. Usage: 1 or '1,11'
+ gamePks : int, str
+ Insert gamePks to return all schedules based on a particular
+ scheduleType for specific games. Usage: 531493 or '531493,531497'
+ venueIds : int
+ Insert venueId to return all schedules based on a particular
+ scheduleType for a specific venueId.
+ gameTypes : str
+ Insert gameTypes to return schedule information for all games in
+ particular gameTypes. For a list of all gameTypes:
+ https://statsapi.mlb.com/api/v1/gameTypes
+
+ Returns
+ -------
+ list of ScheduleGames
+ returns a list of matching games
+
+ See Also
+ --------
+ AsyncMlb.get_game_ids : return a list of game ids
+ AsyncMlb.get_game : return a specific game from game id
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... games = await mlb.get_scheduled_games_by_date("2022-10-13")
+ [ScheduleGames, ScheduleGames]
+ """
+ params = build_schedule_params(
+ date=date,
+ start_date=start_date,
+ end_date=end_date,
+ sport_id=sport_id,
+ **params,
+ )
+
+ # Mirrors Mlb.get_scheduled_games_by_date, which returns None -- not
+ # the empty list its annotation promises -- when no date selector was
+ # given. Preserved for parity, not introduced here.
+ 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 []
+
+ return parse_scheduled_games(mlb_data.data)
+
+ async def get_gamepace(
+ self,
+ season: str,
+ sport_id=1,
+ **params,
+ ) -> GamePace | None:
+ """
+ Get pace of game metrics for specific sport, league or team.
+
+ Async counterpart of ``Mlb.get_gamepace``.
+
+ Parameters
+ ----------
+ season : str
+ Insert year to return a directory of pace of game metrics for a
+ given season.
+ sport_id : int
+ Insert a sportId to return a directory of pace of game metrics
+ for a specific sport, defaults to 1
+
+ Other Parameters
+ ----------------
+ teamIds : int
+ Insert a teamIds to return directory of pace of game metrics for
+ a given team. Format '110' or '110,147'
+ leagueId : int
+ Insert leagueIds to return a directory of pace of game metrics
+ for a given league. Format '103' or '103,104'
+ leagueListId : str
+ Insert a unique League List Identifier to return a directory of
+ pace of game metrics for a specific league listId.
+ gameType : str
+ Insert gameType(s) to return a directory of pace of game metrics
+ for a specific gameType. For a list of all gameTypes:
+ https://statsapi.mlb.com/api/v1/gameTypes
+ orgType : str
+ Insert a orgType to return a directory of pace of game metrics
+ based on team, league or sport.
+ Available values : T- TEAM, L- LEAGUE, S- SPORT
+ includeChildren : bool
+ Insert includeChildren to return a directory of pace of game
+ metrics for all child teams in a given parent sport.
+ fields : str
+ Comma delimited list of specific fields to be returned.
+ Format: topLevelNode, childNode, attribute
+
+ Returns
+ -------
+ GamePace
+
+ Examples
+ --------
+ >>> async with AsyncMlb() as mlb:
+ ... gamepace = await mlb.get_gamepace("2021")
+ GamePace
+ """
+ # Mlb.get_gamepace embeds the season in the endpoint string
+ # ("gamePace?season=2021") and lets Requests merge that query with
+ # ep_params. HTTPX does not merge -- passing params replaces a query
+ # already present on the URL -- so copying that idiom here would drop
+ # the season silently. Passing it as a param produces the identical
+ # request on both clients.
+ params["season"] = season
+ params["sportId"] = sport_id
+
+ mlb_data = await self._mlb_adapter_v1.get(
+ endpoint="gamePace",
+ ep_params=params,
+ )
+
+ if 400 <= mlb_data.status_code <= 499:
+ return None
+
+ return parse_gamepace(mlb_data.data)
diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py
new file mode 100644
index 00000000..ec676316
--- /dev/null
+++ b/mlbstatsapi/async_mlb_dataadapter.py
@@ -0,0 +1,202 @@
+import logging
+
+from typing import Dict
+
+from ._async_support import import_httpx
+from ._async_transport import create_library_async_client
+from .exceptions import (
+ MlbDecodeError,
+ MlbTimeoutError,
+ MlbTransportError,
+)
+from .mlb_dataadapter import (
+ DEFAULT_TIMEOUT,
+ MlbResult,
+ TimeoutType,
+)
+
+from ._http import (
+ _build_http_error,
+ _warn_http_compatibility,
+)
+
+# HTTPX is optional; it ships with the ``async`` extra. Importing it through
+# the shared boundary means a sync-only install that reaches for async
+# functionality gets install guidance instead of a bare ModuleNotFoundError
+# naming a library it never asked for. Binding the module here keeps every
+# ``httpx.`` reference below unchanged.
+httpx = import_httpx()
+
+
+class AsyncMlbDataAdapter:
+ """Async data adapter for MLB API."""
+
+
+ def __init__(
+ self,
+ hostname: str = "statsapi.mlb.com",
+ ver: str = "v1",
+ logger: logging.Logger | None = None,
+ timeout: TimeoutType = DEFAULT_TIMEOUT,
+ client: httpx.AsyncClient | None = None,
+ *,
+ strict_http: bool = True,
+ ):
+ self.url = f"https://{hostname}/api/{ver}/"
+ self._logger = logger or logging.getLogger(__name__)
+ self._timeout = timeout
+ self._strict_http = strict_http
+ self._owns_client = client is None
+
+ if client is None:
+ # A library-created client carries the package User-Agent and the
+ # library retry transport. Retries are a property of the client,
+ # not of this adapter, exactly as they are a property of the
+ # Session on the sync side.
+ self._client = create_library_async_client()
+ else:
+ # An injected client stays exactly as the caller configured it,
+ # retry transport included or not.
+ self._client = client
+
+ self._closed = False
+
+ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult:
+ """Get data from the MLB API."""
+ """
+ return a MlbResult from endpoint
+
+ Parameters
+ ----------
+ endpoint : str
+ rest api endpoint
+ ep_params : dict
+ params
+ data : dict
+ data to send with requests (we aren't using this)
+
+ Returns
+ -------
+ MlbResult
+ """
+
+ full_url = self.url + endpoint
+ logline_pre = f'url={full_url}'
+ logline_post = " ,".join(
+ (
+ logline_pre,
+ 'success={}, status_code={}, message={}, url={}'
+ )
+ )
+
+ try:
+ self._logger.debug(logline_post)
+ response = await self._client.get(
+ url=full_url,
+ params=ep_params,
+ timeout=self._translate_timeout(self._timeout),
+ )
+
+ except httpx.TimeoutException as exc:
+ self._logger.error(msg=(str(exc)))
+ raise MlbTimeoutError("Request failed") from exc
+ except httpx.RequestError as exc:
+ self._logger.error(msg=(str(exc)))
+ raise MlbTransportError("Request failed") from exc
+
+ status_code = response.status_code
+
+ if 400 <= status_code <= 499:
+ self._logger.error(msg=logline_post.format(
+ 'Invalid Request',
+ status_code,
+ response.reason_phrase,
+ str(response.url),
+ ))
+ # Strict mode raises for final non-404 4xx after retries are exhausted.
+ # 404 stays an empty MlbResult so endpoints keep None / [] / {} behavior.
+ if self._strict_http and status_code != 404:
+ raise _build_http_error(
+ response,
+ status_code=response.status_code,
+ reason=response.reason_phrase,
+ url=str(response.url) if response.url else full_url,
+ method="GET",
+ )
+ if status_code != 404:
+ _warn_http_compatibility(
+ status_code=status_code,
+ url=str(response.url) if response.url else full_url,
+ )
+ return MlbResult(
+ status_code=status_code,
+ message=response.reason_phrase,
+ data={},
+ )
+
+ if 500 <= status_code <= 599:
+ self._logger.error(msg=logline_post.format(
+ 'Internal error occurred',
+ status_code,
+ response.reason_phrase,
+ str(response.url),
+ ))
+ raise _build_http_error(
+ response,
+ status_code=response.status_code,
+ reason=response.reason_phrase,
+ url=str(response.url) if response.url else full_url,
+ method="GET",
+ )
+
+ if not 200 <= status_code <= 299:
+ raise _build_http_error(
+ response,
+ status_code=response.status_code,
+ reason=response.reason_phrase,
+ url=str(response.url) if response.url else full_url,
+ method="GET",
+ )
+
+ self._logger.debug(msg=logline_post.format(
+ 'success',
+ status_code,
+ response.reason_phrase,
+ str(response.url),
+ ))
+
+ if not response.content:
+ response_data = {}
+ else:
+ try:
+ response_data = response.json()
+ except ValueError as exc:
+ self._logger.error(msg=(str(exc)))
+ raise MlbDecodeError(
+ "Bad JSON in response"
+ ) from exc
+
+ return MlbResult(
+ status_code,
+ message=response.reason_phrase,
+ data=response_data,
+ )
+
+ @staticmethod
+ def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout:
+ if isinstance(timeout, tuple):
+ connect_timeout, read_timeout = timeout
+
+ return httpx.Timeout(
+ connect=connect_timeout,
+ read=read_timeout,
+ write=read_timeout,
+ pool=connect_timeout,
+ )
+
+ return httpx.Timeout(timeout)
+
+ async def aclose(self) -> None:
+ if self._owns_client and not self._closed:
+ await self._client.aclose()
+ self._closed = True
diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py
index 6216fc77..9ca1d429 100644
--- a/mlbstatsapi/mlb_api.py
+++ b/mlbstatsapi/mlb_api.py
@@ -22,6 +22,26 @@
from mlbstatsapi.models.homerunderby import HomeRunDerby
from mlbstatsapi.models.standings import Standings
+
+from ._helpers.id_lookup import find_ids_by_key
+from ._parsers.attendance import parse_attendance
+from ._parsers.awards import parse_awards
+from ._parsers.divisions import parse_divisions, parse_division
+from ._parsers.draft import parse_draft
+from ._parsers.games import parse_boxscore, parse_game, parse_game_ids, parse_linescore, parse_plays
+from ._parsers.homerunderby import parse_homerun_derby
+from ._parsers.leagues import parse_leagues, parse_league
+from ._parsers.people import parse_people, parse_person
+from ._parsers.roster import parse_roster_coaches, parse_roster_players
+from ._parsers.seasons import parse_seasons, parse_season
+from ._parsers.sports import parse_sports, parse_sport
+from ._parsers.standings import parse_standings
+from ._parsers.stats import parse_split_stats
+from ._parsers.teams import parse_teams, parse_team
+from ._parsers.gamepace import parse_gamepace
+from ._parsers.schedules import parse_schedule, parse_scheduled_games
+from ._parsers.venues import parse_venues, parse_venue
+
from .mlb_dataadapter import (
DEFAULT_TIMEOUT,
MlbDataAdapter,
@@ -145,7 +165,7 @@ def get_people(self, sport_id: int = 1, **params) -> List[Person]:
people = []
if 'people' in mlb_data.data and mlb_data.data['people']:
- people = [Person(**person) for person in mlb_data.data['people']]
+ people = parse_people(mlb_data.data)
return people
@@ -181,9 +201,7 @@ def get_person(self, player_id: int, **params) -> Union[Person, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'people' in mlb_data.data and mlb_data.data['people']:
- for person in mlb_data.data['people']:
- return Person(**person)
+ return parse_person(mlb_data.data)
def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Person]:
"""
@@ -243,13 +261,7 @@ def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Perso
if 400 <= mlb_data.status_code <= 499:
return []
- person_list = []
-
- if 'people' in mlb_data.data and mlb_data.data['people']:
- for person in mlb_data.data['people']:
- person_list.append(Person(**person))
-
- return person_list
+ return parse_people(mlb_data.data)
def get_people_id(self, fullname: str, sport_id: int = 1,
search_key: str = 'fullName', **params) -> List[int]:
@@ -294,16 +306,7 @@ def get_people_id(self, fullname: str, sport_id: int = 1,
if 400 <= mlb_data.status_code <= 499:
return []
- player_ids = []
-
- if 'people' in mlb_data.data and mlb_data.data['people']:
- for person in mlb_data.data['people']:
- try:
- if person[search_key].lower() == fullname.lower():
- player_ids.append(person['id'])
- except KeyError:
- continue
- return player_ids
+ return find_ids_by_key(mlb_data.data.get('people') or [], search_key, fullname)
def get_teams(self, sport_id: int = 1, **params) -> List[Team]:
"""
@@ -379,12 +382,8 @@ def get_teams(self, sport_id: int = 1, **params) -> List[Team]:
if 400 <= mlb_data.status_code <= 499:
return []
- teams = []
+ return parse_teams(mlb_data.data)
- if 'teams' in mlb_data.data and mlb_data.data['teams']:
- teams = [Team(**team) for team in mlb_data.data['teams']]
-
- return teams
def get_team(self, team_id: int, **params) -> Union[Team, None]:
"""
@@ -450,9 +449,7 @@ def get_team(self, team_id: int, **params) -> Union[Team, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'teams' in mlb_data.data and mlb_data.data['teams']:
- for team in mlb_data.data['teams']:
- return Team(**team)
+ return parse_team(mlb_data.data)
def get_team_id(self, team_name: str,
search_key: str = 'name', **params) -> List[int]:
@@ -497,16 +494,7 @@ def get_team_id(self, team_name: str,
if 400 <= mlb_data.status_code <= 499:
return []
- team_ids = []
-
- if 'teams' in mlb_data.data and mlb_data.data['teams']:
- for team in mlb_data.data['teams']:
- try:
- if team[search_key].lower() == team_name.lower():
- team_ids.append(team['id'])
- except (KeyError):
- continue
- return team_ids
+ return find_ids_by_key(mlb_data.data.get('teams') or [], search_key, team_name)
def get_team_roster(self, team_id: int, **params) -> List[Player]:
"""
@@ -582,13 +570,7 @@ def get_team_roster(self, team_id: int, **params) -> List[Player]:
if 400 <= mlb_data.status_code <= 499:
return []
- players = []
-
- if 'roster' in mlb_data.data and mlb_data.data['roster']:
- for player in mlb_data.data['roster']:
- players.append(Player(**mlb_module.merge_keys(player, ['person'])))
-
- return players
+ return parse_roster_players(mlb_data.data)
def get_team_coaches(self, team_id: int, **params) -> List[Coach]:
"""
@@ -632,13 +614,7 @@ def get_team_coaches(self, team_id: int, **params) -> List[Coach]:
if 400 <= mlb_data.status_code <= 499:
return []
- coaches = []
-
- if 'roster' in mlb_data.data and mlb_data.data['roster']:
- for coach in mlb_data.data['roster']:
- coaches.append(Coach(**mlb_module.merge_keys(coach, ['person'])))
-
- return coaches
+ return parse_roster_coaches(mlb_data.data)
def get_schedule(self,
date: str = None,
@@ -830,8 +806,7 @@ def get_schedule(self,
# can sometimes be an empty list when there are no scheduled game for the date(s).
# Only check for existance 'dates' key for this reason.
- if 'dates' in mlb_data.data and mlb_data.data['dates']:
- return Schedule(**mlb_data.data)
+ return parse_schedule(mlb_data.data)
def get_scheduled_games_by_date(self, date: str = None,
start_date: str = None,
@@ -895,18 +870,11 @@ def get_scheduled_games_by_date(self, date: str = None,
params["sportId"] = sport_id
- games = []
-
mlb_data = self._mlb_adapter_v1.get(endpoint='schedule', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
return []
- if 'dates' in mlb_data.data and mlb_data.data['dates']:
- for date in mlb_data.data['dates']:
- for game in date['games']:
- games.append(ScheduleGames(**game))
-
- return games
+ return parse_scheduled_games(mlb_data.data)
def get_game(self, game_id: int, **params) -> Union[Game, None]:
"""
@@ -962,8 +930,7 @@ def get_game(self, game_id: int, **params) -> Union[Game, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'gamePk' in mlb_data.data and mlb_data.data['gamePk'] == game_id:
- return Game(**mlb_data.data)
+ return parse_game(mlb_data.data, game_id)
def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]:
"""
@@ -1007,8 +974,7 @@ def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'allPlays' in mlb_data.data and mlb_data.data['allPlays']:
- return Plays(**mlb_data.data)
+ return parse_plays(mlb_data.data)
def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]:
"""
@@ -1050,8 +1016,7 @@ def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]:
mlb_data = self._mlb_adapter_v1.get(endpoint=f'game/{game_id}/linescore', ep_params=params)
- if 'teams' in mlb_data.data and mlb_data.data['teams']:
- return Linescore(**mlb_data.data)
+ return parse_linescore(mlb_data.data)
def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]:
"""
@@ -1095,8 +1060,7 @@ def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'teams' in mlb_data.data and mlb_data.data['teams']:
- return BoxScore(**mlb_data.data)
+ return parse_boxscore(mlb_data.data)
def get_game_ids(self, date: str = None,
@@ -1149,14 +1113,7 @@ def get_game_ids(self, date: str = None,
if 400 <= mlb_data.status_code <= 499:
return []
- game_ids = []
-
- if 'dates' in mlb_data.data and mlb_data.data['dates']:
- for date in mlb_data.data['dates']:
- for game in date['games']:
- game_ids.append(game['gamePk'])
-
- return game_ids
+ return parse_game_ids(mlb_data.data)
def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, None]:
"""
@@ -1219,11 +1176,7 @@ def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, Non
if 400 <= mlb_data.status_code <= 499:
return None
- if ('teams' in mlb_data.data and mlb_data.data['teams']
- or 'leagues' in mlb_data.data and mlb_data.data['leagues']
- or 'sports' in mlb_data.data and mlb_data.data['sports']):
-
- return GamePace(**mlb_data.data)
+ return parse_gamepace(mlb_data.data)
def get_venue(self, venue_id: int, **params) -> Union[Venue, None]:
"""
@@ -1258,11 +1211,11 @@ def get_venue(self, venue_id: int, **params) -> Union[Venue, None]:
mlb_data = self._mlb_adapter_v1.get(endpoint=f'venues/{venue_id}', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
+ # Documented quirk: this returns [] rather than None here, unlike
+ # every other single-resource endpoint. See docs/public-api.md.
return []
- if 'venues' in mlb_data.data and mlb_data.data['venues']:
- for venue in mlb_data.data['venues']:
- return Venue(**venue)
+ return parse_venue(mlb_data.data)
def get_venues(self, **params) -> List[Venue]:
"""
@@ -1306,12 +1259,7 @@ def get_venues(self, **params) -> List[Venue]:
if 400 <= mlb_data.status_code <= 499:
return []
- venues = []
-
- if 'venues' in mlb_data.data and mlb_data.data['venues']:
- venues = [Venue(**venue) for venue in mlb_data.data['venues']]
-
- return venues
+ return parse_venues(mlb_data.data)
def get_venue_id(self, venue_name: str,
search_key: str = 'name', **params) -> List[int]:
@@ -1343,16 +1291,7 @@ def get_venue_id(self, venue_name: str,
if 400 <= mlb_data.status_code <= 499:
return []
- venue_ids = []
-
- if 'venues' in mlb_data.data and mlb_data.data['venues']:
- for venue in mlb_data.data['venues']:
- try:
- if venue[search_key].lower() == venue_name.lower():
- venue_ids.append(venue['id'])
- except KeyError:
- continue
- return venue_ids
+ return find_ids_by_key(mlb_data.data.get('venues') or [], search_key, venue_name)
def get_sport(self, sport_id: int, **params) -> Union[Sport, None]:
"""
@@ -1391,9 +1330,7 @@ def get_sport(self, sport_id: int, **params) -> Union[Sport, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'sports' in mlb_data.data and mlb_data.data['sports']:
- for sport in mlb_data.data['sports']:
- return Sport(**sport)
+ return parse_sport(mlb_data.data)
def get_sports(self, **params) -> List[Sport]:
"""
@@ -1426,12 +1363,7 @@ def get_sports(self, **params) -> List[Sport]:
if 400 <= mlb_data.status_code <= 499:
return []
- sports = []
-
- if 'sports' in mlb_data.data and mlb_data.data['sports']:
- sports = [Sport(**sport) for sport in mlb_data.data['sports']]
-
- return sports
+ return parse_sports(mlb_data.data)
def get_sport_id(self, sport_name: str,
search_key: str = 'name', **params) -> List[int]:
@@ -1466,17 +1398,7 @@ def get_sport_id(self, sport_name: str,
if 400 <= mlb_data.status_code <= 499:
return []
- sport_ids = []
-
- if 'sports' in mlb_data.data and mlb_data.data['sports']:
- for sport in mlb_data.data['sports']:
- try:
- if sport[search_key].lower() == sport_name.lower():
- sport_ids.append(sport['id'])
- except KeyError:
- continue
-
- return sport_ids
+ return find_ids_by_key(mlb_data.data.get('sports') or [], search_key, sport_name)
def get_league(self, league_id: int, **params) -> Union[League, None]:
"""
@@ -1513,9 +1435,7 @@ def get_league(self, league_id: int, **params) -> Union[League, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'leagues' in mlb_data.data and mlb_data.data['leagues']:
- for league in mlb_data.data['leagues']:
- return League(**league)
+ return parse_league(mlb_data.data)
def get_leagues(self, **params) -> List[League]:
"""
@@ -1556,12 +1476,7 @@ def get_leagues(self, **params) -> List[League]:
if 400 <= mlb_data.status_code <= 499:
return []
- leagues = []
-
- if 'leagues' in mlb_data.data and mlb_data.data['leagues']:
- leagues = [League(**league) for league in mlb_data.data['leagues']]
-
- return leagues
+ return parse_leagues(mlb_data.data)
def get_league_id(self, league_name: str,
search_key: str = 'name', **params) -> List[int]:
@@ -1595,16 +1510,7 @@ def get_league_id(self, league_name: str,
if 400 <= mlb_data.status_code <= 499:
return []
- league_ids = []
-
- if 'leagues' in mlb_data.data and mlb_data.data['leagues']:
- for league in mlb_data.data['leagues']:
- try:
- if league[search_key].lower() == league_name.lower():
- league_ids.append(league['id'])
- except KeyError:
- continue
- return league_ids
+ return find_ids_by_key(mlb_data.data.get('leagues') or [], search_key, league_name)
def get_division(self, division_id: int, **params) -> Union[Division, None]:
"""
@@ -1636,9 +1542,7 @@ def get_division(self, division_id: int, **params) -> Union[Division, None]:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'divisions' in mlb_data.data and mlb_data.data['divisions']:
- for division in mlb_data.data['divisions']:
- return Division(**division)
+ return parse_division(mlb_data.data)
def get_divisions(self, **params) -> List[Division]:
"""
@@ -1677,12 +1581,7 @@ def get_divisions(self, **params) -> List[Division]:
if 400 <= mlb_data.status_code <= 499:
return []
- divisions = []
-
- if 'divisions' in mlb_data.data and mlb_data.data['divisions']:
- divisions = [Division(**division) for division in mlb_data.data['divisions']]
-
- return divisions
+ return parse_divisions(mlb_data.data)
def get_division_id(self, division_name: str,
search_key: str = 'name', **params) -> List[int]:
@@ -1716,17 +1615,8 @@ def get_division_id(self, division_name: str,
mlb_data = self._mlb_adapter_v1.get(endpoint='divisions', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
return []
-
- division_ids = []
- if 'divisions' in mlb_data.data and mlb_data.data['divisions']:
- for division in mlb_data.data['divisions']:
- try:
- if division[search_key].lower() == division_name.lower():
- division_ids.append(division['id'])
- except KeyError:
- continue
- return division_ids
+ return find_ids_by_key(mlb_data.data.get('divisions') or [], search_key, division_name)
def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season:
"""
@@ -1769,9 +1659,7 @@ def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season:
if 400 <= mlb_data.status_code <= 499:
return None
- if 'seasons' in mlb_data.data and mlb_data.data['seasons']:
- for season in mlb_data.data['seasons']:
- return Season(**season)
+ return parse_season(mlb_data.data)
def get_seasons(self, sport_id: int = 1, **params) -> List[Season]:
"""
@@ -1826,13 +1714,7 @@ def get_seasons(self, sport_id: int = 1, **params) -> List[Season]:
if 400 <= mlb_data.status_code <= 499:
return []
- season_list = []
-
- if 'seasons' in mlb_data.data and mlb_data.data['seasons']:
- for season in mlb_data.data['seasons']:
- season_list.append(Season(**season))
-
- return season_list
+ return parse_seasons(mlb_data.data)
def get_standings(self, league_id: int, season: str, **params):
"""
@@ -1903,14 +1785,8 @@ def get_standings(self, league_id: int, season: str, **params):
mlb_data = self._mlb_adapter_v1.get(endpoint=f'standings', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
return []
-
- standings_list = []
- if 'records' in mlb_data.data and mlb_data.data['records']:
- for standing in mlb_data.data['records']:
- standings_list.append(Standings(**standing))
-
- return standings_list
+ return parse_standings(mlb_data.data)
def get_attendance(self, team_id: int = None, league_id: int = None,
@@ -1965,8 +1841,8 @@ def get_attendance(self, team_id: int = None, league_id: int = None,
"""
required_args = {'teamId': team_id, 'leagueId': league_id, 'leagueListId': league_list_id}
- if not any(required_args):
- return
+ if not any(required_args.values()):
+ return None
# let's create a list of the args passed
# this will filter out None
@@ -1978,8 +1854,7 @@ def get_attendance(self, team_id: int = None, league_id: int = None,
if 400 <= mlb_data.status_code <= 499:
return None
- if 'records' in mlb_data.data and mlb_data.data['records']:
- return Attendance(**mlb_data.data)
+ return parse_attendance(mlb_data.data)
def get_draft(self, year_id: int, **params) -> List[Round]:
"""
@@ -2026,13 +1901,7 @@ def get_draft(self, year_id: int, **params) -> List[Round]:
if 400 <= mlb_data.status_code <= 499:
return []
- round_list = []
-
- if 'drafts' in mlb_data.data and mlb_data.data['drafts']:
- if mlb_data.data['drafts']['rounds']:
- for round in mlb_data.data['drafts']['rounds']:
- round_list.append(Round(**round))
- return round_list
+ return parse_draft(mlb_data.data)
def get_awards(self, award_id: str, **params) -> List[Award]:
"""
@@ -2066,14 +1935,8 @@ def get_awards(self, award_id: str, **params) -> List[Award]:
mlb_data = self._mlb_adapter_v1.get(endpoint=f'awards/{award_id}/recipients?', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
return []
-
- awards_list = []
- if 'awards' in mlb_data.data and mlb_data.data['awards']:
- for award in mlb_data.data['awards']:
- awards_list.append(Award(**award))
-
- return awards_list
+ return parse_awards(mlb_data.data)
def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]:
"""
@@ -2105,10 +1968,9 @@ def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]:
"""
mlb_data = self._mlb_adapter_v1.get(endpoint=f'homeRunDerby/{game_id}', ep_params=params)
if 400 <= mlb_data.status_code <= 499:
- None
-
- if 'status' in mlb_data.data and mlb_data.data['status']:
- return HomeRunDerby(**mlb_data.data)
+ return None
+
+ return parse_homerun_derby(mlb_data.data)
def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> dict:
@@ -2156,12 +2018,7 @@ def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> d
if 400 <= mlb_data.status_code <= 499:
return {}
- if 'stats' in mlb_data.data and mlb_data.data['stats']:
- splits = mlb_module.create_split_data(mlb_data.data['stats'])
- else:
- return {}
-
- return splits
+ return parse_split_stats(mlb_data.data)
def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> dict:
"""
@@ -2172,9 +2029,9 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) ->
Parameters
----------
person_id : int
- the team id
- game_id : list
- list of stat types
+ the person id
+ game_id : int
+ the game id
Returns
-------
@@ -2192,20 +2049,16 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) ->
>>> mlb = Mlb()
>>> player_id = 663728
>>> game_id = 715757
- >>> stats = mlb.get_player_stats_for_game(person_id=person_id, game_id=game_id)
+ >>> stats = mlb.get_players_stats_for_game(person_id=person_id, game_id=game_id)
>>> print(stats['stats']['gameLog'])
>>> print(stats['hitting']['playLog'])
"""
- mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}')
+ mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}',
+ ep_params=params)
if 400 <= mlb_data.status_code <= 499:
return {}
- if 'stats' in mlb_data.data and mlb_data.data['stats']:
- splits = mlb_module.create_split_data(mlb_data.data['stats'])
- else:
- return {}
-
- return splits
+ return parse_split_stats(mlb_data.data)
def get_player_stats(self, person_id: int, stats: list, groups: list, **params) -> dict:
"""
@@ -2254,12 +2107,7 @@ def get_player_stats(self, person_id: int, stats: list, groups: list, **params)
if 400 <= mlb_data.status_code <= 499:
return {}
- if 'stats' in mlb_data.data and mlb_data.data['stats']:
- splits = mlb_module.create_split_data(mlb_data.data['stats'])
- else:
- return {}
-
- return splits
+ return parse_split_stats(mlb_data.data)
def get_stats(self, stats: list, groups: list, **params: dict) -> dict:
"""
@@ -2313,11 +2161,6 @@ def get_stats(self, stats: list, groups: list, **params: dict) -> dict:
if 400 <= mlb_data.status_code <= 499:
return {}
- if 'stats' in mlb_data.data and mlb_data.data['stats']:
- splits = mlb_module.create_split_data(mlb_data.data['stats'])
- else:
- return {}
-
- return splits
+ return parse_split_stats(mlb_data.data)
# This is to test pypi, please delete later
diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py
index 8082896c..ae10ba64 100644
--- a/mlbstatsapi/mlb_dataadapter.py
+++ b/mlbstatsapi/mlb_dataadapter.py
@@ -3,19 +3,19 @@
from .exceptions import (
MlbDecodeError,
- MlbHttpError,
MlbTimeoutError,
MlbTransportError,
)
-from .warnings import MlbHttpCompatibilityWarning
-import inspect
import logging
-import warnings
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
+from ._http import (
+ _build_http_error,
+ _warn_http_compatibility,
+)
# Connect timeout, then read timeout. Callers may override with a scalar or tuple.
DEFAULT_TIMEOUT = (3.05, 30.0)
@@ -26,131 +26,6 @@
PACKAGE_DISTRIBUTION_NAME = "python-mlb-statsapi"
UNKNOWN_PACKAGE_VERSION = "unknown"
-# Bounded excerpt for error response bodies attached to MlbHttpError.
-HTTP_ERROR_BODY_EXCERPT_LIMIT = 500
-
-
-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.
-
- A fixed stack level cannot serve both direct MlbDataAdapter.get() calls and
- public Mlb endpoint methods that wrap the adapter. Walk frames from the
- caller of this helper outward and stop at the first module outside the
- mlbstatsapi package namespace.
- """
- 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:
- """Warn that compatibility mode suppressed an error strict mode would raise.
-
- Only the status code and URL are reported; response bodies, headers, and
- credentials must never reach a warning message.
- """
- 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: requests.Response,
-) -> dict | list | None:
- """Best-effort JSON object/list extraction from an error response.
-
- Returns None for empty bodies, invalid JSON, scalars, or unexpected failures.
- Must not raise; context extraction cannot replace the original HTTP error.
- """
- 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: requests.Response,
-) -> str | None:
- """Best-effort bounded text excerpt from an error response body.
-
- Returns None for empty bodies or unexpected text-decoding failures.
- Must not raise; context extraction cannot replace the original HTTP error.
- """
- 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: requests.Response,
- *,
- method: str,
- fallback_url: str,
-) -> MlbHttpError:
- """Build an MlbHttpError with best-effort response context.
-
- Extraction failures must not prevent raising MlbHttpError with status,
- reason, URL, and method.
- """
- 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=response.status_code,
- reason=response.reason,
- url=response.url or fallback_url,
- method=method,
- response_data=response_data,
- body_excerpt=body_excerpt,
- )
-
def create_retry_policy() -> Retry:
"""Create a new instance of the default MLB HTTP retry policy."""
@@ -340,8 +215,10 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe
if self._strict_http and status_code != 404:
raise _build_http_error(
response,
+ status_code=response.status_code,
+ reason=response.reason,
+ url=response.url or full_url,
method="GET",
- fallback_url=full_url,
)
if status_code != 404:
_warn_http_compatibility(
@@ -363,15 +240,19 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe
))
raise _build_http_error(
response,
+ status_code=response.status_code,
+ reason=response.reason,
+ url=response.url or full_url,
method="GET",
- fallback_url=full_url,
)
if not 200 <= status_code <= 299:
raise _build_http_error(
response,
+ status_code=response.status_code,
+ reason=response.reason,
+ url=response.url or full_url,
method="GET",
- fallback_url=full_url,
)
self._logger.debug(msg=logline_post.format(
diff --git a/poetry.lock b/poetry.lock
index ac3da903..7cb9084a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -11,6 +11,25 @@ files = [
{file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"},
]
+[[package]]
+name = "anyio"
+version = "4.14.2"
+description = "High-level concurrency and networking framework on top of asyncio or Trio"
+optional = true
+python-versions = ">=3.10"
+files = [
+ {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
+ {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
+idna = ">=2.8"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
+
+[package.extras]
+trio = ["trio (>=0.32.0)"]
+
[[package]]
name = "backports-tarfile"
version = "1.2.0"
@@ -174,104 +193,183 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "charset-normalizer"
-version = "3.4.9"
+version = "3.5.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
files = [
- {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"},
- {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"},
- {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"},
- {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"},
- {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
- {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
- {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"},
- {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"},
- {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
- {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-win32.whl", hash = "sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c"},
+ {file = "charset_normalizer-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db"},
+ {file = "charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14"},
+ {file = "charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a"},
+ {file = "charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3"},
+ {file = "charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775"},
+ {file = "charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74"},
+ {file = "charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-win32.whl", hash = "sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb"},
+ {file = "charset_normalizer-3.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f"},
+ {file = "charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea"},
+ {file = "charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e"},
]
[[package]]
@@ -375,6 +473,62 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
+[[package]]
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = true
+python-versions = ">=3.8"
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+description = "A minimal low-level HTTP client."
+optional = true
+python-versions = ">=3.8"
+files = [
+ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
+ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
+]
+
+[package.dependencies]
+certifi = "*"
+h11 = ">=0.16"
+
+[package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<1.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+description = "The next generation HTTP client."
+optional = true
+python-versions = ">=3.8"
+files = [
+ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
+ {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+httpcore = "==1.*"
+idna = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+zstd = ["zstandard (>=0.18.0)"]
+
[[package]]
name = "id"
version = "1.6.1"
@@ -1090,17 +1244,17 @@ files = [
[[package]]
name = "typing-inspection"
-version = "0.4.2"
+version = "0.4.4"
description = "Runtime typing introspection tools"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
files = [
- {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
- {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
+ {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"},
+ {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"},
]
[package.dependencies]
-typing-extensions = ">=4.12.0"
+typing-extensions = ">=4.15.0"
[[package]]
name = "urllib3"
@@ -1138,7 +1292,10 @@ enabler = ["pytest-enabler (>=3.4)"]
test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy (>=1.0.1)"]
+[extras]
+async = ["httpx"]
+
[metadata]
lock-version = "2.0"
python-versions = ">=3.10"
-content-hash = "a010df85afbd7110b3c9a8eef0bee07e69eaf5828d35fe29e1c7d71b161d4885"
+content-hash = "c5e22c5fd1323c2657a2cb0f076bd72bc15ac450a6ccacfbcf5fdfaaa316362b"
diff --git a/pyproject.toml b/pyproject.toml
index c4f2feab..dd6458c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "python-mlb-statsapi"
-version = "1.0.1"
+version = "1.1.0"
description = "mlbstatsapi python wrapper"
authors = [
"Matthew Spah ",
@@ -24,6 +24,10 @@ classifiers = [
python = ">=3.10"
requests = ">=2"
pydantic = "^2.0"
+httpx = { version = ">=0.28.1,<1.0", optional = true }
+
+[tool.poetry.extras]
+async = ["httpx"]
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
diff --git a/scripts/validate_release.py b/scripts/validate_release.py
index 956d8ae2..e05bb45f 100644
--- a/scripts/validate_release.py
+++ b/scripts/validate_release.py
@@ -1,8 +1,8 @@
"""Validate the built python-mlb-statsapi distributions before a release.
Checks the artifacts in ``dist/``, then clean-installs each distribution
-artifact into its own throwaway virtual environment and runs a public-API
-smoke test against the *installed* package.
+artifact into throwaway virtual environments and runs synchronous and async
+public-API smoke tests against the *installed* package.
Both the wheel and the source distribution are installed separately so a
broken sdist build, a missing runtime dependency, or an omitted package file
@@ -12,12 +12,12 @@
checkout cannot shadow the installed distribution artifact.
Nothing here contacts the MLB API. Every HTTP response exercised by the smoke
-test is produced by an injected fake Session.
+tests is produced by an injected fake Session or HTTPX MockTransport.
Usage::
python scripts/validate_release.py
- python scripts/validate_release.py --expected-version 1.0.0
+ python scripts/validate_release.py --expected-version 1.1.0
python scripts/validate_release.py --dist dist
Without ``--expected-version`` the expected artifact version is read from the
@@ -57,6 +57,12 @@
"mlbstatsapi/__init__.py",
"mlbstatsapi/exceptions.py",
"mlbstatsapi/warnings.py",
+ "mlbstatsapi/_async_support.py",
+ "mlbstatsapi/_async_transport.py",
+ "mlbstatsapi/_env_proxies.py",
+ "mlbstatsapi/_http.py",
+ "mlbstatsapi/async_mlb.py",
+ "mlbstatsapi/async_mlb_dataadapter.py",
"mlbstatsapi/mlb_api.py",
"mlbstatsapi/mlb_dataadapter.py",
"mlbstatsapi/mlb_module.py",
@@ -70,6 +76,12 @@
ADAPTER_STRICT_DEFAULT_MESSAGE = (
"MlbDataAdapter.strict_http must default to True for the 1.0 contract"
)
+ASYNC_MLB_STRICT_DEFAULT_MESSAGE = (
+ "AsyncMlb.strict_http must default to True for the 1.1 contract"
+)
+ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = (
+ "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract"
+)
SMOKE_TEST_SOURCE = '''
"""Public API smoke test for an installed python-mlb-statsapi artifact.
@@ -504,6 +516,293 @@ def close(self):
'''
+ASYNC_SMOKE_TEST_SOURCE = '''
+"""Async public API smoke test for an installed artifact with its async extra.
+
+Runs inside a throwaway virtual environment against the installed
+distribution, never against a repository checkout. Every exercised HTTP
+response comes from HTTPX MockTransport, so this test performs no network I/O
+and never reaches the MLB API.
+"""
+
+import asyncio
+import importlib.metadata
+import inspect
+import logging
+import sys
+import sysconfig
+import warnings
+from pathlib import Path
+
+import httpx
+
+import mlbstatsapi
+from mlbstatsapi import (
+ AsyncMlb,
+ AsyncMlbDataAdapter,
+ MlbHttpCompatibilityWarning,
+ MlbHttpError,
+)
+
+expected_version = sys.argv[1]
+
+# Final 403 responses exercise both strict and 1.x compatibility behavior
+# without contacting the live service.
+FORBIDDEN_PAYLOAD = {"messageNumber": 403, "message": "Forbidden"}
+SPORTS_URL = "https://statsapi.mlb.com/api/v1/sports"
+ASYNC_MLB_STRICT_DEFAULT_MESSAGE = (
+ "AsyncMlb.strict_http must default to True for the 1.1 contract"
+)
+ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = (
+ "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract"
+)
+
+# Expected final 403s are logged by the adapter. Keep release output concise
+# without configuring logging from inside the installed package.
+package_logger = logging.getLogger("mlbstatsapi")
+package_logger.addHandler(logging.NullHandler())
+package_logger.propagate = False
+
+
+# --- The installed artifact and its optional dependency ---
+
+assert sys.prefix != sys.base_prefix, (
+ "the async smoke test must run inside the throwaway virtual environment"
+)
+
+site_packages = Path(sysconfig.get_paths()["purelib"]).resolve()
+package_file = Path(mlbstatsapi.__file__).resolve()
+assert package_file.is_relative_to(site_packages), (
+ f"mlbstatsapi was imported from {package_file}, not from the installed "
+ f"distribution artifact under {site_packages}"
+)
+
+installed_version = importlib.metadata.version("python-mlb-statsapi")
+assert installed_version == expected_version, (
+ f"installed metadata reports {installed_version}, expected {expected_version}"
+)
+
+# In this otherwise-clean environment, importing HTTPX and reading its
+# distribution metadata proves that installing the local artifact's [async]
+# extra installed the optional transport dependency.
+installed_httpx_version = importlib.metadata.version("httpx")
+assert installed_httpx_version, "the async extra did not install HTTPX metadata"
+assert httpx.__version__ == installed_httpx_version
+
+for name in ("AsyncMlb", "AsyncMlbDataAdapter"):
+ assert hasattr(mlbstatsapi, name), f"mlbstatsapi.{name} is not importable"
+ assert getattr(mlbstatsapi, name) is not None, f"mlbstatsapi.{name} is None"
+
+
+# --- Public constructor and lifecycle contracts ---
+
+async_mlb_init = inspect.signature(AsyncMlb.__init__).parameters
+async_adapter_init = inspect.signature(AsyncMlbDataAdapter.__init__).parameters
+
+assert list(async_mlb_init) == [
+ "self",
+ "hostname",
+ "logger",
+ "timeout",
+ "client",
+ "strict_http",
+]
+assert async_mlb_init["hostname"].default == "statsapi.mlb.com"
+assert async_mlb_init["logger"].default is None
+assert async_mlb_init["timeout"].default == (3.05, 30.0)
+assert async_mlb_init["client"].default is None
+assert async_mlb_init["strict_http"].default is True, (
+ ASYNC_MLB_STRICT_DEFAULT_MESSAGE
+)
+assert async_mlb_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY
+
+assert list(async_adapter_init) == [
+ "self",
+ "hostname",
+ "ver",
+ "logger",
+ "timeout",
+ "client",
+ "strict_http",
+]
+assert async_adapter_init["hostname"].default == "statsapi.mlb.com"
+assert async_adapter_init["ver"].default == "v1"
+assert async_adapter_init["logger"].default is None
+assert async_adapter_init["timeout"].default == (3.05, 30.0)
+assert async_adapter_init["client"].default is None
+assert async_adapter_init["strict_http"].default is True, (
+ ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE
+)
+assert async_adapter_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY
+assert inspect.iscoroutinefunction(AsyncMlb.aclose)
+assert inspect.iscoroutinefunction(AsyncMlbDataAdapter.aclose)
+
+
+def forbidden_response(request: httpx.Request) -> httpx.Response:
+ """Return one deterministic final 403 through HTTPX's fake transport."""
+ return httpx.Response(
+ 403,
+ headers={"Content-Type": "application/json"},
+ json=FORBIDDEN_PAYLOAD,
+ request=request,
+ )
+
+
+def assert_forbidden_error(exc: MlbHttpError, *, label: str) -> None:
+ assert exc.status_code == 403, f"{label}: status_code={exc.status_code}"
+ assert exc.reason == "Forbidden", f"{label}: reason={exc.reason!r}"
+ assert exc.method == "GET", f"{label}: method={exc.method!r}"
+ assert exc.url == SPORTS_URL, f"{label}: url={exc.url!r}"
+ assert isinstance(exc.response_data, dict), (
+ f"{label}: response_data={exc.response_data!r}"
+ )
+ for key, value in FORBIDDEN_PAYLOAD.items():
+ assert exc.response_data.get(key) == value, (
+ f"{label}: response_data={exc.response_data!r}"
+ )
+
+
+def compatibility_warnings(caught):
+ return [
+ record
+ for record in caught
+ if issubclass(record.category, MlbHttpCompatibilityWarning)
+ ]
+
+
+async def check_library_owned_lifecycle_and_user_agent() -> None:
+ expected_user_agent = f"python-mlb-statsapi/{expected_version}"
+
+ # Construction plus async context-manager cleanup. No request is made with
+ # this library-created client; its configuration is inspected directly.
+ client = AsyncMlb()
+ owned_httpx_client = client._client
+ assert owned_httpx_client.headers["User-Agent"] == expected_user_agent
+ assert client._mlb_adapter_v1._strict_http is True, (
+ ASYNC_MLB_STRICT_DEFAULT_MESSAGE
+ )
+ async with client as entered:
+ assert entered is client
+ assert owned_httpx_client.is_closed is False
+ assert owned_httpx_client.is_closed is True
+
+ # Explicit cleanup is supported and idempotent for a library-owned client.
+ explicitly_closed = AsyncMlb()
+ explicitly_owned_httpx_client = explicitly_closed._client
+ await explicitly_closed.aclose()
+ assert explicitly_owned_httpx_client.is_closed is True
+ await explicitly_closed.aclose()
+ assert explicitly_owned_httpx_client.is_closed is True
+
+
+async def check_strict_http_and_caller_ownership() -> None:
+ transport = httpx.MockTransport(forbidden_response)
+ caller_client = httpx.AsyncClient(
+ transport=transport,
+ headers={
+ "User-Agent": "release-async-smoke-test/1.0",
+ "X-Release-Test": "preserved",
+ },
+ )
+ headers_before = dict(caller_client.headers)
+
+ try:
+ # Omitting strict_http exercises the real True default. The context
+ # manager must leave the injected HTTPX client caller-owned and open.
+ async with AsyncMlb(client=caller_client) as strict_client:
+ assert strict_client._client is caller_client
+ assert strict_client._mlb_adapter_v1._strict_http is True, (
+ ASYNC_MLB_STRICT_DEFAULT_MESSAGE
+ )
+ try:
+ await strict_client.get_sports()
+ except MlbHttpError as exc:
+ assert_forbidden_error(
+ exc,
+ label="AsyncMlb(strict_http=True).get_sports()",
+ )
+ else:
+ raise AssertionError(
+ "AsyncMlb strict_http=True did not raise MlbHttpError"
+ )
+
+ assert caller_client.is_closed is False, (
+ "AsyncMlb must not close a caller-injected httpx.AsyncClient"
+ )
+ assert dict(caller_client.headers) == headers_before
+
+ # Compatibility mode remains available through 1.x and returns the
+ # endpoint's historical empty result with its compatibility warning.
+ async with AsyncMlb(
+ client=caller_client,
+ strict_http=False,
+ ) as compatibility_client:
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ sports = await compatibility_client.get_sports()
+
+ assert sports == [], (
+ f"AsyncMlb(strict_http=False).get_sports() returned {sports!r}"
+ )
+ captured = compatibility_warnings(caught)
+ assert len(captured) == 1, (
+ "AsyncMlb(strict_http=False) expected exactly one "
+ f"MlbHttpCompatibilityWarning, captured {captured!r}"
+ )
+ assert "strict_http=False" in str(captured[0].message)
+ assert caller_client.is_closed is False, (
+ "compatibility mode closed a caller-injected httpx.AsyncClient"
+ )
+ finally:
+ await caller_client.aclose()
+
+ assert caller_client.is_closed is True
+
+
+async def check_standalone_data_adapter_library_owned_lifecycle() -> None:
+ """Directly exercise AsyncMlbDataAdapter() and its own library-owned client.
+
+ AsyncMlb() only ever constructs AsyncMlbDataAdapter through its own client,
+ so this constructs the public standalone adapter with client=None to prove
+ the library-owned-client construction and cleanup path independently.
+ """
+ expected_user_agent = f"python-mlb-statsapi/{expected_version}"
+
+ adapter = AsyncMlbDataAdapter()
+ owned_httpx_client = adapter._client
+ assert adapter._owns_client is True, (
+ "AsyncMlbDataAdapter() must own the httpx.AsyncClient it creates"
+ )
+ assert adapter._strict_http is True, ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE
+ assert owned_httpx_client.headers["User-Agent"] == expected_user_agent, (
+ f"library-created httpx.AsyncClient sends User-Agent "
+ f"{owned_httpx_client.headers['User-Agent']!r}, expected "
+ f"{expected_user_agent!r}"
+ )
+ assert owned_httpx_client.is_closed is False
+
+ await adapter.aclose()
+ assert owned_httpx_client.is_closed is True
+
+ # Repeated cleanup of a library-owned client is safe/idempotent.
+ await adapter.aclose()
+ assert owned_httpx_client.is_closed is True
+
+
+async def main() -> None:
+ await check_library_owned_lifecycle_and_user_agent()
+ await check_strict_http_and_caller_ownership()
+ await check_standalone_data_adapter_library_owned_lifecycle()
+
+
+asyncio.run(main())
+print(
+ f"async smoke test passed for python-mlb-statsapi {installed_version} "
+ f"with HTTPX {installed_httpx_version}"
+)
+'''
+
+
class ValidationError(Exception):
"""A release validation check failed."""
@@ -648,7 +947,7 @@ def _create_clean_environment(venv_dir: Path) -> Path:
def _check_clean_install(artifact: Path, expected_version: str, *, label: str) -> None:
- """Clean-install one distribution artifact and smoke test the result.
+ """Clean-install one distribution artifact and smoke test the sync result.
Each artifact gets its own virtual environment so the wheel and the source
distribution are never validated against a shared install.
@@ -686,6 +985,66 @@ def _check_clean_install(artifact: Path, expected_version: str, *, label: str) -
)
+def _check_async_clean_install(
+ artifact: Path,
+ expected_version: str,
+ *,
+ label: str,
+) -> None:
+ """Clean-install one artifact with ``[async]`` and smoke test the result.
+
+ This environment is separate from both sync artifact environments. That
+ separation proves HTTPX arrives through the exact local wheel or sdist's
+ optional extra rather than being left over from another validation phase.
+ """
+ with tempfile.TemporaryDirectory(
+ prefix="python-mlb-statsapi-release-async-"
+ ) as tmp:
+ workspace = Path(tmp)
+ venv_dir = workspace / "venv"
+
+ _log(
+ f" creating clean async virtual environment for the {label} "
+ f"in {venv_dir}"
+ )
+ python = _create_clean_environment(venv_dir)
+
+ _run(
+ [str(python), "-m", "pip", "install", "--upgrade", "--quiet", "pip"],
+ cwd=workspace,
+ label=f"pip upgrade for the {label} async environment",
+ )
+
+ artifact_with_extra = f"{artifact.resolve()}[async]"
+ _log(f" installing {label} with async extra: {artifact.name}")
+ _run(
+ [
+ str(python),
+ "-m",
+ "pip",
+ "install",
+ "--quiet",
+ artifact_with_extra,
+ ],
+ cwd=workspace,
+ label=f"{label} async-extra installation of {artifact.name}",
+ )
+
+ smoke_test = workspace / "release_async_smoke_test.py"
+ smoke_test.write_text(ASYNC_SMOKE_TEST_SOURCE, encoding="utf-8")
+
+ # Running from the temporary workspace keeps the repository checkout
+ # off sys.path, exactly as the sync installed-artifact phase does.
+ _log(
+ f" running {label} async smoke test against the installed artifact"
+ )
+ _run(
+ [str(python), str(smoke_test), expected_version],
+ cwd=workspace,
+ label=f"{label} async smoke test for {artifact.name}",
+ )
+
+
def validate(dist_dir: Path, expected_version: str) -> None:
_log(f"Validating release {expected_version} in {dist_dir}")
@@ -711,6 +1070,11 @@ def validate(dist_dir: Path, expected_version: str) -> None:
_check_clean_install(wheel, expected_version, label=WHEEL_LABEL)
_check_clean_install(sdist, expected_version, label=SDIST_LABEL)
+ # The optional dependency must be resolved from each exact artifact in a
+ # fresh environment; a working wheel must not mask a broken sdist extra.
+ _check_async_clean_install(wheel, expected_version, label=WHEEL_LABEL)
+ _check_async_clean_install(sdist, expected_version, label=SDIST_LABEL)
+
_log(f"Release validation passed for {DISTRIBUTION_NAME} {expected_version}")
diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py
new file mode 100644
index 00000000..949b9919
--- /dev/null
+++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py
@@ -0,0 +1,292 @@
+import asyncio
+
+from mlbstatsapi import AsyncMlb
+from mlbstatsapi.models.attendances import Attendance
+from mlbstatsapi.models.awards import Award
+from mlbstatsapi.models.divisions import Division
+from mlbstatsapi.models.drafts import Round
+from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays
+from mlbstatsapi.models.homerunderby import HomeRunDerby
+from mlbstatsapi.models.leagues import League
+from mlbstatsapi.models.people import Coach, Person, Player
+from mlbstatsapi.models.schedules import Schedule
+from mlbstatsapi.models.seasons import Season
+from mlbstatsapi.models.sports import Sport
+from mlbstatsapi.models.standings import Standings
+from mlbstatsapi.models.teams import Team
+from mlbstatsapi.models.venues import Venue
+
+
+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_team_roster():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ roster = await mlb.get_team_roster(133)
+
+ assert roster
+ assert isinstance(roster[0], Player)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_team_coaches():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ coaches = await mlb.get_team_coaches(133)
+
+ assert coaches
+ assert isinstance(coaches[0], Coach)
+
+ 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_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())
+
+
+def test_async_get_sport():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ sport = await mlb.get_sport(1)
+
+ assert isinstance(sport, Sport)
+ assert sport.id == 1
+
+ asyncio.run(scenario())
+
+
+def test_async_get_league():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ league = await mlb.get_league(103)
+
+ assert isinstance(league, League)
+ assert league.id == 103
+
+ asyncio.run(scenario())
+
+
+def test_async_get_division():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ division = await mlb.get_division(200)
+
+ assert isinstance(division, Division)
+ assert division.id == 200
+
+ asyncio.run(scenario())
+
+
+def test_async_get_season():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ season = await mlb.get_season("2021")
+
+ assert isinstance(season, Season)
+ assert season.season_id == "2021"
+
+ asyncio.run(scenario())
+
+
+def test_async_get_venue():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ venue = await mlb.get_venue(31)
+
+ assert isinstance(venue, Venue)
+ assert venue.id == 31
+
+ asyncio.run(scenario())
+
+
+def test_async_get_standings():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ standings = await mlb.get_standings(103, "2022")
+
+ assert standings
+ assert isinstance(standings[0], Standings)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_attendance():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ attendance = await mlb.get_attendance(team_id=133, season=2022)
+
+ assert isinstance(attendance, Attendance)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_draft():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ rounds = await mlb.get_draft(2019)
+
+ assert rounds
+ assert isinstance(rounds[0], Round)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_awards():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ awards = await mlb.get_awards("ALMVP")
+
+ assert awards
+ assert isinstance(awards[0], Award)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_homerun_derby():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ derby = await mlb.get_homerun_derby(511101)
+
+ assert isinstance(derby, HomeRunDerby)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_team_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_team_id("Athletics")
+
+ assert ids == [133]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_people_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_people_id("Ty France")
+
+ assert ids == [664034]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_sport_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_sport_id("Major League Baseball")
+
+ assert ids == [1]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_league_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_league_id("American League")
+
+ assert ids == [103]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_division_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_division_id("American League West")
+
+ assert ids == [200]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_venue_id():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_venue_id("PNC Park")
+
+ assert ids == [31]
+
+ asyncio.run(scenario())
+
+
+def test_async_get_game():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ game = await mlb.get_game(717911)
+
+ assert isinstance(game, Game)
+ assert game.id == 717911
+
+ asyncio.run(scenario())
+
+
+def test_async_get_game_play_by_play():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ plays = await mlb.get_game_play_by_play(717911)
+
+ assert isinstance(plays, Plays)
+ assert plays.all_plays
+
+ asyncio.run(scenario())
+
+
+def test_async_get_game_line_score():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ linescore = await mlb.get_game_line_score(717911)
+
+ assert isinstance(linescore, Linescore)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_game_box_score():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ boxscore = await mlb.get_game_box_score(717911)
+
+ assert isinstance(boxscore, BoxScore)
+
+ asyncio.run(scenario())
+
+
+def test_async_get_game_ids():
+ async def scenario():
+ async with AsyncMlb() as mlb:
+ ids = await mlb.get_game_ids(date="2023-06-03")
+
+ assert 717911 in ids
+
+ asyncio.run(scenario())
diff --git a/tests/external_tests/mlb/test_mlb_smoke.py b/tests/external_tests/mlb/test_mlb_smoke.py
new file mode 100644
index 00000000..be48da76
--- /dev/null
+++ b/tests/external_tests/mlb/test_mlb_smoke.py
@@ -0,0 +1,28 @@
+from mlbstatsapi import Mlb
+from mlbstatsapi.models.people import Person
+from mlbstatsapi.models.schedules import Schedule
+from mlbstatsapi.models.teams import Team
+
+
+def test_get_team():
+ with Mlb() as mlb:
+ team = mlb.get_team(133)
+
+ assert isinstance(team, Team)
+ assert team.id == 133
+
+
+def test_get_person():
+ with Mlb() as mlb:
+ person = mlb.get_person(664034)
+
+ assert isinstance(person, Person)
+ assert person.id == 664034
+
+
+def test_get_schedule():
+ with Mlb() as mlb:
+ schedule = mlb.get_schedule(date="2022-10-07")
+
+ assert isinstance(schedule, Schedule)
+ assert schedule.dates
diff --git a/tests/helpers/test_id_lookup.py b/tests/helpers/test_id_lookup.py
new file mode 100644
index 00000000..85ce9bab
--- /dev/null
+++ b/tests/helpers/test_id_lookup.py
@@ -0,0 +1,35 @@
+from mlbstatsapi._helpers.id_lookup import find_ids_by_key
+
+
+def test_find_ids_by_key_matches_case_insensitively():
+ items = [
+ {"id": 133, "name": "Athletics"},
+ {"id": 147, "name": "Yankees"},
+ ]
+
+ assert find_ids_by_key(items, "name", "athletics") == [133]
+
+
+def test_find_ids_by_key_returns_every_match():
+ items = [
+ {"id": 1, "name": "Duplicate"},
+ {"id": 2, "name": "Duplicate"},
+ {"id": 3, "name": "Other"},
+ ]
+
+ assert find_ids_by_key(items, "name", "Duplicate") == [1, 2]
+
+
+def test_find_ids_by_key_returns_empty_list_for_no_match():
+ assert find_ids_by_key([{"id": 1, "name": "Athletics"}], "name", "Yankees") == []
+ assert find_ids_by_key([], "name", "Athletics") == []
+
+
+def test_find_ids_by_key_skips_items_missing_the_search_key_or_id():
+ items = [
+ {"id": 1},
+ {"name": "Athletics"},
+ {"id": 2, "name": "Athletics"},
+ ]
+
+ assert find_ids_by_key(items, "name", "Athletics") == [2]
diff --git a/tests/parsers/test_attendance_parser.py b/tests/parsers/test_attendance_parser.py
new file mode 100644
index 00000000..4b101d54
--- /dev/null
+++ b/tests/parsers/test_attendance_parser.py
@@ -0,0 +1,50 @@
+from mlbstatsapi._parsers.attendance import parse_attendance
+from mlbstatsapi.models.attendances import Attendance
+
+
+ATTENDANCE_PAYLOAD = {
+ "records": [
+ {
+ "openingsTotal": 160,
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "gamesTotal": 162,
+ "gamesAwayTotal": 82,
+ "gamesHomeTotal": 80,
+ "year": "2022",
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ "gameType": {"id": "R", "description": "Regular Season"},
+ "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"},
+ }
+ ],
+ "aggregateTotals": {
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "openingsTotalYtd": 0,
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ },
+}
+
+
+def test_parse_attendance():
+ """parse_attendance builds an Attendance when records is non-empty."""
+ assert parse_attendance({}) is None
+ assert parse_attendance({"records": []}) is None
+
+ attendance = parse_attendance(ATTENDANCE_PAYLOAD)
+
+ assert isinstance(attendance, Attendance)
+ assert attendance.aggregate_totals.attendance_total == 2896460
+ assert attendance.records[0].team.name == "Oakland Athletics"
diff --git a/tests/parsers/test_awards_parser.py b/tests/parsers/test_awards_parser.py
new file mode 100644
index 00000000..0a3da9c4
--- /dev/null
+++ b/tests/parsers/test_awards_parser.py
@@ -0,0 +1,23 @@
+from mlbstatsapi._parsers.awards import parse_awards
+from mlbstatsapi.models.awards import Award
+
+
+AWARD_PAYLOAD = {
+ "id": "ALMVP",
+ "name": "AL Most Valuable Player",
+ "date": "2022-11-17",
+ "season": "2022",
+ "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"},
+ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"},
+}
+
+
+def test_parse_awards():
+ """parse_awards reads the MLB awards envelope and returns Award models."""
+ assert parse_awards({}) == []
+ assert parse_awards({"awards": []}) == []
+
+ awards = parse_awards({"awards": [AWARD_PAYLOAD]})
+
+ assert awards == [Award(**AWARD_PAYLOAD)]
+ assert awards[0].player.full_name == "Aaron Judge"
diff --git a/tests/parsers/test_divisions.py b/tests/parsers/test_divisions.py
new file mode 100644
index 00000000..0e1bbeda
--- /dev/null
+++ b/tests/parsers/test_divisions.py
@@ -0,0 +1,49 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.divisions import parse_division, parse_divisions
+from mlbstatsapi.models.divisions import Division
+
+
+def test_parse_divisions():
+ """parse_divisions reads the MLB divisions envelope and returns Division models."""
+ assert parse_divisions({}) == []
+ assert parse_divisions({"divisions": []}) == []
+
+ divisions = parse_divisions(
+ {
+ "divisions": [
+ {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"},
+ {"id": 201, "link": "/api/v1/divisions/201", "name": "American League East"},
+ ]
+ }
+ )
+
+ assert divisions == [
+ Division(id=200, link="/api/v1/divisions/200", name="American League West"),
+ Division(id=201, link="/api/v1/divisions/201", name="American League East"),
+ ]
+
+
+def test_parse_division():
+ """parse_division builds a Division from one division payload."""
+ assert parse_division({}) is None
+
+ division = parse_division(
+ {
+ "divisions": [
+ {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"}
+ ]
+ }
+ )
+
+ assert isinstance(division, Division)
+ assert division == Division(
+ id=200, link="/api/v1/divisions/200", name="American League West"
+ )
+
+
+def test_parse_division_requires_link():
+ """Division requires link, the same required field used by the MLB API."""
+ with pytest.raises(ValidationError):
+ parse_division({"divisions": [{"id": 200, "name": "American League West"}]})
diff --git a/tests/parsers/test_draft_parser.py b/tests/parsers/test_draft_parser.py
new file mode 100644
index 00000000..eb96f333
--- /dev/null
+++ b/tests/parsers/test_draft_parser.py
@@ -0,0 +1,13 @@
+from mlbstatsapi._parsers.draft import parse_draft
+from mlbstatsapi.models.drafts import Round
+
+
+def test_parse_draft():
+ """parse_draft reads the nested drafts.rounds envelope and returns Round models."""
+ assert parse_draft({}) == []
+ assert parse_draft({"drafts": {}}) == []
+ assert parse_draft({"drafts": {"rounds": []}}) == []
+
+ rounds = parse_draft({"drafts": {"rounds": [{"round": "1"}, {"round": "1B"}]}})
+
+ assert rounds == [Round(round="1"), Round(round="1B")]
diff --git a/tests/parsers/test_gamepace_parser.py b/tests/parsers/test_gamepace_parser.py
new file mode 100644
index 00000000..e036a4e3
--- /dev/null
+++ b/tests/parsers/test_gamepace_parser.py
@@ -0,0 +1,65 @@
+from mlbstatsapi._parsers.gamepace import parse_gamepace
+from mlbstatsapi.models.gamepace import GamePace
+
+
+SPORT_PACE = {
+ "hitsPer9Inn": 16.68,
+ "runsPer9Inn": 9.3,
+ "pitchesPer9Inn": 299.83,
+ "totalGames": 2429,
+ "timePerGame": "03:11:26",
+ "season": "2021",
+ "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"},
+}
+
+TEAM_PACE = dict(
+ SPORT_PACE,
+ team={"id": 133, "name": "Athletics", "link": "/api/v1/teams/133"},
+)
+
+LEAGUE_PACE = dict(
+ SPORT_PACE,
+ league={"id": 103, "name": "American League", "link": "/api/v1/league/103"},
+)
+
+
+def test_parses_sports_pace():
+ gamepace = parse_gamepace({"sports": [SPORT_PACE]})
+
+ assert isinstance(gamepace, GamePace)
+ assert len(gamepace.sports) == 1
+ assert gamepace.sports[0].season == "2021"
+
+
+def test_parses_teams_pace():
+ gamepace = parse_gamepace({"teams": [TEAM_PACE]})
+
+ assert isinstance(gamepace, GamePace)
+ assert len(gamepace.teams) == 1
+
+
+def test_parses_leagues_pace():
+ gamepace = parse_gamepace({"leagues": [LEAGUE_PACE]})
+
+ assert isinstance(gamepace, GamePace)
+ assert len(gamepace.leagues) == 1
+
+
+def test_any_one_populated_key_is_enough():
+ """The endpoint keys metrics by orgType, so only one of the three arrives."""
+ gamepace = parse_gamepace({"teams": [], "leagues": [], "sports": [SPORT_PACE]})
+
+ assert isinstance(gamepace, GamePace)
+
+
+def test_a_body_with_none_of_the_three_keys_returns_none():
+ assert parse_gamepace({"copyright": "NOTICE"}) is None
+
+
+def test_a_body_whose_keys_are_all_empty_returns_none():
+ assert parse_gamepace({"teams": [], "leagues": [], "sports": []}) is None
+
+
+def test_empty_body_returns_none():
+ assert parse_gamepace({}) is None
+ assert parse_gamepace(None) is None
diff --git a/tests/parsers/test_games.py b/tests/parsers/test_games.py
new file mode 100644
index 00000000..6fa9a538
--- /dev/null
+++ b/tests/parsers/test_games.py
@@ -0,0 +1,127 @@
+from mlbstatsapi._parsers.games import (
+ parse_boxscore,
+ parse_game,
+ parse_game_ids,
+ parse_linescore,
+ parse_plays,
+)
+from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays
+
+
+GAME_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"}
+
+PLAY_PAYLOAD = {
+ "result": {
+ "type": "atBat",
+ "event": "Single",
+ "eventType": "single",
+ "description": "x",
+ "rbi": 0,
+ "awayScore": 0,
+ "homeScore": 0,
+ },
+ "about": {
+ "atBatIndex": 0,
+ "halfInning": "top",
+ "isTopInning": True,
+ "inning": 1,
+ "isComplete": True,
+ "isScoringPlay": False,
+ "hasOut": True,
+ "captivatingIndex": 0,
+ },
+ "count": {"balls": 0, "outs": 1, "strikes": 0},
+ "matchup": {
+ "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"},
+ "batSide": {"code": "R", "description": "Right"},
+ "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"},
+ "pitchHand": {"code": "R", "description": "Right"},
+ "batterHotColdZones": [],
+ "pitcherHotColdZones": [],
+ "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"},
+ },
+ "pitchIndex": [],
+ "actionIndex": [],
+ "runnerIndex": [],
+ "atBatIndex": 0,
+}
+PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]}
+
+TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}
+LINESCORE_PAYLOAD = {
+ "scheduledInnings": 9,
+ "teams": {"home": {}, "away": {}},
+ "defense": {"team": TEAM_PAYLOAD},
+ "offense": {"team": TEAM_PAYLOAD},
+}
+
+BOXSCORE_SIDE = {
+ "team": TEAM_PAYLOAD,
+ "teamStats": {},
+ "players": {},
+ "batters": [],
+ "pitchers": [],
+ "bench": [],
+ "bullpen": [],
+ "battingOrder": [],
+ "info": [],
+}
+BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}}
+
+SCHEDULE_WITH_GAMES_PAYLOAD = {
+ "dates": [
+ {"games": [{"gamePk": 1}, {"gamePk": 2}]},
+ {"games": [{"gamePk": 3}]},
+ ]
+}
+
+
+def test_parse_game():
+ """parse_game only accepts a payload whose gamePk matches the requested id."""
+ assert parse_game({}, 717911) is None
+ assert parse_game({"gamePk": 1, "link": "x"}, 717911) is None
+
+ game = parse_game(GAME_PAYLOAD, 717911)
+
+ assert isinstance(game, Game)
+ assert game.id == 717911
+
+
+def test_parse_plays():
+ """parse_plays requires a non-empty allPlays list."""
+ assert parse_plays({}) is None
+ assert parse_plays({"allPlays": []}) is None
+
+ plays = parse_plays(PLAYS_PAYLOAD)
+
+ assert isinstance(plays, Plays)
+ assert len(plays.all_plays) == 1
+
+
+def test_parse_linescore():
+ """parse_linescore requires a non-empty teams object."""
+ assert parse_linescore({}) is None
+ assert parse_linescore({"teams": {}}) is None
+
+ linescore = parse_linescore(LINESCORE_PAYLOAD)
+
+ assert isinstance(linescore, Linescore)
+ assert linescore.scheduled_innings == 9
+
+
+def test_parse_boxscore():
+ """parse_boxscore requires a non-empty teams object."""
+ assert parse_boxscore({}) is None
+ assert parse_boxscore({"teams": {}}) is None
+
+ boxscore = parse_boxscore(BOXSCORE_PAYLOAD)
+
+ assert isinstance(boxscore, BoxScore)
+
+
+def test_parse_game_ids():
+ """parse_game_ids flattens dates -> games -> gamePk."""
+ assert parse_game_ids({}) == []
+ assert parse_game_ids({"dates": []}) == []
+
+ assert parse_game_ids(SCHEDULE_WITH_GAMES_PAYLOAD) == [1, 2, 3]
diff --git a/tests/parsers/test_homerunderby_parser.py b/tests/parsers/test_homerunderby_parser.py
new file mode 100644
index 00000000..86138dd9
--- /dev/null
+++ b/tests/parsers/test_homerunderby_parser.py
@@ -0,0 +1,40 @@
+from mlbstatsapi._parsers.homerunderby import parse_homerun_derby
+from mlbstatsapi.models.homerunderby import HomeRunDerby
+
+
+HOMERUN_DERBY_PAYLOAD = {
+ "info": {
+ "id": 511101,
+ "nonGameGuid": "test-guid",
+ "name": "Home Run Derby",
+ "eventType": {"code": "O", "name": "Other"},
+ "eventDate": "2017-07-11T00:00:00Z",
+ "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"},
+ "isMultiDay": False,
+ "isPrimaryCalendar": True,
+ "fileCode": "2017/07/10/mlb-112",
+ "eventNumber": 103,
+ "publicFacing": True,
+ },
+ "status": {
+ "state": "Final",
+ "currentRound": 3,
+ "currentRoundTimeLeft": "0:00",
+ "inTieBreaker": False,
+ "tieBreakerNum": 0,
+ "clockStopped": True,
+ "bonusTime": False,
+ },
+}
+
+
+def test_parse_homerun_derby():
+ """parse_homerun_derby builds a HomeRunDerby when status is present."""
+ assert parse_homerun_derby({}) is None
+ assert parse_homerun_derby({"status": {}}) is None
+
+ derby = parse_homerun_derby(HOMERUN_DERBY_PAYLOAD)
+
+ assert isinstance(derby, HomeRunDerby)
+ assert derby.status.state == "Final"
+ assert derby.info.name == "Home Run Derby"
diff --git a/tests/parsers/test_leagues.py b/tests/parsers/test_leagues.py
new file mode 100644
index 00000000..efcea7e2
--- /dev/null
+++ b/tests/parsers/test_leagues.py
@@ -0,0 +1,43 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.leagues import parse_league, parse_leagues
+from mlbstatsapi.models.leagues import League
+
+
+def test_parse_leagues():
+ """parse_leagues reads the MLB leagues envelope and returns League models."""
+ assert parse_leagues({}) == []
+ assert parse_leagues({"leagues": []}) == []
+
+ leagues = parse_leagues(
+ {
+ "leagues": [
+ {"id": 103, "link": "/api/v1/leagues/103", "name": "American League"},
+ {"id": 104, "link": "/api/v1/leagues/104", "name": "National League"},
+ ]
+ }
+ )
+
+ assert leagues == [
+ League(id=103, link="/api/v1/leagues/103", name="American League"),
+ League(id=104, link="/api/v1/leagues/104", name="National League"),
+ ]
+
+
+def test_parse_league():
+ """parse_league builds a League from one league payload."""
+ assert parse_league({}) is None
+
+ league = parse_league(
+ {"leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}]}
+ )
+
+ assert isinstance(league, League)
+ assert league == League(id=103, link="/api/v1/leagues/103", name="American League")
+
+
+def test_parse_league_requires_link():
+ """League requires link, the same required field used by the MLB API."""
+ with pytest.raises(ValidationError):
+ parse_league({"leagues": [{"id": 103, "name": "American League"}]})
diff --git a/tests/parsers/test_people.py b/tests/parsers/test_people.py
new file mode 100644
index 00000000..429d7206
--- /dev/null
+++ b/tests/parsers/test_people.py
@@ -0,0 +1,43 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.people import parse_people, parse_person
+from mlbstatsapi.models.people import Person
+
+
+def test_parse_people():
+ """parse_people reads the MLB people envelope and returns Person models."""
+ assert parse_people({}) == []
+ assert parse_people({"people": []}) == []
+
+ people = parse_people(
+ {
+ "people": [
+ {"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"},
+ {"id": 2, "link": "/api/v1/people/2", "fullName": "Person 2"},
+ ]
+ }
+ )
+
+ assert people == [
+ Person(id=1, link="/api/v1/people/1", full_name="Person 1"),
+ Person(id=2, link="/api/v1/people/2", full_name="Person 2"),
+ ]
+
+
+def test_parse_person():
+ """parse_person builds a Person from one person payload."""
+ assert parse_person({}) is None
+
+ person = parse_person(
+ {"people": [{"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"}]}
+ )
+
+ assert isinstance(person, Person)
+ assert person == Person(id=1, link="/api/v1/people/1", full_name="Person 1")
+
+
+def test_parse_person_requires_link():
+ """Person requires link, the same required field used by the MLB API."""
+ with pytest.raises(ValidationError):
+ parse_person({"people": [{"id": 1, "fullName": "Person 1"}]})
diff --git a/tests/parsers/test_roster_parser.py b/tests/parsers/test_roster_parser.py
new file mode 100644
index 00000000..c8c11e26
--- /dev/null
+++ b/tests/parsers/test_roster_parser.py
@@ -0,0 +1,65 @@
+from mlbstatsapi._parsers.roster import parse_roster_coaches, parse_roster_players
+from mlbstatsapi.models.people import Coach, Player
+
+
+PLAYER_ROSTER_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"},
+ "jerseyNumber": "12",
+ "status": {"code": "A", "description": "Active"},
+ "parentTeamId": 133,
+ }
+ ]
+}
+
+COACH_ROSTER_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"},
+ "jerseyNumber": "7",
+ "job": "Manager",
+ "jobId": "MNGR",
+ "title": "Manager",
+ }
+ ]
+}
+
+
+def test_parse_roster_players():
+ """parse_roster_players merges the nested person dict and returns Players."""
+ assert parse_roster_players({}) == []
+ assert parse_roster_players({"roster": []}) == []
+
+ players = parse_roster_players(PLAYER_ROSTER_PAYLOAD)
+
+ assert players == [
+ Player(
+ id=675961,
+ full_name="Alika Williams",
+ link="/api/v1/people/675961",
+ jersey_number="12",
+ status={"code": "A", "description": "Active"},
+ parent_team_id=133,
+ )
+ ]
+
+
+def test_parse_roster_coaches():
+ """parse_roster_coaches merges the nested person dict and returns Coaches."""
+ assert parse_roster_coaches({}) == []
+ assert parse_roster_coaches({"roster": []}) == []
+
+ coaches = parse_roster_coaches(COACH_ROSTER_PAYLOAD)
+
+ assert coaches == [
+ Coach(
+ id=117276,
+ full_name="Mark Kotsay",
+ link="/api/v1/people/117276",
+ jersey_number="7",
+ job="Manager",
+ job_id="MNGR",
+ title="Manager",
+ )
+ ]
diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py
new file mode 100644
index 00000000..61c27f15
--- /dev/null
+++ b/tests/parsers/test_schedules.py
@@ -0,0 +1,152 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.schedules import parse_schedule, parse_scheduled_games
+from mlbstatsapi.models.schedules import Schedule, ScheduleGames
+
+
+def test_parse_schedule():
+ """parse_schedule builds a Schedule from the full MLB schedule body."""
+ assert parse_schedule({}) is None
+
+ payload = {
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "dates": [
+ {
+ "date": "2026-08-12",
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "games": [],
+ }
+ ]
+ }
+ schedule = parse_schedule(payload)
+
+ assert isinstance(schedule, Schedule)
+ assert schedule == Schedule(**payload)
+
+
+def test_parse_schedule_requires_totals():
+ """Schedule requires the MLB total* fields from the response body."""
+ with pytest.raises(ValidationError):
+ parse_schedule(
+ {
+ "dates": [
+ {
+ "date": "2026-08-12",
+ "totalItems": 0,
+ "totalEvents": 0,
+ "totalGames": 0,
+ "totalGamesInProgress": 0,
+ "games": [],
+ }
+ ]
+ }
+ )
+
+
+def _game(game_pk: int) -> dict:
+ return {
+ "gamePk": game_pk,
+ "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc",
+ "link": f"/api/v1.1/game/{game_pk}/feed/live",
+ "gameType": "D",
+ "season": "2022",
+ "gameDate": "2022-10-13T19:37:00Z",
+ "officialDate": "2022-10-13",
+ "status": {
+ "abstractGameState": "Final",
+ "codedGameState": "F",
+ "detailedState": "Final",
+ "statusCode": "F",
+ "startTimeTBD": False,
+ "abstractGameCode": "F",
+ },
+ "teams": {
+ "away": {
+ "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"},
+ "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"},
+ "score": 2,
+ "isWinner": False,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ "home": {
+ "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"},
+ "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"},
+ "score": 4,
+ "isWinner": True,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ },
+ "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"},
+ "content": {"link": f"/api/v1/game/{game_pk}/content"},
+ "isTie": False,
+ "gameNumber": 1,
+ "publicFacing": True,
+ "doubleHeader": "N",
+ "gamedayType": "P",
+ "tiebreaker": "N",
+ "calendarEventID": f"14-{game_pk}-2022-10-13",
+ "seasonDisplay": "2022",
+ "dayNight": "day",
+ "description": "ALDS Game 2",
+ "scheduledInnings": 9,
+ "reverseHomeAwayStatus": False,
+ "inningBreakLength": 120,
+ "gamesInSeries": 5,
+ "seriesGameNumber": 2,
+ "seriesDescription": "AL Division Series",
+ "recordSource": "S",
+ "ifNecessary": "N",
+ "ifNecessaryDescription": "Normal Game",
+ }
+
+
+def _date(date: str, *games: dict) -> dict:
+ return {
+ "date": date,
+ "totalItems": len(games),
+ "totalEvents": 0,
+ "totalGames": len(games),
+ "totalGamesInProgress": 0,
+ "games": list(games),
+ }
+
+
+def test_parse_scheduled_games_builds_models():
+ games = parse_scheduled_games({"dates": [_date("2022-10-13", _game(715757))]})
+
+ assert len(games) == 1
+ assert isinstance(games[0], ScheduleGames)
+ assert games[0].game_pk == 715757
+
+
+def test_parse_scheduled_games_flattens_across_dates():
+ """The response groups games by date; the parser drops that grouping."""
+ games = parse_scheduled_games(
+ {
+ "dates": [
+ _date("2022-10-13", _game(715757), _game(715758)),
+ _date("2022-10-14", _game(715759)),
+ ]
+ }
+ )
+
+ assert [game.game_pk for game in games] == [715757, 715758, 715759]
+
+
+def test_parse_scheduled_games_with_no_dates_returns_empty_list():
+ assert parse_scheduled_games({"dates": []}) == []
+ assert parse_scheduled_games({}) == []
+ assert parse_scheduled_games(None) == []
+
+
+def test_parse_scheduled_games_with_a_date_carrying_no_games_returns_empty_list():
+ assert parse_scheduled_games({"dates": [_date("2022-10-13")]}) == []
diff --git a/tests/parsers/test_seasons_parser.py b/tests/parsers/test_seasons_parser.py
new file mode 100644
index 00000000..a9844e8d
--- /dev/null
+++ b/tests/parsers/test_seasons_parser.py
@@ -0,0 +1,32 @@
+from mlbstatsapi._parsers.seasons import parse_season, parse_seasons
+from mlbstatsapi.models.seasons import Season
+
+
+def test_parse_seasons():
+ """parse_seasons reads the MLB seasons envelope and returns Season models."""
+ assert parse_seasons({}) == []
+ assert parse_seasons({"seasons": []}) == []
+
+ seasons = parse_seasons(
+ {
+ "seasons": [
+ {"seasonId": "2021", "hasWildcard": True},
+ {"seasonId": "2022", "hasWildcard": True},
+ ]
+ }
+ )
+
+ assert seasons == [
+ Season(seasonId="2021", hasWildcard=True),
+ Season(seasonId="2022", hasWildcard=True),
+ ]
+
+
+def test_parse_season():
+ """parse_season builds a Season from one season payload."""
+ assert parse_season({}) is None
+
+ season = parse_season({"seasons": [{"seasonId": "2021", "hasWildcard": True}]})
+
+ assert isinstance(season, Season)
+ assert season == Season(seasonId="2021", hasWildcard=True)
diff --git a/tests/parsers/test_sports.py b/tests/parsers/test_sports.py
new file mode 100644
index 00000000..a97f0615
--- /dev/null
+++ b/tests/parsers/test_sports.py
@@ -0,0 +1,43 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.sports import parse_sport, parse_sports
+from mlbstatsapi.models.sports import Sport
+
+
+def test_parse_sports():
+ """parse_sports reads the MLB sports envelope and returns Sport models."""
+ assert parse_sports({}) == []
+ assert parse_sports({"sports": []}) == []
+
+ sports = parse_sports(
+ {
+ "sports": [
+ {"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"},
+ {"id": 11, "link": "/api/v1/sports/11", "name": "Triple-A"},
+ ]
+ }
+ )
+
+ assert sports == [
+ Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball"),
+ Sport(id=11, link="/api/v1/sports/11", name="Triple-A"),
+ ]
+
+
+def test_parse_sport():
+ """parse_sport builds a Sport from one sport payload."""
+ assert parse_sport({}) is None
+
+ sport = parse_sport(
+ {"sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}]}
+ )
+
+ assert isinstance(sport, Sport)
+ assert sport == Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball")
+
+
+def test_parse_sport_requires_link():
+ """Sport requires link, the same required field used by the MLB API."""
+ with pytest.raises(ValidationError):
+ parse_sport({"sports": [{"id": 1, "name": "Major League Baseball"}]})
diff --git a/tests/parsers/test_standings_parser.py b/tests/parsers/test_standings_parser.py
new file mode 100644
index 00000000..8e7af17a
--- /dev/null
+++ b/tests/parsers/test_standings_parser.py
@@ -0,0 +1,92 @@
+from mlbstatsapi._parsers.standings import parse_standings
+from mlbstatsapi.models.standings import Standings
+
+
+STANDINGS_RECORD = {
+ "standingsType": "regularSeason",
+ "league": {"id": 103, "link": "/api/v1/league/103"},
+ "division": {"id": 201, "link": "/api/v1/divisions/201"},
+ "sport": {"id": 1, "link": "/api/v1/sports/1"},
+ "roundRobin": {"status": "false"},
+ "lastUpdated": "2025-10-16T23:15:55.082Z",
+ "teamRecords": [
+ {
+ "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"},
+ "season": "2022",
+ "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2},
+ "clinchIndicator": "y",
+ "divisionRank": "1",
+ "leagueRank": "2",
+ "sportRank": "5",
+ "gamesPlayed": 162,
+ "gamesBack": "-",
+ "wildCardGamesBack": "-",
+ "leagueGamesBack": "7.0",
+ "springLeagueGamesBack": "-",
+ "sportGamesBack": "7.0",
+ "divisionGamesBack": "-",
+ "conferenceGamesBack": "-",
+ "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"},
+ "lastUpdated": "2025-10-16T23:14:26Z",
+ "records": {
+ "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "divisionRecords": [
+ {
+ "wins": 17,
+ "losses": 16,
+ "pct": ".515",
+ "division": {
+ "id": 200,
+ "name": "American League West",
+ "link": "/api/v1/divisions/200",
+ },
+ }
+ ],
+ "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "leagueRecords": [
+ {
+ "wins": 89,
+ "losses": 53,
+ "pct": ".627",
+ "league": {
+ "id": 103,
+ "name": "American League",
+ "link": "/api/v1/league/103",
+ },
+ }
+ ],
+ "expectedRecords": [
+ {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"}
+ ],
+ },
+ "runsAllowed": 567,
+ "runsScored": 807,
+ "divisionChamp": True,
+ "divisionLeader": True,
+ "hasWildcard": True,
+ "clinched": True,
+ "eliminationNumber": "-",
+ "eliminationNumberSport": "E",
+ "eliminationNumberLeague": "E",
+ "eliminationNumberDivision": "-",
+ "eliminationNumberConference": "E",
+ "wildCardEliminationNumber": "-",
+ "magicNumber": "-",
+ "wins": 99,
+ "losses": 63,
+ "runDifferential": 240,
+ "winningPercentage": ".611",
+ }
+ ],
+}
+
+
+def test_parse_standings():
+ """parse_standings reads the MLB standings envelope and returns Standings models."""
+ assert parse_standings({}) == []
+ assert parse_standings({"records": []}) == []
+
+ standings = parse_standings({"records": [STANDINGS_RECORD]})
+
+ assert standings == [Standings(**STANDINGS_RECORD)]
+ assert standings[0].team_records[0].team.name == "Yankees"
diff --git a/tests/parsers/test_stats_parser.py b/tests/parsers/test_stats_parser.py
new file mode 100644
index 00000000..97526e58
--- /dev/null
+++ b/tests/parsers/test_stats_parser.py
@@ -0,0 +1,88 @@
+from mlbstatsapi._parsers.stats import parse_split_stats
+from mlbstatsapi.models.stats import Stat
+
+
+HITTING_SEASON = {
+ "type": {"displayName": "season"},
+ "group": {"displayName": "hitting"},
+ "totalSplits": 1,
+ "splits": [
+ {
+ "season": "2022",
+ "stat": {
+ "gamesPlayed": 157,
+ "atBats": 586,
+ "hits": 160,
+ "homeRuns": 34,
+ "avg": ".273",
+ },
+ "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"},
+ "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"},
+ }
+ ],
+}
+
+PITCHING_SEASON = {
+ "type": {"displayName": "season"},
+ "group": {"displayName": "pitching"},
+ "totalSplits": 1,
+ "splits": [
+ {
+ "season": "2022",
+ "stat": {"gamesPlayed": 28, "wins": 15, "losses": 9, "era": "2.33"},
+ "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"},
+ "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"},
+ }
+ ],
+}
+
+
+def test_parses_a_single_group_and_type():
+ stats = parse_split_stats({"stats": [HITTING_SEASON]})
+
+ assert list(stats) == ["hitting"]
+ assert list(stats["hitting"]) == ["season"]
+ assert isinstance(stats["hitting"]["season"], Stat)
+
+
+def test_keys_by_group_then_type():
+ stats = parse_split_stats({"stats": [HITTING_SEASON, PITCHING_SEASON]})
+
+ assert set(stats) == {"hitting", "pitching"}
+ assert stats["hitting"]["season"].group == "hitting"
+ assert stats["pitching"]["season"].group == "pitching"
+
+
+def test_carries_the_split_payload_through():
+ stats = parse_split_stats({"stats": [HITTING_SEASON]})
+
+ split = stats["hitting"]["season"].splits[0]
+ assert split.season == "2022"
+ assert split.stat.home_runs == 34
+
+
+def test_missing_stats_key_returns_an_empty_mapping():
+ assert parse_split_stats({}) == {}
+
+
+def test_empty_stats_list_returns_an_empty_mapping():
+ assert parse_split_stats({"stats": []}) == {}
+
+
+def test_empty_body_returns_an_empty_mapping():
+ assert parse_split_stats(None) == {}
+
+
+def test_a_group_with_no_splits_is_skipped():
+ """create_split_data drops entries carrying no splits rather than keying an empty Stat."""
+ empty = dict(HITTING_SEASON, splits=[])
+
+ assert parse_split_stats({"stats": [empty]}) == {}
+
+
+def test_a_group_with_no_splits_does_not_suppress_its_siblings():
+ empty = dict(HITTING_SEASON, splits=[])
+
+ stats = parse_split_stats({"stats": [empty, PITCHING_SEASON]})
+
+ assert list(stats) == ["pitching"]
diff --git a/tests/parsers/test_teams.py b/tests/parsers/test_teams.py
new file mode 100644
index 00000000..6fe9c4e4
--- /dev/null
+++ b/tests/parsers/test_teams.py
@@ -0,0 +1,41 @@
+import pytest
+from pydantic import ValidationError
+
+from mlbstatsapi._parsers.teams import parse_team, parse_teams
+from mlbstatsapi.models.teams import Team
+
+
+def test_parse_teams():
+ """parse_teams reads the MLB teams envelope and returns Team models."""
+ assert parse_teams({}) == []
+ assert parse_teams({"teams": []}) == []
+
+ teams = parse_teams(
+ {
+ "teams": [
+ {"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"},
+ {"id": 2, "link": "/api/v1/teams/2", "name": "Team 2"},
+ ]
+ }
+ )
+
+ assert teams == [
+ Team(id=1, link="/api/v1/teams/1", name="Team 1"),
+ Team(id=2, link="/api/v1/teams/2", name="Team 2"),
+ ]
+
+
+def test_parse_team():
+ """parse_team builds a Team from one team payload."""
+ assert parse_team({}) is None
+
+ team = parse_team({"teams": [{"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"}]})
+
+ assert isinstance(team, Team)
+ assert team == Team(id=1, link="/api/v1/teams/1", name="Team 1")
+
+
+def test_parse_team_requires_link():
+ """Team requires link, the same required field used by the MLB API."""
+ with pytest.raises(ValidationError):
+ parse_team({"teams": [{"id": 1, "name": "Team 1"}]})
diff --git a/tests/parsers/test_venues.py b/tests/parsers/test_venues.py
new file mode 100644
index 00000000..879a7fc8
--- /dev/null
+++ b/tests/parsers/test_venues.py
@@ -0,0 +1,32 @@
+from mlbstatsapi._parsers.venues import parse_venue, parse_venues
+from mlbstatsapi.models.venues import Venue
+
+
+def test_parse_venues():
+ """parse_venues reads the MLB venues envelope and returns Venue models."""
+ assert parse_venues({}) == []
+ assert parse_venues({"venues": []}) == []
+
+ venues = parse_venues(
+ {
+ "venues": [
+ {"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"},
+ {"id": 1, "link": "/api/v1/venues/1", "name": "Angel Stadium"},
+ ]
+ }
+ )
+
+ assert venues == [
+ Venue(id=31, link="/api/v1/venues/31", name="PNC Park"),
+ Venue(id=1, link="/api/v1/venues/1", name="Angel Stadium"),
+ ]
+
+
+def test_parse_venue():
+ """parse_venue builds a Venue from one venue payload."""
+ assert parse_venue({}) is None
+
+ venue = parse_venue({"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]})
+
+ assert isinstance(venue, Venue)
+ assert venue == Venue(id=31, link="/api/v1/venues/31", name="PNC Park")
diff --git a/tests/test_async_env_proxies.py b/tests/test_async_env_proxies.py
new file mode 100644
index 00000000..5744b2b7
--- /dev/null
+++ b/tests/test_async_env_proxies.py
@@ -0,0 +1,253 @@
+"""Tests for async client env-proxy wiring in _async_transport.py (issue #324).
+
+PR #323 moved async retries into a custom HTTPX transport, mounted onto
+library-created clients via ``AsyncClient(transport=...)``. httpx 0.28.1 only
+builds its own environment-proxy mounts when the caller leaves
+``transport=None`` (``allow_env_proxies = trust_env and transport is None`` in
+``httpx.Client.__init__``), so passing a transport silently disabled
+``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` support for
+every library-created async client.
+
+``create_library_async_client`` (in ``mlbstatsapi/_async_transport.py``)
+rebuilds that discovery via ``mlbstatsapi._env_proxies.environment_proxy_map``
+and wires it through HTTPX's public ``mounts=`` argument instead. This module
+covers that wiring: shared retry policy, transport cleanup, injected-client
+isolation, retries through a proxy, and — at the bottom — a differential test
+against HTTPX's own env-proxy discovery. The pure parsing behind the map is
+covered separately in tests/test_env_proxies.py, which has no HTTPX
+dependency and runs even without the ``async`` extra.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+# The whole module needs a real HTTPX-backed client, so it skips as a unit
+# when the optional ``async`` extra is not installed, matching the guard used
+# throughout the async test suite (see tests/test_async_optional_dependency.py).
+httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)")
+
+from mlbstatsapi._async_transport import ( # noqa: E402
+ MlbAsyncRetryTransport,
+ create_library_async_client,
+)
+from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402
+
+from test_env_proxies import set_proxy_env # noqa: E402
+
+SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep"
+INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport"
+
+
+# ---------------------------------------------------------------------------
+# create_library_async_client(): wiring the map into HTTPX
+# ---------------------------------------------------------------------------
+
+
+def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch):
+ set_proxy_env(
+ monkeypatch,
+ {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"},
+ )
+
+ async def scenario():
+ client = create_library_async_client()
+ try:
+ transports = [client._transport] + [
+ mount for mount in client._mounts.values() if mount is not None
+ ]
+ assert len(transports) == 3
+ assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports)
+
+ policy = transports[0]._retry_policy
+ assert all(t._retry_policy is policy for t in transports)
+ finally:
+ await client.aclose()
+
+ asyncio.run(scenario())
+
+
+def test_aclose_closes_every_proxy_transport(monkeypatch):
+ # NO_PROXY adds a bypass mount. That is what makes this a real regression
+ # test: the bypass branch mounts None specifically so HTTPX falls back to
+ # client._transport for that pattern instead of routing through (and
+ # later double-closing) a second reference to the same `direct` object.
+ # Without a bypass entry in the fixture, `len(proxy_mounts) == 2` below
+ # would still hold even if the bypass branch mounted `direct` instead of
+ # None, since there would be nothing to tell the two apart.
+ set_proxy_env(
+ monkeypatch,
+ {
+ "HTTPS_PROXY": "http://corp:8080",
+ "HTTP_PROXY": "http://corp:9090",
+ "NO_PROXY": "mlb.com",
+ },
+ )
+
+ async def scenario():
+ with patch.object(
+ httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock
+ ) as mock_aclose:
+ client = create_library_async_client()
+ assert len(client._mounts) == 3
+
+ proxy_mounts = [m for m in client._mounts.values() if m is not None]
+ # Pinned to 2, not derived after the fact: if the bypass branch
+ # ever mounts `direct` instead of None, this becomes 3 and fails
+ # here, before the tautological count below could paper over it.
+ assert len(proxy_mounts) == 2
+
+ await client.aclose()
+
+ # The direct transport plus the two real proxy transports; the
+ # NO_PROXY bypass mount is None and contributes no separate close.
+ assert mock_aclose.call_count == 1 + len(proxy_mounts)
+
+ asyncio.run(scenario())
+
+
+def test_injected_client_is_unmodified_by_proxy_env(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"})
+
+ transport = httpx.MockTransport(lambda request: httpx.Response(200))
+ client = httpx.AsyncClient(transport=transport)
+ original_mounts = dict(client._mounts)
+
+ async def scenario():
+ adapter = AsyncMlbDataAdapter(client=client)
+
+ assert adapter._owns_client is False
+ assert adapter._client is client
+ assert adapter._client._transport is transport
+ assert adapter._client._mounts == original_mounts
+
+ await client.aclose()
+
+ asyncio.run(scenario())
+
+
+def test_retry_fires_through_a_proxied_transport(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"})
+
+ call_count = 0
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ nonlocal call_count
+ call_count += 1
+ return httpx.Response(503) if call_count == 1 else httpx.Response(200)
+
+ async def scenario():
+ with (
+ patch(
+ INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)
+ ),
+ patch(SLEEP_TARGET, new_callable=AsyncMock),
+ ):
+ client = create_library_async_client()
+ try:
+ # Pin resolution to the proxy mount rather than the direct
+ # fallback, so a request to statsapi.mlb.com with HTTPS_PROXY
+ # set is guaranteed to exercise the proxied transport below,
+ # not just happen to because both wrap the same handler.
+ target = httpx.URL("https://statsapi.mlb.com/api/v1/sports")
+ assert client._transport_for_url(target) is not client._transport
+
+ return await client.get(str(target))
+ finally:
+ await client.aclose()
+
+ response = asyncio.run(scenario())
+ assert response.status_code == 200
+ assert call_count == 2
+
+
+# ---------------------------------------------------------------------------
+# Differential test: pin our wiring to HTTPX's own env-proxy discovery.
+#
+# Each case was hand-verified against stock httpx 0.28.1 discovery
+# (httpx.AsyncClient() with no transport=). If this starts failing against a
+# newer 0.x httpx, that is the drift alarm: it means NO_PROXY / proxy
+# semantics moved out from under environment_proxy_map's stdlib
+# reimplementation, and the two need to be reconciled, not the test loosened.
+# ---------------------------------------------------------------------------
+
+
+def proxy_target(transport):
+ inner = getattr(transport, "_inner", transport)
+ pool = getattr(inner, "_pool", None)
+ url = getattr(pool, "_proxy_url", None)
+ return str(url) if url is not None else None
+
+
+DIFFERENTIAL_CASES = [
+ (
+ {"HTTPS_PROXY": "http://corp:8080"},
+ ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"],
+ ),
+ (
+ {"HTTP_PROXY": "http://corp:8080"},
+ ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"],
+ ),
+ (
+ {"ALL_PROXY": "http://corp:9"},
+ ["https://statsapi.mlb.com/api", "http://anything.test/"],
+ ),
+ (
+ {"HTTPS_PROXY": "corp:8080"},
+ ["https://statsapi.mlb.com/api"],
+ ),
+ (
+ {
+ "HTTPS_PROXY": "http://corp:8080",
+ "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1",
+ },
+ [
+ "https://statsapi.mlb.com/api",
+ "https://mlb.com/",
+ "https://other.test/",
+ "http://localhost:8000/",
+ "https://127.0.0.1/",
+ "https://[::1]/",
+ ],
+ ),
+ (
+ {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"},
+ ["https://statsapi.mlb.com/api"],
+ ),
+ (
+ {},
+ ["https://statsapi.mlb.com/api"],
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "env, urls",
+ DIFFERENTIAL_CASES,
+ ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES],
+)
+def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls):
+ set_proxy_env(monkeypatch, env)
+
+ async def scenario():
+ stock = httpx.AsyncClient()
+ ours = create_library_async_client()
+ try:
+ for url in urls:
+ stock_transport = stock._transport_for_url(httpx.URL(url))
+ our_transport = ours._transport_for_url(httpx.URL(url))
+
+ assert isinstance(our_transport, MlbAsyncRetryTransport), url
+ assert proxy_target(our_transport) == proxy_target(
+ stock_transport
+ ), url
+ finally:
+ await stock.aclose()
+ await ours.aclose()
+
+ asyncio.run(scenario())
diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py
new file mode 100644
index 00000000..87c68b3d
--- /dev/null
+++ b/tests/test_async_mlb.py
@@ -0,0 +1,1773 @@
+"""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
+from urllib.parse import parse_qsl
+
+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_transport import MlbAsyncRetryTransport # noqa: E402
+from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402
+from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402
+from mlbstatsapi.models.attendances import Attendance # noqa: E402
+from mlbstatsapi.models.awards import Award # noqa: E402
+from mlbstatsapi.models.divisions import Division # noqa: E402
+from mlbstatsapi.models.drafts import Round # noqa: E402
+from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402
+from mlbstatsapi.models.gamepace import GamePace # noqa: E402
+from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402
+from mlbstatsapi.models.leagues import League # noqa: E402
+from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402
+from mlbstatsapi.models.schedules import Schedule # noqa: E402
+from mlbstatsapi.models.seasons import Season # noqa: E402
+from mlbstatsapi.models.sports import Sport # noqa: E402
+from mlbstatsapi.models.standings import Standings # noqa: E402
+from mlbstatsapi.models.stats import Stat # noqa: E402
+from mlbstatsapi.models.teams import Team # noqa: E402
+from mlbstatsapi.models.venues import Venue # 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"}]
+}
+SPORT_PAYLOAD = {
+ "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}]
+}
+LEAGUE_PAYLOAD = {
+ "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}]
+}
+DIVISION_PAYLOAD = {
+ "divisions": [
+ {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"}
+ ]
+}
+ROSTER_PLAYER_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"},
+ "jerseyNumber": "12",
+ "status": {"code": "A", "description": "Active"},
+ "parentTeamId": 133,
+ }
+ ]
+}
+ROSTER_COACH_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"},
+ "jerseyNumber": "7",
+ "job": "Manager",
+ "jobId": "MNGR",
+ "title": "Manager",
+ }
+ ]
+}
+SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]}
+VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]}
+STANDINGS_RECORD = {
+ "standingsType": "regularSeason",
+ "league": {"id": 103, "link": "/api/v1/league/103"},
+ "division": {"id": 201, "link": "/api/v1/divisions/201"},
+ "sport": {"id": 1, "link": "/api/v1/sports/1"},
+ "roundRobin": {"status": "false"},
+ "lastUpdated": "2025-10-16T23:15:55.082Z",
+ "teamRecords": [
+ {
+ "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"},
+ "season": "2022",
+ "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2},
+ "clinchIndicator": "y",
+ "divisionRank": "1",
+ "leagueRank": "2",
+ "sportRank": "5",
+ "gamesPlayed": 162,
+ "gamesBack": "-",
+ "wildCardGamesBack": "-",
+ "leagueGamesBack": "7.0",
+ "springLeagueGamesBack": "-",
+ "sportGamesBack": "7.0",
+ "divisionGamesBack": "-",
+ "conferenceGamesBack": "-",
+ "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"},
+ "lastUpdated": "2025-10-16T23:14:26Z",
+ "records": {
+ "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "divisionRecords": [
+ {
+ "wins": 17,
+ "losses": 16,
+ "pct": ".515",
+ "division": {
+ "id": 200,
+ "name": "American League West",
+ "link": "/api/v1/divisions/200",
+ },
+ }
+ ],
+ "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "leagueRecords": [
+ {
+ "wins": 89,
+ "losses": 53,
+ "pct": ".627",
+ "league": {
+ "id": 103,
+ "name": "American League",
+ "link": "/api/v1/league/103",
+ },
+ }
+ ],
+ "expectedRecords": [
+ {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"}
+ ],
+ },
+ "runsAllowed": 567,
+ "runsScored": 807,
+ "divisionChamp": True,
+ "divisionLeader": True,
+ "hasWildcard": True,
+ "clinched": True,
+ "eliminationNumber": "-",
+ "eliminationNumberSport": "E",
+ "eliminationNumberLeague": "E",
+ "eliminationNumberDivision": "-",
+ "eliminationNumberConference": "E",
+ "wildCardEliminationNumber": "-",
+ "magicNumber": "-",
+ "wins": 99,
+ "losses": 63,
+ "runDifferential": 240,
+ "winningPercentage": ".611",
+ }
+ ],
+}
+STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]}
+ATTENDANCE_PAYLOAD = {
+ "records": [
+ {
+ "openingsTotal": 160,
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "gamesTotal": 162,
+ "gamesAwayTotal": 82,
+ "gamesHomeTotal": 80,
+ "year": "2022",
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ "gameType": {"id": "R", "description": "Regular Season"},
+ "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"},
+ }
+ ],
+ "aggregateTotals": {
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "openingsTotalYtd": 0,
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ },
+}
+DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}}
+AWARD_PAYLOAD = {
+ "id": "ALMVP",
+ "name": "AL Most Valuable Player",
+ "date": "2022-11-17",
+ "season": "2022",
+ "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"},
+ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"},
+}
+AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]}
+HOMERUN_DERBY_PAYLOAD = {
+ "info": {
+ "id": 511101,
+ "nonGameGuid": "test-guid",
+ "name": "Home Run Derby",
+ "eventType": {"code": "O", "name": "Other"},
+ "eventDate": "2017-07-11T00:00:00Z",
+ "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"},
+ "isMultiDay": False,
+ "isPrimaryCalendar": True,
+ "fileCode": "2017/07/10/mlb-112",
+ "eventNumber": 103,
+ "publicFacing": True,
+ },
+ "status": {
+ "state": "Final",
+ "currentRound": 3,
+ "currentRoundTimeLeft": "0:00",
+ "inTieBreaker": False,
+ "tieBreakerNum": 0,
+ "clockStopped": True,
+ "bonusTime": False,
+ },
+}
+GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"}
+PLAY_PAYLOAD = {
+ "result": {
+ "type": "atBat",
+ "event": "Single",
+ "eventType": "single",
+ "description": "x",
+ "rbi": 0,
+ "awayScore": 0,
+ "homeScore": 0,
+ },
+ "about": {
+ "atBatIndex": 0,
+ "halfInning": "top",
+ "isTopInning": True,
+ "inning": 1,
+ "isComplete": True,
+ "isScoringPlay": False,
+ "hasOut": True,
+ "captivatingIndex": 0,
+ },
+ "count": {"balls": 0, "outs": 1, "strikes": 0},
+ "matchup": {
+ "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"},
+ "batSide": {"code": "R", "description": "Right"},
+ "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"},
+ "pitchHand": {"code": "R", "description": "Right"},
+ "batterHotColdZones": [],
+ "pitcherHotColdZones": [],
+ "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"},
+ },
+ "pitchIndex": [],
+ "actionIndex": [],
+ "runnerIndex": [],
+ "atBatIndex": 0,
+}
+PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]}
+GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}
+LINESCORE_PAYLOAD = {
+ "scheduledInnings": 9,
+ "teams": {"home": {}, "away": {}},
+ "defense": {"team": GAME_TEAM_PAYLOAD},
+ "offense": {"team": GAME_TEAM_PAYLOAD},
+}
+BOXSCORE_SIDE = {
+ "team": GAME_TEAM_PAYLOAD,
+ "teamStats": {},
+ "players": {},
+ "batters": [],
+ "pitchers": [],
+ "bench": [],
+ "bullpen": [],
+ "battingOrder": [],
+ "info": [],
+}
+BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}}
+SCHEDULE_WITH_GAMES_PAYLOAD = {
+ "dates": [
+ {"games": [{"gamePk": 1}, {"gamePk": 2}]},
+ {"games": [{"gamePk": 3}]},
+ ]
+}
+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"
+)
+EXPECTED_SPORT = Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball")
+EXPECTED_LEAGUE = League(id=103, link="/api/v1/leagues/103", name="American League")
+EXPECTED_DIVISION = Division(
+ id=200, link="/api/v1/divisions/200", name="American League West"
+)
+EXPECTED_ROSTER_PLAYER = Player(
+ id=675961,
+ full_name="Alika Williams",
+ link="/api/v1/people/675961",
+ jersey_number="12",
+ status={"code": "A", "description": "Active"},
+ parent_team_id=133,
+)
+EXPECTED_ROSTER_COACH = Coach(
+ id=117276,
+ full_name="Mark Kotsay",
+ link="/api/v1/people/117276",
+ jersey_number="7",
+ job="Manager",
+ job_id="MNGR",
+ title="Manager",
+)
+EXPECTED_SEASON = Season(seasonId="2021", hasWildcard=True)
+EXPECTED_VENUE = Venue(id=31, link="/api/v1/venues/31", name="PNC Park")
+
+# The two ways an endpoint legitimately comes back with nothing to parse.
+SCHEDULED_GAME = {
+ "gamePk": 715757,
+ "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc",
+ "link": "/api/v1.1/game/715757/feed/live",
+ "gameType": "D",
+ "season": "2022",
+ "gameDate": "2022-10-13T19:37:00Z",
+ "officialDate": "2022-10-13",
+ "status": {
+ "abstractGameState": "Final",
+ "codedGameState": "F",
+ "detailedState": "Final",
+ "statusCode": "F",
+ "startTimeTBD": False,
+ "abstractGameCode": "F",
+ },
+ "teams": {
+ "away": {
+ "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"},
+ "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"},
+ "score": 2,
+ "isWinner": False,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ "home": {
+ "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"},
+ "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"},
+ "score": 4,
+ "isWinner": True,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ },
+ "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"},
+ "content": {"link": "/api/v1/game/715757/content"},
+ "isTie": False,
+ "gameNumber": 1,
+ "publicFacing": True,
+ "doubleHeader": "N",
+ "gamedayType": "P",
+ "tiebreaker": "N",
+ "calendarEventID": "14-715757-2022-10-13",
+ "seasonDisplay": "2022",
+ "dayNight": "day",
+ "description": "ALDS Game 2",
+ "scheduledInnings": 9,
+ "reverseHomeAwayStatus": False,
+ "inningBreakLength": 120,
+ "gamesInSeries": 5,
+ "seriesGameNumber": 2,
+ "seriesDescription": "AL Division Series",
+ "recordSource": "S",
+ "ifNecessary": "N",
+ "ifNecessaryDescription": "Normal Game",
+}
+
+SCHEDULED_GAMES_PAYLOAD = {
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "dates": [
+ {
+ "date": "2022-10-13",
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "games": [SCHEDULED_GAME],
+ }
+ ],
+}
+
+GAMEPACE_PAYLOAD = {
+ "sports": [
+ {
+ "hitsPer9Inn": 16.68,
+ "runsPer9Inn": 9.3,
+ "pitchesPer9Inn": 299.83,
+ "totalGames": 2429,
+ "timePerGame": "03:11:26",
+ "season": "2021",
+ "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"},
+ }
+ ]
+}
+
+STATS_PAYLOAD = {
+ "stats": [
+ {
+ "type": {"displayName": "season"},
+ "group": {"displayName": "hitting"},
+ "totalSplits": 1,
+ "splits": [
+ {
+ "season": "2022",
+ "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"},
+ "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"},
+ "player": {
+ "id": 660271,
+ "fullName": "Shohei Ohtani",
+ "link": "/api/v1/people/660271",
+ },
+ }
+ ],
+ }
+ ]
+}
+
+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 client, its retry transport and its adapters through
+ the production path; only the innermost network transport is swapped.
+ Teardown closes the client directly rather than calling AsyncMlb.aclose(),
+ so the lifecycle tests that replace aclose with a mock still get their real
+ client closed.
+ """
+ with pytest.MonkeyPatch.context() as monkeypatch:
+ monkeypatch.setattr(
+ "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport",
+ lambda **kwargs: httpx.MockTransport(handler),
+ )
+ mlb = AsyncMlb()
+
+ try:
+ yield mlb
+ finally:
+ await mlb._client.aclose()
+
+
+def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict, str]:
+ """Return the endpoint, params, and API version ``Mlb`` builds for a call.
+
+ The adapters are stubbed, so this reaches no network; it just reads back
+ what the synchronous client asked for. Most methods call the v1 adapter;
+ get_game calls v1.1, so both are stubbed and whichever one was actually
+ called wins.
+ """
+ with Mlb() as sync_mlb:
+ sync_mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(status_code=200, message=None, data={})
+ )
+ sync_mlb._mlb_adapter_v1_1.get = MagicMock(
+ return_value=MlbResult(status_code=200, message=None, data={})
+ )
+ getattr(sync_mlb, method)(*args, **kwargs)
+ if sync_mlb._mlb_adapter_v1.get.called:
+ call, ver = sync_mlb._mlb_adapter_v1.get.call_args, "v1"
+ else:
+ call, ver = sync_mlb._mlb_adapter_v1_1.get.call_args, "v1.1"
+
+ # Most Mlb methods pass endpoint as a keyword; get_attendance passes it
+ # positionally, so fall back to the first positional argument.
+ endpoint = call.kwargs["endpoint"] if "endpoint" in call.kwargs else call.args[0]
+ return endpoint, call.kwargs["ep_params"], ver
+
+
+def _flatten_params(params: dict) -> list[tuple[str, str]]:
+ """Expand a params dict into (key, str(value)) pairs, list values repeated.
+
+ Mirrors how both Requests and HTTPX serialize a list-valued query
+ parameter: as the same key repeated once per item, e.g.
+ ``?hydrate=a&hydrate=b`` rather than a single comma-joined value.
+ """
+ pairs: list[tuple[str, str]] = []
+ for key, value in params.items():
+ if isinstance(value, list):
+ pairs.extend((key, str(item)) for item in value)
+ else:
+ pairs.append((key, str(value)))
+ return sorted(pairs)
+
+
+def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None:
+ """Assert an observed request is the one ``Mlb`` would have made.
+
+ Some Mlb endpoint strings carry their own query: get_gamepace embeds the
+ season, and get_awards ends in a bare "?". Requests merges that query with
+ ep_params, so the expectation is the two combined -- which is what either
+ client has to end up sending, however it chose to build the URL.
+ """
+ endpoint, params, ver = sync_request_for(method, *args, **kwargs)
+
+ path, _, embedded_query = endpoint.partition("?")
+ expected = _flatten_params(params) + list(parse_qsl(embedded_query))
+
+ assert request.url.path == f"/api/{ver}/{path}"
+ assert sorted(request.url.params.multi_items()) == sorted(expected)
+
+
+# ---------------------------------------------------------------------------
+# 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._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._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.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._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_v1_and_v1_1_adapters_share_the_client_this_client_owns():
+ """One client is shared by both adapters and owned by AsyncMlb itself,
+ mirroring Mlb's shared Session."""
+
+ async def scenario():
+ async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb:
+ assert mlb._mlb_adapter_v1._client is mlb._client
+ assert mlb._mlb_adapter_v1_1._client is mlb._client
+ # Close-ownership lives on AsyncMlb, so neither adapter can close
+ # the shared client out from under the other.
+ assert mlb._owns_client is True
+ assert mlb._mlb_adapter_v1._owns_client is False
+ assert mlb._mlb_adapter_v1_1._owns_client is False
+
+ asyncio.run(scenario())
+
+
+def test_both_api_versions_retry_because_the_shared_client_carries_the_policy():
+ """Retries belong to the shared client's transport, not to an adapter, so
+ the two versions cannot disagree about them (matching Mlb, which mounts
+ one retry policy on the shared Session)."""
+
+ async def scenario():
+ async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb:
+ assert isinstance(mlb._client._transport, MlbAsyncRetryTransport)
+
+ asyncio.run(scenario())
+
+
+def test_caller_injected_client_keeps_its_own_transport():
+ """The library mounts nothing on a client it did not create, so an
+ injected client retries exactly as much as its caller configured."""
+ handler = _Handler(_json(TEAM_PAYLOAD))
+ transport = httpx.MockTransport(handler)
+ client = httpx.AsyncClient(transport=transport)
+
+ async def scenario():
+ try:
+ async with AsyncMlb(client=client) as mlb:
+ assert mlb._client is client
+ assert mlb._client._transport is transport
+ 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")
+
+
+def test_get_sport_requests_the_sport_endpoint_and_parses_the_result():
+ handler = _Handler(_json(SPORT_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_sport(1)
+
+ sport = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_sport", 1)
+ assert sport == EXPECTED_SPORT
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_sport_returns_none_when_there_is_no_sport(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_sport(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_sports_request_matches_the_sync_client():
+ handler = _Handler(_json({"sports": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_sports()
+
+ assert asyncio.run(scenario()) == []
+ assert_matches_sync(handler.request, "get_sports")
+
+
+def test_get_league_requests_the_league_endpoint_and_parses_the_result():
+ handler = _Handler(_json(LEAGUE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_league(103)
+
+ league = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_league", 103)
+ assert league == EXPECTED_LEAGUE
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_league_returns_none_when_there_is_no_league(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_league(103)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_leagues_request_matches_the_sync_client():
+ handler = _Handler(_json({"leagues": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_leagues()
+
+ assert asyncio.run(scenario()) == []
+ assert_matches_sync(handler.request, "get_leagues")
+
+
+def test_get_division_requests_the_division_endpoint_and_parses_the_result():
+ handler = _Handler(_json(DIVISION_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_division(200)
+
+ division = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_division", 200)
+ assert division == EXPECTED_DIVISION
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_division_returns_none_when_there_is_no_division(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_division(200)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_divisions_request_matches_the_sync_client():
+ handler = _Handler(_json({"divisions": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_divisions()
+
+ assert asyncio.run(scenario()) == []
+ assert_matches_sync(handler.request, "get_divisions")
+
+
+def test_get_team_roster_requests_the_roster_endpoint_and_parses_the_result():
+ handler = _Handler(_json(ROSTER_PLAYER_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_roster(133, rosterType="40Man")
+
+ roster = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_team_roster", 133, rosterType="40Man")
+ assert roster == [EXPECTED_ROSTER_PLAYER]
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_team_roster_returns_empty_list_when_there_is_no_roster(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_roster(133)
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_team_coaches_requests_the_coaches_endpoint_and_parses_the_result():
+ handler = _Handler(_json(ROSTER_COACH_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_coaches(133)
+
+ coaches = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_team_coaches", 133)
+ assert coaches == [EXPECTED_ROSTER_COACH]
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_team_coaches_returns_empty_list_when_there_are_no_coaches(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_coaches(133)
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_season_requests_the_season_endpoint_and_parses_the_result():
+ handler = _Handler(_json(SEASON_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_season("2021")
+
+ season = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_season", "2021")
+ assert season == EXPECTED_SEASON
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_season_returns_none_when_there_is_no_season(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_season("2021")
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_seasons_request_matches_the_sync_client():
+ handler = _Handler(_json({"seasons": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_seasons(11)
+
+ assert asyncio.run(scenario()) == []
+ assert_matches_sync(handler.request, "get_seasons", 11)
+
+
+def test_get_venue_requests_the_venue_endpoint_and_parses_the_result():
+ handler = _Handler(_json(VENUE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_venue(31)
+
+ venue = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_venue", 31)
+ assert venue == EXPECTED_VENUE
+
+
+def test_get_venue_returns_empty_list_on_404():
+ """get_venue mirrors Mlb's documented quirk: [] rather than None on 4xx."""
+ handler = _Handler(NO_RESULT_RESPONSES["404"])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_venue(1)
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_venue_returns_none_on_empty_200():
+ """Unlike the 4xx quirk, an empty 200 falls through to the normal None."""
+ handler = _Handler(NO_RESULT_RESPONSES["empty 200"])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_venue(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_venues_request_matches_the_sync_client():
+ handler = _Handler(_json({"venues": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_venues()
+
+ assert asyncio.run(scenario()) == []
+ assert_matches_sync(handler.request, "get_venues")
+
+
+def test_get_standings_requests_the_standings_endpoint_and_parses_the_result():
+ handler = _Handler(_json(STANDINGS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_standings(103, "2022")
+
+ standings = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_standings", 103, "2022")
+ assert standings == [Standings(**STANDINGS_RECORD)]
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_standings_returns_empty_list_when_there_are_no_standings(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_standings(103, "2022")
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_attendance_requests_the_attendance_endpoint_and_parses_the_result():
+ handler = _Handler(_json(ATTENDANCE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_attendance(team_id=133)
+
+ attendance = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_attendance", team_id=133)
+ assert isinstance(attendance, Attendance)
+ assert attendance.aggregate_totals.attendance_total == 2896460
+
+
+def test_get_attendance_without_an_identifier_returns_none_without_requesting():
+ """Regression coverage for the any(dict) vs any(dict.values()) guard bug."""
+ handler = _Handler(_json(ATTENDANCE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_attendance()
+
+ assert asyncio.run(scenario()) is None
+ assert handler.requests == []
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_attendance_returns_none_when_there_is_no_attendance(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_attendance(team_id=133)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_draft_requests_the_draft_endpoint_and_parses_the_result():
+ handler = _Handler(_json(DRAFT_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_draft(2019)
+
+ rounds = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_draft", 2019)
+ assert rounds == [Round(round="1")]
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_draft_returns_empty_list_when_there_is_no_draft(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_draft(2019)
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_awards_requests_the_awards_endpoint_and_parses_the_result():
+ handler = _Handler(_json(AWARDS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_awards("ALMVP")
+
+ awards = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_awards", "ALMVP")
+ assert awards == [Award(**AWARD_PAYLOAD)]
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_awards_returns_empty_list_when_there_are_no_awards(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_awards("ALMVP")
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_homerun_derby_requests_the_homerunderby_endpoint_and_parses_the_result():
+ handler = _Handler(_json(HOMERUN_DERBY_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_homerun_derby(511101)
+
+ derby = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_homerun_derby", 511101)
+ assert isinstance(derby, HomeRunDerby)
+ assert derby.status.state == "Final"
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_homerun_derby_returns_none_when_there_is_no_derby(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_homerun_derby(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_stats_request_matches_the_sync_client():
+ handler = _Handler(_json(STATS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_stats(["season"], ["hitting"])
+
+ stats = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_stats", ["season"], ["hitting"])
+ assert list(stats) == ["hitting"]
+ assert isinstance(stats["hitting"]["season"], Stat)
+
+
+def test_get_player_stats_request_matches_the_sync_client():
+ handler = _Handler(_json(STATS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_player_stats(660271, ["season"], ["hitting"])
+
+ stats = asyncio.run(scenario())
+
+ assert_matches_sync(
+ handler.request, "get_player_stats", 660271, ["season"], ["hitting"]
+ )
+ assert isinstance(stats["hitting"]["season"], Stat)
+
+
+def test_get_team_stats_request_matches_the_sync_client():
+ handler = _Handler(_json(STATS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_stats(133, ["season"], ["hitting"])
+
+ stats = asyncio.run(scenario())
+
+ assert_matches_sync(
+ handler.request, "get_team_stats", 133, ["season"], ["hitting"]
+ )
+ assert isinstance(stats["hitting"]["season"], Stat)
+
+
+def test_get_players_stats_for_game_request_matches_the_sync_client():
+ handler = _Handler(_json(STATS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_players_stats_for_game(660271, 715757)
+
+ stats = asyncio.run(scenario())
+
+ assert_matches_sync(
+ handler.request, "get_players_stats_for_game", 660271, 715757
+ )
+ assert isinstance(stats["hitting"]["season"], Stat)
+
+
+def test_get_players_stats_for_game_forwards_extra_params():
+ """The signature accepts **params, so they have to reach the query string."""
+ handler = _Handler(_json(STATS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_players_stats_for_game(
+ 660271, 715757, eventType="single"
+ )
+
+ asyncio.run(scenario())
+
+ assert handler.request.url.params["eventType"] == "single"
+
+
+@pytest.mark.parametrize(
+ "method, args",
+ [
+ ("get_stats", (["season"], ["hitting"])),
+ ("get_player_stats", (660271, ["season"], ["hitting"])),
+ ("get_team_stats", (133, ["season"], ["hitting"])),
+ ("get_players_stats_for_game", (660271, 715757)),
+ ],
+)
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_stat_endpoints_return_an_empty_mapping_when_there_are_no_stats(
+ method, args, label
+):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await getattr(mlb, method)(*args)
+
+ assert asyncio.run(scenario()) == {}
+
+
+def test_get_persons_request_matches_the_sync_client():
+ handler = _Handler(_json(PERSON_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_persons("660271,605151")
+
+ people = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_persons", "660271,605151")
+ assert people == [EXPECTED_PERSON]
+
+
+def test_get_persons_accepts_a_list_of_ids():
+ """The signature allows a list as well as a comma-delimited string."""
+ handler = _Handler(_json(PERSON_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_persons([660271, 605151])
+
+ asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_persons", [660271, 605151])
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_persons_returns_an_empty_list_when_there_are_no_people(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_persons("1")
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_scheduled_games_by_date_request_matches_the_sync_client():
+ handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_scheduled_games_by_date("2022-10-13")
+
+ games = asyncio.run(scenario())
+
+ assert_matches_sync(
+ handler.request, "get_scheduled_games_by_date", "2022-10-13"
+ )
+ assert [game.game_pk for game in games] == [715757]
+
+
+def test_get_scheduled_games_by_date_accepts_a_date_range():
+ handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_scheduled_games_by_date(
+ start_date="2022-10-13", end_date="2022-10-14"
+ )
+
+ asyncio.run(scenario())
+
+ assert_matches_sync(
+ handler.request,
+ "get_scheduled_games_by_date",
+ start_date="2022-10-13",
+ end_date="2022-10-14",
+ )
+
+
+def test_get_scheduled_games_by_date_accepts_game_pks_without_a_date():
+ """gamePks is its own selector; no date is required alongside it."""
+ handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_scheduled_games_by_date(gamePks=715757)
+
+ asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_scheduled_games_by_date", gamePks=715757)
+
+
+def test_get_scheduled_games_by_date_without_a_selector_returns_none_without_requesting():
+ """Mirrors Mlb, which returns None rather than [] when nothing selects a date."""
+ handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_scheduled_games_by_date()
+
+ assert asyncio.run(scenario()) is None
+ assert handler.requests == []
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_scheduled_games_by_date_returns_an_empty_list_when_there_are_no_games(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_scheduled_games_by_date("2022-10-13")
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_gamepace_request_matches_the_sync_client():
+ handler = _Handler(_json(GAMEPACE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_gamepace("2021")
+
+ gamepace = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_gamepace", "2021")
+ assert isinstance(gamepace, GamePace)
+ assert gamepace.sports[0].season == "2021"
+
+
+def test_get_gamepace_puts_the_season_in_the_query_string():
+ """The season rides in the endpoint string rather than in ep_params."""
+ handler = _Handler(_json(GAMEPACE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_gamepace("2021")
+
+ asyncio.run(scenario())
+
+ assert handler.request.url.path == "/api/v1/gamePace"
+ assert handler.request.url.params["season"] == "2021"
+ assert handler.request.url.params["sportId"] == "1"
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_gamepace_returns_none_when_there_is_no_pace_data(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_gamepace("2021")
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_team_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_id("Athletics")
+
+ assert asyncio.run(scenario()) == [133]
+ assert_matches_sync(handler.request, "get_team_id", "Athletics")
+
+
+def test_get_team_id_returns_empty_list_when_there_is_no_match():
+ handler = _Handler(_json({"teams": []}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_team_id("Nonexistent")
+
+ assert asyncio.run(scenario()) == []
+
+
+def test_get_people_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"people": [{"id": 664034, "fullName": "Ty France"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_people_id("Ty France")
+
+ assert asyncio.run(scenario()) == [664034]
+ assert_matches_sync(handler.request, "get_people_id", "Ty France")
+
+
+def test_get_sport_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"sports": [{"id": 1, "name": "Major League Baseball"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_sport_id("Major League Baseball")
+
+ assert asyncio.run(scenario()) == [1]
+ assert_matches_sync(handler.request, "get_sport_id", "Major League Baseball")
+
+
+def test_get_league_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"leagues": [{"id": 103, "name": "American League"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_league_id("American League")
+
+ assert asyncio.run(scenario()) == [103]
+ assert_matches_sync(handler.request, "get_league_id", "American League")
+
+
+def test_get_division_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"divisions": [{"id": 200, "name": "American League West"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_division_id("American League West")
+
+ assert asyncio.run(scenario()) == [200]
+ assert_matches_sync(handler.request, "get_division_id", "American League West")
+
+
+def test_get_venue_id_request_matches_the_sync_client():
+ handler = _Handler(_json({"venues": [{"id": 31, "name": "PNC Park"}]}))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_venue_id("PNC Park")
+
+ assert asyncio.run(scenario()) == [31]
+ assert_matches_sync(handler.request, "get_venue_id", "PNC Park")
+
+
+def test_get_game_requests_the_v1_1_feed_endpoint_and_parses_the_result():
+ handler = _Handler(_json(GAME_FEED_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game(717911)
+
+ game = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_game", 717911)
+ assert isinstance(game, Game)
+ assert game.id == 717911
+ assert handler.request.url.path == "/api/v1.1/game/717911/feed/live"
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_returns_none_when_there_is_no_game(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_game_play_by_play_requests_the_playbyplay_endpoint_and_parses_the_result():
+ handler = _Handler(_json(PLAYS_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_play_by_play(717911)
+
+ plays = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_game_play_by_play", 717911)
+ assert isinstance(plays, Plays)
+ assert len(plays.all_plays) == 1
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_play_by_play_returns_none_when_there_are_no_plays(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_play_by_play(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_game_line_score_requests_the_linescore_endpoint_and_parses_the_result():
+ handler = _Handler(_json(LINESCORE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_line_score(717911)
+
+ linescore = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_game_line_score", 717911)
+ assert isinstance(linescore, Linescore)
+ assert linescore.scheduled_innings == 9
+
+
+def test_get_game_line_score_returns_none_on_an_empty_200_without_a_status_guard():
+ """get_game_line_score has no 400-499 guard; documented in public-api.md."""
+ handler = _Handler(NO_RESULT_RESPONSES["empty 200"])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_line_score(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_game_box_score_requests_the_boxscore_endpoint_and_parses_the_result():
+ handler = _Handler(_json(BOXSCORE_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_box_score(717911)
+
+ boxscore = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_game_box_score", 717911)
+ assert isinstance(boxscore, BoxScore)
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_box_score_returns_none_when_there_is_no_boxscore(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_box_score(1)
+
+ assert asyncio.run(scenario()) is None
+
+
+def test_get_game_ids_requests_the_schedule_endpoint_and_parses_the_result():
+ handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_ids(date="2022-09-26")
+
+ game_ids = asyncio.run(scenario())
+
+ assert_matches_sync(handler.request, "get_game_ids", date="2022-09-26")
+ assert game_ids == [1, 2, 3]
+
+
+def test_get_game_ids_without_a_selector_returns_none_without_requesting():
+ handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD))
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_ids()
+
+ assert asyncio.run(scenario()) is None
+ assert handler.requests == []
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_ids_returns_empty_list_when_there_are_no_games(label):
+ handler = _Handler(NO_RESULT_RESPONSES[label])
+
+ async def scenario():
+ async with async_mlb(handler) as mlb:
+ return await mlb.get_game_ids(date="2022-09-26")
+
+ assert asyncio.run(scenario()) == []
+
+
+# ---------------------------------------------------------------------------
+# 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_team_id",
+ "get_team_roster",
+ "get_team_coaches",
+ "get_person",
+ "get_people",
+ "get_people_id",
+ "get_schedule",
+ "get_sport",
+ "get_sports",
+ "get_sport_id",
+ "get_league",
+ "get_leagues",
+ "get_league_id",
+ "get_division",
+ "get_divisions",
+ "get_division_id",
+ "get_season",
+ "get_seasons",
+ "get_venue",
+ "get_venues",
+ "get_venue_id",
+ "get_standings",
+ "get_attendance",
+ "get_draft",
+ "get_awards",
+ "get_homerun_derby",
+ "get_stats",
+ "get_player_stats",
+ "get_team_stats",
+ "get_players_stats_for_game",
+ "get_persons",
+ "get_scheduled_games_by_date",
+ "get_gamepace",
+ "get_game",
+ "get_game_play_by_play",
+ "get_game_line_score",
+ "get_game_box_score",
+ "get_game_ids",
+ ):
+ 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_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py
new file mode 100644
index 00000000..90737323
--- /dev/null
+++ b/tests/test_async_mlb_dataadapter.py
@@ -0,0 +1,1242 @@
+"""Focused offline tests for the AsyncMlbDataAdapter implementation.
+
+Covers the behavior delivered in issue #301: successful GETs, the HTTP status
+contract, exception mapping, lifecycle and ownership, timeout translation,
+User-Agent, bounded retry-with-backoff, cancellation, and concurrency. The
+exhaustive async transport-contract matrix belongs to #302.
+
+The retry assertions mirror the contract asserted for the sync adapter in
+tests/test_mlb_retries.py, adapted to httpx.MockTransport instead of a real
+threaded HTTP server, since the async retry loop here is hand-rolled Python
+rather than logic buried inside urllib3/requests internals.
+
+HTTPX ships only with the ``async`` extra, so the whole module skips when it is
+absent. See the import section below.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import inspect
+import warnings
+from importlib.metadata import PackageNotFoundError
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+# Every test below drives 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, which runs with or without HTTPX.
+httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)")
+
+from mlbstatsapi import ( # noqa: E402
+ MlbDecodeError,
+ MlbHttpCompatibilityWarning,
+ MlbHttpError,
+ MlbTimeoutError,
+ MlbTransportError,
+)
+from mlbstatsapi._async_transport import ( # noqa: E402
+ MlbAsyncRetryTransport,
+ create_library_async_client,
+)
+from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402
+from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402
+
+from http_contract_support import ( # noqa: E402
+ HTTP_REASON_BY_STATUS,
+ RETRYABLE_STATUS_CODES,
+ SERVER_ERRORS,
+ assert_library_retry_policy,
+)
+
+
+BASE_URL = "https://statsapi.mlb.com/api/v1/"
+
+SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep"
+
+# Patched only while a test adapter is constructed, so the adapter creates its
+# own library-owned client the way production does. Only the innermost network
+# transport is swapped, so the library retry transport under test is the real
+# one, wrapping a MockTransport instead of a socket.
+INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport"
+
+# Matches tests/test_mlb_session.py, so both adapters assert the same contract.
+MOCKED_PACKAGE_VERSION = "9.8.7"
+MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}"
+
+# Obvious sentinels, so a leak into a compatibility warning is unmistakable.
+SECRET_BODY_MARKER = "SUPER_SECRET_RESPONSE"
+SECRET_HEADER_MARKER = "SUPER_SECRET_HEADER"
+
+# Failure guard for the concurrency tests: a request that should never wait is
+# bounded so a serializing regression fails fast instead of hanging CI.
+BLOCKED_REQUEST_TIMEOUT = 10
+
+
+# Adapters built by _owned_adapter(); run_async() closes them inside the same
+# event loop that used them, so no AsyncClient is left open by a test.
+_ADAPTERS_TO_CLOSE: list[AsyncMlbDataAdapter] = []
+
+
+def run_async(coro):
+ async def runner():
+ try:
+ return await coro
+ finally:
+ while _ADAPTERS_TO_CLOSE:
+ await _ADAPTERS_TO_CLOSE.pop().aclose()
+
+ return asyncio.run(runner())
+
+
+class _ScriptedHandler:
+ """Serve a scripted sequence of httpx Responses/exceptions.
+
+ The last entry repeats for any call beyond the script's length, so a
+ single-item script models a persistent failure.
+ """
+
+ def __init__(self, *script: httpx.Response | Exception):
+ self._script = list(script)
+ self.call_count = 0
+
+ def __call__(self, request: httpx.Request) -> httpx.Response:
+ self.call_count += 1
+ index = min(self.call_count - 1, len(self._script) - 1)
+ item = self._script[index]
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+
+def _response(status_code: int, *, headers: dict | None = None, text: str | None = None) -> httpx.Response:
+ return httpx.Response(status_code, headers=headers or {}, text=text)
+
+
+def _owned_adapter(handler, **kwargs) -> AsyncMlbDataAdapter:
+ """Build an adapter that owns its client, so retries are active.
+
+ The adapter still builds its own client through the production path — only
+ the innermost network transport is swapped for a MockTransport — so
+ ownership, headers, and retry behavior are exactly what the library does at
+ runtime, and no client is constructed and then discarded. Call this from
+ inside a run_async() scenario; run_async() closes what it creates.
+ """
+ with patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)):
+ adapter = AsyncMlbDataAdapter(**kwargs)
+
+ _ADAPTERS_TO_CLOSE.append(adapter)
+ return adapter
+
+
+def _retry_policy_of(adapter: AsyncMlbDataAdapter):
+ """Read the retry policy the adapter's client actually uses."""
+ return adapter._client._transport._retry_policy
+
+
+def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter:
+ """Build an adapter with a caller-supplied client, so retries are bypassed."""
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ return AsyncMlbDataAdapter(client=client, **kwargs)
+
+
+def test_retry_policy_matches_library_default():
+ adapter = AsyncMlbDataAdapter()
+ assert_library_retry_policy(_retry_policy_of(adapter))
+
+
+def test_library_created_client_mounts_the_retry_transport():
+ """Retries are configured onto the client at creation, the way the sync
+ side mounts them onto a library-created Session."""
+ client = create_library_async_client()
+
+ assert isinstance(client._transport, MlbAsyncRetryTransport)
+
+
+def test_injected_client_transport_is_left_alone():
+ """The library mounts nothing on a client it did not create, so an
+ injected client keeps exactly the retry behavior its caller gave it."""
+ transport = httpx.MockTransport(_ScriptedHandler(_response(200)))
+ client = httpx.AsyncClient(transport=transport)
+ adapter = AsyncMlbDataAdapter(client=client)
+
+ assert adapter._client._transport is transport
+ assert adapter._owns_client is False
+
+
+def test_mounting_the_retry_transport_makes_an_injected_client_retry():
+ """The supported way for a caller to opt their own client into library
+ retry behavior, mirroring the sync create_retry_policy() recipe."""
+ handler = _ScriptedHandler(_response(503), _response(200))
+
+ async def scenario():
+ client = httpx.AsyncClient(
+ transport=MlbAsyncRetryTransport(httpx.MockTransport(handler)),
+ )
+ adapter = AsyncMlbDataAdapter(client=client)
+ try:
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ return await adapter.get(endpoint="sports")
+ finally:
+ await client.aclose()
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+ assert handler.call_count == 2
+
+
+def test_200_succeeds_with_no_retry():
+ handler = _ScriptedHandler(_response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock:
+ result = await adapter.get(endpoint="sports")
+ return result, sleep_mock
+
+ result, sleep_mock = run_async(scenario())
+ assert result.status_code == 200
+ assert handler.call_count == 1
+ sleep_mock.assert_not_awaited()
+
+
+def test_200_response_returns_actual_json_data():
+ payload = {"sports": [{"id": 1, "name": "Major League Baseball"}]}
+ handler = _ScriptedHandler(httpx.Response(200, json=payload))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+ assert result.data == payload
+
+
+def test_explicit_empty_successful_response_returns_empty_data():
+ handler = _ScriptedHandler(_response(204, text=""))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 204
+ assert result.data == {}
+
+
+def test_mlb_http_error_has_structured_context():
+ payload = {"messageNumber": 1, "message": "Internal error occurred"}
+ handler = _ScriptedHandler(httpx.Response(500, json=payload))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert error.status_code == 500
+ assert error.reason == "Internal Server Error"
+ assert error.method == "GET"
+ assert error.url == f"{BASE_URL}sports"
+ assert error.response_data == payload
+ assert error.body_excerpt is not None
+ assert "Internal error occurred" in error.body_excerpt
+
+
+def test_library_owned_client_closes():
+ async def scenario():
+ adapter = AsyncMlbDataAdapter()
+ was_open = not adapter._client.is_closed
+ await adapter.aclose()
+ return was_open, adapter._client.is_closed
+
+ was_open, is_closed = run_async(scenario())
+ assert was_open is True
+ assert is_closed is True
+
+
+def test_aclose_is_idempotent():
+ async def scenario():
+ adapter = AsyncMlbDataAdapter()
+ await adapter.aclose()
+ with patch.object(adapter._client, "aclose", new_callable=AsyncMock) as aclose_mock:
+ await adapter.aclose()
+ return aclose_mock
+
+ aclose_mock = run_async(scenario())
+ aclose_mock.assert_not_awaited()
+
+
+def test_injected_client_is_not_closed():
+ async def scenario():
+ client = httpx.AsyncClient()
+ adapter = AsyncMlbDataAdapter(client=client)
+ await adapter.aclose()
+ was_closed = client.is_closed
+ await client.aclose()
+ return was_closed
+
+ was_closed = run_async(scenario())
+ assert was_closed is False
+
+
+def test_injected_client_timeout_configuration_is_not_mutated():
+ """The library's timeout is applied per request, not written to the client."""
+ handler = _ScriptedHandler(_response(200))
+
+ async def scenario():
+ client = httpx.AsyncClient(
+ transport=httpx.MockTransport(handler),
+ timeout=httpx.Timeout(11.0),
+ )
+ try:
+ adapter = AsyncMlbDataAdapter(client=client, timeout=(1.0, 2.0))
+ await adapter.get(endpoint="sports")
+ return client.timeout
+ finally:
+ await client.aclose()
+
+ timeout = run_async(scenario())
+ assert timeout.connect == 11.0
+ assert timeout.read == 11.0
+ assert timeout.write == 11.0
+ assert timeout.pool == 11.0
+
+
+# --- Explicit cleanup after the adapter has been used ---
+#
+# test_library_owned_client_closes covers an adapter that never issued a
+# request. These cover the states a request can leave behind: success, a
+# public failure, and caller cancellation. In each case explicit cleanup must
+# still close the library-owned client without altering what the caller
+# already observed.
+
+
+def test_owned_client_closes_after_a_successful_request():
+ """A used adapter is still safely closable."""
+ handler = _ScriptedHandler(httpx.Response(200, json={"id": "sports"}))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ result = await adapter.get(endpoint="sports")
+ await adapter.aclose()
+ return result, adapter._client.is_closed
+
+ result, is_closed = run_async(scenario())
+ assert result.status_code == 200
+ assert result.data == {"id": "sports"}
+ assert is_closed is True
+
+
+def test_owned_client_closes_after_a_failed_request():
+ """A failed request leaves the adapter closable, and the error intact."""
+ handler = _ScriptedHandler(_response(503))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+
+ error = exc_info.value
+ await adapter.aclose()
+ return error, adapter._client.is_closed
+
+ error, is_closed = run_async(scenario())
+ assert error.status_code == 503
+ assert error.reason == HTTP_REASON_BY_STATUS[503]
+ assert error.method == "GET"
+ assert error.url == f"{BASE_URL}sports"
+ assert is_closed is True
+
+
+def test_owned_client_closes_after_a_cancelled_request():
+ """Cancelling an in-flight request still leaves the adapter closable.
+
+ The cancellation itself stays the caller's outcome: aclose() runs after
+ CancelledError has already propagated, and does not replace it.
+ """
+ async def scenario():
+ request_started = asyncio.Event()
+
+ async def hanging_handler(request: httpx.Request) -> httpx.Response:
+ request_started.set()
+ await asyncio.sleep(10)
+ raise AssertionError("handler should have been cancelled before returning")
+
+ adapter = _owned_adapter(hanging_handler)
+ task = asyncio.ensure_future(adapter.get(endpoint="sports"))
+ # Cancel only once the request is genuinely in flight.
+ await asyncio.wait_for(request_started.wait(), BLOCKED_REQUEST_TIMEOUT)
+
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ await adapter.aclose()
+ return adapter._client.is_closed
+
+ is_closed = run_async(scenario())
+ assert is_closed is True
+
+
+def test_scalar_timeout_translation():
+ result = AsyncMlbDataAdapter._translate_timeout(5)
+ assert result.connect == 5
+ assert result.read == 5
+ assert result.write == 5
+ assert result.pool == 5
+
+
+def test_tuple_timeout_translation():
+ result = AsyncMlbDataAdapter._translate_timeout((3.05, 30.0))
+ assert result.connect == 3.05
+ assert result.pool == 3.05
+ assert result.read == 30.0
+ assert result.write == 30.0
+
+
+def test_multiple_concurrent_requests_on_one_adapter():
+ """Concurrent requests keep their own params and their own response.
+
+ Each request carries different ep_params, so neither the query sent to the
+ transport nor the returned data may pick up the other request's values.
+ """
+ responses = {
+ "sports": httpx.Response(200, json={"id": "sports"}),
+ "teams": httpx.Response(200, json={"id": "teams"}),
+ }
+ observed_params: dict[str, dict[str, str]] = {}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ endpoint = request.url.path.rsplit("/", 1)[-1]
+ observed_params[endpoint] = dict(request.url.params)
+ return responses[endpoint]
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ return await asyncio.gather(
+ adapter.get(endpoint="sports", ep_params={"sportId": 1}),
+ adapter.get(endpoint="teams", ep_params={"season": 2026}),
+ )
+
+ sports_result, teams_result = run_async(scenario())
+ assert sports_result.data == {"id": "sports"}
+ assert teams_result.data == {"id": "teams"}
+ # Query values arrive as strings; each endpoint sees only its own params.
+ assert observed_params == {
+ "sports": {"sportId": "1"},
+ "teams": {"season": "2026"},
+ }
+
+
+def test_failure_of_one_concurrent_request_does_not_affect_another():
+ """A failing request must not disturb an unrelated concurrent request.
+
+ Request A exhausts the status retry budget and raises MlbHttpError while
+ request B, sharing the same adapter and client, still completes normally
+ on its single attempt.
+ """
+ attempts: dict[str, int] = {"sports": 0, "teams": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ endpoint = request.url.path.rsplit("/", 1)[-1]
+ attempts[endpoint] += 1
+ if endpoint == "sports":
+ return _response(503)
+ return httpx.Response(200, json={"id": "teams"})
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ # Caller-controlled orchestration: gather is the caller's choice,
+ # so a failure in A is reported without cancelling B.
+ return await asyncio.gather(
+ adapter.get(endpoint="sports"),
+ adapter.get(endpoint="teams"),
+ return_exceptions=True,
+ )
+
+ failure, success = run_async(scenario())
+ assert isinstance(failure, MlbHttpError)
+ assert failure.status_code == 503
+ assert success.status_code == 200
+ assert success.data == {"id": "teams"}
+ # A spent its full status budget; B was never retried on A's behalf.
+ assert attempts == {"sports": 4, "teams": 1}
+
+
+def test_backoff_in_one_request_does_not_block_another():
+ """Another request makes progress while one is waiting out its backoff.
+
+ test_retry_sleep_is_async_and_non_blocking proves an unrelated task keeps
+ running during backoff; this proves the same for a second request on the
+ same adapter, without depending on wall-clock timing: B's result exists
+ before b_completed is set, so A cannot have left its backoff first.
+ """
+ attempts: dict[str, int] = {"sports": 0, "teams": 0}
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ endpoint = request.url.path.rsplit("/", 1)[-1]
+ attempts[endpoint] += 1
+ # The first retry has no delay, so A must fail twice to reach a real
+ # backoff wait; the third attempt succeeds once the test releases it.
+ if endpoint == "sports" and attempts["sports"] <= 2:
+ return _response(503)
+ return httpx.Response(200, json={"id": endpoint})
+
+ async def scenario():
+ a_in_backoff = asyncio.Event()
+ b_completed = asyncio.Event()
+
+ async def parked_backoff(delay):
+ a_in_backoff.set()
+ await b_completed.wait()
+
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, parked_backoff):
+ a_task = asyncio.ensure_future(adapter.get(endpoint="sports"))
+ await asyncio.wait_for(a_in_backoff.wait(), BLOCKED_REQUEST_TIMEOUT)
+
+ # Not a timing assertion: on the passing path nothing waits. The
+ # bound only turns a regression that serializes requests into a
+ # fast failure instead of a hung test run.
+ b_result = await asyncio.wait_for(
+ adapter.get(endpoint="teams"),
+ BLOCKED_REQUEST_TIMEOUT,
+ )
+
+ b_completed.set()
+ a_result = await a_task
+
+ return a_result, b_result
+
+ a_result, b_result = run_async(scenario())
+ assert b_result.status_code == 200
+ assert b_result.data == {"id": "teams"}
+ assert a_result.status_code == 200
+ assert attempts == {"sports": 3, "teams": 1}
+
+
+def test_cancelling_one_request_does_not_cancel_another():
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("hang"):
+ await asyncio.sleep(10)
+ raise AssertionError("handler should have been cancelled before returning")
+ return _response(200)
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+
+ hanging_task = asyncio.ensure_future(adapter.get(endpoint="hang"))
+ await asyncio.sleep(0)
+
+ other_task = asyncio.ensure_future(adapter.get(endpoint="sports"))
+
+ hanging_task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await hanging_task
+
+ return await other_task
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+
+
+def test_injected_client_persistent_server_error_is_not_retried():
+ handler = _ScriptedHandler(_response(500))
+
+ async def scenario():
+ adapter = _injected_adapter(handler)
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value.status_code
+
+ status_code = run_async(scenario())
+ assert status_code == 500
+ assert handler.call_count == 1
+
+
+def test_injected_client_does_not_consume_a_second_scripted_response():
+ handler = _ScriptedHandler(_response(500), _response(200))
+
+ async def scenario():
+ adapter = _injected_adapter(handler)
+ with pytest.raises(MlbHttpError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 1
+
+
+@pytest.mark.parametrize("status_code", RETRYABLE_STATUS_CODES)
+def test_owned_client_retries_retryable_status_then_succeeds(status_code):
+ handler = _ScriptedHandler(_response(status_code), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+ assert handler.call_count == 2
+
+
+@pytest.mark.parametrize("status_code", SERVER_ERRORS)
+def test_owned_client_exhausts_retries_on_persistent_server_error(status_code):
+ handler = _ScriptedHandler(_response(status_code))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value.status_code
+
+ returned_status = run_async(scenario())
+ assert returned_status == status_code
+ assert handler.call_count == 4
+
+
+def test_persistent_server_error_raises_despite_compatibility_mode():
+ """A final 5xx raises MlbHttpError regardless of strict_http.
+
+ Compatibility mode suppresses non-404 4xx only. A server error is never
+ downgraded to a warned empty MlbResult, so strict_http=False must not
+ change either the exception or the retry behavior here.
+ """
+ handler = _ScriptedHandler(_response(503))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=False)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always", MlbHttpCompatibilityWarning)
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+
+ return exc_info.value.status_code, caught
+
+ status_code, caught = run_async(scenario())
+ assert status_code == 503
+ # One initial attempt plus the status retry budget.
+ assert handler.call_count == 4
+ compatibility = [
+ warning
+ for warning in caught
+ if issubclass(warning.category, MlbHttpCompatibilityWarning)
+ ]
+ assert compatibility == []
+
+
+def test_owned_client_final_429_raises_under_strict_http():
+ handler = _ScriptedHandler(_response(429))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=True)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value.status_code
+
+ status_code = run_async(scenario())
+ assert status_code == 429
+ assert handler.call_count == 4
+
+
+def test_owned_client_final_429_returns_empty_result_under_compatibility_mode():
+ handler = _ScriptedHandler(_response(429))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=False)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.warns(MlbHttpCompatibilityWarning) as warning_info:
+ result = await adapter.get(endpoint="sports")
+ return result, warning_info
+
+ result, warning_info = run_async(scenario())
+ assert result.status_code == 429
+ assert result.data == {}
+ assert len(warning_info) == 1
+ assert handler.call_count == 4
+
+
+def test_400_is_not_retried():
+ handler = _ScriptedHandler(_response(400), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with pytest.raises(MlbHttpError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 1
+
+
+def test_404_is_not_retried():
+ handler = _ScriptedHandler(_response(404), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 404
+ assert result.data == {}
+ assert handler.call_count == 1
+
+
+def test_other_non_2xx_status_raises_http_error():
+ """A final non-2xx outside the 4xx/5xx ranges still raises MlbHttpError."""
+ handler = _ScriptedHandler(
+ _response(302, headers={"Location": "https://example.test/moved"}),
+ )
+
+ async def scenario():
+ # Redirects are not followed, so the 302 reaches the status contract.
+ adapter = _owned_adapter(handler)
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert error.status_code == 302
+ assert error.method == "GET"
+ assert handler.call_count == 1
+
+
+def test_timeout_retried_then_succeeds():
+ handler = _ScriptedHandler(httpx.ReadTimeout("timed out"), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+ assert handler.call_count == 2
+
+
+def test_timeout_exhausts_retries_and_raises_mlb_timeout_error():
+ handler = _ScriptedHandler(httpx.ReadTimeout("timed out"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbTimeoutError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 3
+
+
+def test_connect_timeout_exhausts_retries_and_raises_mlb_timeout_error():
+ """A connect timeout stays a timeout for the caller.
+
+ httpx.ConnectTimeout subclasses httpx.TimeoutException, so it needs its
+ own branch to spend the connect budget while still raising
+ MlbTimeoutError rather than MlbTransportError.
+ """
+ handler = _ScriptedHandler(httpx.ConnectTimeout("connect timed out"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbTimeoutError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert handler.call_count == 4
+ # MlbTimeoutError subclasses MlbTransportError, so only the exact type
+ # distinguishes a timeout from a plain transport failure.
+ assert type(error) is MlbTimeoutError
+ assert isinstance(error.__cause__, httpx.ConnectTimeout)
+
+
+def test_connect_timeout_spends_the_connect_retry_budget():
+ """The connect budget bounds a connect timeout, not the total or read one.
+
+ The default policy uses total=3 and connect=3, so attempt counts alone
+ cannot tell those two budgets apart. Narrowing connect makes the
+ difference observable: falling through to the generic timeout branch
+ would still allow four attempts here.
+ """
+ handler = _ScriptedHandler(httpx.ConnectTimeout("connect timed out"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ _retry_policy_of(adapter).connect = 1
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbTimeoutError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 2
+
+
+def test_transport_error_retried_then_succeeds():
+ handler = _ScriptedHandler(httpx.ConnectError("connection refused"), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ return await adapter.get(endpoint="sports")
+
+ result = run_async(scenario())
+ assert result.status_code == 200
+ assert handler.call_count == 2
+
+
+def test_transport_error_exhausts_retries_and_raises_mlb_transport_error():
+ handler = _ScriptedHandler(httpx.ConnectError("connection refused"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbTransportError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 4
+
+
+# --- Retry budget independence ---
+#
+# The default policy uses total=3, connect=3 and status=3, so an attempt count
+# of four cannot tell those budgets apart. Each test below narrows the single
+# budget it is about, which makes the observed attempt count uniquely
+# attributable to that budget while the public failure stays unchanged.
+
+
+@pytest.mark.parametrize(
+ "failure, expected_exception",
+ ((httpx.PoolTimeout("pool timed out"), MlbTimeoutError),),
+ ids=("timeout",),
+)
+def test_generic_failures_spend_the_total_retry_budget(failure, expected_exception):
+ """A generic timeout is bounded by the total budget.
+
+ A pool timeout is neither a connect nor a read timeout, so it falls
+ through to the generic timeout handling. Narrowing total to one retry
+ makes that budget observable: spending connect (3) or read (2) instead
+ would allow four or three attempts here.
+ """
+ handler = _ScriptedHandler(failure)
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ _retry_policy_of(adapter).total = 1
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(expected_exception) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert handler.call_count == 2
+ # MlbTimeoutError subclasses MlbTransportError, so only the exact type
+ # separates a timeout from a plain transport failure.
+ assert type(error) is expected_exception
+ assert isinstance(error.__cause__, type(failure))
+
+
+def test_connect_error_spends_the_connect_retry_budget():
+ """A connection failure is bounded by the connect budget, not the total one.
+
+ test_transport_error_exhausts_retries_and_raises_mlb_transport_error shows
+ four attempts under the default policy, which total=3 would also produce.
+ """
+ handler = _ScriptedHandler(httpx.ConnectError("connection refused"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ _retry_policy_of(adapter).connect = 1
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbTransportError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 2
+
+
+def test_retryable_status_spends_the_status_retry_budget():
+ """Retryable statuses are bounded by the status budget, not the total one."""
+ handler = _ScriptedHandler(_response(503))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ _retry_policy_of(adapter).status = 1
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value.status_code
+
+ status_code = run_async(scenario())
+ assert status_code == 503
+ assert handler.call_count == 2
+
+
+def test_retry_after_header_drives_sleep_duration():
+ handler = _ScriptedHandler(
+ _response(429, headers={"Retry-After": "7"}),
+ _response(200),
+ )
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock:
+ await adapter.get(endpoint="sports")
+ return sleep_mock
+
+ sleep_mock = run_async(scenario())
+ sleep_mock.assert_awaited_once_with(7)
+
+
+def test_no_delay_before_first_retry():
+ handler = _ScriptedHandler(_response(500), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock:
+ await adapter.get(endpoint="sports")
+ return sleep_mock
+
+ sleep_mock = run_async(scenario())
+ sleep_mock.assert_not_awaited()
+
+
+def test_backoff_grows_exponentially_between_retries():
+ handler = _ScriptedHandler(_response(500))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock:
+ with pytest.raises(MlbHttpError):
+ await adapter.get(endpoint="sports")
+ return sleep_mock
+
+ sleep_mock = run_async(scenario())
+ assert [call.args[0] for call in sleep_mock.await_args_list] == [1.0, 2.0]
+
+
+def test_retry_sleep_is_async_and_non_blocking():
+ """A real (unmocked) backoff wait must yield the event loop.
+
+ If the retry transport's backoff ever used a blocking call (e.g.
+ time.sleep) instead of `await asyncio.sleep(...)`, the whole event loop
+ would freeze for the wait's duration and the concurrently running marker
+ task below would make zero progress during it.
+ """
+ handler = _ScriptedHandler(_response(500), _response(500), _response(200))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ # Small but real backoff so the test stays fast without mocking sleep.
+ _retry_policy_of(adapter).backoff_factor = 0.05
+
+ marker_ticks = 0
+
+ async def marker():
+ nonlocal marker_ticks
+ for _ in range(50):
+ await asyncio.sleep(0.005)
+ marker_ticks += 1
+
+ marker_task = asyncio.ensure_future(marker())
+ try:
+ result = await adapter.get(endpoint="sports")
+ finally:
+ marker_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await marker_task
+
+ return result, marker_ticks
+
+ result, marker_ticks = run_async(scenario())
+ assert result.status_code == 200
+ assert marker_ticks > 0
+
+
+def test_cancelled_error_propagates_without_retry_during_network_call():
+ call_count = 0
+
+ async def hanging_handler(request: httpx.Request) -> httpx.Response:
+ nonlocal call_count
+ call_count += 1
+ await asyncio.sleep(10)
+ raise AssertionError("handler should have been cancelled before returning")
+
+ async def scenario():
+ adapter = _owned_adapter(hanging_handler)
+ task = asyncio.ensure_future(adapter.get(endpoint="sports"))
+ await asyncio.sleep(0)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ run_async(scenario())
+ assert call_count == 1
+
+
+def test_cancelled_error_propagates_without_retry_during_backoff_sleep():
+ handler = _ScriptedHandler(_response(500), _response(500), _response(200))
+
+ async def cancelling_sleep(delay):
+ raise asyncio.CancelledError()
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(SLEEP_TARGET, side_effect=cancelling_sleep):
+ with pytest.raises(asyncio.CancelledError):
+ await adapter.get(endpoint="sports")
+
+ run_async(scenario())
+ assert handler.call_count == 2
+
+
+def test_json_decode_failure_is_not_retried():
+ handler = _ScriptedHandler(_response(200, text="not json"))
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with pytest.raises(MlbDecodeError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ # Matches the sync adapter: the underlying decode failure stays the cause.
+ assert isinstance(error.__cause__, ValueError)
+ assert handler.call_count == 1
+
+
+# --- Final non-404 4xx contract ---
+
+
+def test_final_non_404_client_error_raises_under_strict_http():
+ """Strict mode raises MlbHttpError with the sync structured context.
+
+ test_400_is_not_retried already proves a 4xx is final on the first
+ response; this asserts the #298 decision-table outcome for an explicit
+ strict_http=True adapter, including the structured error context.
+ """
+ handler = _ScriptedHandler(_response(403))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=True)
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert error.status_code == 403
+ assert error.reason == HTTP_REASON_BY_STATUS[403]
+ assert error.method == "GET"
+ assert error.url == f"{BASE_URL}sports"
+ assert handler.call_count == 1
+
+
+def test_final_non_404_client_error_returns_empty_result_in_compatibility_mode():
+ """strict_http=False suppresses a non-404 4xx into a warned empty result."""
+ handler = _ScriptedHandler(_response(403, text='{"message": "denied"}'))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=False)
+ with pytest.warns(MlbHttpCompatibilityWarning) as warning_info:
+ result = await adapter.get(endpoint="sports")
+ return result, [str(warning.message) for warning in warning_info]
+
+ result, messages = run_async(scenario())
+ assert result.status_code == 403
+ assert result.message == HTTP_REASON_BY_STATUS[403]
+ assert result.data == {}
+ assert len(messages) == 1
+ assert "403" in messages[0]
+ assert f"{BASE_URL}sports" in messages[0]
+ assert handler.call_count == 1
+
+
+# --- Compatibility warning safety ---
+
+
+def test_compatibility_warning_does_not_leak_response_body_or_headers():
+ """Response bodies and headers must never reach the warning message."""
+ handler = _ScriptedHandler(
+ _response(
+ 403,
+ headers={"X-Debug-Token": SECRET_HEADER_MARKER},
+ text=f'{{"message": "{SECRET_BODY_MARKER}"}}',
+ ),
+ )
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=False)
+ with pytest.warns(MlbHttpCompatibilityWarning) as warning_info:
+ await adapter.get(endpoint="sports")
+ return [str(warning.message) for warning in warning_info]
+
+ messages = run_async(scenario())
+ assert len(messages) == 1
+ assert SECRET_BODY_MARKER not in messages[0]
+ assert SECRET_HEADER_MARKER not in messages[0]
+ assert "X-Debug-Token" not in messages[0]
+
+
+def test_compatibility_warning_points_to_awaiting_caller_line():
+ """The warning is attributed to the awaiting caller, not package internals.
+
+ Mirrors test_http_warnings.test_compatibility_warning_points_to_direct_
+ adapter_caller_line for an awaited call.
+ """
+ handler = _ScriptedHandler(_response(403))
+
+ async def scenario():
+ adapter = _owned_adapter(handler, strict_http=False)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always", MlbHttpCompatibilityWarning)
+ expected_lineno = inspect.currentframe().f_lineno + 1
+ await adapter.get(endpoint="sports")
+ return caught, expected_lineno
+
+ caught, expected_lineno = run_async(scenario())
+ compatibility = [
+ warning
+ for warning in caught
+ if issubclass(warning.category, MlbHttpCompatibilityWarning)
+ ]
+ assert len(compatibility) == 1
+ assert compatibility[0].filename == __file__
+ assert compatibility[0].lineno == expected_lineno
+
+
+# --- Structured MlbHttpError context ---
+
+
+def test_error_context_extraction_failure_does_not_replace_http_error():
+ """A broken optional-context extraction must not hide the HTTP failure.
+
+ The best-effort response context is a debugging aid, so a failure while
+ collecting it degrades that one field instead of raising something other
+ than the original MlbHttpError.
+ """
+ handler = _ScriptedHandler(
+ _response(500, text='{"message": "Internal error occurred"}'),
+ )
+
+ async def scenario():
+ adapter = _owned_adapter(handler)
+ with patch(
+ "mlbstatsapi._http._extract_error_response_data",
+ side_effect=RuntimeError("error-context extraction failed"),
+ ):
+ with patch(SLEEP_TARGET, new_callable=AsyncMock):
+ with pytest.raises(MlbHttpError) as exc_info:
+ await adapter.get(endpoint="sports")
+ return exc_info.value
+
+ error = run_async(scenario())
+ assert error.status_code == 500
+ assert error.reason == HTTP_REASON_BY_STATUS[500]
+ assert error.method == "GET"
+ assert error.url == f"{BASE_URL}sports"
+ assert error.response_data is None
+ # The independent excerpt extraction still succeeds.
+ assert "Internal error occurred" in (error.body_excerpt or "")
+
+
+# --- Versioned User-Agent ---
+
+
+def test_library_owned_client_has_versioned_user_agent():
+ """A library-created AsyncClient sends the package and version User-Agent."""
+ with patch(
+ "mlbstatsapi.mlb_dataadapter.package_version",
+ return_value=MOCKED_PACKAGE_VERSION,
+ ) as lookup:
+ adapter = AsyncMlbDataAdapter()
+ try:
+ assert adapter._client.headers["User-Agent"] == MOCKED_USER_AGENT
+ finally:
+ run_async(adapter.aclose())
+
+ lookup.assert_called_with(PACKAGE_DISTRIBUTION_NAME)
+
+
+def test_library_owned_client_user_agent_uses_installed_version():
+ """Without patching, the User-Agent still names this package."""
+ adapter = AsyncMlbDataAdapter()
+ try:
+ assert adapter._client.headers["User-Agent"].startswith(
+ f"{PACKAGE_DISTRIBUTION_NAME}/",
+ )
+ finally:
+ run_async(adapter.aclose())
+
+
+def test_library_owned_client_user_agent_falls_back_when_metadata_missing():
+ """Missing distribution metadata yields the "unknown" fallback, not an error."""
+ with patch(
+ "mlbstatsapi.mlb_dataadapter.package_version",
+ side_effect=PackageNotFoundError(PACKAGE_DISTRIBUTION_NAME),
+ ):
+ adapter = AsyncMlbDataAdapter()
+ try:
+ assert adapter._client.headers["User-Agent"] == "python-mlb-statsapi/unknown"
+ finally:
+ run_async(adapter.aclose())
+
+
+def test_library_owned_client_preserves_httpx_default_headers():
+ """Only User-Agent changes; HTTPX's other default headers are untouched.
+
+ Mirrors test_mlb_session.test_library_created_session_preserves_requests_
+ default_headers for the async client.
+ """
+ baseline = httpx.AsyncClient()
+ adapter = AsyncMlbDataAdapter()
+ try:
+ for header, value in baseline.headers.items():
+ if header.lower() == "user-agent":
+ continue
+ assert adapter._client.headers[header] == value
+
+ for header in ("Accept", "Accept-Encoding", "Connection"):
+ assert adapter._client.headers[header] == baseline.headers[header]
+
+ assert adapter._client.headers["User-Agent"] != baseline.headers["User-Agent"]
+ finally:
+ run_async(adapter.aclose())
+ run_async(baseline.aclose())
+
+
+def test_injected_client_headers_are_unchanged():
+ """Headers on a caller-supplied client survive adapter construction."""
+ async def scenario():
+ client = httpx.AsyncClient(
+ headers={
+ "User-Agent": "my-baseball-project/1.0",
+ "X-Application": "scoreboard",
+ },
+ )
+ headers_before = dict(client.headers)
+ try:
+ adapter = AsyncMlbDataAdapter(client=client)
+
+ assert adapter._client is client
+ assert dict(client.headers) == headers_before
+ assert client.headers["User-Agent"] == "my-baseball-project/1.0"
+ assert client.headers["X-Application"] == "scoreboard"
+ finally:
+ await client.aclose()
+
+ run_async(scenario())
diff --git a/tests/test_async_optional_dependency.py b/tests/test_async_optional_dependency.py
new file mode 100644
index 00000000..b6d8cca4
--- /dev/null
+++ b/tests/test_async_optional_dependency.py
@@ -0,0 +1,427 @@
+"""Offline tests for the async optional-dependency boundary (issue #301).
+
+HTTPX ships only with the ``python-mlb-statsapi[async]`` extra, so three things
+have to hold at once:
+
+* ``from mlbstatsapi import AsyncMlbDataAdapter`` works when the extra is
+ installed
+* ``import mlbstatsapi`` and the whole sync surface keep working when it is not
+* reaching for async functionality without it produces actionable install
+ guidance instead of a bare ``ModuleNotFoundError``
+
+That guidance is reserved for a genuinely missing HTTPX: an installed but broken
+HTTPX must keep reporting its own failure.
+
+Optional-import behavior is easy to test misleadingly, because ``httpx`` and
+``mlbstatsapi`` are already in ``sys.modules`` by the time this file runs. Every
+"HTTPX is missing" case therefore runs in a child interpreter that blocks the
+import at ``sys.meta_path`` before ``mlbstatsapi`` is imported at all, which
+also means the developer environment never has to uninstall anything.
+
+Unlike tests/test_async_mlb_dataadapter.py, this module must never skip as a
+whole: most of what it asserts is exactly the behavior of an install that has no
+HTTPX, so it has to keep running in one. Nothing that needs HTTPX is imported at
+module scope; the few cases that do require it skip individually.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+import mlbstatsapi
+
+from test_public_api import (
+ OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS,
+ SUPPORTED_PACKAGE_ROOT_SYMBOLS,
+)
+
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+
+# The guidance callers must be able to act on. Asserted as a substring so the
+# surrounding sentence can be reworded without breaking these tests.
+ASYNC_EXTRA_REQUIREMENT = "python-mlb-statsapi[async]"
+
+# Prepended to a child program to simulate a sync-only install. The finder
+# rejects httpx before any path-based finder can satisfy it, so an installed
+# HTTPX in this environment is invisible to the child.
+BLOCK_HTTPX = """
+import sys
+
+
+class _HttpxBlocker:
+ # Makes httpx look uninstalled, exactly as ModuleNotFoundError would.
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "httpx" or fullname.startswith("httpx."):
+ raise ModuleNotFoundError(
+ f"No module named {fullname!r}", name=fullname
+ )
+ return None
+
+
+sys.meta_path.insert(0, _HttpxBlocker())
+assert "httpx" not in sys.modules, "child started with httpx already imported"
+assert "mlbstatsapi" not in sys.modules, "child started with mlbstatsapi imported"
+"""
+
+# Prepended to a child program to simulate an installed but broken HTTPX: the
+# httpx import fails, yet httpx itself is present. The user's problem is a
+# broken dependency tree, not a missing extra, so the boundary must not rewrite
+# it into install guidance.
+BREAK_HTTPX_DEPENDENCY = """
+import sys
+
+
+class _BrokenHttpxDependency:
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == "httpx":
+ raise ModuleNotFoundError(
+ "No module named 'httpcore'", name="httpcore"
+ )
+ return None
+
+
+sys.meta_path.insert(0, _BrokenHttpxDependency())
+assert "httpx" not in sys.modules, "child started with httpx already imported"
+"""
+
+
+def _run_child(
+ body: str,
+ *,
+ block_httpx: bool = False,
+ break_httpx: bool = False,
+) -> str:
+ """Run ``body`` in a fresh interpreter against this working tree.
+
+ ``block_httpx`` makes HTTPX look uninstalled; ``break_httpx`` makes it look
+ installed but unimportable. They describe different environments, so a test
+ picks exactly one.
+ """
+ assert not (block_httpx and break_httpx), "pick one HTTPX environment"
+
+ program = textwrap.dedent(body)
+ if block_httpx:
+ program = BLOCK_HTTPX + program
+ elif break_httpx:
+ program = BREAK_HTTPX_DEPENDENCY + program
+
+ completed = subprocess.run(
+ [sys.executable, "-c", program],
+ cwd=PROJECT_ROOT,
+ # Import the working tree rather than any installed copy of the package.
+ env={**os.environ, "PYTHONPATH": str(PROJECT_ROOT)},
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+
+ assert completed.returncode == 0, (
+ "child interpreter failed\n"
+ f"--- stdout ---\n{completed.stdout}\n"
+ f"--- stderr ---\n{completed.stderr}"
+ )
+ return completed.stdout
+
+
+# ---------------------------------------------------------------------------
+# Package-root boundary
+#
+# These run in any environment. The two cases that need a real HTTPX to prove
+# anything skip individually rather than taking the module with them.
+# ---------------------------------------------------------------------------
+
+
+def test_async_adapter_is_exported_from_the_package_root() -> None:
+ pytest.importorskip("httpx", reason="requires the async extra (HTTPX)")
+ from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter
+
+ from mlbstatsapi import AsyncMlbDataAdapter as exported
+
+ assert exported is AsyncMlbDataAdapter
+ assert mlbstatsapi.AsyncMlbDataAdapter is AsyncMlbDataAdapter
+ assert exported.__module__ == "mlbstatsapi.async_mlb_dataadapter"
+
+
+def test_async_adapter_is_discoverable_from_the_package_root() -> None:
+ assert "AsyncMlbDataAdapter" in dir(mlbstatsapi)
+
+
+def test_package_root_does_not_expose_httpx() -> None:
+ """HTTPX stays an implementation detail of the async adapter."""
+ assert not hasattr(mlbstatsapi, "httpx")
+
+
+def test_unknown_package_root_attribute_still_raises_attribute_error() -> None:
+ with pytest.raises(AttributeError):
+ mlbstatsapi.NotARealPublicSymbol # noqa: B018
+
+
+def test_importing_the_package_does_not_import_httpx() -> None:
+ """The boundary is lazy: a sync-only caller never pays for HTTPX."""
+ _run_child(
+ """
+ import sys
+
+ import mlbstatsapi
+ from mlbstatsapi import Mlb, MlbDataAdapter
+
+ imported = sorted(name for name in sys.modules if name.startswith("httpx"))
+ assert not imported, imported
+ assert "mlbstatsapi.async_mlb_dataadapter" not in sys.modules
+ """,
+ block_httpx=False,
+ )
+
+
+def test_async_access_imports_httpx_on_demand() -> None:
+ pytest.importorskip("httpx", reason="requires the async extra (HTTPX)")
+ _run_child(
+ """
+ import sys
+
+ import mlbstatsapi
+
+ assert "httpx" not in sys.modules
+ adapter_class = mlbstatsapi.AsyncMlbDataAdapter
+ assert "httpx" in sys.modules
+ assert adapter_class.__module__ == "mlbstatsapi.async_mlb_dataadapter"
+
+ # Resolved once, then cached as an ordinary module attribute.
+ assert mlbstatsapi.AsyncMlbDataAdapter is adapter_class
+ """,
+ block_httpx=False,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Without HTTPX installed
+# ---------------------------------------------------------------------------
+
+
+def test_sync_only_install_can_import_the_package() -> None:
+ _run_child(
+ """
+ import sys
+
+ import mlbstatsapi
+ from mlbstatsapi import Mlb
+ from mlbstatsapi import MlbDataAdapter
+
+ assert "httpx" not in sys.modules
+ """,
+ block_httpx=True,
+ )
+
+
+def test_sync_only_install_keeps_every_supported_package_root_symbol() -> None:
+ """Every always-available public symbol must resolve without the extra.
+
+ ``SUPPORTED_PACKAGE_ROOT_SYMBOLS`` is the always-available half of the 1.x
+ package-root API. The async half is covered separately below; both halves
+ are public API.
+ """
+ _run_child(
+ f"""
+ import mlbstatsapi
+
+ for name in {list(SUPPORTED_PACKAGE_ROOT_SYMBOLS)!r}:
+ assert getattr(mlbstatsapi, name) is not None, name
+ """,
+ block_httpx=True,
+ )
+
+
+def test_sync_only_install_reports_the_extra_for_every_async_symbol() -> None:
+ """The optional async manifest is exactly what the extra unlocks."""
+ _run_child(
+ f"""
+ import mlbstatsapi
+
+ for name in {list(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)!r}:
+ try:
+ getattr(mlbstatsapi, name)
+ except ImportError as exc:
+ assert "python-mlb-statsapi[async]" in str(exc), str(exc)
+ else:
+ raise AssertionError(f"expected an ImportError for {{name}}")
+ """,
+ block_httpx=True,
+ )
+
+
+def test_sync_only_install_can_still_use_the_sync_adapter() -> None:
+ """The boundary changes no sync behavior, including Session ownership."""
+ _run_child(
+ """
+ import sys
+
+ from mlbstatsapi import Mlb, MlbDataAdapter, MlbResult
+
+ adapter = MlbDataAdapter()
+ try:
+ assert adapter.url == "https://statsapi.mlb.com/api/v1/"
+ assert adapter._owns_session is True
+ assert "python-mlb-statsapi/" in adapter._session.headers["User-Agent"]
+ finally:
+ adapter.close()
+ assert adapter._closed is True
+
+ with Mlb() as mlb:
+ assert mlb._owns_session is True
+
+ result = MlbResult(404, "Not Found")
+ assert result.data == {}
+
+ assert "httpx" not in sys.modules
+ """,
+ block_httpx=True,
+ )
+
+
+def test_missing_httpx_reports_the_async_extra_from_the_package_root() -> None:
+ stdout = _run_child(
+ """
+ import mlbstatsapi
+
+ try:
+ from mlbstatsapi import AsyncMlbDataAdapter
+ except ImportError as exc:
+ message = str(exc)
+ cause = exc.__cause__
+ else:
+ raise AssertionError("expected an ImportError without httpx")
+
+ assert "python-mlb-statsapi[async]" in message, message
+ assert "pip install" in message, message
+ # The real failure stays diagnosable behind the friendly message.
+ assert isinstance(cause, ModuleNotFoundError), cause
+ assert cause.name == "httpx", cause.name
+
+ print(message)
+ """,
+ block_httpx=True,
+ )
+
+ assert ASYNC_EXTRA_REQUIREMENT in stdout
+
+
+def test_missing_httpx_reports_the_async_extra_from_attribute_access() -> None:
+ _run_child(
+ """
+ import mlbstatsapi
+
+ try:
+ mlbstatsapi.AsyncMlbDataAdapter
+ except ImportError as exc:
+ message = str(exc)
+ else:
+ raise AssertionError("expected an ImportError without httpx")
+
+ assert "python-mlb-statsapi[async]" in message, message
+ """,
+ block_httpx=True,
+ )
+
+
+def test_missing_httpx_reports_the_async_extra_from_the_async_module() -> None:
+ """Importing the module directly hits the same boundary, not a raw httpx error."""
+ _run_child(
+ """
+ try:
+ import mlbstatsapi.async_mlb_dataadapter # noqa: F401
+ except ImportError as exc:
+ message = str(exc)
+ else:
+ raise AssertionError("expected an ImportError without httpx")
+
+ assert "python-mlb-statsapi[async]" in message, message
+ assert "pip install" in message, message
+ """,
+ block_httpx=True,
+ )
+
+
+def test_async_name_stays_discoverable_without_httpx() -> None:
+ """Discoverability must not require the optional dependency."""
+ _run_child(
+ f"""
+ import sys
+
+ import mlbstatsapi
+
+ for name in {list(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)!r}:
+ assert name in dir(mlbstatsapi), name
+ assert "httpx" not in sys.modules
+ """,
+ block_httpx=True,
+ )
+
+
+def test_failed_async_access_leaves_the_sync_api_usable() -> None:
+ _run_child(
+ """
+ import mlbstatsapi
+
+ for _ in range(2):
+ try:
+ mlbstatsapi.AsyncMlbDataAdapter
+ except ImportError as exc:
+ assert "python-mlb-statsapi[async]" in str(exc), str(exc)
+ else:
+ raise AssertionError("expected an ImportError without httpx")
+
+ adapter = mlbstatsapi.MlbDataAdapter()
+ try:
+ assert adapter.url == "https://statsapi.mlb.com/api/v1/"
+ finally:
+ adapter.close()
+ """,
+ block_httpx=True,
+ )
+
+
+# ---------------------------------------------------------------------------
+# With HTTPX installed but broken
+# ---------------------------------------------------------------------------
+
+
+def test_broken_httpx_install_is_not_reported_as_a_missing_extra() -> None:
+ """Installing the extra would not fix a broken HTTPX, so do not suggest it."""
+ _run_child(
+ """
+ try:
+ import mlbstatsapi.async_mlb_dataadapter # noqa: F401
+ except ModuleNotFoundError as exc:
+ assert exc.name == "httpcore", exc.name
+ assert "python-mlb-statsapi[async]" not in str(exc), str(exc)
+ else:
+ raise AssertionError("expected the underlying import failure")
+ """,
+ break_httpx=True,
+ )
+
+
+def test_broken_httpx_install_surfaces_from_the_package_root_too() -> None:
+ _run_child(
+ """
+ import mlbstatsapi
+
+ try:
+ mlbstatsapi.AsyncMlbDataAdapter
+ except ModuleNotFoundError as exc:
+ assert exc.name == "httpcore", exc.name
+ assert "python-mlb-statsapi[async]" not in str(exc), str(exc)
+ else:
+ raise AssertionError("expected the underlying import failure")
+ """,
+ break_httpx=True,
+ )
diff --git a/tests/test_env_proxies.py b/tests/test_env_proxies.py
new file mode 100644
index 00000000..c6dfbc55
--- /dev/null
+++ b/tests/test_env_proxies.py
@@ -0,0 +1,103 @@
+"""Tests for ``mlbstatsapi._env_proxies.environment_proxy_map`` (issue #324).
+
+PR #323 moved async retries into a custom HTTPX transport, which had the side
+effect of disabling HTTPX's own environment-proxy discovery for library-created
+async clients (see ``mlbstatsapi/_env_proxies.py`` for the full story).
+``environment_proxy_map`` is the stdlib-only replacement for that discovery.
+
+This module covers only the pure parsing in ``environment_proxy_map`` itself
+and imports nothing from HTTPX, so it runs — and is meant to run — in the
+no-httpx CI job: a stdlib-only helper is exactly where that job's coverage
+matters most. The tests that exercise how the map is wired into an HTTPX
+client (``create_library_async_client``) live in
+tests/test_async_env_proxies.py, which skips as a whole without the ``async``
+extra.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+from mlbstatsapi._env_proxies import environment_proxy_map
+
+# Both cases of every proxy variable urllib.request.getproxies() reads, so a
+# proxy set in the developer's own shell can never leak into a fixture.
+PROXY_ENV_VARS = (
+ "HTTP_PROXY",
+ "http_proxy",
+ "HTTPS_PROXY",
+ "https_proxy",
+ "ALL_PROXY",
+ "all_proxy",
+ "NO_PROXY",
+ "no_proxy",
+)
+
+
+def clear_proxy_env(monkeypatch) -> None:
+ for name in PROXY_ENV_VARS:
+ monkeypatch.delenv(name, raising=False)
+
+
+def set_proxy_env(monkeypatch, env: dict) -> None:
+ clear_proxy_env(monkeypatch)
+ for key, value in env.items():
+ monkeypatch.setenv(key, value)
+
+
+def test_https_proxy_only(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"})
+ assert environment_proxy_map() == {"https://": "http://corp:8080"}
+
+
+def test_http_proxy_only(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"})
+ assert environment_proxy_map() == {"http://": "http://corp:8080"}
+
+
+def test_all_proxy(monkeypatch):
+ set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:9"})
+ assert environment_proxy_map() == {"all://": "http://corp:9"}
+
+
+def test_bare_host_port_normalizes_to_http(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"})
+ assert environment_proxy_map() == {"https://": "http://corp:8080"}
+
+
+def test_no_proxy_subdomain_wildcard(monkeypatch):
+ set_proxy_env(
+ monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"}
+ )
+ assert environment_proxy_map() == {
+ "https://": "http://corp:8080",
+ "all://*mlb.com": None,
+ }
+
+
+def test_no_proxy_localhost_ipv4_ipv6(monkeypatch):
+ set_proxy_env(
+ monkeypatch,
+ {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "localhost,127.0.0.1,::1"},
+ )
+ assert environment_proxy_map() == {
+ "https://": "http://corp:8080",
+ "all://localhost": None,
+ "all://127.0.0.1": None,
+ "all://[::1]": None,
+ }
+
+
+def test_no_proxy_star_disables_every_proxy(monkeypatch):
+ set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"})
+ assert environment_proxy_map() == {}
+
+
+def test_empty_env(monkeypatch):
+ set_proxy_env(monkeypatch, {})
+ assert environment_proxy_map() == {}
+
+
+def test_trust_env_false_ignores_everything(monkeypatch):
+ set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"})
+ assert environment_proxy_map(trust_env=False) == {}
diff --git a/tests/test_mlb_attendance.py b/tests/test_mlb_attendance.py
new file mode 100644
index 00000000..da9f1153
--- /dev/null
+++ b/tests/test_mlb_attendance.py
@@ -0,0 +1,88 @@
+"""Offline coverage for Mlb.get_attendance, including a regression test for a
+guard bug found while porting this endpoint to AsyncMlb (issue #305):
+``any(required_args)`` iterates dict keys (always truthy) instead of values,
+so the documented "at least one of team_id/league_id/league_list_id" guard
+never actually fired. Fixed to ``any(required_args.values())``.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from mlbstatsapi import Mlb
+from mlbstatsapi.mlb_dataadapter import MlbResult
+from mlbstatsapi.models.attendances import Attendance
+
+
+ATTENDANCE_PAYLOAD = {
+ "records": [
+ {
+ "openingsTotal": 160,
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "gamesTotal": 162,
+ "gamesAwayTotal": 82,
+ "gamesHomeTotal": 80,
+ "year": "2022",
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ "gameType": {"id": "R", "description": "Regular Season"},
+ "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"},
+ }
+ ],
+ "aggregateTotals": {
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "openingsTotalYtd": 0,
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ },
+}
+
+
+def test_get_attendance_with_no_identifier_does_not_request_and_returns_none():
+ """Regression test: no team/league/league-list id must short-circuit."""
+ with Mlb() as mlb:
+ mock = MagicMock()
+ mlb._mlb_adapter_v1.get = mock
+
+ result = mlb.get_attendance()
+
+ assert result is None
+ mock.assert_not_called()
+
+
+def test_get_attendance_with_team_id_requests_and_parses_the_result():
+ with Mlb() as mlb:
+ mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(status_code=200, message=None, data=ATTENDANCE_PAYLOAD)
+ )
+
+ result = mlb.get_attendance(team_id=133)
+
+ assert isinstance(result, Attendance)
+ assert result.aggregate_totals.attendance_total == 2896460
+ mlb._mlb_adapter_v1.get.assert_called_once_with(
+ "attendance", ep_params={"teamId": 133}
+ )
+
+
+def test_get_attendance_returns_none_on_client_error():
+ with Mlb() as mlb:
+ mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(status_code=404, message=None, data={})
+ )
+
+ assert mlb.get_attendance(team_id=133) is None
diff --git a/tests/test_mlb_exceptions.py b/tests/test_mlb_exceptions.py
index effbff31..1ba92ac6 100644
--- a/tests/test_mlb_exceptions.py
+++ b/tests/test_mlb_exceptions.py
@@ -14,9 +14,9 @@
MlbTransportError,
TheMlbStatsApiException,
)
-from mlbstatsapi.mlb_dataadapter import (
- HTTP_ERROR_BODY_EXCERPT_LIMIT,
+from mlbstatsapi._http import (
_build_http_error,
+ HTTP_ERROR_BODY_EXCERPT_LIMIT,
)
@@ -404,11 +404,15 @@ def test_url_fallback_when_response_url_missing():
response.json.return_value = {"message": "boom"}
response.text = '{"message": "boom"}'
- exc = _build_http_error(
- response,
- method="GET",
- fallback_url=f"{BASE_URL}sports",
- )
+ session = MagicMock()
+ session.get.return_value = response
+
+ adapter = MlbDataAdapter(session=session)
+
+ with pytest.raises(MlbHttpError) as exc_info:
+ adapter.get(endpoint="sports")
+
+ exc = exc_info.value
assert exc.url == f"{BASE_URL}sports"
assert exc.method == "GET"
@@ -426,11 +430,11 @@ def test_best_effort_extraction_failure_still_raises_mlb_http_error(requests_moc
with (
patch(
- "mlbstatsapi.mlb_dataadapter._extract_error_response_data",
+ "mlbstatsapi._http._extract_error_response_data",
side_effect=RuntimeError("unexpected json failure"),
),
patch(
- "mlbstatsapi.mlb_dataadapter._extract_error_body_excerpt",
+ "mlbstatsapi._http._extract_error_body_excerpt",
side_effect=RuntimeError("unexpected text failure"),
),
pytest.raises(MlbHttpError) as exc_info,
diff --git a/tests/test_mlb_homerun_derby.py b/tests/test_mlb_homerun_derby.py
new file mode 100644
index 00000000..89150da3
--- /dev/null
+++ b/tests/test_mlb_homerun_derby.py
@@ -0,0 +1,79 @@
+"""Offline coverage for Mlb.get_homerun_derby, including a regression test for
+a bug found while porting this endpoint to AsyncMlb (issue #305): the 400-499
+branch executed a bare ``None`` expression instead of ``return None``, so
+execution fell through to the parsing logic below. In the common case that
+logic still landed on None (an error response rarely has a truthy "status"
+key), but a 404 or compatibility-mode 4xx response that happened to include
+one would have raised a ValidationError instead of cleanly returning None.
+Fixed to ``return None``.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from mlbstatsapi import Mlb
+from mlbstatsapi.mlb_dataadapter import MlbResult
+from mlbstatsapi.models.homerunderby import HomeRunDerby
+
+
+HOMERUN_DERBY_PAYLOAD = {
+ "info": {
+ "id": 511101,
+ "nonGameGuid": "test-guid",
+ "name": "Home Run Derby",
+ "eventType": {"code": "O", "name": "Other"},
+ "eventDate": "2017-07-11T00:00:00Z",
+ "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"},
+ "isMultiDay": False,
+ "isPrimaryCalendar": True,
+ "fileCode": "2017/07/10/mlb-112",
+ "eventNumber": 103,
+ "publicFacing": True,
+ },
+ "status": {
+ "state": "Final",
+ "currentRound": 3,
+ "currentRoundTimeLeft": "0:00",
+ "inTieBreaker": False,
+ "tieBreakerNum": 0,
+ "clockStopped": True,
+ "bonusTime": False,
+ },
+}
+
+
+def test_get_homerun_derby_requests_and_parses_the_result():
+ with Mlb() as mlb:
+ mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(status_code=200, message=None, data=HOMERUN_DERBY_PAYLOAD)
+ )
+
+ result = mlb.get_homerun_derby(511101)
+
+ assert isinstance(result, HomeRunDerby)
+ assert result.status.state == "Final"
+
+
+def test_get_homerun_derby_returns_none_on_client_error():
+ with Mlb() as mlb:
+ mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(status_code=404, message=None, data={})
+ )
+
+ assert mlb.get_homerun_derby(1) is None
+
+
+def test_get_homerun_derby_returns_none_without_raising_on_a_malformed_error_body():
+ """Regression test: a 4xx body with a truthy "status" key must not reach
+ HomeRunDerby(**data) and raise, now that the guard actually returns."""
+ with Mlb() as mlb:
+ mlb._mlb_adapter_v1.get = MagicMock(
+ return_value=MlbResult(
+ status_code=404, message=None, data={"status": "error"}
+ )
+ )
+
+ assert mlb.get_homerun_derby(1) is None
diff --git a/tests/test_public_api.py b/tests/test_public_api.py
index 6d2dc85c..d909bad3 100644
--- a/tests/test_public_api.py
+++ b/tests/test_public_api.py
@@ -2,15 +2,24 @@
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
+either manifest is public and stable in 1.x; only the async manifest needs the
+optional ``async`` extra to resolve.
They must not contact the live MLB API.
"""
from __future__ import annotations
+import importlib.util
import inspect
+import re
import warnings
+from pathlib import Path
from typing import Any
import pytest
@@ -36,11 +45,18 @@
from http_contract_support import assert_library_retry_policy
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+PUBLIC_API_DOC = PROJECT_ROOT / "docs" / "public-api.md"
+
+
# ---------------------------------------------------------------------------
# Package-root manifests
# ---------------------------------------------------------------------------
-# Intentionally supported package-root symbols for the 1.x series.
+# Supported package-root symbols for the 1.x series that are always available,
+# including in a sync-only install without the ``async`` extra. Sync-only
+# environments freeze their surface against this manifest, so a symbol that
+# needs an optional dependency must not be added here.
SUPPORTED_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = (
"Mlb",
"MlbDataAdapter",
@@ -56,6 +72,19 @@
"return_splits",
)
+# Supported package-root symbols for the 1.x series that require the optional
+# ``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",
+)
+
+# The complete supported package-root API for the 1.x series.
+SUPPORTED_PACKAGE_ROOT_API: tuple[str, ...] = (
+ SUPPORTED_PACKAGE_ROOT_SYMBOLS + OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS
+)
+
# Legacy helpers remain supported but are not preferred for new code.
LEGACY_PACKAGE_ROOT_HELPERS: tuple[str, ...] = (
"get_stat_attributes",
@@ -74,6 +103,26 @@
)
+# HTTPX ships only with the ``async`` extra, so this module must stay runnable
+# in a sync-only environment. Cases that assert async availability are skipped
+# there; tests/test_async_optional_dependency.py covers the sync-only half of
+# the contract in child interpreters that block HTTPX outright.
+def _async_extra_installed() -> bool:
+ """Report whether HTTPX is available, without importing it here."""
+ try:
+ return importlib.util.find_spec("httpx") is not None
+ except ImportError:
+ # An environment may also make httpx unavailable by raising from a meta
+ # path finder instead of reporting no spec.
+ return False
+
+
+requires_async_extra = pytest.mark.skipif(
+ not _async_extra_installed(),
+ reason="requires the optional async extra (HTTPX)",
+)
+
+
# Python 3.14 renders typing.Union[a, b] as "a | b" while Python 3.10-3.13
# render "Union[a, b]". The annotation object itself is unchanged, so the legacy
# spelling is rewritten here and one manifest stays valid across the whole
@@ -84,7 +133,11 @@
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
@@ -177,6 +230,68 @@ 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_team_roster": "(team_id: int, **params)",
+ "get_team_coaches": "(team_id: int, **params)",
+ "get_person": "(player_id: int, **params)",
+ "get_people": "(sport_id: int=1, **params)",
+ "get_schedule": (
+ "(date: str=None, start_date: str=None, end_date: str=None, "
+ "sport_id: int=1, team_id: int=None, **params)"
+ ),
+ "get_sport": "(sport_id: int, **params)",
+ "get_sports": "(**params)",
+ "get_league": "(league_id: int, **params)",
+ "get_leagues": "(**params)",
+ "get_division": "(division_id: int, **params)",
+ "get_divisions": "(**params)",
+ "get_season": "(season_id: str, sport_id: int=1, **params)",
+ "get_seasons": "(sport_id: int=1, **params)",
+ "get_venue": "(venue_id: int, **params)",
+ "get_venues": "(**params)",
+ "get_standings": "(league_id: int, season: str, **params)",
+ "get_attendance": (
+ "(team_id: int=None, league_id: int=None, "
+ "league_list_id: str=None, **params)"
+ ),
+ "get_draft": "(year_id: int, **params)",
+ "get_awards": "(award_id: str, **params)",
+ "get_homerun_derby": "(game_id, **params)",
+ "get_team_stats": "(team_id: int, stats: list, groups: list, **params)",
+ "get_players_stats_for_game": "(person_id: int, game_id: int, **params)",
+ "get_player_stats": "(person_id: int, stats: list, groups: list, **params)",
+ "get_stats": "(stats: list, groups: list, **params)",
+ "get_persons": "(person_ids: str | list[int], **params)",
+ "get_scheduled_games_by_date": (
+ "(date: str=None, start_date: str=None, end_date: str=None, "
+ "sport_id: int=1, **params)"
+ ),
+ "get_gamepace": "(season: str, sport_id=1, **params)",
+ "get_team_id": "(team_name: str, search_key: str='name', **params)",
+ "get_people_id": (
+ "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)"
+ ),
+ "get_sport_id": "(sport_name: str, search_key: str='name', **params)",
+ "get_league_id": "(league_name: str, search_key: str='name', **params)",
+ "get_division_id": "(division_name: str, search_key: str='name', **params)",
+ "get_venue_id": "(venue_name: str, search_key: str='name', **params)",
+ "get_game": "(game_id: int, **params)",
+ "get_game_play_by_play": "(game_id: int, **params)",
+ "get_game_line_score": "(game_id: int, **params)",
+ "get_game_box_score": "(game_id: int, **params)",
+ "get_game_ids": (
+ "(date: str=None, start_date: str=None, end_date: str=None, "
+ "sport_id: int=1, **params)"
+ ),
+}
+
# ---------------------------------------------------------------------------
# Package-root symbols
@@ -187,6 +302,33 @@ def test_supported_package_root_symbols_are_unique() -> None:
assert len(SUPPORTED_PACKAGE_ROOT_SYMBOLS) == len(set(SUPPORTED_PACKAGE_ROOT_SYMBOLS))
+def test_optional_async_package_root_symbols_are_unique() -> None:
+ assert len(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS) == len(
+ set(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)
+ )
+
+
+def test_package_root_manifests_are_disjoint() -> None:
+ """A symbol is either always available or gated behind the async extra."""
+ assert not set(SUPPORTED_PACKAGE_ROOT_SYMBOLS) & set(
+ OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS
+ )
+
+
+def test_supported_package_root_api_is_the_union_of_both_manifests() -> None:
+ assert set(SUPPORTED_PACKAGE_ROOT_API) == set(SUPPORTED_PACKAGE_ROOT_SYMBOLS) | set(
+ OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS
+ )
+ assert len(SUPPORTED_PACKAGE_ROOT_API) == len(set(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:
for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS:
assert hasattr(mlbstatsapi, name), name
@@ -201,12 +343,40 @@ def test_supported_symbols_are_importable_by_name(name: str) -> None:
assert namespace[name] is getattr(mlbstatsapi, name)
+@pytest.mark.parametrize("name", OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)
+def test_optional_async_symbols_are_discoverable_without_the_extra(name: str) -> None:
+ """Discoverability is unconditional; only resolution needs HTTPX."""
+ assert name in dir(mlbstatsapi)
+
+
+@requires_async_extra
+@pytest.mark.parametrize("name", OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)
+def test_optional_async_symbols_are_importable_with_the_extra(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)
+
+
+@requires_async_extra
+def test_async_data_adapter_resolves_to_the_async_module() -> None:
+ adapter_class = mlbstatsapi.AsyncMlbDataAdapter
+ assert adapter_class.__module__ == "mlbstatsapi.async_mlb_dataadapter"
+ assert adapter_class.__name__ == "AsyncMlbDataAdapter"
+
+
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:
+ """Only the always-available manifest is asserted here.
+
+ Async symbols resolve lazily, so whether a wildcard import sees them depends
+ on whether something already touched them in this interpreter. Their
+ documented access path is an explicit import, not ``import *``.
+ """
namespace: dict[str, Any] = {}
exec("from mlbstatsapi import *", namespace)
for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS:
@@ -230,6 +400,40 @@ def test_legacy_helpers_remain_package_root_importable() -> None:
assert name in SUPPORTED_PACKAGE_ROOT_SYMBOLS
+# ---------------------------------------------------------------------------
+# Documented classification
+# ---------------------------------------------------------------------------
+
+
+def _documented_package_root_classifications() -> dict[str, str]:
+ """Return the symbol/status rows of the classification table in the docs."""
+ text = PUBLIC_API_DOC.read_text(encoding="utf-8")
+ section = text.split("### Classification of package-root symbols", 1)[1]
+ section = re.split(r"\n#{2,} ", section, maxsplit=1)[0]
+
+ rows: dict[str, str] = {}
+ for line in section.splitlines():
+ match = re.match(r"^\|\s*`([A-Za-z_][A-Za-z0-9_]*)`\s*\|(.+?)\|\s*$", line)
+ if match:
+ rows[match.group(1)] = match.group(2).strip()
+ return rows
+
+
+def test_documentation_classifies_every_supported_package_root_symbol() -> None:
+ documented = _documented_package_root_classifications()
+ for name in SUPPORTED_PACKAGE_ROOT_API:
+ assert name in documented, f"{name} is missing from the classification table"
+
+
+def test_documentation_classifies_async_symbols_as_public_and_optional() -> None:
+ """Public API status and optional-dependency availability stay separate."""
+ documented = _documented_package_root_classifications()
+ for name in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS:
+ status = documented[name]
+ assert "Public and stable in 1.x" in status, status
+ assert "`async` extra" in status, status
+
+
# ---------------------------------------------------------------------------
# Constructor signatures
# ---------------------------------------------------------------------------
@@ -261,6 +465,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
@@ -348,6 +572,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
# ---------------------------------------------------------------------------
diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py
index fd759ea9..444983a7 100644
--- a/tests/test_release_validation.py
+++ b/tests/test_release_validation.py
@@ -38,9 +38,9 @@
EXTERNAL_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "external-tests.yml"
# Release notes for the version this branch is preparing. Kept explicit so the
-# current-document checks do not depend on the pyproject version bump, which is
-# owned by a separate issue.
-CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.0.1.md"
+# current-document checks cannot silently classify an unreviewed notes file as
+# the current release merely because the declared version changed.
+CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.1.0.md"
# Historical notes keep their own version-specific statements and must not be
# rewritten to match the current release.
@@ -50,11 +50,11 @@
RELEASE_NOTES_DIR / "0.8.0.md",
RELEASE_NOTES_DIR / "0.9.0.md",
RELEASE_NOTES_DIR / "1.0.0.md",
+ RELEASE_NOTES_DIR / "1.0.1.md",
)
-# Deterministic CI contract for the 1.0 release.
-RELEASE_BRANCH = "release/1.0.0"
-STALE_RELEASE_BRANCH = "release/0.9.0"
+# Deterministic CI contract for maintained release branches.
+RELEASE_BRANCH_PATTERN = 'release/**'
SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
CI_VALIDATED_PYTHON_RANGE = "3.10 through 3.14"
# Prerelease during this work, so it is deliberately excluded from the matrix.
@@ -320,12 +320,16 @@ def _write_sdist(
def _classify_command(command) -> str:
parts = [str(part) for part in command]
joined = " ".join(parts)
+ if "release_async_smoke_test.py" in joined:
+ return "async-smoke"
if "release_smoke_test.py" in joined:
- return "smoke"
+ return "sync-smoke"
if "--upgrade" in parts:
return "pip-upgrade"
if "install" in parts:
- return "install"
+ if any(part.endswith("[async]") for part in parts):
+ return "async-install"
+ return "sync-install"
return "other"
@@ -337,10 +341,10 @@ def __init__(self, returncode: int):
def _stub_clean_install(monkeypatch, *, failing: str | None = None) -> list[list[str]]:
"""Stub environment creation and subprocess execution for install tests.
- ``failing`` selects the step that returns a non-zero exit code: ``install``
- for the artifact installation or ``smoke`` for the installed-package smoke
- test. Only the validator's own ``subprocess`` reference is replaced, so no
- real interpreter, environment, or download is involved.
+ ``failing`` selects the classified step that returns a non-zero exit code,
+ such as ``sync-install``, ``sync-smoke``, ``async-install``, or
+ ``async-smoke``. Only the validator's own ``subprocess`` reference is
+ replaced, so no real interpreter, environment, or download is involved.
"""
commands: list[list[str]] = []
@@ -576,12 +580,22 @@ def test_missing_required_source_distribution_path_is_reported(
def test_required_source_distribution_paths_cover_the_package_entry_points() -> None:
- """The required list must include the files needed to rebuild and import."""
+ """The required list must cover both public clients and async support."""
required = set(validator.REQUIRED_SDIST_PATHS)
assert {"README.md", "pyproject.toml", "mlbstatsapi/__init__.py"} <= required
- assert "mlbstatsapi/mlb_api.py" in required
- assert "mlbstatsapi/mlb_dataadapter.py" in required
+ assert {
+ "mlbstatsapi/mlb_api.py",
+ "mlbstatsapi/mlb_dataadapter.py",
+ "mlbstatsapi/async_mlb.py",
+ "mlbstatsapi/async_mlb_dataadapter.py",
+ } <= required
+ assert {
+ "mlbstatsapi/_async_support.py",
+ "mlbstatsapi/_async_transport.py",
+ "mlbstatsapi/_env_proxies.py",
+ "mlbstatsapi/_http.py",
+ } <= required
# Tests, docs, and scripts are intentionally absent from the sdist.
assert not any(path.startswith(("tests/", "docs/", "scripts/")) for path in required)
@@ -596,7 +610,7 @@ def test_wheel_installation_failure_identifies_the_artifact(
tmp_path: Path,
) -> None:
wheel = _write_wheel(tmp_path)
- _stub_clean_install(monkeypatch, failing="install")
+ _stub_clean_install(monkeypatch, failing="sync-install")
with pytest.raises(validator.ValidationError) as exc_info:
validator._check_clean_install(
@@ -616,7 +630,7 @@ def test_source_distribution_installation_failure_identifies_the_artifact(
tmp_path: Path,
) -> None:
sdist = _write_sdist(tmp_path)
- _stub_clean_install(monkeypatch, failing="install")
+ _stub_clean_install(monkeypatch, failing="sync-install")
with pytest.raises(validator.ValidationError) as exc_info:
validator._check_clean_install(
@@ -645,7 +659,7 @@ def test_smoke_test_failure_identifies_the_artifact(
if label == validator.WHEEL_LABEL
else _write_sdist(tmp_path)
)
- _stub_clean_install(monkeypatch, failing="smoke")
+ _stub_clean_install(monkeypatch, failing="sync-smoke")
with pytest.raises(validator.ValidationError) as exc_info:
validator._check_clean_install(artifact, SYNTHETIC_VERSION, label=label)
@@ -655,6 +669,66 @@ def test_smoke_test_failure_identifies_the_artifact(
assert "exit code 1" in message
+@pytest.mark.parametrize(
+ "label",
+ (validator.WHEEL_LABEL, validator.SDIST_LABEL),
+)
+def test_async_extra_installation_failure_identifies_the_artifact_and_phase(
+ monkeypatch,
+ tmp_path: Path,
+ label: str,
+) -> None:
+ artifact = (
+ _write_wheel(tmp_path)
+ if label == validator.WHEEL_LABEL
+ else _write_sdist(tmp_path)
+ )
+ _stub_clean_install(monkeypatch, failing="async-install")
+
+ with pytest.raises(validator.ValidationError) as exc_info:
+ validator._check_async_clean_install(
+ artifact,
+ SYNTHETIC_VERSION,
+ label=label,
+ )
+
+ message = str(exc_info.value)
+ assert label in message
+ assert artifact.name in message
+ assert "async-extra installation" in message
+ assert "exit code 1" in message
+
+
+@pytest.mark.parametrize(
+ "label",
+ (validator.WHEEL_LABEL, validator.SDIST_LABEL),
+)
+def test_async_smoke_failure_identifies_the_artifact_and_phase(
+ monkeypatch,
+ tmp_path: Path,
+ label: str,
+) -> None:
+ artifact = (
+ _write_wheel(tmp_path)
+ if label == validator.WHEEL_LABEL
+ else _write_sdist(tmp_path)
+ )
+ _stub_clean_install(monkeypatch, failing="async-smoke")
+
+ with pytest.raises(validator.ValidationError) as exc_info:
+ validator._check_async_clean_install(
+ artifact,
+ SYNTHETIC_VERSION,
+ label=label,
+ )
+
+ message = str(exc_info.value)
+ assert label in message
+ assert artifact.name in message
+ assert "async smoke test" in message
+ assert "exit code 1" in message
+
+
def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace(
monkeypatch,
tmp_path: Path,
@@ -669,12 +743,12 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace(
)
steps = [_classify_command(command) for command in commands]
- assert steps == ["pip-upgrade", "install", "smoke"]
+ assert steps == ["pip-upgrade", "sync-install", "sync-smoke"]
- install_command = commands[steps.index("install")]
+ install_command = commands[steps.index("sync-install")]
assert str(wheel.resolve()) in install_command
- smoke_command = commands[steps.index("smoke")]
+ smoke_command = commands[steps.index("sync-smoke")]
assert smoke_command[-1] == SYNTHETIC_VERSION
smoke_script = Path(smoke_command[-2])
# The script is written into a throwaway workspace, never the checkout.
@@ -682,6 +756,41 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace(
assert PROJECT_ROOT not in smoke_script.parents
+@pytest.mark.parametrize(
+ "label",
+ (validator.WHEEL_LABEL, validator.SDIST_LABEL),
+)
+def test_async_clean_install_requests_the_local_artifact_extra(
+ monkeypatch,
+ tmp_path: Path,
+ label: str,
+) -> None:
+ artifact = (
+ _write_wheel(tmp_path)
+ if label == validator.WHEEL_LABEL
+ else _write_sdist(tmp_path)
+ )
+ commands = _stub_clean_install(monkeypatch)
+
+ validator._check_async_clean_install(
+ artifact,
+ SYNTHETIC_VERSION,
+ label=label,
+ )
+
+ steps = [_classify_command(command) for command in commands]
+ assert steps == ["pip-upgrade", "async-install", "async-smoke"]
+
+ install_command = commands[steps.index("async-install")]
+ assert f"{artifact.resolve()}[async]" in install_command
+
+ smoke_command = commands[steps.index("async-smoke")]
+ assert smoke_command[-1] == SYNTHETIC_VERSION
+ smoke_script = Path(smoke_command[-2])
+ assert smoke_script.name == "release_async_smoke_test.py"
+ assert PROJECT_ROOT not in smoke_script.parents
+
+
def test_each_artifact_is_installed_into_its_own_environment(
monkeypatch,
tmp_path: Path,
@@ -711,28 +820,62 @@ def record_environment(venv_dir: Path) -> Path:
SYNTHETIC_VERSION,
label=validator.SDIST_LABEL,
)
+ validator._check_async_clean_install(
+ wheel,
+ SYNTHETIC_VERSION,
+ label=validator.WHEEL_LABEL,
+ )
+ validator._check_async_clean_install(
+ sdist,
+ SYNTHETIC_VERSION,
+ label=validator.SDIST_LABEL,
+ )
- assert len(created) == 2
- assert created[0] != created[1]
+ assert len(created) == 4
+ assert len(set(created)) == 4
-def test_validate_clean_installs_both_artifacts(monkeypatch, tmp_path: Path) -> None:
- """validate() must clean-install the wheel and the source distribution."""
+def test_validate_runs_sync_and_async_clean_installs_for_both_artifacts(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ """validate() must exercise both install modes for wheel and sdist."""
wheel = _write_wheel(tmp_path)
sdist = _write_sdist(tmp_path)
- installs: list[tuple[Path, str, str]] = []
-
- def record_install(artifact: Path, expected_version: str, *, label: str) -> None:
- installs.append((artifact, expected_version, label))
-
- monkeypatch.setattr(validator, "_check_clean_install", record_install)
+ sync_installs: list[tuple[Path, str, str]] = []
+ async_installs: list[tuple[Path, str, str]] = []
+
+ def record_sync_install(
+ artifact: Path,
+ expected_version: str,
+ *,
+ label: str,
+ ) -> None:
+ sync_installs.append((artifact, expected_version, label))
+
+ def record_async_install(
+ artifact: Path,
+ expected_version: str,
+ *,
+ label: str,
+ ) -> None:
+ async_installs.append((artifact, expected_version, label))
+
+ monkeypatch.setattr(validator, "_check_clean_install", record_sync_install)
+ monkeypatch.setattr(
+ validator,
+ "_check_async_clean_install",
+ record_async_install,
+ )
validator.validate(tmp_path, SYNTHETIC_VERSION)
- assert installs == [
+ expected = [
(wheel, SYNTHETIC_VERSION, validator.WHEEL_LABEL),
(sdist, SYNTHETIC_VERSION, validator.SDIST_LABEL),
]
+ assert sync_installs == expected
+ assert async_installs == expected
def test_validate_reports_success_for_both_artifacts(
@@ -751,6 +894,10 @@ def test_validate_reports_success_for_both_artifacts(
assert f"running {validator.WHEEL_LABEL} smoke test" in output
assert f"installing {validator.SDIST_LABEL}" in output
assert f"running {validator.SDIST_LABEL} smoke test" in output
+ assert f"installing {validator.WHEEL_LABEL} with async extra" in output
+ assert f"running {validator.WHEEL_LABEL} async smoke test" in output
+ assert f"installing {validator.SDIST_LABEL} with async extra" in output
+ assert f"running {validator.SDIST_LABEL} async smoke test" in output
assert "Release validation passed" in output
@@ -768,6 +915,14 @@ def test_smoke_test_source_is_valid_python() -> None:
compile(validator.SMOKE_TEST_SOURCE, "release_smoke_test.py", "exec")
+def test_async_smoke_test_source_is_valid_python() -> None:
+ compile(
+ validator.ASYNC_SMOKE_TEST_SOURCE,
+ "release_async_smoke_test.py",
+ "exec",
+ )
+
+
def test_smoke_test_labels_reverted_strict_defaults() -> None:
"""A reverted strict default must fail with an explanatory message.
@@ -798,6 +953,24 @@ def test_smoke_test_labels_reverted_strict_defaults() -> None:
assert message in source
+def test_async_smoke_test_labels_reverted_strict_defaults() -> None:
+ assert validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE == (
+ "AsyncMlb.strict_http must default to True for the 1.1 contract"
+ )
+ assert validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE == (
+ "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract"
+ )
+
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+ assert 'async_mlb_init["strict_http"].default is True' in source
+ assert 'async_adapter_init["strict_http"].default is True' in source
+ for message in (
+ validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE,
+ validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE,
+ ):
+ assert message in source
+
+
def test_smoke_test_asserts_strict_http_default() -> None:
"""The installed-artifact smoke test must match the 1.0 strict default."""
text = VALIDATE_RELEASE.read_text(encoding="utf-8")
@@ -846,6 +1019,64 @@ def test_smoke_test_checks_library_created_session_configuration() -> None:
assert "create_retry_policy() must return a new Retry instance per call" in source
+def test_async_smoke_test_checks_optional_public_surface_and_httpx_metadata() -> None:
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+
+ assert "import httpx" in source
+ assert 'importlib.metadata.version("httpx")' in source
+ assert " AsyncMlb,\n" in source
+ assert " AsyncMlbDataAdapter,\n" in source
+ assert 'for name in ("AsyncMlb", "AsyncMlbDataAdapter")' in source
+
+
+def test_async_smoke_test_checks_lifecycle_strict_modes_and_ownership() -> None:
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+
+ assert "async with client as entered:" in source
+ assert "assert entered is client" in source
+ assert source.count("await explicitly_closed.aclose()") == 2
+ assert "AsyncMlb(client=caller_client)" in source
+ assert "strict_http=False" in source
+ assert "MlbHttpError" in source
+ assert "MlbHttpCompatibilityWarning" in source
+ assert "AsyncMlb must not close a caller-injected httpx.AsyncClient" in source
+ assert 'f"python-mlb-statsapi/{expected_version}"' in source
+
+
+def test_async_smoke_test_checks_standalone_data_adapter_lifecycle() -> None:
+ """AsyncMlbDataAdapter()'s own library-owned client path must be exercised
+ directly, not only indirectly through AsyncMlb()."""
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+
+ assert (
+ "async def check_standalone_data_adapter_library_owned_lifecycle"
+ in source
+ )
+ assert "AsyncMlbDataAdapter()" in source
+ assert "adapter._owns_client is True" in source
+ assert "adapter._strict_http is True" in source
+ assert source.count("await adapter.aclose()") == 2
+ assert "check_standalone_data_adapter_library_owned_lifecycle()" in source
+
+
+def test_async_smoke_test_runs_against_the_installed_distribution() -> None:
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+
+ assert "sys.prefix != sys.base_prefix" in source
+ assert 'sysconfig.get_paths()["purelib"]' in source
+ assert "is_relative_to(site_packages)" in source
+ assert 'importlib.metadata.version("python-mlb-statsapi")' in source
+
+
+def test_async_smoke_test_makes_no_live_mlb_request() -> None:
+ source = validator.ASYNC_SMOKE_TEST_SOURCE
+
+ assert "httpx.get(" not in source
+ assert "httpx.request(" not in source
+ assert "httpx.MockTransport(forbidden_response)" in source
+ assert "never reaches the MLB API" in source
+
+
@pytest.mark.parametrize(
"symbol",
(
@@ -921,22 +1152,27 @@ def _matrix_python_versions() -> list[str]:
assert match is not None, "no python-version matrix found in the offline workflow"
return re.findall(r'- "([^"]+)"', match.group(1))
-
-def test_ci_watches_the_current_release_branch() -> None:
- """Pull requests and pushes must watch main and release/1.0.0.
-
- The trigger is asserted literally instead of being derived from the package
- version, which is still 0.9.0 until the release bump lands.
- """
+def test_ci_watches_main_and_release_branches() -> None:
+ """Pull requests and pushes must watch main and release branches."""
text = OFFLINE_WORKFLOW.read_text(encoding="utf-8")
- assert text.count(f"- {RELEASE_BRANCH}") == 2, text
+ assert text.count(f'- "{RELEASE_BRANCH_PATTERN}"') == 2, text
assert text.count("- main") == 2, text
- assert STALE_RELEASE_BRANCH not in text, (
- f"the stale {STALE_RELEASE_BRANCH} trigger must be removed"
- )
assert "workflow_dispatch:" in text
+def test_ci_matrix_installs_the_async_extra() -> None:
+ text = OFFLINE_WORKFLOW.read_text(encoding="utf-8")
+
+ assert "poetry install --no-interaction -E async" in text
+
+def test_ci_preserves_a_sync_only_installation_check() -> None:
+ text = OFFLINE_WORKFLOW.read_text(encoding="utf-8")
+
+ assert "sync-only:" in text
+ assert "poetry install --no-interaction --only main" in text
+ assert 'find_spec("httpx") is None' in text
+ assert "from mlbstatsapi import Mlb, MlbDataAdapter" in text
+
def test_ci_matrix_covers_every_supported_python_version() -> None:
assert _matrix_python_versions() == list(SUPPORTED_PYTHON_VERSIONS)
diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py
new file mode 100644
index 00000000..38540ce6
--- /dev/null
+++ b/tests/test_sync_async_parity.py
@@ -0,0 +1,1512 @@
+"""Sync/async behavioral parity tests (issue #304).
+
+`Mlb` is the compatibility baseline. These tests prove that `AsyncMlb`'s
+public endpoint behavior stays aligned with it: the same response produces the
+same request, the same model type, the same parsed values, and the same
+"nothing to return" answer.
+
+The scope is deliberately narrow. Detailed transport behavior — retries,
+timing, backoff, and transport-specific context — is already covered by
+tests/test_http_contract.py, tests/test_mlb_dataadapter.py and
+tests/test_async_mlb_dataadapter.py, and payload parsing by tests/parsers/.
+None of that is re-asserted here, and nothing compares Requests internals with
+HTTPX internals. Each test drives both public clients over an equivalent canned
+response or failure and compares only what a caller can see.
+
+These tests must not contact the live MLB API.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+from http import HTTPStatus
+from typing import Any
+from urllib.parse import parse_qsl, urlsplit
+
+import pytest
+import requests
+import requests_mock
+
+# The async client needs the optional HTTPX extra; without it there is no
+# async side to compare against, so the whole module is skipped rather than
+# failing a sync-only install at import time.
+httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)")
+
+from mlbstatsapi import ( # noqa: E402
+ AsyncMlb,
+ Mlb,
+ MlbDecodeError,
+ MlbHttpCompatibilityWarning,
+ MlbHttpError,
+ MlbTimeoutError,
+ MlbTransportError,
+)
+from mlbstatsapi.models.attendances import Attendance # noqa: E402
+from mlbstatsapi.models.awards import Award # noqa: E402
+from mlbstatsapi.models.divisions import Division # noqa: E402
+from mlbstatsapi.models.drafts import Round # noqa: E402
+from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402
+from mlbstatsapi.models.gamepace import GamePace # noqa: E402
+from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402
+from mlbstatsapi.models.leagues import League # noqa: E402
+from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402
+from mlbstatsapi.models.schedules import Schedule # noqa: E402
+from mlbstatsapi.models.seasons import Season # noqa: E402
+from mlbstatsapi.models.sports import Sport # noqa: E402
+from mlbstatsapi.models.standings import Standings # noqa: E402
+from mlbstatsapi.models.stats import Stat # noqa: E402
+from mlbstatsapi.models.teams import Team # noqa: E402
+from mlbstatsapi.models.venues import Venue # 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"}]
+}
+SPORT_PAYLOAD = {
+ "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}]
+}
+LEAGUE_PAYLOAD = {
+ "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}]
+}
+DIVISION_PAYLOAD = {
+ "divisions": [
+ {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"}
+ ]
+}
+ROSTER_PLAYER_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"},
+ "jerseyNumber": "12",
+ "status": {"code": "A", "description": "Active"},
+ "parentTeamId": 133,
+ }
+ ]
+}
+ROSTER_COACH_PAYLOAD = {
+ "roster": [
+ {
+ "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"},
+ "jerseyNumber": "7",
+ "job": "Manager",
+ "jobId": "MNGR",
+ "title": "Manager",
+ }
+ ]
+}
+SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]}
+VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]}
+STANDINGS_RECORD = {
+ "standingsType": "regularSeason",
+ "league": {"id": 103, "link": "/api/v1/league/103"},
+ "division": {"id": 201, "link": "/api/v1/divisions/201"},
+ "sport": {"id": 1, "link": "/api/v1/sports/1"},
+ "roundRobin": {"status": "false"},
+ "lastUpdated": "2025-10-16T23:15:55.082Z",
+ "teamRecords": [
+ {
+ "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"},
+ "season": "2022",
+ "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2},
+ "clinchIndicator": "y",
+ "divisionRank": "1",
+ "leagueRank": "2",
+ "sportRank": "5",
+ "gamesPlayed": 162,
+ "gamesBack": "-",
+ "wildCardGamesBack": "-",
+ "leagueGamesBack": "7.0",
+ "springLeagueGamesBack": "-",
+ "sportGamesBack": "7.0",
+ "divisionGamesBack": "-",
+ "conferenceGamesBack": "-",
+ "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"},
+ "lastUpdated": "2025-10-16T23:14:26Z",
+ "records": {
+ "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "divisionRecords": [
+ {
+ "wins": 17,
+ "losses": 16,
+ "pct": ".515",
+ "division": {
+ "id": 200,
+ "name": "American League West",
+ "link": "/api/v1/divisions/200",
+ },
+ }
+ ],
+ "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}],
+ "leagueRecords": [
+ {
+ "wins": 89,
+ "losses": 53,
+ "pct": ".627",
+ "league": {
+ "id": 103,
+ "name": "American League",
+ "link": "/api/v1/league/103",
+ },
+ }
+ ],
+ "expectedRecords": [
+ {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"}
+ ],
+ },
+ "runsAllowed": 567,
+ "runsScored": 807,
+ "divisionChamp": True,
+ "divisionLeader": True,
+ "hasWildcard": True,
+ "clinched": True,
+ "eliminationNumber": "-",
+ "eliminationNumberSport": "E",
+ "eliminationNumberLeague": "E",
+ "eliminationNumberDivision": "-",
+ "eliminationNumberConference": "E",
+ "wildCardEliminationNumber": "-",
+ "magicNumber": "-",
+ "wins": 99,
+ "losses": 63,
+ "runDifferential": 240,
+ "winningPercentage": ".611",
+ }
+ ],
+}
+STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]}
+ATTENDANCE_PAYLOAD = {
+ "records": [
+ {
+ "openingsTotal": 160,
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "gamesTotal": 162,
+ "gamesAwayTotal": 82,
+ "gamesHomeTotal": 80,
+ "year": "2022",
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ "gameType": {"id": "R", "description": "Regular Season"},
+ "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"},
+ }
+ ],
+ "aggregateTotals": {
+ "openingsTotalAway": 81,
+ "openingsTotalHome": 79,
+ "openingsTotalLost": 2,
+ "openingsTotalYtd": 0,
+ "attendanceAverageYtd": 18103,
+ "attendanceHigh": 40065,
+ "attendanceHighDate": "2022-08-06T00:00:00",
+ "attendanceTotal": 2896460,
+ "attendanceTotalAway": 2108558,
+ "attendanceTotalHome": 787902,
+ },
+}
+DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}}
+AWARD_PAYLOAD = {
+ "id": "ALMVP",
+ "name": "AL Most Valuable Player",
+ "date": "2022-11-17",
+ "season": "2022",
+ "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"},
+ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"},
+}
+AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]}
+HOMERUN_DERBY_PAYLOAD = {
+ "info": {
+ "id": 511101,
+ "nonGameGuid": "test-guid",
+ "name": "Home Run Derby",
+ "eventType": {"code": "O", "name": "Other"},
+ "eventDate": "2017-07-11T00:00:00Z",
+ "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"},
+ "isMultiDay": False,
+ "isPrimaryCalendar": True,
+ "fileCode": "2017/07/10/mlb-112",
+ "eventNumber": 103,
+ "publicFacing": True,
+ },
+ "status": {
+ "state": "Final",
+ "currentRound": 3,
+ "currentRoundTimeLeft": "0:00",
+ "inTieBreaker": False,
+ "tieBreakerNum": 0,
+ "clockStopped": True,
+ "bonusTime": False,
+ },
+}
+GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"}
+PLAY_PAYLOAD = {
+ "result": {
+ "type": "atBat",
+ "event": "Single",
+ "eventType": "single",
+ "description": "x",
+ "rbi": 0,
+ "awayScore": 0,
+ "homeScore": 0,
+ },
+ "about": {
+ "atBatIndex": 0,
+ "halfInning": "top",
+ "isTopInning": True,
+ "inning": 1,
+ "isComplete": True,
+ "isScoringPlay": False,
+ "hasOut": True,
+ "captivatingIndex": 0,
+ },
+ "count": {"balls": 0, "outs": 1, "strikes": 0},
+ "matchup": {
+ "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"},
+ "batSide": {"code": "R", "description": "Right"},
+ "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"},
+ "pitchHand": {"code": "R", "description": "Right"},
+ "batterHotColdZones": [],
+ "pitcherHotColdZones": [],
+ "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"},
+ },
+ "pitchIndex": [],
+ "actionIndex": [],
+ "runnerIndex": [],
+ "atBatIndex": 0,
+}
+PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]}
+GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}
+LINESCORE_PAYLOAD = {
+ "scheduledInnings": 9,
+ "teams": {"home": {}, "away": {}},
+ "defense": {"team": GAME_TEAM_PAYLOAD},
+ "offense": {"team": GAME_TEAM_PAYLOAD},
+}
+BOXSCORE_SIDE = {
+ "team": GAME_TEAM_PAYLOAD,
+ "teamStats": {},
+ "players": {},
+ "batters": [],
+ "pitchers": [],
+ "bench": [],
+ "bullpen": [],
+ "battingOrder": [],
+ "info": [],
+}
+BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}}
+SCHEDULE_WITH_GAMES_PAYLOAD = {
+ "dates": [
+ {"games": [{"gamePk": 1}, {"gamePk": 2}]},
+ {"games": [{"gamePk": 3}]},
+ ]
+}
+SCHEDULE_PAYLOAD = {
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "dates": [
+ {
+ "date": "2022-10-07",
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "games": [],
+ }
+ ],
+}
+
+# Every way a call legitimately comes back with nothing to parse, held as the
+# keyword arguments that produce it. Both clients are expected to answer None.
+NO_RESULT_RESPONSES = {
+ "empty 200": {"payload": {}},
+ "empty body": {"raw_body": b""},
+ "404": {"status": 404, "payload": {}},
+}
+# A schedule can also answer with a well-formed envelope holding no dates.
+SCHEDULE_NO_RESULT_RESPONSES = NO_RESULT_RESPONSES | {
+ "no dates": {
+ "payload": {
+ "totalItems": 0,
+ "totalEvents": 0,
+ "totalGames": 0,
+ "totalGamesInProgress": 0,
+ "dates": [],
+ }
+ },
+}
+
+SCHEDULED_GAMES_PAYLOAD = {
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "dates": [
+ {
+ "date": "2022-10-13",
+ "totalItems": 1,
+ "totalEvents": 0,
+ "totalGames": 1,
+ "totalGamesInProgress": 0,
+ "games": [
+ {
+ "gamePk": 715757,
+ "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc",
+ "link": "/api/v1.1/game/715757/feed/live",
+ "gameType": "D",
+ "season": "2022",
+ "gameDate": "2022-10-13T19:37:00Z",
+ "officialDate": "2022-10-13",
+ "status": {
+ "abstractGameState": "Final",
+ "codedGameState": "F",
+ "detailedState": "Final",
+ "statusCode": "F",
+ "startTimeTBD": False,
+ "abstractGameCode": "F",
+ },
+ "teams": {
+ "away": {
+ "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"},
+ "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"},
+ "score": 2,
+ "isWinner": False,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ "home": {
+ "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"},
+ "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"},
+ "score": 4,
+ "isWinner": True,
+ "splitSquad": False,
+ "seriesNumber": 1,
+ },
+ },
+ "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"},
+ "content": {"link": "/api/v1/game/715757/content"},
+ "isTie": False,
+ "gameNumber": 1,
+ "publicFacing": True,
+ "doubleHeader": "N",
+ "gamedayType": "P",
+ "tiebreaker": "N",
+ "calendarEventID": "14-715757-2022-10-13",
+ "seasonDisplay": "2022",
+ "dayNight": "day",
+ "description": "ALDS Game 2",
+ "scheduledInnings": 9,
+ "reverseHomeAwayStatus": False,
+ "inningBreakLength": 120,
+ "gamesInSeries": 5,
+ "seriesGameNumber": 2,
+ "seriesDescription": "AL Division Series",
+ "recordSource": "S",
+ "ifNecessary": "N",
+ "ifNecessaryDescription": "Normal Game",
+ }
+ ],
+ }
+ ],
+}
+
+GAMEPACE_PAYLOAD = {
+ "sports": [
+ {
+ "hitsPer9Inn": 16.68,
+ "runsPer9Inn": 9.3,
+ "pitchesPer9Inn": 299.83,
+ "totalGames": 2429,
+ "timePerGame": "03:11:26",
+ "season": "2021",
+ "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"},
+ }
+ ]
+}
+
+STATS_PAYLOAD = {
+ "stats": [
+ {
+ "type": {"displayName": "season"},
+ "group": {"displayName": "hitting"},
+ "totalSplits": 1,
+ "splits": [
+ {
+ "season": "2022",
+ "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"},
+ "team": {
+ "id": 108,
+ "name": "Los Angeles Angels",
+ "link": "/api/v1/teams/108",
+ },
+ "player": {
+ "id": 660271,
+ "fullName": "Shohei Ohtani",
+ "link": "/api/v1/people/660271",
+ },
+ }
+ ],
+ }
+ ]
+}
+
+# The canned transport failures, per client. Each pair is the closest
+# equivalent the two libraries offer, so the public exception is the only
+# thing being compared.
+SYNC_FAILURES = {
+ "timeout": requests.exceptions.Timeout("timed out"),
+ "transport": requests.exceptions.ConnectionError("connection refused"),
+}
+ASYNC_FAILURES = {
+ "timeout": lambda request: httpx.ReadTimeout("timed out", request=request),
+ "transport": lambda request: httpx.ConnectError(
+ "connection refused", request=request
+ ),
+}
+
+RequestSignature = tuple[str, str, dict[str, str]]
+
+
+def request_signature(method: str, url: str) -> RequestSignature:
+ """Normalize one observed request for transport-independent comparison."""
+ parsed_url = urlsplit(url)
+ return method, parsed_url.path, dict(parse_qsl(parsed_url.query))
+
+
+def call_sync(
+ method: str,
+ *args,
+ status: int = 200,
+ payload: dict | None = None,
+ raw_body: bytes | None = None,
+ failure: str | None = None,
+ mlb_options: dict | None = None,
+ observed: list[RequestSignature] | None = None,
+ **kwargs,
+):
+ """Call a method on `Mlb` against a canned response."""
+ adapter = requests_mock.Adapter()
+ if failure is not None:
+ adapter.register_uri("GET", requests_mock.ANY, exc=SYNC_FAILURES[failure])
+ else:
+ body = {"content": raw_body} if raw_body is not None else {"json": payload}
+ adapter.register_uri(
+ "GET",
+ requests_mock.ANY,
+ status_code=status,
+ reason=HTTPStatus(status).phrase,
+ **body,
+ )
+
+ session = requests.Session()
+ session.mount("https://", adapter)
+
+ try:
+ with Mlb(session=session, **(mlb_options or {})) as mlb:
+ return getattr(mlb, method)(*args, **kwargs)
+ finally:
+ if observed is not None:
+ observed.extend(
+ request_signature(request.method, request.url)
+ for request in adapter.request_history
+ )
+ # Mlb leaves a caller-injected session open, so closing it is this
+ # helper's job.
+ session.close()
+
+
+def call_async(
+ method: str,
+ *args,
+ status: int = 200,
+ payload: dict | None = None,
+ raw_body: bytes | None = None,
+ failure: str | None = None,
+ mlb_options: dict | None = None,
+ observed: list[RequestSignature] | None = None,
+ **kwargs,
+):
+ """Call the matching method on `AsyncMlb` against the same canned response."""
+ seen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ if failure is not None:
+ raise ASYNC_FAILURES[failure](request)
+ if raw_body is not None:
+ return httpx.Response(status, content=raw_body)
+ return httpx.Response(status, json=payload)
+
+ async def scenario():
+ client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ try:
+ async with AsyncMlb(client=client, **(mlb_options or {})) as mlb:
+ return await getattr(mlb, method)(*args, **kwargs)
+ finally:
+ # AsyncMlb leaves a caller-injected client open, as Mlb does above.
+ await client.aclose()
+
+ try:
+ return asyncio.run(scenario())
+ finally:
+ if observed is not None:
+ observed.extend(
+ request_signature(request.method, str(request.url))
+ for request in seen
+ )
+
+
+@dataclass(frozen=True)
+class ParityResult:
+ """What each client returned, plus the one request they both sent."""
+
+ sync: Any
+ asynchronous: Any
+ request: RequestSignature
+
+
+def call_both(method: str, *args, **kwargs) -> ParityResult:
+ """Drive both public clients over one canned response.
+
+ Each client is handed an equivalent response through its own public
+ constructor, so a failure below names the side that drifted. Request
+ parity is asserted here rather than per test, which also rules out a
+ client that quietly fanned one call out into several.
+ """
+ sync_requests: list[RequestSignature] = []
+ async_requests: list[RequestSignature] = []
+
+ sync_result = call_sync(method, *args, observed=sync_requests, **kwargs)
+ async_result = call_async(method, *args, observed=async_requests, **kwargs)
+
+ assert len(sync_requests) == 1, f"sync sent {len(sync_requests)} requests"
+ assert async_requests == sync_requests, "the clients sent different requests"
+
+ return ParityResult(sync_result, async_result, sync_requests[0])
+
+
+def raise_both(expected: type[BaseException], method: str, *args, **kwargs):
+ """Return the exception each client raised for one canned failure."""
+ with pytest.raises(expected) as sync_exc:
+ call_sync(method, *args, **kwargs)
+ with pytest.raises(expected) as async_exc:
+ call_async(method, *args, **kwargs)
+
+ # pytest.raises accepts subclasses, so pin the exact type on both sides:
+ # MlbTimeoutError is itself an MlbTransportError.
+ assert type(sync_exc.value) is type(async_exc.value) is expected
+
+ return sync_exc.value, async_exc.value
+
+
+# ---------------------------------------------------------------------------
+# Successful responses
+# ---------------------------------------------------------------------------
+
+
+def test_get_team_success_parity():
+ """A successful team response parses to the same Team on both clients."""
+ result = call_both("get_team", 133, payload=TEAM_PAYLOAD)
+
+ assert isinstance(result.sync, Team), "sync get_team did not return a Team"
+ assert (result.sync.id, result.sync.link, result.sync.name) == (
+ 133,
+ "/api/v1/teams/133",
+ "Athletics",
+ )
+ # Pydantic equality compares the model class too, so this pins the async
+ # return type as well as every parsed field.
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/teams/133", {})
+
+
+def test_get_person_success_parity():
+ """A successful person response parses to the same Person on both clients."""
+ result = call_both("get_person", 660271, payload=PERSON_PAYLOAD)
+
+ assert isinstance(result.sync, Person), "sync get_person did not return a Person"
+ assert (result.sync.id, result.sync.link, result.sync.full_name) == (
+ 660271,
+ "/api/v1/people/660271",
+ "Shohei Ohtani",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/people/660271", {})
+
+
+def test_get_schedule_success_parity():
+ """A successful date schedule parses identically and sends the same request."""
+ result = call_both("get_schedule", date="2022-10-07", payload=SCHEDULE_PAYLOAD)
+
+ assert isinstance(result.sync, Schedule), "sync get_schedule did not return a Schedule"
+ assert (
+ result.sync.total_items,
+ result.sync.total_games,
+ result.sync.dates[0].date,
+ result.sync.dates[0].total_games,
+ ) == (1, 1, "2022-10-07", 1)
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/schedule",
+ {"date": "2022-10-07", "sportId": "1"},
+ )
+
+
+def test_get_schedule_range_team_and_sport_request_parity():
+ """A date range, team, and non-default sport produce equivalent requests."""
+ result = call_both(
+ "get_schedule",
+ start_date="2022-10-07",
+ end_date="2022-10-09",
+ team_id=133,
+ sport_id=11,
+ payload=SCHEDULE_PAYLOAD,
+ )
+
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/schedule",
+ {
+ "startDate": "2022-10-07",
+ "endDate": "2022-10-09",
+ "teamId": "133",
+ "sportId": "11",
+ },
+ )
+
+
+def test_get_sport_success_parity():
+ """A successful sport response parses to the same Sport on both clients."""
+ result = call_both("get_sport", 1, payload=SPORT_PAYLOAD)
+
+ assert isinstance(result.sync, Sport), "sync get_sport did not return a Sport"
+ assert (result.sync.id, result.sync.link, result.sync.name) == (
+ 1,
+ "/api/v1/sports/1",
+ "Major League Baseball",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/sports/1", {})
+
+
+def test_get_league_success_parity():
+ """A successful league response parses to the same League on both clients."""
+ result = call_both("get_league", 103, payload=LEAGUE_PAYLOAD)
+
+ assert isinstance(result.sync, League), "sync get_league did not return a League"
+ assert (result.sync.id, result.sync.link, result.sync.name) == (
+ 103,
+ "/api/v1/leagues/103",
+ "American League",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/leagues/103", {})
+
+
+def test_get_division_success_parity():
+ """A successful division response parses to the same Division on both clients."""
+ result = call_both("get_division", 200, payload=DIVISION_PAYLOAD)
+
+ assert isinstance(result.sync, Division), "sync get_division did not return a Division"
+ assert (result.sync.id, result.sync.link, result.sync.name) == (
+ 200,
+ "/api/v1/divisions/200",
+ "American League West",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/divisions/200", {})
+
+
+def test_get_team_roster_success_parity():
+ """A successful roster response parses to the same Players on both clients."""
+ result = call_both("get_team_roster", 133, payload=ROSTER_PLAYER_PAYLOAD)
+
+ assert isinstance(result.sync, list) and isinstance(result.sync[0], Player), (
+ "sync get_team_roster did not return a list of Player"
+ )
+ assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].jersey_number) == (
+ 675961,
+ "Alika Williams",
+ "12",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/teams/133/roster", {})
+
+
+def test_get_team_coaches_success_parity():
+ """A successful coaches response parses to the same Coaches on both clients."""
+ result = call_both("get_team_coaches", 133, payload=ROSTER_COACH_PAYLOAD)
+
+ assert isinstance(result.sync, list) and isinstance(result.sync[0], Coach), (
+ "sync get_team_coaches did not return a list of Coach"
+ )
+ assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].job) == (
+ 117276,
+ "Mark Kotsay",
+ "Manager",
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/teams/133/coaches", {})
+
+
+def test_get_season_success_parity():
+ """A successful season response parses to the same Season on both clients."""
+ result = call_both("get_season", "2021", payload=SEASON_PAYLOAD)
+
+ assert isinstance(result.sync, Season), "sync get_season did not return a Season"
+ assert (result.sync.season_id, result.sync.has_wildcard) == ("2021", True)
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/seasons/2021", {"sportId": "1"})
+
+
+def test_get_venue_success_parity():
+ """A successful venue response parses to the same Venue on both clients."""
+ result = call_both("get_venue", 31, payload=VENUE_PAYLOAD)
+
+ assert isinstance(result.sync, Venue), "sync get_venue did not return a Venue"
+ assert (result.sync.id, result.sync.link, result.sync.name) == (
+ 31,
+ "/api/v1/venues/31",
+ "PNC Park",
+ )
+ assert result.asynchronous == result.sync
+ # hydrate is sent as a repeated query param (?hydrate=a&hydrate=b&...);
+ # request_signature's dict(parse_qsl(...)) keeps only the last value, so
+ # this only proves the two clients agree, not the full query string.
+ assert result.request == ("GET", "/api/v1/venues/31", {"hydrate": "timezone"})
+
+
+def test_get_standings_success_parity():
+ """A successful standings response parses to the same Standings on both clients."""
+ result = call_both("get_standings", 103, "2022", payload=STANDINGS_PAYLOAD)
+
+ assert isinstance(result.sync, list) and isinstance(result.sync[0], Standings), (
+ "sync get_standings did not return a list of Standings"
+ )
+ assert result.sync[0].team_records[0].team.name == "Yankees"
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/standings", {"leagueId": "103", "season": "2022"})
+
+
+def test_get_attendance_success_parity():
+ """A successful attendance response parses to the same Attendance on both clients."""
+ result = call_both("get_attendance", team_id=133, payload=ATTENDANCE_PAYLOAD)
+
+ assert isinstance(result.sync, Attendance), "sync get_attendance did not return an Attendance"
+ assert result.sync.aggregate_totals.attendance_total == 2896460
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/attendance", {"teamId": "133"})
+
+
+def test_get_draft_success_parity():
+ """A successful draft response parses to the same Round list on both clients."""
+ result = call_both("get_draft", 2019, payload=DRAFT_PAYLOAD)
+
+ assert result.sync == [Round(round="1")], "sync get_draft did not return the round"
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/draft/2019", {})
+
+
+def test_get_awards_success_parity():
+ """A successful awards response parses to the same Award list on both clients."""
+ result = call_both("get_awards", "ALMVP", payload=AWARDS_PAYLOAD)
+
+ assert result.sync == [Award(**AWARD_PAYLOAD)], "sync get_awards did not return the award"
+ assert result.asynchronous == result.sync
+ # The endpoint string has a trailing "?"; both clients strip it as an
+ # empty query separator, so it never appears in the request path.
+ assert result.request == ("GET", "/api/v1/awards/ALMVP/recipients", {})
+
+
+def test_get_homerun_derby_success_parity():
+ """A successful homerun derby response parses to the same object on both clients."""
+ result = call_both("get_homerun_derby", 511101, payload=HOMERUN_DERBY_PAYLOAD)
+
+ assert isinstance(result.sync, HomeRunDerby), (
+ "sync get_homerun_derby did not return a HomeRunDerby"
+ )
+ assert result.sync.status.state == "Final"
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {})
+
+
+def test_get_stats_success_parity():
+ """A successful stats response parses to the same split mapping on both clients."""
+ result = call_both("get_stats", ["season"], ["hitting"], payload=STATS_PAYLOAD)
+
+ assert list(result.sync) == ["hitting"], "sync get_stats did not key by group"
+ assert isinstance(result.sync["hitting"]["season"], Stat)
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/stats",
+ {"stats": "season", "group": "hitting"},
+ )
+
+
+def test_get_player_stats_success_parity():
+ """A successful player stats response parses the same on both clients."""
+ result = call_both(
+ "get_player_stats", 660271, ["season"], ["hitting"], payload=STATS_PAYLOAD
+ )
+
+ assert isinstance(result.sync["hitting"]["season"], Stat)
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/people/660271/stats",
+ {"stats": "season", "group": "hitting"},
+ )
+
+
+def test_get_team_stats_success_parity():
+ """A successful team stats response parses the same on both clients."""
+ result = call_both(
+ "get_team_stats", 133, ["season"], ["hitting"], payload=STATS_PAYLOAD
+ )
+
+ assert isinstance(result.sync["hitting"]["season"], Stat)
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/teams/133/stats",
+ {"stats": "season", "group": "hitting"},
+ )
+
+
+def test_get_players_stats_for_game_success_parity():
+ """A successful per-game stats response parses the same on both clients."""
+ result = call_both(
+ "get_players_stats_for_game", 660271, 715757, payload=STATS_PAYLOAD
+ )
+
+ assert isinstance(result.sync["hitting"]["season"], Stat)
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/people/660271/stats/game/715757",
+ {},
+ )
+
+
+def test_get_players_stats_for_game_forwards_params_on_both_clients():
+ """Regression coverage: **params used to be accepted and silently dropped.
+
+ ``get_players_stats_for_game`` advertises ``**params`` but never passed
+ ``ep_params`` to the adapter, so every caller-supplied keyword vanished
+ before the request was built. Both clients now forward them.
+ """
+ result = call_both(
+ "get_players_stats_for_game",
+ 660271,
+ 715757,
+ eventType="single",
+ payload=STATS_PAYLOAD,
+ )
+
+ assert result.request == (
+ "GET",
+ "/api/v1/people/660271/stats/game/715757",
+ {"eventType": "single"},
+ )
+
+
+def test_get_persons_success_parity():
+ """A successful people response parses to the same Person list on both clients."""
+ result = call_both("get_persons", "660271", payload=PERSON_PAYLOAD)
+
+ assert result.sync == [Person(**PERSON_PAYLOAD["people"][0])]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/people", {"personIds": "660271"})
+
+
+def test_get_scheduled_games_by_date_success_parity():
+ """A successful schedule response parses to the same game list on both clients."""
+ result = call_both(
+ "get_scheduled_games_by_date", "2022-10-13", payload=SCHEDULED_GAMES_PAYLOAD
+ )
+
+ assert [game.game_pk for game in result.sync] == [715757]
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/schedule",
+ {"date": "2022-10-13", "sportId": "1"},
+ )
+
+
+def test_get_gamepace_success_parity():
+ """A successful gamePace response parses to the same GamePace on both clients.
+
+ The season is the part that matters here. Mlb embeds it in the endpoint
+ string and relies on Requests merging that query with ep_params; HTTPX
+ replaces rather than merges, so AsyncMlb passes it as a param instead.
+ Asserting one shared request signature pins that the two routes converge.
+ """
+ result = call_both("get_gamepace", "2021", payload=GAMEPACE_PAYLOAD)
+
+ assert isinstance(result.sync, GamePace), "sync get_gamepace did not return a GamePace"
+ assert result.sync.sports[0].season == "2021"
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/gamePace",
+ {"season": "2021", "sportId": "1"},
+ )
+
+
+def test_get_team_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_team_id", "Athletics", payload={"teams": [{"id": 133, "name": "Athletics"}]}
+ )
+
+ assert result.sync == [133]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/teams", {"fields": "teams,id,name"})
+
+
+def test_get_people_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_people_id",
+ "Ty France",
+ payload={"people": [{"id": 664034, "fullName": "Ty France"}]},
+ )
+
+ assert result.sync == [664034]
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/sports/1/players",
+ {"fields": "people,id,fullName"},
+ )
+
+
+def test_get_sport_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_sport_id",
+ "Major League Baseball",
+ payload={"sports": [{"id": 1, "name": "Major League Baseball"}]},
+ )
+
+ assert result.sync == [1]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/sports", {})
+
+
+def test_get_league_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_league_id",
+ "American League",
+ payload={"leagues": [{"id": 103, "name": "American League"}]},
+ )
+
+ assert result.sync == [103]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/leagues", {"fields": "leagues,id,name"})
+
+
+def test_get_division_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_division_id",
+ "American League West",
+ payload={"divisions": [{"id": 200, "name": "American League West"}]},
+ )
+
+ assert result.sync == [200]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/divisions", {})
+
+
+def test_get_venue_id_success_parity():
+ """A matching name is resolved to the same id list on both clients."""
+ result = call_both(
+ "get_venue_id", "PNC Park", payload={"venues": [{"id": 31, "name": "PNC Park"}]}
+ )
+
+ assert result.sync == [31]
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/venues", {})
+
+
+def test_get_game_success_parity():
+ """A successful game feed response parses to the same Game on both clients,
+ hitting the v1.1 endpoint on both."""
+ result = call_both("get_game", 717911, payload=GAME_FEED_PAYLOAD)
+
+ assert isinstance(result.sync, Game), "sync get_game did not return a Game"
+ assert result.sync.id == 717911
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1.1/game/717911/feed/live", {})
+
+
+def test_get_game_play_by_play_success_parity():
+ """A successful play-by-play response parses to the same Plays on both clients."""
+ result = call_both("get_game_play_by_play", 717911, payload=PLAYS_PAYLOAD)
+
+ assert isinstance(result.sync, Plays), (
+ "sync get_game_play_by_play did not return a Plays"
+ )
+ assert len(result.sync.all_plays) == 1
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/game/717911/playByPlay", {})
+
+
+def test_get_game_line_score_success_parity():
+ """A successful linescore response parses to the same Linescore on both clients."""
+ result = call_both("get_game_line_score", 717911, payload=LINESCORE_PAYLOAD)
+
+ assert isinstance(result.sync, Linescore), (
+ "sync get_game_line_score did not return a Linescore"
+ )
+ assert result.sync.scheduled_innings == 9
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/game/717911/linescore", {})
+
+
+def test_get_game_box_score_success_parity():
+ """A successful boxscore response parses to the same BoxScore on both clients."""
+ result = call_both("get_game_box_score", 717911, payload=BOXSCORE_PAYLOAD)
+
+ assert isinstance(result.sync, BoxScore), (
+ "sync get_game_box_score did not return a BoxScore"
+ )
+ assert result.asynchronous == result.sync
+ assert result.request == ("GET", "/api/v1/game/717911/boxscore", {})
+
+
+def test_get_game_ids_success_parity():
+ """A successful schedule response resolves to the same gamePk list on both clients."""
+ result = call_both("get_game_ids", date="2022-09-26", payload=SCHEDULE_WITH_GAMES_PAYLOAD)
+
+ assert result.sync == [1, 2, 3]
+ assert result.asynchronous == result.sync
+ assert result.request == (
+ "GET",
+ "/api/v1/schedule",
+ {"date": "2022-09-26", "sportId": "1"},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Nothing to return
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_team_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_team", 133, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_team returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_team returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_person_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_person", 660271, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_person returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_person returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(SCHEDULE_NO_RESULT_RESPONSES))
+def test_get_schedule_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both(
+ "get_schedule", date="2022-10-07", **SCHEDULE_NO_RESULT_RESPONSES[label]
+ )
+
+ assert result.sync is None, f"sync get_schedule returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_schedule returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_sport_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_sport", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_sport returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_sport returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_league_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_league", 103, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_league returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_league returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_division_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_division", 200, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_division returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_division returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_team_roster_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_team_roster", 133, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_team_roster returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_team_roster returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_team_coaches_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_team_coaches", 133, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_team_coaches returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_team_coaches returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_season_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_season", "2021", **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_season returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_season returned {result.asynchronous!r} for {label}"
+ )
+
+
+def test_get_venue_no_result_parity_404():
+ """404 hits Mlb.get_venue's documented quirk: [] rather than None."""
+ result = call_both("get_venue", 1, status=404, payload={})
+
+ assert result.sync == [], f"sync get_venue returned {result.sync!r} for 404"
+ assert result.asynchronous == [], (
+ f"async get_venue returned {result.asynchronous!r} for 404"
+ )
+
+
+@pytest.mark.parametrize("label", ["empty 200", "empty body"])
+def test_get_venue_no_result_parity_non_4xx(label):
+ """Unlike the 404 quirk, a non-4xx empty response falls through to None."""
+ result = call_both("get_venue", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_venue returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_venue returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_standings_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_standings", 103, "2022", **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_standings returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_standings returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_attendance_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_attendance", team_id=133, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_attendance returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_attendance returned {result.asynchronous!r} for {label}"
+ )
+
+
+def test_get_attendance_without_an_identifier_parity():
+ """Regression coverage: the any(dict) vs any(dict.values()) guard bug fix."""
+ sync_requests: list = []
+ async_requests: list = []
+
+ sync_result = call_sync("get_attendance", observed=sync_requests)
+ async_result = call_async("get_attendance", observed=async_requests)
+
+ assert sync_result is None
+ assert async_result is None
+ assert sync_requests == []
+ assert async_requests == []
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_draft_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_draft", 2019, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_draft returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_draft returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_awards_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_awards", "ALMVP", **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_awards returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_awards returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_homerun_derby_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_homerun_derby", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_homerun_derby returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_homerun_derby returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize(
+ "method, args",
+ [
+ ("get_stats", (["season"], ["hitting"])),
+ ("get_player_stats", (660271, ["season"], ["hitting"])),
+ ("get_team_stats", (133, ["season"], ["hitting"])),
+ ("get_players_stats_for_game", (660271, 715757)),
+ ],
+)
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_stat_endpoint_no_result_parity(method, args, label):
+ """Every no-result response returns an empty mapping on either client."""
+ result = call_both(method, *args, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == {}, f"sync {method} returned {result.sync!r} for {label}"
+ assert result.asynchronous == {}, (
+ f"async {method} returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_persons_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both("get_persons", "1", **NO_RESULT_RESPONSES[label])
+
+ assert result.sync == [], f"sync get_persons returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_persons returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_scheduled_games_by_date_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both(
+ "get_scheduled_games_by_date", "2022-10-13", **NO_RESULT_RESPONSES[label]
+ )
+
+ assert result.sync == [], (
+ f"sync get_scheduled_games_by_date returned {result.sync!r} for {label}"
+ )
+ assert result.asynchronous == [], (
+ f"async get_scheduled_games_by_date returned {result.asynchronous!r} for {label}"
+ )
+
+
+def test_get_scheduled_games_by_date_without_a_selector_parity():
+ """Both clients return None -- not [] -- when nothing selects a date.
+
+ The annotation promises list[ScheduleGames]. Mlb returns a bare None here
+ and AsyncMlb preserves that rather than quietly correcting it, so the two
+ stay interchangeable.
+ """
+ assert call_sync("get_scheduled_games_by_date") is None
+ assert call_async("get_scheduled_games_by_date") is None
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_gamepace_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_gamepace", "2021", **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_gamepace returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_gamepace returned {result.asynchronous!r} for {label}"
+ )
+
+
+def test_get_homerun_derby_malformed_error_body_parity():
+ """Regression coverage: the bare-None-instead-of-return-None bug fix.
+
+ A 4xx body with a truthy "status" key must not reach HomeRunDerby(**data)
+ and raise on either client, now that the guard actually returns.
+ """
+ result = call_both(
+ "get_homerun_derby", 1, status=404, payload={"status": "error"}
+ )
+
+ assert result.sync is None
+ assert result.asynchronous is None
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_no_result_parity(label):
+ """Every no-result response returns None on either client (v1.1 endpoint)."""
+ result = call_both("get_game", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, f"sync get_game returned {result.sync!r} for {label}"
+ assert result.asynchronous is None, (
+ f"async get_game returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_play_by_play_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_game_play_by_play", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, (
+ f"sync get_game_play_by_play returned {result.sync!r} for {label}"
+ )
+ assert result.asynchronous is None, (
+ f"async get_game_play_by_play returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_line_score_no_result_parity(label):
+ """Every no-result response returns None on either client, even without
+ get_game_line_score's missing 400-499 guard (documented quirk)."""
+ result = call_both("get_game_line_score", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, (
+ f"sync get_game_line_score returned {result.sync!r} for {label}"
+ )
+ assert result.asynchronous is None, (
+ f"async get_game_line_score returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_box_score_no_result_parity(label):
+ """Every no-result response returns None on either client."""
+ result = call_both("get_game_box_score", 1, **NO_RESULT_RESPONSES[label])
+
+ assert result.sync is None, (
+ f"sync get_game_box_score returned {result.sync!r} for {label}"
+ )
+ assert result.asynchronous is None, (
+ f"async get_game_box_score returned {result.asynchronous!r} for {label}"
+ )
+
+
+@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES))
+def test_get_game_ids_no_result_parity(label):
+ """Every no-result response returns an empty list on either client."""
+ result = call_both(
+ "get_game_ids", date="2022-09-26", **NO_RESULT_RESPONSES[label]
+ )
+
+ assert result.sync == [], f"sync get_game_ids returned {result.sync!r} for {label}"
+ assert result.asynchronous == [], (
+ f"async get_game_ids returned {result.asynchronous!r} for {label}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Representative public failure behavior (get_team)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "status, reason",
+ [
+ # A final non-404 4xx under the strict_http default, and one
+ # representative 5xx.
+ (403, "Forbidden"),
+ (500, "Internal Server Error"),
+ ],
+)
+def test_get_team_http_error_parity(status, reason):
+ """Both clients expose the same stable public HTTP error context."""
+ payload = {"message": "no"}
+
+ sync_error, async_error = raise_both(
+ MlbHttpError, "get_team", 133, status=status, payload=payload
+ )
+
+ expected = (
+ status,
+ reason,
+ "GET",
+ "https://statsapi.mlb.com/api/v1/teams/133",
+ payload,
+ )
+ attributes = ("status_code", "reason", "method", "url", "response_data")
+ assert tuple(getattr(sync_error, name) for name in attributes) == expected
+ assert tuple(getattr(async_error, name) for name in attributes) == expected
+
+
+def test_get_team_compatibility_client_error_parity():
+ """Compatibility mode warns and returns None on both public clients."""
+ response = {
+ "status": 403,
+ "payload": {"message": "access denied"},
+ "mlb_options": {"strict_http": False},
+ }
+
+ with pytest.warns(MlbHttpCompatibilityWarning) as sync_warnings:
+ sync_team = call_sync("get_team", 133, **response)
+ with pytest.warns(MlbHttpCompatibilityWarning) as async_warnings:
+ async_team = call_async("get_team", 133, **response)
+
+ assert sync_team is None
+ assert async_team is None
+ assert len(sync_warnings) == len(async_warnings) == 1
+
+
+@pytest.mark.parametrize(
+ "failure, expected",
+ [
+ ("timeout", MlbTimeoutError),
+ ("transport", MlbTransportError),
+ ],
+)
+def test_get_team_transport_failure_parity(failure, expected):
+ """A deterministic transport failure raises the same exception on both."""
+ raise_both(expected, "get_team", 133, failure=failure)
+
+
+def test_get_team_invalid_json_parity():
+ """Invalid JSON in a successful response raises on both public clients."""
+ raise_both(MlbDecodeError, "get_team", 133, raw_body=b'{"teams": [')