diff --git a/.github/workflows/build-and-test-mlbstatsapi-prd.yml b/.github/workflows/build-and-test-mlbstatsapi-prd.yml deleted file mode 100644 index eb88028..0000000 --- a/.github/workflows/build-and-test-mlbstatsapi-prd.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Python Build MLBstats API - -on: - push: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install Poetry - uses: snok/install-poetry@v1 - with: - virtualenvs-create: true - virtualenvs-in-project: true - - name: Install dependencies - run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ - - name: Build package - run: poetry build - - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/build-and-test-mlbstatsapi-test.yml b/.github/workflows/build-and-test-mlbstatsapi-test.yml deleted file mode 100644 index 0421b4e..0000000 --- a/.github/workflows/build-and-test-mlbstatsapi-test.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build - -on: - push: - branches: - - development - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install Poetry - uses: snok/install-poetry@v1 - with: - virtualenvs-create: true - virtualenvs-in-project: true - - name: Install dependencies - run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ - - name: Build package - run: poetry build - - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c5d1fe0..0cd4bcd 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,17 +1,33 @@ -name: Python Build MLBstats API +name: Offline CI on: + pull_request: + branches: + - main + - release/0.8.0 push: - branches-ignore: - - 'main' - - 'development' + branches: + - main + - release/0.8.0 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + offline-tests: + name: Offline tests - Python ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: + - "3.10" + - "3.11" + - "3.12" steps: - uses: actions/checkout@v4 @@ -26,7 +42,29 @@ jobs: virtualenvs-in-project: true - name: Install dependencies run: poetry install --no-interaction - - name: Test external tests with pytest - run: poetry run pytest tests/external_tests/ + - name: Run offline tests + run: | + poetry run pytest \ + tests/ \ + --ignore=tests/external_tests + + build-package: + name: Build package + needs: offline-tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + - name: Install dependencies + run: poetry install --no-interaction - name: Build package run: poetry build diff --git a/.github/workflows/external-tests.yml b/.github/workflows/external-tests.yml new file mode 100644 index 0000000..cc76258 --- /dev/null +++ b/.github/workflows/external-tests.yml @@ -0,0 +1,34 @@ +name: External MLB API Tests + +on: + workflow_dispatch: + schedule: + - cron: "0 12 * * 1" + +permissions: + contents: read + +jobs: + external-tests: + name: External MLB API tests - Python 3.12 + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + - name: Install dependencies + run: poetry install --no-interaction + - name: Run external MLB API tests + run: | + poetry run pytest \ + tests/external_tests/ \ + -v diff --git a/README.md b/README.md index acfbc4f..0d4998a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **The Unofficial Python Wrapper for the MLB Stats API** [![PyPI version](https://badge.fury.io/py/python-mlb-statsapi.svg)](https://badge.fury.io/py/python-mlb-statsapi) -![Development Branch Status](https://github.com/zero-sum-seattle/python-mlb-statsapi/actions/workflows/build-and-test-mlbstatsapi-test.yml/badge.svg?event=push) +[![Offline CI](https://github.com/zero-sum-seattle/python-mlb-statsapi/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/zero-sum-seattle/python-mlb-statsapi/actions/workflows/build-and-test.yml) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) @@ -65,6 +65,181 @@ Ty France Seattle Mariners Seattle ``` +## HTTP Sessions, Timeouts, and Retries + +Version 0.8.0 adds shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. 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. + +### Recommended context-manager usage + +Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: + +```python +import mlbstatsapi + +with mlbstatsapi.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. + +### Existing construction remains valid + +Existing construction continues to work: + +```python +import mlbstatsapi + +mlb = mlbstatsapi.Mlb() +player = mlb.get_person(664034) +``` + +Callers who do not use a context manager may call `mlb.close()`. Repeated `close()` calls are safe. + +### Custom timeouts + +Every request uses an explicit timeout. The defaults are: + +```text +Connection timeout: 3.05 seconds +Read timeout: 30 seconds +``` + +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: + +```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) +``` + +```text +5.0 seconds: connection timeout +60.0 seconds: read timeout +``` + +### Injecting a custom Session + +Advanced callers may inject a caller-owned Session: + +```python +import requests +import mlbstatsapi + +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() +``` + +Ownership rules: + +```text +Library-created Session + The library owns and closes it +Caller-injected Session + The caller owns and closes it +``` + +`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 on an injected Session. Callers control custom retry, TLS, proxy, and adapter configuration. + +### Structured exception handling + +```python +import mlbstatsapi + +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.status_code, + exc.reason, + exc.url, + ) +except mlbstatsapi.MlbDecodeError: + print("The MLB API returned invalid JSON") +``` + +* `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 + +### Backward-compatible exception handling + +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 +``` + +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. + +### Existing 404 compatibility + +Version 0.8.0 preserves existing endpoint-specific not-found behavior. Depending on the endpoint, a 404 may still produce: + +```text +None +[] +{} +``` + +Not every 404 raises `MlbHttpError`. + +See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, cleanup behavior, and exception hierarchy. + ## Working with Pydantic Models All returned objects are Pydantic models, giving you access to powerful serialization and validation features. @@ -180,17 +355,35 @@ Contributions are welcome! Whether it's bug fixes, new features, or documentatio ### Development +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 -# Run tests -poetry run pytest +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: -# Run external tests (requires internet) -poetry run pytest tests/external_tests/ +```bash +poetry run pytest tests/ +poetry build ``` +Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. + ### Pull Request Guidelines -- **All tests must pass** before submitting a PR +- 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 @@ -207,14 +400,6 @@ Found a bug or have a feature request? Please [open an issue](https://github.com - Expected vs actual behavior - Python version and package version -### Note on External Tests - -Some tests make real API calls to the MLB Stats API. These may occasionally fail due to: -- API changes (new fields, removed endpoints) -- Season/data availability - -If you notice external test failures, please check if the MLB API has changed and update the models accordingly. - ## Examples diff --git a/RELEASE_NOTES_v0.7.1.md b/RELEASE_NOTES_v0.7.1.md deleted file mode 100644 index 18c2de6..0000000 --- a/RELEASE_NOTES_v0.7.1.md +++ /dev/null @@ -1,122 +0,0 @@ -# Release Notes v0.7.1 - -## 🎉 Major Changes - -### Removed Key Transformation Layer -Removed the `_transform_keys_in_data()` function that was lowercasing all API response keys. Models now directly use the MLB Stats API's native `camelCase` format, simplifying the codebase and ensuring accurate representation of API responses. - -### Complete Pydantic Migration -This release includes the complete migration from Python dataclasses to Pydantic v2 models across the entire codebase (from v0.7.0). All models now use Pydantic for validation, serialization, and data handling. - -**Key Benefits:** -- Automatic data validation with clear error messages -- Built-in serialization with `model_dump()` and `model_dump_json()` -- Pythonic `snake_case` field names while maintaining API `camelCase` compatibility -- Better type safety and IDE support - -### Removed Mock Tests -All mock tests and associated JSON fixtures have been removed (22,370+ lines). The test suite now focuses exclusively on external tests that validate against the actual MLB Stats API. - -## 📝 Breaking Changes - -⚠️ **This is a major version release with breaking changes:** - -1. **Field Access**: All model fields now use `snake_case` instead of `camelCase`: - ```python - # Before - game.gamedata.weather - - # After - game.game_data.weather - ``` - -2. **Validation Errors**: Invalid data now raises `pydantic.ValidationError` instead of `TypeError` - -3. **Serialization**: Use Pydantic methods for serialization: - ```python - # New methods - model.model_dump() - model.model_dump_json() - ``` - -## 🔧 Model Updates - -### Core Models -- All models migrated to Pydantic `MLBBaseModel` -- Field aliases updated to match actual API `camelCase` format -- Optional fields properly marked for fields that may be missing - -### Specific Model Fixes -- **Game Models**: `gamePk`, `gameData`, `liveData`, `metaData` aliases corrected -- **HomeRunDerby**: `homeRun`, `tieBreaker`, `isHomeRun`, `isTieBreaker` aliases fixed -- **Stats Models**: `wobaCon`, `pitchArsenal` aliases corrected -- **Schedule Models**: `calendarEventId` made optional -- **Standings Models**: `wildcardGamesBack`, `wildcardEliminationNumber` made optional - -## 🐛 Bug Fixes - -- Fixed missing fields in various stat models -- Added `age` and `caughtstealingpercentage` to `SimplePitchingSplit` -- Fixed `flyballpercentage` field in `AdvancedPitchingSplit` -- Updated models for new MLB API fields -- Fixed missing keys in live feed data - -## 📚 Documentation - -- Updated README with Pydantic migration examples -- Added "Working with Pydantic Models" section -- Added Contributing section to README -- Updated all code examples to use `snake_case` field names - -## 🔄 Migration Guide - -If you're upgrading from v0.5.x or v0.6.x: - -1. **Update field access**: Change all `camelCase` field names to `snake_case` - ```python - # Old - game.gamedata.weather - stat.totalsplits - - # New - game.game_data.weather - stat.total_splits - ``` - -2. **Update error handling**: Catch `ValidationError` instead of `TypeError` - ```python - from pydantic import ValidationError - - try: - game = Game(**data) - except ValidationError as e: - # Handle validation error - ``` - -3. **Use Pydantic serialization**: Replace `__dict__` access with `model_dump()` - ```python - # Old - data = game.__dict__ - - # New - data = game.model_dump() - ``` - -## 📦 Dependencies - -- Added `pydantic>=2.0` as a core dependency -- Migrated from setuptools to Poetry for dependency management - -## 🧪 Testing - -- Removed all mock tests (22,370+ lines) -- External tests updated and passing -- CI/CD pipelines updated to remove mock test runs - -## 🙏 Contributors - -Thank you to all contributors who helped with this major release! - ---- - -**Full Changelog**: See commit history from v0.5.3 to HEAD diff --git a/docs/http-transport.md b/docs/http-transport.md new file mode 100644 index 0000000..9150f64 --- /dev/null +++ b/docs/http-transport.md @@ -0,0 +1,277 @@ +# HTTP Transport + +This document describes the HTTP transport behavior introduced in version 0.8.0. + +The public client remains synchronous. Ordinary usage does not need to configure sessions or retries. + +## Existing usage + +Existing construction continues to work: + +```python +import mlbstatsapi + +mlb = mlbstatsapi.Mlb() +player = mlb.get_person(664034) +``` + +The client remains synchronous. Async support is not part of version 0.8.0. + +## Context manager + +Prefer a context manager when you want automatic cleanup of a library-created Session: + +```python +import mlbstatsapi + +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +``` + +Exiting the block closes a Session created by the library. + +It does not close a caller-owned injected Session. + +## Explicit cleanup + +You can also close the client explicitly: + +```python +mlb = mlbstatsapi.Mlb() + +try: + player = mlb.get_person(664034) +finally: + mlb.close() +``` + +Repeated `close()` calls are safe. + +## Default timeout + +Every request uses an explicit timeout. + +The default is: + +```python +DEFAULT_TIMEOUT = (3.05, 30.0) +``` + +That means: + +```text +3.05 seconds: connection timeout +30 seconds: read timeout +``` + +The read timeout is the maximum wait while reading response data. It is not one total wall-clock duration for the complete request. + +## Custom timeout + +A scalar applies the same value to both connect and read phases: + +```python +mlb = mlbstatsapi.Mlb(timeout=10) +``` + +A tuple provides separate connect and read values: + +```python +mlb = mlbstatsapi.Mlb( + timeout=(5.0, 60.0), +) +``` + +## Shared Session model + +One `requests.Session` is shared by the client's adapters: + +```text +Mlb client +├── v1 adapter ────┐ +│ ├── shared requests.Session +└── v1.1 adapter ──┘ +``` + +A Session manages: + +* Reusable connection pools +* Shared HTTP configuration +* Mounted retry adapters on library-created Sessions + +A Session is not: + +* A response cache +* One guaranteed permanent TCP connection +* An async transport + +## Session injection + +Advanced callers may inject a Session: + +```python +import requests +import mlbstatsapi + +session = requests.Session() + +try: + mlb = mlbstatsapi.Mlb(session=session) + player = mlb.get_person(664034) +finally: + session.close() +``` + +Ownership rules: + +```text +Library-created Session + The library owns and closes it + +Caller-injected Session + The caller owns and closes it +``` + +The library does not install or replace retry adapters on caller-injected Sessions. + +Callers who inject a Session control its retry, TLS, proxy, and adapter configuration. + +## Default retry policy + +Library-created Sessions mount a bounded retry policy for GET requests. + +```text +Initial request: 1 +Maximum retries: 3 +Maximum total attempts: 4 +Allowed method: GET +Backoff factor: 0.5 +Retry-After respected: yes +``` + +Retryable HTTP statuses: + +```text +429 +500 +502 +503 +504 +``` + +Non-retryable ordinary client statuses: + +```text +400 +401 +403 +404 +``` + +Additional rules: + +* Retries are bounded +* Only GET requests are retried +* Invalid JSON is not retried +* Pydantic validation failures are not retried +* Application parsing failures are not retried +* A final 404 preserves existing not-found behavior +* A final 429 preserves existing 4xx compatibility +* A final 5xx raises `MlbHttpError` + +Retries improve resilience for transient failures. They do not guarantee success. + +## Structured exceptions + +```text +TheMlbStatsApiException +├── MlbTransportError +│ └── MlbTimeoutError +├── MlbHttpError +└── MlbDecodeError +``` + +Imports: + +```python +from mlbstatsapi import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, +) +``` + +Precise handling: + +```python +try: + player = 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, + exc.url, + ) +except MlbDecodeError: + print("The MLB API returned invalid JSON") +``` + +Backward-compatible handling remains valid because all new errors inherit from `TheMlbStatsApiException`: + +```python +try: + player = mlb.get_person(664034) +except TheMlbStatsApiException: + print("The MLB request failed") +``` + +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 + +## HTTP exception attributes + +`MlbHttpError` exposes: + +```text +status_code +reason +url +``` + +It does not expose a response body or Response object. + +## Existing 404 behavior + +Version 0.8.0 preserves endpoint-specific not-found behavior. + +Depending on the endpoint, a 404 may become: + +```text +None +[] +{} +``` + +Not every 404 raises `MlbHttpError`. + +## No response caching + +Shared Sessions pool network connections. They do not cache MLB response bodies. + +The client has no default response cache. + +## No async support + +The client remains synchronous. + +Async support is not part of version 0.8.0. diff --git a/docs/releases/0.7.1.md b/docs/releases/0.7.1.md new file mode 100644 index 0000000..9e9ed95 --- /dev/null +++ b/docs/releases/0.7.1.md @@ -0,0 +1,288 @@ +# python-mlb-statsapi 0.7.1 + +Version 0.7.1 completes the package’s migration to Pydantic v2 and removes the response-key transformation layer that previously altered data returned by the MLB Stats API. + +This release includes breaking model and field-access changes for applications upgrading from the `0.5.x` or `0.6.x` releases. + +## Highlights + +### Native MLB response keys + +The `_transform_keys_in_data()` compatibility layer has been removed. + +Previous versions lowercased keys returned by the MLB Stats API before passing them into package models. Models now validate directly against MLB’s native response keys and use explicit Pydantic aliases where necessary. + +This change: + +* Removes an unnecessary data-transformation step +* Preserves the original MLB response structure +* Makes unusual MLB capitalization easier to support +* Reduces the risk of key collisions or silently altered field names +* Simplifies response parsing and model validation + +### Complete Pydantic v2 migration + +The migration from Python dataclasses to Pydantic v2 models, started in version 0.7.0, is now complete. + +All package models now use Pydantic for: + +* Input validation +* Type conversion +* Field aliases +* Serialization +* Nested model construction +* Optional and missing-field handling + +Returned objects continue to expose Python-style `snake_case` attributes while accepting MLB’s native response keys through Pydantic aliases. + +Example: + +```python +game.game_data.weather +``` + +Pydantic models can be serialized with: + +```python +data = model.model_dump() +json_data = model.model_dump_json() +``` + +### Simplified test suite + +The previous mocked-response tests and their associated JSON fixtures have been removed. + +This deleted more than 22,000 lines of test and fixture data. The remaining test suite focuses on integration behavior against the live MLB Stats API. + +## Breaking changes + +### Model attributes use `snake_case` + +Applications must access model fields using Python-style `snake_case` names. + +Before: + +```python +game.gamedata.weather +stat.totalsplits +``` + +After: + +```python +game.game_data.weather +stat.total_splits +``` + +### Validation failures use Pydantic exceptions + +Invalid model data now raises: + +```python +pydantic.ValidationError +``` + +Applications that previously expected `TypeError` during model construction may need to update their exception handling. + +### Serialization uses Pydantic methods + +Applications should use Pydantic’s public serialization methods instead of reading model internals directly. + +Before: + +```python +data = game.__dict__ +``` + +After: + +```python +data = game.model_dump() +``` + +JSON serialization is available through: + +```python +data = game.model_dump_json() +``` + +## Model updates + +### Core models + +* Migrated remaining models to the shared Pydantic `MLBBaseModel` +* Updated aliases to match MLB’s native response keys +* Added optional handling for fields that may be missing or null +* Improved nested-model validation +* Preserved Python-style `snake_case` attribute access + +### Game models + +Corrected aliases for: + +```text +gamePk +gameData +liveData +metaData +``` + +### Home Run Derby models + +Corrected aliases for: + +```text +homeRun +tieBreaker +isHomeRun +isTieBreaker +``` + +### Statistics models + +Corrected aliases for: + +```text +wobaCon +pitchArsenal +``` + +Additional statistics fixes include: + +* Added `age` to `SimplePitchingSplit` +* Added `caughtstealingpercentage` to `SimplePitchingSplit` +* Corrected `flyballpercentage` in `AdvancedPitchingSplit` +* Added support for newly observed MLB statistics fields +* Fixed missing values in several statistics models + +### Schedule models + +Made `calendarEventId` optional to support responses where the field is absent. + +### Standings models + +Made the following fields optional: + +```text +wildcardGamesBack +wildcardEliminationNumber +``` + +### Live-feed models + +Updated live-feed models to support previously missing response keys. + +## Compatibility + +Applications already using the Pydantic models introduced in version 0.7.0 should require fewer changes than applications upgrading from `0.5.x` or `0.6.x`. + +Applications upgrading from older releases should review: + +* Model attribute naming +* Validation exception handling +* Serialization code +* Any direct assumptions about transformed response keys + +The public endpoint methods remain available, but the model objects they return now consistently follow Pydantic v2 behavior. + +## Migration guide + +### Update field access + +Replace older compact or camelCase-style field access with the model’s `snake_case` attributes. + +Before: + +```python +game.gamedata.weather +stat.totalsplits +``` + +After: + +```python +game.game_data.weather +stat.total_splits +``` + +### Update validation handling + +Import and catch Pydantic’s validation exception: + +```python +from pydantic import ValidationError + +try: + game = Game(**data) +except ValidationError as exc: + print(exc) +``` + +### Update serialization + +Replace direct `__dict__` access with: + +```python +data = game.model_dump() +``` + +To exclude fields containing `None`: + +```python +data = game.model_dump(exclude_none=True) +``` + +To serialize as JSON: + +```python +json_data = game.model_dump_json() +``` + +## Dependencies and packaging + +* Added Pydantic v2 as a core dependency +* Completed the migration from setuptools-based project management to Poetry +* Updated dependency and build configuration for the Poetry workflow + +## Testing and CI + +This release: + +* Removes the previous mocked-response test suite +* Removes the associated JSON fixtures +* Updates external tests for the Pydantic model changes +* Updates CI configuration to stop running the removed mocked tests + +The test suite for this release relies primarily on the live MLB Stats API. Because MLB data and responses can change, external test results may be affected by upstream API availability or response changes. + +## Documentation + +The README now includes: + +* Pydantic model examples +* `model_dump()` and `model_dump_json()` usage +* Python-style `snake_case` field examples +* Updated code samples +* A contributor section + +## Not included + +Version 0.7.1 does not add: + +* New MLB endpoints +* HTTP retries +* Explicit request timeouts +* Shared HTTP Sessions +* Structured transport exceptions +* Response caching +* Async support + +Those transport reliability improvements are planned separately. + +## Contributors + +Thank you to everyone who contributed model corrections, migration work, testing, and documentation updates for this release. + +## Full changelog + +Review the repository commit history and merged pull requests associated with versions `0.7.0` and `0.7.1` for the complete set of changes. diff --git a/docs/releases/0.8.0.md b/docs/releases/0.8.0.md new file mode 100644 index 0000000..8866341 --- /dev/null +++ b/docs/releases/0.8.0.md @@ -0,0 +1,154 @@ +# python-mlb-statsapi 0.8.0 + +Version 0.8.0 is the HTTP reliability release. + +This release modernizes the package’s network layer while preserving the existing synchronous public API, endpoint return types, and endpoint-specific 404 behavior. + +## Highlights + +### Shared HTTP Sessions + +Each `Mlb` client now uses one shared `requests.Session` for its v1 and v1.1 adapters. + +This allows repeated requests to reuse pooled network connections. + +Library-created Sessions are closed automatically when using the client as a context manager: + +```python +import mlbstatsapi + +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) +``` + +Callers may also inject their own Session. Injected Sessions remain caller-owned and are not closed or reconfigured by the library. + +### Explicit timeouts + +Every request now has an explicit timeout. + +The default is: + +```text +Connect timeout: 3.05 seconds +Read timeout: 30 seconds +``` + +Custom scalar or connect/read timeout values may be supplied: + +```python +with mlbstatsapi.Mlb(timeout=(5.0, 60.0)) as mlb: + player = mlb.get_person(664034) +``` + +### Bounded retries + +Library-created Sessions automatically retry temporary GET failures for: + +```text +429 +500 +502 +503 +504 +``` + +The initial request may be followed by up to three retries. Retries use exponential backoff and respect `Retry-After` headers. + +Ordinary client errors such as 400, 401, 403, and 404 are not retried. + +Caller-injected Sessions retain their existing retry and adapter configuration. + +### Structured exceptions + +Version 0.8.0 introduces: + +```text +TheMlbStatsApiException +├── MlbTransportError +│ └── MlbTimeoutError +├── MlbHttpError +└── MlbDecodeError +``` + +All new exceptions inherit from `TheMlbStatsApiException`, preserving compatibility with applications that already catch the package’s base exception. + +`MlbHttpError` exposes: + +```text +status_code +reason +url +``` + +### HTTP response correctness + +The HTTP adapter now: + +* Supports the complete successful 2xx status range +* Handles status codes before decoding error bodies +* Handles successful empty responses safely +* Avoids shared mutable result dictionaries +* Avoids modifying caller-owned response dictionaries +* Distinguishes transport, timeout, HTTP, and JSON decoding failures + +### Preserved compatibility + +This release preserves: + +* The synchronous `Mlb` client +* Existing endpoint methods and constructor usage +* Existing endpoint return types +* Existing `MlbResult` attributes +* Existing broad exception handling +* Endpoint-specific 404 results such as `None`, `[]`, and `{}` + +A final 429 continues through the package’s existing 4xx compatibility behavior. Final 5xx responses raise `MlbHttpError`. + +### Testing and CI + +The release adds deterministic offline transport coverage for: + +* Sessions and ownership +* Timeout forwarding +* Retry behavior +* HTTP status handling +* Empty and malformed responses +* Structured exceptions +* Context-manager cleanup + +Offline CI now runs on Python 3.10, 3.11, and 3.12. + +Live MLB API tests run separately through a manual and scheduled workflow. Normal pushes no longer publish automatically to TestPyPI or PyPI. + +The final release package was validated by: + +* The deterministic offline test suite +* The live MLB API test suite +* A clean wheel installation +* Public import checks +* Live README example execution + +## Documentation + +The README now includes practical examples for: + +* Context-manager usage +* Shared Sessions +* Custom timeouts +* Injected Sessions +* Retry behavior +* Structured exceptions + +See `docs/http-transport.md` for the complete HTTP transport documentation. + +## Not included + +Version 0.8.0 does not add: + +* Async support +* Response caching +* New MLB endpoints +* Strict exceptions for every 4xx response +* Global rate limiting diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index d61935d..a2d6669 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -1,6 +1,12 @@ from .mlb_api import Mlb from .mlb_dataadapter import MlbDataAdapter, MlbResult -from .exceptions import TheMlbStatsApiException +from .exceptions import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, +) from .mlb_module import ( return_splits, diff --git a/mlbstatsapi/exceptions.py b/mlbstatsapi/exceptions.py index 03a329c..7270de3 100644 --- a/mlbstatsapi/exceptions.py +++ b/mlbstatsapi/exceptions.py @@ -1,2 +1,32 @@ class TheMlbStatsApiException(Exception): - pass \ No newline at end of file + pass + + +class MlbTransportError(TheMlbStatsApiException): + """A network or request transport failure.""" + + +class MlbTimeoutError(MlbTransportError): + """A connection or read timeout.""" + + +class MlbHttpError(TheMlbStatsApiException): + """An unexpected HTTP response.""" + + def __init__( + self, + status_code: int, + reason: str, + url: str | None = None, + ): + self.status_code = int(status_code) + self.reason = str(reason) + self.url = url + + super().__init__( + f"{self.status_code}: {self.reason}" + ) + + +class MlbDecodeError(TheMlbStatsApiException): + """A successful response contained invalid JSON.""" diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 40657ba..26921e6 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -3,6 +3,8 @@ from typing import List, Union +import requests + from mlbstatsapi.models.people import Person, Player, Coach from mlbstatsapi.models.teams import Team from mlbstatsapi.models.sports import Sport @@ -20,7 +22,12 @@ from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.standings import Standings -from .mlb_dataadapter import MlbDataAdapter +from .mlb_dataadapter import ( + DEFAULT_TIMEOUT, + MlbDataAdapter, + TimeoutType, + _configure_retry_adapters, +) # from .exceptions import TheMlbStatsApiException from . import mlb_module @@ -38,12 +45,56 @@ class Mlb: logger: logging.Loger logger """ - def __init__(self, hostname: str = 'statsapi.mlb.com', logger: logging.Logger = None): - self._mlb_adapter_v1 = MlbDataAdapter(hostname, 'v1', logger) - self._mlb_adapter_v1_1 = MlbDataAdapter(hostname, 'v1.1', logger) + def __init__( + self, + hostname: str = 'statsapi.mlb.com', + logger: logging.Logger | None = None, + timeout: TimeoutType = DEFAULT_TIMEOUT, + session: requests.Session | None = None, + ): + # One session is shared by the v1 and v1.1 adapters. The library closes + # only sessions it creates; caller-injected sessions remain caller-owned. + # Retry adapters are installed only on library-created Sessions. + self._owns_session = session is None + if session is None: + self._session = requests.Session() + _configure_retry_adapters(self._session) + else: + self._session = session + self._closed = False + self._timeout = timeout + self._mlb_adapter_v1 = MlbDataAdapter( + hostname, + 'v1', + logger, + timeout=timeout, + session=self._session, + ) + self._mlb_adapter_v1_1 = MlbDataAdapter( + hostname, + 'v1.1', + logger, + timeout=timeout, + session=self._session, + ) self._logger = logger or logging.getLogger(__name__) self._logger.setLevel(logging.DEBUG) + def close(self) -> None: + """Close the HTTP session when this client owns it. + + Safe to call more than once. Caller-injected sessions are left alone. + """ + if self._owns_session and not self._closed: + self._session.close() + self._closed = True + + def __enter__(self) -> "Mlb": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + self.close() + def get_people(self, sport_id: int = 1, **params) -> List[Person]: """ return the all players for sportid diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index 8648fa6..f9a416c 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -1,8 +1,64 @@ from typing import Dict -from .exceptions import TheMlbStatsApiException -import requests + +from .exceptions import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) import logging +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +# Connect timeout, then read timeout. Callers may override with a scalar or tuple. +DEFAULT_TIMEOUT = (3.05, 30.0) +TimeoutType = int | float | tuple[float, float] + + +def _build_retry_policy() -> Retry: + return Retry( + total=3, + connect=3, + read=2, + status=3, + backoff_factor=0.5, + status_forcelist=( + 429, + 500, + 502, + 503, + 504, + ), + allowed_methods=frozenset({"GET"}), + respect_retry_after_header=True, + raise_on_status=False, + ) + + +def _configure_retry_adapters( + session: requests.Session, +) -> None: + """Mount default retry adapters on a library-created Session. + + Caller-injected Sessions must not be passed here; their adapters stay + under the caller's control. + """ + session.mount( + "https://", + HTTPAdapter( + max_retries=_build_retry_policy() + ), + ) + session.mount( + "http://", + HTTPAdapter( + max_retries=_build_retry_policy() + ), + ) + class MlbResult: """ @@ -18,13 +74,18 @@ class MlbResult: JSON Data received from request """ - def __init__(self, status_code: int, message: str, data: Dict = {}): + def __init__( + self, + status_code: int, + message: str, + data: Dict | None = None, + ): self.status_code = int(status_code) self.message = str(message) - self.data = data - if 'copyright' in data: - del data['copyright'] + # Copy so caller-owned dictionaries are not mutated when copyright is removed. + self.data = dict(data) if data is not None else {} + self.data.pop("copyright", None) class MlbDataAdapter: @@ -41,10 +102,24 @@ class MlbDataAdapter: instance of logger class """ - def __init__(self, hostname: str = 'statsapi.mlb.com', ver: str = 'v1', logger: logging.Logger = None): + def __init__( + self, + hostname: str = 'statsapi.mlb.com', + ver: str = 'v1', + logger: logging.Logger | None = None, + timeout: TimeoutType = DEFAULT_TIMEOUT, + session: requests.Session | None = None, + ): self.url = f'https://{hostname}/api/{ver}/' self._logger = logger or logging.getLogger(__name__) - self._logger.setLevel(logging.DEBUG) + self._timeout = timeout + self._owns_session = session is None + if session is None: + self._session = requests.Session() + _configure_retry_adapters(self._session) + else: + self._session = session + self._closed = False def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult: """ @@ -70,38 +145,83 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe try: self._logger.debug(logline_post) - response = requests.get(url=full_url, params=ep_params) - - except requests.exceptions.RequestException as e: - self._logger.error(msg=(str(e))) - raise TheMlbStatsApiException('Request failed') from e - - try: - data = response.json() - - except (ValueError, requests.JSONDecodeError) as e: - self._logger.error(msg=(str(e))) - raise TheMlbStatsApiException('Bad JSON in response') from e - - if response.status_code <= 200 and response.status_code <= 299: - self._logger.debug(msg=logline_post.format('success', - response.status_code, response.reason, response.url)) - - return MlbResult(response.status_code, message=response.reason, data=data) - - elif response.status_code >= 400 and response.status_code <= 499: - self._logger.error(msg=logline_post.format('Invalid Request', - response.status_code, response.reason, response.url)) - - # return MlbResult with 404 and empty data - return MlbResult(response.status_code, message=response.reason, data={}) - - elif response.status_code >= 500 and response.status_code <= 599: - - self._logger.error(msg=logline_post.format('Internal error occurred', - response.status_code, response.reason, response.url)) - - raise TheMlbStatsApiException(f"{response.status_code}: {response.reason}") - + response = self._session.get( + url=full_url, + params=ep_params, + timeout=self._timeout, + ) + + except requests.exceptions.Timeout as exc: + self._logger.error(msg=(str(exc))) + raise MlbTimeoutError("Request failed") from exc + except requests.exceptions.RequestException 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, + response.url, + )) + return MlbResult( + status_code=status_code, + message=response.reason, + data={}, + ) + + if 500 <= status_code <= 599: + self._logger.error(msg=logline_post.format( + 'Internal error occurred', + status_code, + response.reason, + response.url, + )) + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, + ) + + if not 200 <= status_code <= 299: + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, + ) + + self._logger.debug(msg=logline_post.format( + 'success', + status_code, + response.reason, + response.url, + )) + + if not response.content: + response_data = {} else: - raise TheMlbStatsApiException(f"{response.status_code}: {response.reason}") + try: + response_data = response.json() + except (ValueError, requests.JSONDecodeError) as exc: + self._logger.error(msg=(str(exc))) + raise MlbDecodeError( + "Bad JSON in response" + ) from exc + + return MlbResult( + status_code, + message=response.reason, + data=response_data, + ) + + def close(self) -> None: + """Close the HTTP session when this adapter owns it. + + Safe to call more than once. Caller-injected sessions are left alone. + """ + if self._owns_session and not self._closed: + self._session.close() + self._closed = True diff --git a/pyproject.toml b/pyproject.toml index c585073..94705db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-mlb-statsapi" -version = "0.7.2" +version = "0.8.0" description = "mlbstatsapi python wrapper" authors = [ "Matthew Spah ", diff --git a/tests/external_tests/mlbdataadapter/test_mlbadapter.py b/tests/external_tests/mlbdataadapter/test_mlbadapter.py index 225fa46..57e554a 100644 --- a/tests/external_tests/mlbdataadapter/test_mlbadapter.py +++ b/tests/external_tests/mlbdataadapter/test_mlbadapter.py @@ -1,7 +1,6 @@ import unittest -import requests -from unittest.mock import patch -from mlbstatsapi import MlbDataAdapter, TheMlbStatsApiException + +from mlbstatsapi import MlbDataAdapter class TestMlbAdapter(unittest.TestCase): @@ -13,7 +12,6 @@ def setUpClass(cls) -> None: def tearDownClass(cls) -> None: pass - def test_mlbadapter_get_200(self): """mlbadapter should return 200 and data for sports endpoint""" @@ -27,25 +25,17 @@ def test_mlbadapter_get_200(self): self.assertTrue(result.data) def test_mlbadapter_get_400(self): - """mlbadapter should return 404, and result.data should be None""" + """mlbadapter should return 404, and result.data should be empty""" - # invalid endpoint + # invalid endpoint result = self.mlb_adapter.get(endpoint="teams/19990") # result.status_code should be 404 self.assertEqual(result.status_code, 404) - # result.data should be None + # result.data should be empty self.assertEqual(result.data, {}) - @unittest.skip("External API no longer returns 500 for this endpoint - covered by mock tests") - def test_mlbadapter_get_500(self): - """mlbadapter should raise TheMlbStatsApiException for 500""" - - # bad endpoint should raise exception due to 500 - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats?stats=vsPlayer&group=catching") - def test_mlbadapter_get_params(self): """mlbadapter should accept params and parse them to the url""" @@ -60,66 +50,3 @@ def test_mlbadapter_get_params(self): # result should have data self.assertTrue(result.data) - -class MlbAdapterMockTesting(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.mlb_adapter = MlbDataAdapter() - cls.response = requests.Response() - - @classmethod - def tearDownClass(cls) -> None: - pass - - def test_mlbadapter_mock_bad_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 200 - self.response._content = '{"some bad json": sdfsd'.encode() - - # params to pass - self.params = {"stats": "season", "group": "hitting"} - - # patch mlbdataadapter to return bad JSON - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to bad JSON - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) - - def test_mlbadapter_mock_404_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 404 - self.response._content = '{ "messageNumber": 10, "message": "Object not found", "timestamp": "2022-10-13T18:16:41.886604Z", "traceId": null }'.encode() - - # params to pass - self.params = { "stats": "standard", "group": "hitting" } - - # patch mlbdataadapter to return 404 response - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to bad JSON - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) - - # result.status_code should be 404 - self.assertEqual(result.status_code, 404) - - # result.data should be None - self.assertEqual(result.data, {}) - - def test_mlbadapter_mock_500_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 500 - self.response._content = '{ "messageNumber" : 1, "message" : "Internal error occurred", "timestamp" : "2022-10-13T18:37:47.600274Z", "traceId" : "9318607c0b50f493e9056648614a5cea" }'.encode() - - # params to pass - self.params = {"stats": "standard", "group": "hitting"} - - # patch mlbdataadapter to return mocked response - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to 500 status code - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) diff --git a/tests/test_mlb_dataadapter.py b/tests/test_mlb_dataadapter.py new file mode 100644 index 0000000..521eb6f --- /dev/null +++ b/tests/test_mlb_dataadapter.py @@ -0,0 +1,290 @@ +"""Offline regression tests for MlbDataAdapter. + +These tests protect HTTP adapter response-handling behavior for version 0.8.0, +including successful 2xx handling, status classification before JSON decoding, +and empty successful bodies. +""" + +import logging + +import requests +import pytest + +from mlbstatsapi import ( + MlbDataAdapter, + MlbDecodeError, + MlbHttpError, + MlbResult, + MlbTransportError, + TheMlbStatsApiException, +) + + +BASE_URL = "https://statsapi.mlb.com/api/v1/" + + +@pytest.fixture +def adapter() -> MlbDataAdapter: + return MlbDataAdapter() + + +def test_successful_json_response_returns_exact_values(adapter, requests_mock): + payload = { + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [{"id": 1, "name": "Major League Baseball"}], + } + requests_mock.get( + f"{BASE_URL}sports", + json=payload, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1, "name": "Major League Baseball"}]} + + +@pytest.mark.parametrize( + ("status_code", "reason", "payload"), + [ + (201, "Created", {"id": 42, "name": "created"}), + (299, "Custom Success", {"ok": True}), + ], +) +def test_other_successful_json_statuses_return_exact_values( + adapter, + requests_mock, + status_code, + reason, + payload, +): + requests_mock.get( + f"{BASE_URL}sports", + json=payload, + status_code=status_code, + reason=reason, + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == status_code + assert result.message == reason + assert result.data == payload + + +def test_empty_successful_response_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + text="", + status_code=204, + reason="No Content", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 204 + assert result.message == "No Content" + assert result.data == {} + + +def test_ep_params_are_sent_as_query_parameters(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + json={"stats": []}, + status_code=200, + reason="OK", + ) + + result = adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "season", "group": "hitting", "season": 2022}, + ) + + assert result.status_code == 200 + assert requests_mock.called + assert requests_mock.last_request.qs == { + "stats": ["season"], + "group": ["hitting"], + "season": ["2022"], + } + + +def test_status_code_and_reason_are_preserved_on_result(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + json={"sports": []}, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.message == "OK" + + +def test_copyright_is_removed_from_result_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + json={ + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [], + }, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert "copyright" not in result.data + assert result.data == {"sports": []} + + +def test_json_404_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/19990", + json={ + "messageNumber": 10, + "message": "Object not found", + "timestamp": "2022-10-13T18:16:41.886604Z", + "traceId": None, + }, + status_code=404, + reason="Not Found", + ) + + result = adapter.get(endpoint="teams/19990") + + assert result.status_code == 404 + assert result.message == "Not Found" + assert result.data == {} + + +def test_non_json_404_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/19990", + text="Not Found", + status_code=404, + reason="Not Found", + headers={"Content-Type": "text/html"}, + ) + + result = adapter.get(endpoint="teams/19990") + + assert result.status_code == 404 + assert result.message == "Not Found" + assert result.data == {} + + +def test_json_500_raises_mlb_http_error(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + json={ + "messageNumber": 1, + "message": "Internal error occurred", + "timestamp": "2022-10-13T18:37:47.600274Z", + "traceId": "9318607c0b50f493e9056648614a5cea", + }, + status_code=500, + reason="Internal Server Error", + ) + + with pytest.raises(MlbHttpError, match=r"^500: Internal Server Error$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "standard", "group": "hitting"}, + ) + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "500: Internal Server Error" + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" + + +def test_html_502_raises_mlb_http_error(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + text="Bad Gateway", + status_code=502, + reason="Bad Gateway", + headers={"Content-Type": "text/html"}, + ) + + with pytest.raises(MlbHttpError, match=r"^502: Bad Gateway$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "502: Bad Gateway" + assert exc_info.value.status_code == 502 + assert exc_info.value.reason == "Bad Gateway" + + +def test_connection_failure_raises_mlb_transport_error(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + + with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Request failed" + assert isinstance(exc_info.value.__cause__, requests.exceptions.ConnectionError) + + +def test_invalid_json_on_successful_response_raises_mlb_decode_error(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + text='{"some bad json": sdfsd', + status_code=200, + reason="OK", + headers={"Content-Type": "application/json"}, + ) + + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "season", "group": "hitting"}, + ) + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Bad JSON in response" + assert isinstance(exc_info.value.__cause__, ValueError) + + +def test_constructor_does_not_change_logger_level(): + logger = logging.Logger( + "mlbstatsapi-test", + level=logging.WARNING, + ) + + MlbDataAdapter(logger=logger) + + assert logger.level == logging.WARNING + + +def test_mlb_result_optional_data_argument_omitted(): + result = MlbResult(200, "OK") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {} + + +def test_mlb_result_optional_data_argument_explicit_dict(): + result = MlbResult(404, "Not Found", {"ignored": True}) + + assert result.status_code == 404 + assert result.message == "Not Found" + assert result.data == {"ignored": True} + + +def test_mlb_result_type_coercion(): + result = MlbResult("200", 123, {"value": True}) + + assert result.status_code == 200 + assert result.message == "123" diff --git a/tests/test_mlb_exceptions.py b/tests/test_mlb_exceptions.py new file mode 100644 index 0000000..66f59b0 --- /dev/null +++ b/tests/test_mlb_exceptions.py @@ -0,0 +1,171 @@ +"""Offline tests for the structured MLB Stats API exception hierarchy.""" + +import pytest +import requests + +from mlbstatsapi import ( + Mlb, + MlbDataAdapter, + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, +) + + +BASE_URL = "https://statsapi.mlb.com/api/v1/" + + +def test_exception_hierarchy(): + assert issubclass(MlbTransportError, TheMlbStatsApiException) + assert issubclass(MlbTimeoutError, MlbTransportError) + assert issubclass(MlbHttpError, TheMlbStatsApiException) + assert issubclass(MlbDecodeError, TheMlbStatsApiException) + + +@pytest.mark.parametrize( + "exc_type", + [ + MlbTransportError, + MlbTimeoutError, + MlbHttpError, + MlbDecodeError, + ], +) +def test_new_exceptions_are_catchable_as_base(exc_type): + if exc_type is MlbHttpError: + raised = MlbHttpError(500, "Internal Server Error", "https://example.test") + else: + raised = exc_type("failure") + + with pytest.raises(TheMlbStatsApiException): + raise raised + + +def test_mlb_http_error_attributes_and_message(): + exc = MlbHttpError( + status_code=500, + reason="Internal Server Error", + url="https://statsapi.mlb.com/api/v1/sports", + ) + + assert exc.status_code == 500 + assert exc.reason == "Internal Server Error" + assert exc.url == "https://statsapi.mlb.com/api/v1/sports" + assert str(exc) == "500: Internal Server Error" + + +def test_timeout_raises_mlb_timeout_error(): + original = requests.exceptions.Timeout("timed out") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, MlbTransportError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Request failed" + assert exc_info.value.__cause__ is original + session.close() + + +@pytest.mark.parametrize( + "timeout_exc", + [ + requests.exceptions.ConnectTimeout("connect timed out"), + requests.exceptions.ReadTimeout("read timed out"), + ], +) +def test_concrete_timeout_subclasses_raise_mlb_timeout_error(timeout_exc): + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(timeout_exc) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, MlbTransportError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.__cause__ is timeout_exc + session.close() + + +def test_connection_error_raises_mlb_transport_error(): + original = requests.exceptions.ConnectionError("connection refused") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert not isinstance(exc_info.value, MlbTimeoutError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Request failed" + assert exc_info.value.__cause__ is original + session.close() + + +def test_invalid_json_raises_mlb_decode_error(requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + text='{"some bad json": sdfsd', + status_code=200, + reason="OK", + headers={"Content-Type": "application/json"}, + ) + adapter = MlbDataAdapter() + + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Bad JSON in response" + assert isinstance(exc_info.value.__cause__, ValueError) + adapter.close() + + +@pytest.mark.parametrize( + ("status_code", "reason"), + [ + (500, "Internal Server Error"), + (502, "Bad Gateway"), + ], +) +def test_server_error_raises_mlb_http_error(status_code, reason, requests_mock): + url = f"{BASE_URL}sports" + requests_mock.get( + url, + text="error", + status_code=status_code, + reason=reason, + headers={"Content-Type": "text/html"}, + ) + adapter = MlbDataAdapter() + + with pytest.raises(MlbHttpError, match=rf"^{status_code}: {reason}$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.status_code == status_code + assert exc_info.value.reason == reason + assert exc_info.value.url == url + assert str(exc_info.value) == f"{status_code}: {reason}" + adapter.close() + + +def test_injected_session_exception_wrapping_still_works(): + original = requests.exceptions.Timeout("timed out") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + mlb = Mlb(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + mlb._mlb_adapter_v1.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.__cause__ is original + session.close() diff --git a/tests/test_mlb_result.py b/tests/test_mlb_result.py new file mode 100644 index 0000000..8ebc2b0 --- /dev/null +++ b/tests/test_mlb_result.py @@ -0,0 +1,93 @@ +"""Offline regression tests for MlbResult. + +These tests protect MlbResult constructor behavior for version 0.8.0, +including independent data dictionaries and caller-owned dictionary safety. +""" + +from mlbstatsapi import MlbResult + + +def test_stores_status_message_and_data(): + result = MlbResult(200, "OK", {"sports": [{"id": 1}]}) + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1}]} + + +def test_converts_status_code_to_int(): + result = MlbResult("200", "OK", {"value": True}) + + assert result.status_code == 200 + assert isinstance(result.status_code, int) + + +def test_converts_message_to_str(): + result = MlbResult(200, 123, {"value": True}) + + assert result.message == "123" + assert isinstance(result.message, str) + + +def test_type_coercion_for_status_and_message(): + result = MlbResult("200", 123, {"value": True}) + + assert result.status_code == 200 + assert result.message == "123" + assert result.data == {"value": True} + + +def test_removes_copyright_from_result_data(): + result = MlbResult( + 200, + "OK", + { + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [{"id": 1, "name": "Major League Baseball"}], + }, + ) + + assert "copyright" not in result.data + assert result.data == {"sports": [{"id": 1, "name": "Major League Baseball"}]} + + +def test_accepts_omitted_data_argument(): + result = MlbResult(200, "OK") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {} + + +def test_accepts_explicitly_supplied_dictionary(): + payload = {"teams": [{"id": 133}]} + result = MlbResult(200, "OK", payload) + + assert result.data == {"teams": [{"id": 133}]} + + +def test_each_instance_gets_independent_data_dict(): + first = MlbResult(200, "OK") + second = MlbResult(200, "OK") + + first.data["changed"] = True + + assert second.data == {} + assert first.data is not second.data + + +def test_does_not_mutate_caller_owned_dictionary(): + payload = { + "copyright": "MLB", + "sports": [], + } + + result = MlbResult(200, "OK", payload) + + assert payload == { + "copyright": "MLB", + "sports": [], + } + assert result.data == { + "sports": [], + } diff --git a/tests/test_mlb_retries.py b/tests/test_mlb_retries.py new file mode 100644 index 0000000..0bae978 --- /dev/null +++ b/tests/test_mlb_retries.py @@ -0,0 +1,275 @@ +"""Offline tests for bounded HTTP retries and retry adapter configuration.""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Iterable + +import pytest +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from mlbstatsapi import Mlb, MlbDataAdapter, MlbHttpError, MlbResult + + +def _assert_retry_policy(retry: Retry) -> None: + assert retry.total == 3 + assert retry.connect == 3 + assert retry.read == 2 + assert retry.status == 3 + assert retry.backoff_factor == 0.5 + assert set(retry.status_forcelist) == {429, 500, 502, 503, 504} + assert retry.allowed_methods == frozenset({"GET"}) + assert retry.respect_retry_after_header is True + assert retry.raise_on_status is False + assert "POST" not in retry.allowed_methods + assert "PATCH" not in retry.allowed_methods + assert "DELETE" not in retry.allowed_methods + + +def test_library_created_mlb_session_has_retry_policy(): + mlb = Mlb() + try: + for scheme in ("https://", "http://"): + adapter = mlb._session.get_adapter(scheme) + _assert_retry_policy(adapter.max_retries) + finally: + mlb.close() + + +def test_library_created_adapter_session_has_retry_policy(): + adapter = MlbDataAdapter() + try: + for scheme in ("https://", "http://"): + http_adapter = adapter._session.get_adapter(scheme) + _assert_retry_policy(http_adapter.max_retries) + finally: + adapter.close() + + +def test_injected_session_adapters_are_not_replaced(): + session = requests.Session() + custom_adapter = HTTPAdapter(max_retries=0) + session.mount("https://", custom_adapter) + + mlb = Mlb(session=session) + try: + assert session.get_adapter("https://") is custom_adapter + finally: + mlb.close() + session.close() + + +def test_injected_adapter_session_adapters_are_not_replaced(): + session = requests.Session() + custom_adapter = HTTPAdapter(max_retries=0) + session.mount("http://", custom_adapter) + + adapter = MlbDataAdapter(session=session) + try: + assert session.get_adapter("http://") is custom_adapter + finally: + adapter.close() + session.close() + + +class _ScriptedHandler(BaseHTTPRequestHandler): + """Serve a scripted sequence of HTTP status codes for retry tests.""" + + statuses: list[int] = [] + request_count = 0 + lock = threading.Lock() + + def do_GET(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler + with self.lock: + index = self.request_count + self.__class__.request_count += 1 + + if index < len(self.statuses): + status = self.statuses[index] + else: + status = self.statuses[-1] if self.statuses else 500 + + body = b"" + if status == 200: + body = json.dumps({"sports": [{"id": 1}]}).encode("utf-8") + + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + +@pytest.fixture +def scripted_http_server(): + """Start a local HTTP server that returns a caller-provided status sequence.""" + + server = ThreadingHTTPServer(("127.0.0.1", 0), _ScriptedHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = server.server_address[1] + + def configure(statuses: Iterable[int]) -> None: + _ScriptedHandler.statuses = list(statuses) + _ScriptedHandler.request_count = 0 + + try: + yield configure, port + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def no_retry_sleep(monkeypatch): + monkeypatch.setattr(Retry, "sleep", lambda self, response=None: None) + + +def _adapter_against_local_server(port: int) -> MlbDataAdapter: + adapter = MlbDataAdapter() + adapter.url = f"http://127.0.0.1:{port}/api/v1/" + return adapter + + +def test_retries_recover_from_two_500_responses( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([500, 500, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert isinstance(result, MlbResult) + assert result.status_code == 200 + assert result.data == {"sports": [{"id": 1}]} + assert _ScriptedHandler.request_count == 3 + + +@pytest.mark.parametrize( + "retryable_status", + [429, 500, 502, 503, 504], +) +def test_retries_retryable_statuses_then_succeed( + retryable_status, + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([retryable_status, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert result.status_code == 200 + assert result.data == {"sports": [{"id": 1}]} + assert _ScriptedHandler.request_count == 2 + + +@pytest.mark.parametrize( + "status_code", + [400, 401, 403, 404], +) +def test_non_retryable_client_errors_are_not_retried( + status_code, + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([status_code, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert result.status_code == status_code + assert result.data == {} + assert _ScriptedHandler.request_count == 1 + + +def test_bounded_persistent_500_raises_after_four_attempts( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([500, 500, 500, 500, 500, 500]) + adapter = _adapter_against_local_server(port) + + try: + with pytest.raises(MlbHttpError) as exc_info: + adapter.get(endpoint="sports") + finally: + adapter.close() + + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" + assert _ScriptedHandler.request_count == 4 + + +def test_final_429_returns_empty_mlb_result( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([429, 429, 429, 429, 429, 429]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert isinstance(result, MlbResult) + assert result.status_code == 429 + assert result.data == {} + assert _ScriptedHandler.request_count == 4 + + +def test_invalid_json_is_not_retried(no_retry_sleep): + class BadJsonHandler(_ScriptedHandler): + def do_GET(self) -> None: # noqa: N802 + with self.lock: + self.__class__.request_count += 1 + body = b'{"bad": json' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), BadJsonHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + BadJsonHandler.request_count = 0 + adapter = MlbDataAdapter() + adapter.url = f"http://127.0.0.1:{server.server_address[1]}/api/v1/" + + try: + from mlbstatsapi import MlbDecodeError + + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$"): + adapter.get(endpoint="sports") + assert BadJsonHandler.request_count == 1 + finally: + adapter.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/test_mlb_session.py b/tests/test_mlb_session.py new file mode 100644 index 0000000..6f0c6fc --- /dev/null +++ b/tests/test_mlb_session.py @@ -0,0 +1,392 @@ +"""Offline tests for shared HTTP sessions and configurable timeouts. + +These tests cover session injection, ownership, cleanup, sharing, and timeout +forwarding without calling the live MLB API. +""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from mlbstatsapi import Mlb, MlbDataAdapter, MlbHttpError, TheMlbStatsApiException +from mlbstatsapi.mlb_dataadapter import DEFAULT_TIMEOUT + + +class RecordingSession: + """Minimal session stand-in that records get() and close() calls.""" + + def __init__(self): + self.calls = [] + self.close_calls = 0 + + def get(self, url, params=None, timeout=None, **kwargs): + self.calls.append( + { + "url": url, + "params": params, + "timeout": timeout, + "kwargs": kwargs, + } + ) + response = MagicMock() + response.status_code = 200 + response.reason = "OK" + response.url = url + response.content = b'{"sports": []}' + response.json.return_value = {"sports": []} + return response + + def close(self): + self.close_calls += 1 + + +def _response( + *, + status_code: int, + reason: str, + url: str, + content: bytes = b"", + payload=None, +): + response = MagicMock() + response.status_code = status_code + response.reason = reason + response.url = url + response.content = content + if payload is None: + response.json.side_effect = ValueError("no json") + else: + response.json.return_value = payload + return response + + +# --- Adapter transport --- + + +def test_adapter_calls_configured_session_get(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.data == {"sports": []} + assert len(session.calls) == 1 + assert session.calls[0]["url"] == "https://statsapi.mlb.com/api/v1/sports" + + +def test_adapter_does_not_use_module_level_requests_get(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session) + + with patch("mlbstatsapi.mlb_dataadapter.requests.get") as module_get: + adapter.get(endpoint="sports") + + module_get.assert_not_called() + assert len(session.calls) == 1 + + +def test_adapter_forwards_query_parameters(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session) + params = {"stats": "season", "group": "hitting", "season": 2022} + + adapter.get(endpoint="teams/133/stats", ep_params=params) + + assert session.calls[0]["params"] == params + + +def test_adapter_successful_response_behavior_with_session(): + session = MagicMock() + session.get.return_value = _response( + status_code=200, + reason="OK", + url="https://statsapi.mlb.com/api/v1/sports", + content=b'{"sports":[{"id":1}]}', + payload={"sports": [{"id": 1}]}, + ) + adapter = MlbDataAdapter(session=session) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1}]} + + +def test_adapter_404_behavior_with_session(): + session = MagicMock() + session.get.return_value = _response( + status_code=404, + reason="Not Found", + url="https://statsapi.mlb.com/api/v1/teams/19990", + content=b'{"message":"Object not found"}', + payload={"message": "Object not found"}, + ) + adapter = MlbDataAdapter(session=session) + + result = adapter.get(endpoint="teams/19990") + + assert result.status_code == 404 + assert result.message == "Not Found" + assert result.data == {} + + +def test_adapter_500_behavior_with_session(): + session = MagicMock() + session.get.return_value = _response( + status_code=500, + reason="Internal Server Error", + url="https://statsapi.mlb.com/api/v1/sports", + content=b'{"message":"Internal error occurred"}', + payload={"message": "Internal error occurred"}, + ) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbHttpError, match=r"^500: Internal Server Error$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" + assert exc_info.value.url == "https://statsapi.mlb.com/api/v1/sports" + + +# --- Timeouts --- + + +def test_default_timeout_constant(): + assert DEFAULT_TIMEOUT == (3.05, 30.0) + + +def test_adapter_passes_default_timeout_to_session(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session) + + adapter.get(endpoint="sports") + + assert session.calls[0]["timeout"] == (3.05, 30.0) + + +def test_adapter_passes_scalar_timeout_unchanged(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session, timeout=10) + + adapter.get(endpoint="sports") + + assert session.calls[0]["timeout"] == 10 + + +def test_adapter_passes_tuple_timeout_unchanged(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session, timeout=(5.0, 60.0)) + + adapter.get(endpoint="sports") + + assert session.calls[0]["timeout"] == (5.0, 60.0) + + +def test_mlb_configured_timeout_reaches_both_adapters(): + session = RecordingSession() + mlb = Mlb(session=session, timeout=(1.5, 20.0)) + + mlb._mlb_adapter_v1.get(endpoint="sports") + mlb._mlb_adapter_v1_1.get(endpoint="game") + + assert session.calls[0]["timeout"] == (1.5, 20.0) + assert session.calls[1]["timeout"] == (1.5, 20.0) + assert session.calls[0]["url"].endswith("/api/v1/sports") + assert session.calls[1]["url"].endswith("/api/v1.1/game") + + +def test_mlb_default_timeout_passed_to_session(): + session = RecordingSession() + mlb = Mlb(session=session) + + mlb._mlb_adapter_v1.get(endpoint="sports") + + assert session.calls[0]["timeout"] == DEFAULT_TIMEOUT + + +# --- Shared session --- + + +def test_injected_session_is_shared_by_both_adapters(): + session = RecordingSession() + mlb = Mlb(session=session) + + assert mlb._mlb_adapter_v1._session is session + assert mlb._mlb_adapter_v1_1._session is session + assert mlb._mlb_adapter_v1._session is mlb._mlb_adapter_v1_1._session + + +def test_library_created_session_is_shared_by_both_adapters(): + with patch("mlbstatsapi.mlb_api.requests.Session") as session_cls: + session = MagicMock() + session_cls.return_value = session + + mlb = Mlb() + + assert session_cls.call_count == 1 + assert mlb._mlb_adapter_v1._session is session + assert mlb._mlb_adapter_v1_1._session is session + + +def test_mlb_creates_exactly_one_session(): + with patch("mlbstatsapi.mlb_api.requests.Session") as session_cls: + Mlb() + assert session_cls.call_count == 1 + + +# --- Mlb ownership --- + + +def test_mlb_close_closes_library_created_session_once(): + with patch("mlbstatsapi.mlb_api.requests.Session") as session_cls: + session = MagicMock() + session_cls.return_value = session + + mlb = Mlb() + mlb.close() + mlb.close() + mlb.close() + + session.close.assert_called_once() + + +def test_mlb_close_does_not_close_injected_session(): + session = RecordingSession() + mlb = Mlb(session=session) + + mlb.close() + mlb.close() + + assert session.close_calls == 0 + + +# --- Adapter ownership --- + + +def test_standalone_adapter_closes_library_created_session_once(): + with patch("mlbstatsapi.mlb_dataadapter.requests.Session") as session_cls: + session = MagicMock() + session_cls.return_value = session + + adapter = MlbDataAdapter() + adapter.close() + adapter.close() + + session.close.assert_called_once() + + +def test_standalone_adapter_does_not_close_injected_session(): + session = RecordingSession() + adapter = MlbDataAdapter(session=session) + + adapter.close() + adapter.close() + + assert session.close_calls == 0 + + +# --- Context manager --- + + +def test_context_manager_enter_returns_same_instance(): + session = RecordingSession() + mlb = Mlb(session=session) + + with mlb as entered: + assert entered is mlb + + +def test_context_manager_closes_library_owned_session(): + with patch("mlbstatsapi.mlb_api.requests.Session") as session_cls: + session = MagicMock() + session_cls.return_value = session + + with Mlb() as mlb: + assert isinstance(mlb, Mlb) + + session.close.assert_called_once() + + +def test_context_manager_does_not_close_injected_session(): + session = RecordingSession() + + with Mlb(session=session) as mlb: + assert mlb._session is session + + assert session.close_calls == 0 + + +def test_context_manager_closes_library_session_on_exception(): + with patch("mlbstatsapi.mlb_api.requests.Session") as session_cls: + session = MagicMock() + session_cls.return_value = session + + with pytest.raises(RuntimeError, match="boom"): + with Mlb() as mlb: + assert isinstance(mlb, Mlb) + raise RuntimeError("boom") + + session.close.assert_called_once() + + +def test_context_manager_does_not_suppress_exception_with_injected_session(): + session = RecordingSession() + + with pytest.raises(RuntimeError, match="boom"): + with Mlb(session=session): + raise RuntimeError("boom") + + assert session.close_calls == 0 + + +# --- Constructor compatibility --- + + +def test_mlb_default_constructor(): + mlb = Mlb() + assert isinstance(mlb._session, requests.Session) + mlb.close() + + +def test_mlb_positional_hostname(): + mlb = Mlb("statsapi.mlb.com") + assert mlb._mlb_adapter_v1.url.startswith("https://statsapi.mlb.com/api/v1/") + mlb.close() + + +def test_mlb_positional_hostname_and_logger(): + logger = MagicMock() + logger.level = 0 + mlb = Mlb("statsapi.mlb.com", logger) + assert mlb._logger is logger + mlb.close() + + +def test_adapter_default_constructor(): + adapter = MlbDataAdapter() + assert isinstance(adapter._session, requests.Session) + adapter.close() + + +def test_adapter_positional_hostname(): + adapter = MlbDataAdapter("statsapi.mlb.com") + assert adapter.url == "https://statsapi.mlb.com/api/v1/" + adapter.close() + + +def test_adapter_positional_hostname_and_version(): + adapter = MlbDataAdapter("statsapi.mlb.com", "v1.1") + assert adapter.url == "https://statsapi.mlb.com/api/v1.1/" + adapter.close() + + +def test_adapter_positional_hostname_version_and_logger(): + logger = MagicMock() + adapter = MlbDataAdapter("statsapi.mlb.com", "v1.1", logger) + assert adapter._logger is logger + adapter.close()