diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 0cd4bcd..4842bd3 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/0.8.0
+ - release/0.9.0
push:
branches:
- main
- - release/0.8.0
+ - release/0.9.0
workflow_dispatch:
permissions:
@@ -49,7 +49,7 @@ jobs:
--ignore=tests/external_tests
build-package:
- name: Build package
+ name: Build and validate package
needs: offline-tests
runs-on: ubuntu-latest
@@ -67,4 +67,11 @@ jobs:
- name: Install dependencies
run: poetry install --no-interaction
- name: Build package
- run: poetry build
+ run: |
+ rm -rf dist
+ poetry build
+ # Validates the built artifacts and a clean wheel installation.
+ # Runs outside the Poetry environment so the smoke test cannot import
+ # the repository checkout instead of the installed distribution.
+ - name: Validate release artifacts
+ run: python scripts/validate_release.py
diff --git a/README.md b/README.md
index 0d4998a..1e10bbc 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ For detailed documentation, check out the [Wiki](https://github.com/zero-sum-sea
## Installation
-```python
+```bash
python3 -m pip install python-mlb-statsapi
```
@@ -65,9 +65,13 @@ Ty France
Seattle Mariners Seattle
```
-## HTTP Sessions, Timeouts, and Retries
+## 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 builds on that transport with configurable HTTP behavior: a public retry policy, richer `MlbHttpError` context, an optional strict mode, compatibility warnings, and a versioned User-Agent.
+
+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.
-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.
+For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [0.9.0 release notes](docs/releases/0.9.0.md).
### Recommended context-manager usage
@@ -83,18 +87,67 @@ with mlbstatsapi.Mlb() as mlb:
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
+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 is the default
+
+Existing construction continues to work unchanged:
+
+```python
+import mlbstatsapi
+
+with mlbstatsapi.Mlb() as mlb:
+ player = mlb.get_person(664034)
+```
+
+That is equivalent to:
+
+```python
+mlb = mlbstatsapi.Mlb(
+ strict_http=False,
+)
+```
+
+Compatibility mode remains the default in version 0.9.0. A final non-404 4xx response still returns the historical empty result instead of raising, so existing applications keep working after upgrading.
+
+### Optional strict HTTP mode
+
+Applications that would rather fail loudly can opt in to strict mode:
+
+```python
+import mlbstatsapi
+
+with mlbstatsapi.Mlb(
+ strict_http=True,
+) as mlb:
+ player = mlb.get_person(664034)
+```
+
+In strict mode:
+
+* A final non-404 4xx response raises `MlbHttpError`
+* A final 5xx response raises `MlbHttpError`, as it already did in compatibility mode
+* A 404 keeps the existing endpoint-specific behavior and does not raise
-Existing construction continues to work:
+"Final" means after the bounded retry policy has been exhausted. Strict mode is opt-in; it is not the default.
+
+### Compatibility warnings
+
+When compatibility mode converts a final non-404 4xx response into the historical empty result, the library emits `MlbHttpCompatibilityWarning`. The warning marks a response that strict mode would have raised on, so it doubles as migration guidance.
+
+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
-mlb = mlbstatsapi.Mlb()
-player = mlb.get_person(664034)
+warnings.filterwarnings(
+ "error",
+ category=mlbstatsapi.MlbHttpCompatibilityWarning,
+)
```
-Callers who do not use a context manager may call `mlb.close()`. Repeated `close()` calls are safe.
+Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. No warning is emitted for successful responses, 404 responses, intermediate retries, final 5xx responses, or in strict mode.
### Custom timeouts
@@ -156,12 +209,50 @@ Ownership rules:
```text
Library-created Session
- The library owns and closes it
+ The library configures and closes it
Caller-injected Session
- The caller owns and closes it
+ The caller configures 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 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()` is public in version 0.9.0. 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:
+
+```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)
+
+try:
+ with mlbstatsapi.Mlb(session=session) as mlb:
+ player = mlb.get_person(664034)
+finally:
+ session.close()
```
-`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.
+* 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:
+
+```text
+python-mlb-statsapi/
+```
+
+For this release that resolves to `python-mlb-statsapi/0.9.0`. 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
@@ -176,11 +267,12 @@ except mlbstatsapi.MlbTimeoutError:
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,
- )
+ 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")
```
@@ -190,6 +282,8 @@ except mlbstatsapi.MlbDecodeError:
* `MlbHttpError` represents an unexpected final HTTP response
* `MlbDecodeError` represents invalid JSON in a successful response
+Version 0.9.0 adds `method`, `response_data`, and `body_excerpt` to `MlbHttpError` alongside the existing `status_code`, `reason`, and `url`. `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.
+
### Backward-compatible exception handling
All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible:
@@ -224,11 +318,11 @@ 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.
+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 version 0.8.0.
### Existing 404 compatibility
-Version 0.8.0 preserves existing endpoint-specific not-found behavior. Depending on the endpoint, a 404 may still produce:
+Version 0.9.0 preserves existing endpoint-specific not-found behavior in both compatibility mode and strict mode. Depending on the endpoint, a 404 may still produce:
```text
None
@@ -236,9 +330,19 @@ None
{}
```
-Not every 404 raises `MlbHttpError`.
+Not every 404 raises `MlbHttpError`, and strict mode does not change that.
-See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, cleanup behavior, and exception hierarchy.
+### HTTP behavior at a glance
+
+| Final response | Compatibility mode (default) | Strict mode |
+| -------------- | ---------------------------- | ----------- |
+| Successful 2xx | Normal result | Normal result |
+| Non-404 4xx | Warning and historical empty result | `MlbHttpError` |
+| 404 | Existing endpoint behavior | Existing endpoint behavior |
+| Final 429 | Warning and historical empty result | `MlbHttpError` |
+| Final 5xx | `MlbHttpError` | `MlbHttpError` |
+
+See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and exception hierarchy, and the [0.9.0 release notes](docs/releases/0.9.0.md) for the release summary and migration guidance.
## Working with Pydantic Models
@@ -376,9 +480,13 @@ Full local validation:
```bash
poetry run pytest tests/
+rm -rf dist
poetry build
+python3 scripts/validate_release.py
```
+`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, installs the wheel into a temporary virtual environment, and runs a public-import smoke test against the installed package. 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
diff --git a/docs/http-transport.md b/docs/http-transport.md
index 89058ef..df04dc0 100644
--- a/docs/http-transport.md
+++ b/docs/http-transport.md
@@ -1,9 +1,34 @@
# HTTP Transport
-This document describes the HTTP transport behavior introduced in version 0.8.0.
+This document describes the HTTP transport behavior of the current release, version 0.9.0.
+
+Version 0.8.0 introduced shared Sessions, explicit timeouts, bounded retries, and structured exceptions. Version 0.9.0 keeps all of that and adds configurable HTTP behavior: a public retry policy, richer `MlbHttpError` context, an optional strict mode, compatibility warnings, and a versioned User-Agent.
The public client remains synchronous. Ordinary usage does not need to configure sessions or retries.
+See [the 0.9.0 release notes](releases/0.9.0.md) for a shorter summary of what changed.
+
+## Public transport API
+
+Everything this document describes is reachable from the package root:
+
+```python
+from mlbstatsapi import (
+ Mlb,
+ MlbDataAdapter,
+ MlbDecodeError,
+ MlbHttpCompatibilityWarning,
+ MlbHttpError,
+ MlbTimeoutError,
+ MlbTransportError,
+ TheMlbStatsApiException,
+ create_retry_policy,
+)
+```
+
+Names that are not exported from `mlbstatsapi` are internal and may change without a
+deprecation cycle.
+
## Existing usage
Existing construction continues to work:
@@ -15,7 +40,7 @@ mlb = mlbstatsapi.Mlb()
player = mlb.get_person(664034)
```
-The client remains synchronous. Async support is not part of version 0.8.0.
+The client remains synchronous. Async support is not part of version 0.9.0.
## Context manager
@@ -53,8 +78,8 @@ Every request uses an explicit timeout.
The default is:
-```python
-DEFAULT_TIMEOUT = (3.05, 30.0)
+```text
+(3.05, 30.0)
```
That means:
@@ -105,6 +130,27 @@ A Session is not:
* One guaranteed permanent TCP connection
* An async transport
+## Session ownership
+
+Session ownership is the single most important rule in this document. Whoever creates the
+Session configures it and closes it.
+
+```text
+Library-created Session
+ Configured and closed by the library
+ Receives retry adapters
+ Receives the package User-Agent
+
+Caller-injected Session
+ Configured and closed by the caller
+ Existing adapters remain untouched
+ Existing headers remain untouched
+```
+
+The library never installs adapters, replaces headers, or closes a Session it did not
+create. `Mlb.close()` and exiting `with Mlb(session=session)` both leave an injected
+Session open.
+
## Session injection
Advanced callers may inject a Session:
@@ -122,19 +168,10 @@ 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.
+Callers who inject a Session control its retry, TLS, proxy, header, and adapter
+configuration. See [Reusing the retry policy on a caller-managed
+Session](#reusing-the-retry-policy-on-a-caller-managed-session) for opting in to the
+library's tested retry policy.
## User-Agent
@@ -144,6 +181,12 @@ Library-created Sessions send a package-specific User-Agent:
python-mlb-statsapi/
```
+For this release that resolves to:
+
+```text
+python-mlb-statsapi/0.9.0
+```
+
The version comes from the installed package metadata, so it always matches the
installed release without a separately maintained version string.
@@ -227,7 +270,7 @@ Additional rules:
* 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 by default
+* A final 429 preserves existing 4xx compatibility by default and warns
* A final 5xx raises `MlbHttpError`
* In strict mode, a final non-404 4xx (including a final 429) raises `MlbHttpError`
@@ -259,12 +302,17 @@ mlb = mlbstatsapi.Mlb(
Behavior:
-| Response | Compatibility | Strict |
-| ----------- | -------------------------------- | -------------------------- |
-| Non-404 4xx | Empty endpoint-compatible result | `MlbHttpError` |
-| 404 | Existing endpoint behavior | Existing endpoint behavior |
-| Final 429 | Empty result | `MlbHttpError` |
-| Final 5xx | `MlbHttpError` | `MlbHttpError` |
+| Final response | Compatibility mode | Strict mode |
+| -------------- | ----------------------------------- | -------------------------- |
+| Successful 2xx | Normal result | Normal result |
+| Non-404 4xx | Warning and historical empty result | `MlbHttpError` |
+| 404 | Existing endpoint behavior | Existing endpoint behavior |
+| Final 429 | Warning and historical empty result | `MlbHttpError` |
+| Final 5xx | `MlbHttpError` | `MlbHttpError` |
+
+Every row describes the *final* response. A retryable status such as 429, 500, 502, 503,
+or 504 is evaluated against this table only after the bounded retry policy has been
+exhausted; intermediate retried responses neither raise nor warn.
Notes:
@@ -320,6 +368,26 @@ change in version 1.0.
The category inherits from `FutureWarning` so the migration notice stays visible under
default Python warning filters.
+A warning is emitted only when all three of the following are true:
+
+* Compatibility mode is active
+* The final response is a non-404 4xx
+* Strict mode would have raised `MlbHttpError` for the same response
+
+No warning is emitted for:
+
+```text
+Successful responses
+404
+Strict mode
+Intermediate retries
+Final 5xx
+Timeouts
+Transport failures
+Decode failures
+Pydantic validation failures
+```
+
When the warning is emitted:
| Response | Warning |
@@ -523,7 +591,8 @@ except mlbstatsapi.MlbHttpError as exc:
## Existing 404 behavior
-Version 0.8.0 preserves endpoint-specific not-found behavior.
+Version 0.9.0 preserves endpoint-specific not-found behavior in both compatibility mode
+and strict mode.
Depending on the endpoint, a 404 may become:
@@ -533,7 +602,23 @@ None
{}
```
-Not every 404 raises `MlbHttpError`.
+Not every 404 raises `MlbHttpError`. Strict mode does not change this, and a 404 never
+emits `MlbHttpCompatibilityWarning`.
+
+## Version 1.0 migration direction
+
+Version 0.9.0 keeps compatibility mode as the default.
+
+The `MlbHttpCompatibilityWarning` notices exist to give applications advance migration
+guidance: each warning marks a response that strict mode would already have raised on.
+
+A future 1.0 release may make stricter non-404 4xx behavior the default. No final 1.0
+decision is implemented here, and nothing about the current return shapes changes in
+version 0.9.0.
+
+Applications that want the future-facing behavior today can enable strict mode, and
+applications that want to find affected call sites early can turn
+`MlbHttpCompatibilityWarning` into an error.
## No response caching
@@ -545,4 +630,4 @@ The client has no default response cache.
The client remains synchronous.
-Async support is not part of version 0.8.0.
+Async support is not part of version 0.9.0.
diff --git a/docs/releases/0.9.0.md b/docs/releases/0.9.0.md
new file mode 100644
index 0000000..bf8c483
--- /dev/null
+++ b/docs/releases/0.9.0.md
@@ -0,0 +1,237 @@
+# python-mlb-statsapi 0.9.0
+
+Version 0.9.0 is the configurable HTTP behavior release.
+
+Version 0.8.0 made the network layer reliable. Version 0.9.0 makes it explainable and
+adjustable: callers can now reuse the library's retry policy, inspect much more context
+on a failed request, and choose whether an unexpected HTTP response raises or falls back
+to the historical empty result.
+
+Nothing about the default behavior changes. Compatibility mode is still the default,
+endpoint return types are unchanged, and existing constructor usage keeps working.
+
+> These notes are being prepared for the 0.9.0 publication. The release has not been
+> tagged or uploaded yet.
+
+## Highlights
+
+### Public retry policy
+
+`create_retry_policy()` is now part of the public API:
+
+```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)
+
+try:
+ with mlbstatsapi.Mlb(session=session) as mlb:
+ player = mlb.get_person(664034)
+finally:
+ session.close()
+```
+
+Each call returns a new `urllib3.util.retry.Retry` instance configured exactly like the
+policy the library mounts on Sessions it creates, so a caller-managed Session can opt in
+to the same tested behavior instead of reinventing it.
+
+Injected Sessions are still never modified automatically. The caller mounts the adapters
+and the caller closes the Session.
+
+The retry values themselves are unchanged from version 0.8.0: up to three retries for
+GET requests, a 0.5 backoff factor, `Retry-After` respected, and a retryable status list
+of 429, 500, 502, 503, and 504.
+
+### Richer HTTP errors
+
+`MlbHttpError` now carries enough context to diagnose a failure without re-running the
+request:
+
+```text
+method
+status_code
+reason
+url
+response_data
+body_excerpt
+```
+
+```python
+try:
+ with mlbstatsapi.Mlb() as mlb:
+ player = mlb.get_person(664034)
+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)
+```
+
+`response_data` holds the decoded JSON dictionary or list when the error body contains
+one, and is `None` for HTML, plain text, empty bodies, and JSON scalars. `body_excerpt`
+is a bounded excerpt of the response text, capped at 500 characters.
+
+Context collection is best-effort and never replaces the original HTTP error. Complete
+response bodies are not retained beyond the excerpt and are not automatically logged, and
+`str(exc)` stays concise.
+
+`status_code`, `reason`, and `url` behave exactly as they did in version 0.8.0.
+
+### Optional strict HTTP mode
+
+Applications that prefer explicit failures can opt in:
+
+```python
+import mlbstatsapi
+
+with mlbstatsapi.Mlb(
+ strict_http=True,
+) as mlb:
+ player = mlb.get_person(664034)
+```
+
+In strict mode a final non-404 4xx response raises `MlbHttpError` instead of returning
+the historical empty result. Final 5xx responses raise in both modes, as they already
+did.
+
+Strict mode is evaluated only after the bounded retry policy is exhausted, it does not
+change 404 handling, and it does not affect timeout, transport, decode, or Pydantic
+validation failures.
+
+### Compatibility warnings
+
+Compatibility mode remains the default, but it is no longer silent. When a final non-404
+4xx response is converted into the historical empty result, the library emits
+`MlbHttpCompatibilityWarning`:
+
+```text
+HTTP 403 for https://statsapi.mlb.com/api/v1/sports was handled through
+compatibility mode and returned the historical empty result. Pass
+strict_http=True to raise MlbHttpError. This compatibility behavior may
+change in version 1.0.
+```
+
+The category inherits from `FutureWarning` so it stays visible under default filters, and
+it can be targeted precisely:
+
+```python
+import warnings
+import mlbstatsapi
+
+warnings.filterwarnings(
+ "error",
+ category=mlbstatsapi.MlbHttpCompatibilityWarning,
+)
+```
+
+Warning messages contain only the status code and the request URL. No warning is emitted
+for successful responses, 404 responses, intermediate retries, final 5xx responses, or in
+strict mode.
+
+### Versioned User-Agent
+
+A Session created by the library now identifies itself:
+
+```text
+python-mlb-statsapi/0.9.0
+```
+
+The version is read from the installed distribution metadata rather than a duplicated
+source constant, so the header always matches the installed release. Only the
+`User-Agent` header is set; the remaining Requests defaults are preserved.
+
+Caller-injected Session headers are left untouched. This header carries nothing beyond
+the package name and version.
+
+### Session ownership remains explicit
+
+```text
+Library-created Session
+ Configured and closed by the library
+ Receives retry adapters
+ Receives the package User-Agent
+
+Caller-injected Session
+ Configured and closed by the caller
+ Existing adapters remain untouched
+ Existing headers remain untouched
+```
+
+Ownership rules are unchanged from version 0.8.0. Version 0.9.0 only makes the
+library-created side more capable, and the injected side is still never reconfigured or
+closed by the library.
+
+### Preserved compatibility
+
+This release preserves:
+
+* The synchronous `Mlb` client and every existing endpoint method
+* Existing constructor arguments, with `strict_http` added as a keyword-only option
+* Existing endpoint return types
+* Endpoint-specific 404 results such as `None`, `[]`, and `{}`
+* Existing `MlbHttpError` attributes
+* Existing broad handling through `TheMlbStatsApiException`
+* Existing retry values
+* Existing Session ownership behavior
+
+### Testing and release validation
+
+The release adds deterministic offline coverage for the HTTP contract, compatibility
+warnings, the public retry policy, exception context, and Session ownership. Warnings are
+asserted directly rather than suppressed.
+
+Packaging is validated by `scripts/validate_release.py`, which builds on the artifacts in
+`dist/` and checks the wheel and source distribution, the reported distribution name and
+version, a clean-virtual-environment installation, the public imports, the retry policy
+return type, both HTTP modes, the User-Agent produced from installed metadata, and that
+injected Session headers survive untouched. The validator never contacts the MLB API and
+runs in offline CI.
+
+## Migration guidance
+
+No action is required to upgrade. Compatibility mode is the default and existing code
+keeps its current behavior.
+
+If `MlbHttpCompatibilityWarning` appears in your logs, it marks a call site where the MLB
+API returned a non-404 4xx and the library returned an empty result anyway. That is a
+real request failure being hidden, and it is worth handling.
+
+The recommended path is:
+
+1. Leave the default in place and watch for the warning
+2. Handle or investigate the call sites the warning identifies
+3. Turn the warning into an error in tests to keep new occurrences from creeping in
+4. Enable `strict_http=True` once those call sites handle `MlbHttpError`
+
+Version 0.9.0 keeps compatibility mode as the default, and the warnings provide advance
+migration guidance. A future 1.0 release may make stricter non-404 4xx behavior the
+default. No final 1.0 decision is being made in this release.
+
+## Installation
+
+Once version 0.9.0 is published:
+
+```bash
+python3 -m pip install --upgrade python-mlb-statsapi
+```
+
+## Not included
+
+Version 0.9.0 does not add:
+
+* Async support
+* Response caching
+* New MLB endpoints
+* Global rate limiting
+* Strict behavior for 404 responses
+* Strict mode as the default
+* New retry values
+* Telemetry
diff --git a/pyproject.toml b/pyproject.toml
index 94705db..ef962dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "python-mlb-statsapi"
-version = "0.8.0"
+version = "0.9.0"
description = "mlbstatsapi python wrapper"
authors = [
"Matthew Spah ",
diff --git a/scripts/validate_release.py b/scripts/validate_release.py
new file mode 100644
index 0000000..ff45dfc
--- /dev/null
+++ b/scripts/validate_release.py
@@ -0,0 +1,343 @@
+"""Validate the built python-mlb-statsapi distributions before a release.
+
+Checks the artifacts in ``dist/``, then installs the wheel into a throwaway
+virtual environment and runs a public-import smoke test against the *installed*
+package.
+
+The smoke test deliberately runs from a temporary directory so the repository
+checkout cannot shadow the installed distribution.
+
+Nothing here contacts the MLB API.
+
+Usage::
+
+ python scripts/validate_release.py
+ python scripts/validate_release.py --dist dist --expected-version 0.9.0
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+import tarfile
+import tempfile
+import venv
+import zipfile
+from email.parser import Parser
+from pathlib import Path
+
+DISTRIBUTION_NAME = "python-mlb-statsapi"
+NORMALIZED_DISTRIBUTION_NAME = "python_mlb_statsapi"
+EXPECTED_REQUIRES_PYTHON = ">=3.10"
+
+# Paths every source distribution must carry so the project can be rebuilt and
+# read from the sdist alone.
+REQUIRED_SDIST_PATHS = (
+ "README.md",
+ "pyproject.toml",
+ "mlbstatsapi/__init__.py",
+)
+
+SMOKE_TEST_SOURCE = '''
+"""Public import smoke test for an installed python-mlb-statsapi wheel."""
+
+import importlib.metadata
+import inspect
+import sys
+from pathlib import Path
+
+import requests
+from urllib3.util.retry import Retry
+
+import mlbstatsapi
+from mlbstatsapi import (
+ Mlb,
+ MlbDataAdapter,
+ MlbDecodeError,
+ MlbHttpCompatibilityWarning,
+ MlbHttpError,
+ MlbTimeoutError,
+ MlbTransportError,
+ TheMlbStatsApiException,
+ create_retry_policy,
+)
+
+expected_version = sys.argv[1]
+
+package_file = Path(mlbstatsapi.__file__).resolve()
+assert "site-packages" in package_file.parts, (
+ f"mlbstatsapi was imported from {package_file}, not from the installed wheel"
+)
+
+installed_version = importlib.metadata.version("python-mlb-statsapi")
+assert installed_version == expected_version, (
+ f"installed metadata reports {installed_version}, expected {expected_version}"
+)
+
+assert callable(create_retry_policy)
+retry_policy = create_retry_policy()
+assert isinstance(retry_policy, Retry), type(retry_policy)
+assert create_retry_policy() is not retry_policy, (
+ "create_retry_policy() must return a new Retry instance per call"
+)
+
+assert issubclass(MlbHttpCompatibilityWarning, FutureWarning)
+assert issubclass(MlbHttpError, TheMlbStatsApiException)
+assert issubclass(MlbTimeoutError, MlbTransportError)
+assert issubclass(MlbTransportError, TheMlbStatsApiException)
+assert issubclass(MlbDecodeError, TheMlbStatsApiException)
+
+# Compatibility mode is the default in this release.
+assert (
+ inspect.signature(Mlb.__init__).parameters["strict_http"].default is False
+)
+assert (
+ inspect.signature(MlbDataAdapter.__init__).parameters["strict_http"].default
+ is False
+)
+
+# A library-created Session is library-owned, so reading its User-Agent through
+# the private attribute is acceptable for internal release validation only.
+expected_user_agent = f"python-mlb-statsapi/{expected_version}"
+with Mlb() as mlb:
+ user_agent = mlb._session.headers["User-Agent"]
+ assert user_agent == expected_user_agent, user_agent
+
+# Strict mode is constructible and injected Session headers stay untouched.
+session = requests.Session()
+session.headers.update(
+ {
+ "User-Agent": "release-smoke-test/1.0",
+ "X-Release-Test": "preserved",
+ }
+)
+try:
+ with Mlb(session=session, strict_http=True):
+ pass
+ assert session.headers["User-Agent"] == "release-smoke-test/1.0"
+ assert session.headers["X-Release-Test"] == "preserved"
+finally:
+ session.close()
+
+print(f"smoke test passed for python-mlb-statsapi {installed_version}")
+'''
+
+
+class ValidationError(Exception):
+ """A release validation check failed."""
+
+
+def _log(message: str) -> None:
+ print(message, flush=True)
+
+
+def _read_expected_version(project_root: Path) -> str:
+ """Read the declared project version from pyproject.toml.
+
+ Uses tomllib when available and falls back to a narrow regex so the
+ validator also runs on Python 3.10, which the package still supports.
+ """
+ pyproject = project_root / "pyproject.toml"
+ text = pyproject.read_text(encoding="utf-8")
+
+ try:
+ import tomllib
+ except ModuleNotFoundError:
+ match = re.search(
+ r'^version\s*=\s*"([^"]+)"',
+ text,
+ flags=re.MULTILINE,
+ )
+ if match is None:
+ raise ValidationError(f"could not find a version in {pyproject}")
+ return match.group(1)
+
+ data = tomllib.loads(text)
+ project_version = data.get("project", {}).get("version")
+ poetry_version = data.get("tool", {}).get("poetry", {}).get("version")
+ version = project_version or poetry_version
+ if not version:
+ raise ValidationError(f"could not find a version in {pyproject}")
+ return version
+
+
+def _find_single(dist_dir: Path, pattern: str, label: str) -> Path:
+ matches = sorted(dist_dir.glob(pattern))
+ if not matches:
+ raise ValidationError(
+ f"no {label} matching {pattern!r} in {dist_dir}; run `poetry build` first"
+ )
+ if len(matches) > 1:
+ names = ", ".join(path.name for path in matches)
+ raise ValidationError(
+ f"expected exactly one {label} in {dist_dir}, found: {names}. "
+ "Remove stale artifacts and rebuild."
+ )
+ return matches[0]
+
+
+def _check_wheel_metadata(wheel: Path, expected_version: str) -> None:
+ with zipfile.ZipFile(wheel) as archive:
+ metadata_names = [
+ name
+ for name in archive.namelist()
+ if name.endswith(".dist-info/METADATA")
+ ]
+ if len(metadata_names) != 1:
+ raise ValidationError(
+ f"expected one METADATA file in {wheel.name}, found {metadata_names}"
+ )
+ raw_metadata = archive.read(metadata_names[0]).decode("utf-8")
+
+ metadata = Parser().parsestr(raw_metadata)
+
+ name = metadata.get("Name")
+ if name != DISTRIBUTION_NAME:
+ raise ValidationError(f"wheel Name is {name!r}, expected {DISTRIBUTION_NAME!r}")
+
+ version = metadata.get("Version")
+ if version != expected_version:
+ raise ValidationError(
+ f"wheel Version is {version!r}, expected {expected_version!r}"
+ )
+
+ requires_python = metadata.get("Requires-Python")
+ if requires_python != EXPECTED_REQUIRES_PYTHON:
+ raise ValidationError(
+ f"wheel Requires-Python is {requires_python!r}, "
+ f"expected {EXPECTED_REQUIRES_PYTHON!r}"
+ )
+
+ _log(
+ f" wheel metadata: Name={name} Version={version} "
+ f"Requires-Python={requires_python}"
+ )
+
+
+def _check_sdist_contents(sdist: Path) -> None:
+ with tarfile.open(sdist, "r:gz") as archive:
+ members = archive.getnames()
+
+ # Every path inside an sdist is prefixed with the versioned root directory.
+ relative_paths = {name.split("/", 1)[1] for name in members if "/" in name}
+
+ missing = [path for path in REQUIRED_SDIST_PATHS if path not in relative_paths]
+ if missing:
+ raise ValidationError(
+ f"source distribution {sdist.name} is missing: {', '.join(missing)}"
+ )
+
+ _log(f" sdist contains: {', '.join(REQUIRED_SDIST_PATHS)}")
+
+
+def _venv_python(venv_dir: Path) -> Path:
+ candidates = (
+ venv_dir / "bin" / "python",
+ venv_dir / "Scripts" / "python.exe",
+ )
+ for candidate in candidates:
+ if candidate.exists():
+ return candidate
+ raise ValidationError(f"no interpreter found in {venv_dir}")
+
+
+def _run(command: list[str], *, cwd: Path) -> None:
+ result = subprocess.run(command, cwd=cwd, check=False)
+ if result.returncode != 0:
+ printable = " ".join(command)
+ raise ValidationError(f"command failed ({result.returncode}): {printable}")
+
+
+def _check_clean_install(wheel: Path, expected_version: str) -> None:
+ with tempfile.TemporaryDirectory(prefix="python-mlb-statsapi-release-") as tmp:
+ workspace = Path(tmp)
+ venv_dir = workspace / "venv"
+
+ _log(f" creating clean virtual environment in {venv_dir}")
+ venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir)
+ python = _venv_python(venv_dir)
+
+ _run(
+ [str(python), "-m", "pip", "install", "--upgrade", "--quiet", "pip"],
+ cwd=workspace,
+ )
+ _log(f" installing {wheel.name}")
+ _run(
+ [str(python), "-m", "pip", "install", "--quiet", str(wheel.resolve())],
+ cwd=workspace,
+ )
+
+ smoke_test = workspace / "release_smoke_test.py"
+ smoke_test.write_text(SMOKE_TEST_SOURCE, encoding="utf-8")
+
+ # Run from the temporary directory so the repository checkout is not on
+ # sys.path and cannot shadow the installed distribution.
+ _log(" running public import smoke test against the installed wheel")
+ _run([str(python), str(smoke_test), expected_version], cwd=workspace)
+
+
+def validate(dist_dir: Path, expected_version: str) -> None:
+ _log(f"Validating release {expected_version} in {dist_dir}")
+
+ wheel = _find_single(
+ dist_dir,
+ f"{NORMALIZED_DISTRIBUTION_NAME}-{expected_version}-*.whl",
+ "wheel",
+ )
+ _log(f" wheel: {wheel.name}")
+
+ sdist = _find_single(
+ dist_dir,
+ f"{NORMALIZED_DISTRIBUTION_NAME}-{expected_version}.tar.gz",
+ "source distribution",
+ )
+ _log(f" source distribution: {sdist.name}")
+
+ _check_wheel_metadata(wheel, expected_version)
+ _check_sdist_contents(sdist)
+ _check_clean_install(wheel, expected_version)
+
+ _log(f"Release validation passed for {DISTRIBUTION_NAME} {expected_version}")
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--project-root",
+ type=Path,
+ default=Path(__file__).resolve().parent.parent,
+ help="repository root containing pyproject.toml",
+ )
+ parser.add_argument(
+ "--dist",
+ type=Path,
+ default=None,
+ help="directory holding the built artifacts (default: /dist)",
+ )
+ parser.add_argument(
+ "--expected-version",
+ default=None,
+ help="version to validate (default: the version declared in pyproject.toml)",
+ )
+ args = parser.parse_args(argv)
+
+ project_root = args.project_root.resolve()
+ dist_dir = (args.dist or project_root / "dist").resolve()
+
+ try:
+ expected_version = args.expected_version or _read_expected_version(project_root)
+ if not dist_dir.is_dir():
+ raise ValidationError(
+ f"{dist_dir} does not exist; run `poetry build` first"
+ )
+ validate(dist_dir, expected_version)
+ except ValidationError as exc:
+ print(f"Release validation failed: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py
new file mode 100644
index 0000000..a20f5de
--- /dev/null
+++ b/tests/test_release_validation.py
@@ -0,0 +1,113 @@
+"""Offline checks that the release documentation stays consistent with the package.
+
+These tests do not build or install anything. Packaging itself is validated by
+``scripts/validate_release.py``, which runs against ``dist/`` and a clean
+virtual environment.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+README = PROJECT_ROOT / "README.md"
+TRANSPORT_DOC = PROJECT_ROOT / "docs" / "http-transport.md"
+RELEASE_NOTES = PROJECT_ROOT / "docs" / "releases" / "0.9.0.md"
+
+PYTHON_BLOCK_PATTERN = re.compile(
+ r"^```python\n(.*?)^```",
+ re.MULTILINE | re.DOTALL,
+)
+
+
+def _project_version() -> str:
+ text = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ match = re.search(r'^version\s*=\s*"([^"]+)"', text, flags=re.MULTILINE)
+ assert match is not None, "no version found in pyproject.toml"
+ return match.group(1)
+
+
+def _python_blocks(path: Path) -> list[tuple[int, str]]:
+ """Return (line number, source) for every non-REPL ```python block."""
+ text = path.read_text(encoding="utf-8")
+ blocks = []
+ for match in PYTHON_BLOCK_PATTERN.finditer(text):
+ source = match.group(1)
+ stripped = source.lstrip()
+ # Interactive blocks interleave prompts and output, so they are not
+ # compilable source and are excluded from syntax validation.
+ if stripped.startswith(">>>"):
+ continue
+ line_number = text.count("\n", 0, match.start()) + 1
+ blocks.append((line_number, source))
+ return blocks
+
+
+def _documented_paths() -> list[Path]:
+ return [README, TRANSPORT_DOC, RELEASE_NOTES]
+
+
+@pytest.mark.parametrize(
+ "path",
+ _documented_paths(),
+ ids=lambda path: path.name,
+)
+def test_documentation_python_examples_are_valid_syntax(path: Path) -> None:
+ blocks = _python_blocks(path)
+ assert blocks, f"expected at least one python example in {path.name}"
+
+ for line_number, source in blocks:
+ compile(source, f"{path.name}:{line_number}", "exec")
+
+
+@pytest.mark.parametrize(
+ "path",
+ _documented_paths(),
+ ids=lambda path: path.name,
+)
+def test_documentation_examples_use_public_api_only(path: Path) -> None:
+ """User-facing examples must not reach into private package internals.
+
+ ``scripts/validate_release.py`` is allowed to read ``Mlb._session`` for
+ release validation; documentation is not.
+ """
+ for line_number, source in _python_blocks(path):
+ assert "_session" not in source, (
+ f"{path.name}:{line_number} uses the private _session attribute"
+ )
+ assert "mlbstatsapi.mlb_dataadapter" not in source, (
+ f"{path.name}:{line_number} imports an internal module"
+ )
+
+
+def test_release_notes_exist_for_the_declared_version() -> None:
+ version = _project_version()
+ notes = PROJECT_ROOT / "docs" / "releases" / f"{version}.md"
+ assert notes.is_file(), f"missing release notes for version {version}"
+ assert notes.read_text(encoding="utf-8").startswith(
+ f"# python-mlb-statsapi {version}"
+ )
+
+
+def test_documented_user_agent_matches_the_declared_version() -> None:
+ """The documented User-Agent must track the version the build will produce."""
+ version = _project_version()
+ expected = f"python-mlb-statsapi/{version}"
+
+ for path in (README, TRANSPORT_DOC, RELEASE_NOTES):
+ text = path.read_text(encoding="utf-8")
+ documented = set(re.findall(r"python-mlb-statsapi/[0-9][^\s`\"']*", text))
+ assert documented == {expected}, (
+ f"{path.name} documents User-Agent versions {sorted(documented)}, "
+ f"expected only {expected!r}"
+ )
+
+
+def test_ci_watches_the_current_release_branch() -> None:
+ workflow = PROJECT_ROOT / ".github" / "workflows" / "build-and-test.yml"
+ text = workflow.read_text(encoding="utf-8")
+ major, minor, _ = _project_version().split(".")
+
+ assert f"release/{major}.{minor}.0" in text
+ assert "- main" in text