From acdff7e385b8991653f9fa2e66989db785ba20fa Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 31 Jul 2026 21:23:43 -0700 Subject: [PATCH 1/9] Adding docs/releases/0.8.0.md --- docs/releases/0.8.0.md | 640 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 640 insertions(+) create mode 100644 docs/releases/0.8.0.md diff --git a/docs/releases/0.8.0.md b/docs/releases/0.8.0.md new file mode 100644 index 0000000..96158b1 --- /dev/null +++ b/docs/releases/0.8.0.md @@ -0,0 +1,640 @@ +# Version 0.8.0 Release Plan + +## Release theme + +Version 0.8.0 is the HTTP reliability release. + +The purpose of this release is to modernize the network layer without breaking the existing public API or changing how users access MLB data. + +The release will focus on: + +* Shared HTTP sessions +* Explicit timeouts +* Bounded retries +* Better exception types +* Dependency injection for tests +* Session cleanup +* Deterministic offline transport tests +* Correct HTTP response handling +* Safer release automation + +This release will not redesign the endpoint API or convert the package to async. + +## Primary objective + +Existing code should continue working without modification: + +```python +import mlbstatsapi + +mlb = mlbstatsapi.Mlb() +player = mlb.get_person(664034) +``` + +Users should gain better reliability without needing to learn a new interface. + +## Compatibility requirements + +Version 0.8.0 must preserve: + +* The synchronous `Mlb` client +* Existing method names +* Existing positional constructor arguments +* Existing endpoint return types +* `MlbResult.status_code` +* `MlbResult.message` +* `MlbResult.data` +* `TheMlbStatsApiException` +* Current not-found behavior + +Many endpoint methods currently translate 404 responses into: + +* `None` +* `[]` +* `{}` + +That behavior must remain unchanged in version 0.8.0. + +Any new package exception must inherit from: + +```python +TheMlbStatsApiException +``` + +Stricter handling of client errors may be considered for a later release. + +## Branch strategy + +Development will follow this flow: + +```text +feature branch + ↓ +release/0.8.0 + ↓ +main + ↓ +v0.8.0 +``` + +The existing `development` branch will not be used for this release. + +The release branch should be created from an updated `main` after the prerequisite model and test work is merged. + +```bash +git switch main +git pull origin main + +git switch -c release/0.8.0 +git push -u origin release/0.8.0 +``` + +Each version 0.8.0 feature branch must: + +1. Start from `release/0.8.0` +2. Target `release/0.8.0` +3. Contain one focused change +4. Pass relevant offline tests +5. Preserve the compatibility requirements + +Individual feature pull requests should normally be squash-merged. + +The final pull request from `release/0.8.0` to `main` should use a merge commit so the release commits remain visible. + +## Prerequisite work + +Before starting the transport changes: + +* Merge or otherwise resolve PR #249 +* Confirm the full offline test suite runs in CI +* Confirm model alias regression tests are active +* Create `AGENTS.md` on `main` +* Create this release plan on `main` +* Create `release/0.8.0` from the updated `main` + +## Planned pull requests + +## PR 1: Establish the HTTP adapter contract + +Suggested branch: + +```text +test/http-adapter-contract +``` + +### Purpose + +Capture the current HTTP adapter behavior before changing its implementation. + +### Planned changes + +Move mocked transport tests out of `tests/external_tests/`. + +Only tests that intentionally contact the live MLB API should remain in the external test directory. + +Add deterministic offline tests for: + +* Successful 200 responses +* Other valid 2xx responses +* Query parameter forwarding +* 404 responses +* 500 responses +* Connection failures +* Timeouts +* Invalid JSON +* Empty responses +* Copyright removal +* Independent `MlbResult` data dictionaries +* Response reason preservation + +### Acceptance criteria + +* No production behavior changes +* No live network dependency for transport unit tests +* Current 404 behavior is documented by tests +* Current exception behavior is documented by tests +* Offline tests are suitable for required CI checks + +## PR 2: Correct HTTP adapter behavior + +Suggested branch: + +```text +fix/http-adapter-correctness +``` + +### Purpose + +Fix implementation problems that do not require changing the public API. + +### Planned changes + +Correct the success status check. + +Current logic only reliably handles status code 200. It should support the full successful range: + +```python +if 200 <= response.status_code <= 299: +``` + +Remove mutable default arguments from `MlbResult`. + +Avoid mutating the original response dictionary when removing the MLB copyright field. + +Handle status codes before decoding error response bodies. + +A server returning HTML with a 500 or 502 status must be treated as an HTTP failure rather than only as invalid JSON. + +Handle valid empty responses safely. + +Stop changing the consuming application’s logger level. + +### Acceptance criteria + +* All valid 2xx responses are handled correctly +* A successful malformed JSON response raises a decoding exception +* A failed HTML response is classified as an HTTP failure +* 404 responses still return empty data +* Existing endpoint behavior remains compatible +* No shared mutable dictionaries remain + +## PR 3: Add shared sessions and timeouts + +Suggested branch: + +```text +feat/shared-http-session +``` + +### Purpose + +Reuse HTTP connections and ensure every request has a bounded duration. + +### Proposed public configuration + +```python +mlb = mlbstatsapi.Mlb( + timeout=(3.05, 30.0), + session=None, +) +``` + +The existing positional constructor arguments must remain valid. + +### Planned changes + +* Replace direct `requests.get()` calls with `Session.get()` +* Create one session inside `Mlb` +* Share the session between the v1 and v1.1 adapters +* Allow callers to inject their own session +* Add a default timeout +* Pass the timeout to every request +* Add `close()` +* Add context-manager support +* Track ownership of the session + +### Resource ownership + +If the library creates the session, the library closes it. + +If the caller injects the session, the caller owns it and the library must not close it. + +### Example usage + +```python +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +``` + +Custom timeout: + +```python +mlb = mlbstatsapi.Mlb(timeout=(5.0, 60.0)) +``` + +Injected session: + +```python +import requests +import mlbstatsapi + +session = requests.Session() +mlb = mlbstatsapi.Mlb(session=session) +``` + +### Acceptance criteria + +* Every request receives a timeout +* The v1 and v1.1 adapters share the same session +* Internally created sessions are closed +* Injected sessions are not closed +* Existing constructor usage remains valid +* Existing endpoint tests continue passing + +## PR 4: Add retries and structured exceptions + +Suggested branch: + +```text +feat/http-retries-errors +``` + +### Purpose + +Recover automatically from temporary MLB or network failures while giving applications more precise error handling. + +### Default retry policy + +Retries should apply only to safe GET requests. + +Retry temporary failures such as: + +* Connection failures +* Temporary read failures +* HTTP 429 +* HTTP 500 +* HTTP 502 +* HTTP 503 +* HTTP 504 + +Do not retry: + +* HTTP 400 +* HTTP 401 +* HTTP 403 +* HTTP 404 +* JSON decoding failures +* Pydantic validation failures + +Retries must: + +* Be bounded +* Use exponential backoff +* Respect `Retry-After` +* Avoid retrying unsafe methods + +Suggested policy: + +```python +Retry( + total=3, + connect=3, + read=2, + status=3, + backoff_factor=0.5, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods={"GET"}, + respect_retry_after_header=True, + raise_on_status=False, +) +``` + +### Exception hierarchy + +```text +TheMlbStatsApiException +├── MlbTransportError +│ └── MlbTimeoutError +├── MlbHttpError +└── MlbDecodeError +``` + +### Exception responsibilities + +`MlbTransportError` + +* Connection failures +* DNS failures +* Other request transport failures + +`MlbTimeoutError` + +* Connection timeout +* Read timeout +* Total request timeout behavior exposed by Requests + +`MlbHttpError` + +* Unexpected HTTP status after retries +* Should preserve useful status and request context + +`MlbDecodeError` + +* Successful response containing invalid JSON + +### Compatibility + +Existing code must continue working: + +```python +except TheMlbStatsApiException: + ... +``` + +Applications may optionally use more precise handling: + +```python +except MlbTimeoutError: + ... +except MlbHttpError as exc: + print(exc.status_code) +``` + +A 404 must still not raise in version 0.8.0. + +### Acceptance criteria + +* New exceptions inherit from the existing base exception +* Timeout failures use `MlbTimeoutError` +* Connection failures use `MlbTransportError` +* Invalid successful JSON uses `MlbDecodeError` +* Final server failure after retries uses `MlbHttpError` +* 404 behavior remains unchanged +* Retry counts are covered by deterministic tests + +## PR 5: Update CI and documentation + +Suggested branch: + +```text +docs/v0.8-http-transport +``` + +### Purpose + +Separate deterministic validation from live MLB API validation and document the new HTTP configuration. + +### CI plan + +Required pull request checks should run offline tests on: + +* Python 3.10 +* Python 3.11 +* Python 3.12 + +Suggested command: + +```bash +poetry run pytest tests/ --ignore=tests/external_tests +``` + +Live MLB API tests should run separately: + +```bash +poetry run pytest tests/external_tests/ +``` + +External tests may run: + +* On a schedule +* Manually +* Before a release +* On one supported Python version + +Live MLB availability should not be the only test gate for ordinary pull requests. + +### Documentation plan + +Document: + +* Default timeout behavior +* Timeout customization +* Shared session behavior +* Session injection +* Session ownership +* Context-manager usage +* Retry status codes +* Retry limits +* Exception hierarchy +* Preserved 404 behavior +* Lack of default caching +* Continued synchronous API + +### Acceptance criteria + +* Offline CI runs on all supported Python versions +* External tests remain available +* Public configuration examples are documented +* Compatibility behavior is documented +* Publishing is separated from normal test workflows + +## Final release preparation + +Suggested branch: + +```text +release/0.8.0-version +``` + +This branch should start from and target `release/0.8.0`. + +### Planned changes + +* Bump package version to `0.8.0` +* Add release notes +* Add changelog entry +* Update README examples if necessary +* Confirm package exports +* Confirm supported Python versions +* Confirm release documentation + +### Acceptance criteria + +* Package metadata reports version 0.8.0 +* Documentation matches actual behavior +* No unfinished feature flags remain +* Release notes clearly describe compatibility +* Build succeeds + +## Publishing workflow + +Normal pushes and pull requests should: + +* Install dependencies +* Run tests +* Build the package + +They should not automatically publish a release. + +Production publishing should occur only after an explicit release action or version tag. + +Suggested tags: + +```text +v0.8.0rc1 +v0.8.0rc2 +v0.8.0 +``` + +Release candidates may publish to TestPyPI. + +The final `v0.8.0` tag may publish to production PyPI. + +Do not publish or create tags without explicit authorization. + +## Integrated release testing + +After all feature pull requests are merged into `release/0.8.0`: + +```bash +git switch release/0.8.0 +git pull origin release/0.8.0 + +poetry install +poetry run pytest tests/ +poetry build +``` + +Install the built wheel into a clean environment: + +```bash +python -m venv /tmp/mlbstatsapi-080 +source /tmp/mlbstatsapi-080/bin/activate + +python -m pip install --upgrade pip +python -m pip install dist/*.whl +``` + +Run a public API smoke test: + +```python +import mlbstatsapi + +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) + print(player.full_name) + + team = mlb.get_team(136) + print(team.name) +``` + +## Required smoke coverage + +Test at least: + +* `get_person` +* `get_team` +* `get_schedule` +* `get_player_stats` +* `get_game` +* A normal 200 response +* A 404 response +* A timeout +* A connection failure +* A retried 500 response +* Invalid JSON +* An injected session +* Context-manager cleanup +* Package imports from the built wheel + +## Out of scope + +Version 0.8.0 will not include: + +* An asynchronous client +* Default response caching +* Persistent storage +* Hardcoded global rate limiting +* Strict exceptions for every 4xx response +* A redesign of public endpoint methods +* A major decomposition of the `Mlb` class +* New analytics features +* New MLB endpoints unrelated to transport reliability +* Issue #245 schedule behavior +* Broad stat type conversions +* Unrelated model cleanup + +These changes should be handled in separate releases or pull requests. + +## Definition of done + +Version 0.8.0 is complete when: + +* Offline tests pass on Python 3.10, 3.11, and 3.12 +* Relevant external MLB tests pass +* Existing public usage remains compatible +* Current 404 behavior remains unchanged +* Every request has an explicit timeout +* Internally created sessions reuse connections +* Temporary failures are retried safely +* Retries are bounded +* Injected sessions are supported +* Caller-owned sessions are not closed +* Library-owned sessions are closed +* New exceptions inherit from `TheMlbStatsApiException` +* Transport failures preserve useful context +* The package builds successfully +* The built wheel installs in a clean environment +* Public API smoke tests pass +* Documentation is complete +* Release notes are accurate +* Publishing requires an explicit release action + +## Final release process + +1. Confirm all planned feature pull requests are merged into `release/0.8.0`. +2. Run the complete test suite. +3. Run the external MLB API tests. +4. Build the package. +5. Install and test the built wheel. +6. Create and test `0.8.0rc1` through TestPyPI if needed. +7. Open the final pull request from `release/0.8.0` to `main`. +8. Review the complete release diff. +9. Confirm CI passes. +10. Merge using a merge commit. +11. Tag the exact merge commit as `v0.8.0`. +12. Publish the GitHub release. +13. Publish the package to PyPI. +14. Confirm the production package installs correctly. +15. Delete the release branch after successful verification. + +## First development task + +The first implementation task is: + +```text +test/http-adapter-contract +``` + +The adapter’s current behavior must be protected by deterministic offline tests before the production transport is refactored. + From e6798d9ff0182cbc796c6976199a897e65d529f8 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 31 Jul 2026 22:22:20 -0700 Subject: [PATCH 2/9] test: establish offline HTTP adapter contract (#253) * test: document MlbDataAdapter and MlbResult contract offline Separate mocked HTTP adapter characterization from live MLB API tests. Add deterministic requests-mock coverage and strict xfail markers for known defects that fix/http-adapter-correctness will address. Co-authored-by: Matthew Spah * test: avoid requiring shared result data reference Co-authored-by: Matthew Spah --------- Co-authored-by: Cursor Agent --- .../mlbdataadapter/test_mlbadapter.py | 83 +------ tests/test_mlb_dataadapter.py | 229 ++++++++++++++++++ tests/test_mlb_result.py | 100 ++++++++ 3 files changed, 334 insertions(+), 78 deletions(-) create mode 100644 tests/test_mlb_dataadapter.py create mode 100644 tests/test_mlb_result.py 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..a826a53 --- /dev/null +++ b/tests/test_mlb_dataadapter.py @@ -0,0 +1,229 @@ +"""Offline characterization tests for MlbDataAdapter. + +These tests document current HTTP adapter behavior with requests-mock before +the transport is refactored in version 0.8.0. Known defects use strict xfail +markers so a future fix causes XPASS until the marker is removed. +""" + +import requests +import pytest + +from mlbstatsapi import MlbDataAdapter, MlbResult, 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"}]} + + +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.data == {} + + +def test_json_500_raises_the_mlb_stats_api_exception(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(TheMlbStatsApiException, match=r"^500: Internal Server Error$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "standard", "group": "hitting"}, + ) + + assert str(exc_info.value) == "500: Internal Server Error" + + +def test_connection_failure_raises_request_failed(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + + with pytest.raises(TheMlbStatsApiException, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert str(exc_info.value) == "Request failed" + assert isinstance(exc_info.value.__cause__, requests.exceptions.ConnectionError) + + +def test_invalid_json_on_successful_response_raises_bad_json(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(TheMlbStatsApiException, match=r"^Bad JSON in response$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "season", "group": "hitting"}, + ) + + assert str(exc_info.value) == "Bad JSON in response" + + +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" + + +@pytest.mark.xfail( + strict=True, + reason=( + "Adapter always JSON-decodes before status handling; " + "fix/http-adapter-correctness should return empty data for empty successful bodies" + ), +) +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.data == {} + + +@pytest.mark.xfail( + strict=True, + reason=( + "Adapter JSON-decodes before HTTP status handling; " + "fix/http-adapter-correctness should return empty data for non-JSON 404 bodies" + ), +) +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.data == {} diff --git a/tests/test_mlb_result.py b/tests/test_mlb_result.py new file mode 100644 index 0000000..9443b1a --- /dev/null +++ b/tests/test_mlb_result.py @@ -0,0 +1,100 @@ +"""Offline characterization tests for MlbResult. + +These tests document current constructor behavior before the HTTP adapter +correctness work in version 0.8.0. Known defects use strict xfail markers. +""" + +import pytest + +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}]} + + +@pytest.mark.xfail( + strict=True, + reason=( + "MlbResult uses a shared mutable default for data; " + "fix/http-adapter-correctness should give each instance its own dict" + ), +) +def test_each_instance_gets_independent_data_dict(): + first = MlbResult(200, "OK") + second = MlbResult(200, "OK") + + assert first.data is not second.data + + +@pytest.mark.xfail( + strict=True, + reason=( + "MlbResult deletes copyright from the caller-owned dictionary; " + "fix/http-adapter-correctness should leave the input unchanged" + ), +) +def test_does_not_mutate_caller_owned_dictionary(): + payload = { + "copyright": "MLB", + "sports": [], + } + + MlbResult(200, "OK", payload) + + assert "copyright" in payload From e63e5ef2033bd32501b986f8608e7ddaf074461b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 05:28:14 +0000 Subject: [PATCH 3/9] fix: correct HTTP adapter response handling Co-authored-by: Matthew Spah --- mlbstatsapi/mlb_dataadapter.py | 95 ++++++++++++++-------- tests/test_mlb_dataadapter.py | 139 ++++++++++++++++++++++----------- tests/test_mlb_result.py | 35 ++++----- 3 files changed, 168 insertions(+), 101 deletions(-) diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index 8648fa6..8c3aa4e 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -18,13 +18,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: @@ -44,7 +49,6 @@ class MlbDataAdapter: def __init__(self, hostname: str = 'statsapi.mlb.com', ver: str = 'v1', logger: logging.Logger = None): self.url = f'https://{hostname}/api/{ver}/' self._logger = logger or logging.getLogger(__name__) - self._logger.setLevel(logging.DEBUG) def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult: """ @@ -76,32 +80,57 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe 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}") - + 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 TheMlbStatsApiException( + f"{status_code}: {response.reason}" + ) + + if not 200 <= status_code <= 299: + raise TheMlbStatsApiException( + f"{status_code}: {response.reason}" + ) + + 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 TheMlbStatsApiException( + "Bad JSON in response" + ) from exc + + return MlbResult( + status_code, + message=response.reason, + data=response_data, + ) diff --git a/tests/test_mlb_dataadapter.py b/tests/test_mlb_dataadapter.py index a826a53..d4af196 100644 --- a/tests/test_mlb_dataadapter.py +++ b/tests/test_mlb_dataadapter.py @@ -1,10 +1,12 @@ -"""Offline characterization tests for MlbDataAdapter. +"""Offline regression tests for MlbDataAdapter. -These tests document current HTTP adapter behavior with requests-mock before -the transport is refactored in version 0.8.0. Known defects use strict xfail -markers so a future fix causes XPASS until the marker is removed. +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 @@ -38,6 +40,49 @@ def test_successful_json_response_returns_exact_values(adapter, requests_mock): 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", @@ -107,6 +152,23 @@ def test_json_404_returns_empty_data(adapter, requests_mock): 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 == {} @@ -132,6 +194,21 @@ def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): assert str(exc_info.value) == "500: Internal Server Error" +def test_html_502_raises_the_mlb_stats_api_exception(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(TheMlbStatsApiException, match=r"^502: Bad Gateway$") as exc_info: + adapter.get(endpoint="sports") + + assert str(exc_info.value) == "502: Bad Gateway" + + def test_connection_failure_raises_request_failed(adapter, requests_mock): requests_mock.get( f"{BASE_URL}sports", @@ -163,6 +240,17 @@ def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_m assert str(exc_info.value) == "Bad JSON in response" +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") @@ -184,46 +272,3 @@ def test_mlb_result_type_coercion(): assert result.status_code == 200 assert result.message == "123" - - -@pytest.mark.xfail( - strict=True, - reason=( - "Adapter always JSON-decodes before status handling; " - "fix/http-adapter-correctness should return empty data for empty successful bodies" - ), -) -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.data == {} - - -@pytest.mark.xfail( - strict=True, - reason=( - "Adapter JSON-decodes before HTTP status handling; " - "fix/http-adapter-correctness should return empty data for non-JSON 404 bodies" - ), -) -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.data == {} diff --git a/tests/test_mlb_result.py b/tests/test_mlb_result.py index 9443b1a..8ebc2b0 100644 --- a/tests/test_mlb_result.py +++ b/tests/test_mlb_result.py @@ -1,11 +1,9 @@ -"""Offline characterization tests for MlbResult. +"""Offline regression tests for MlbResult. -These tests document current constructor behavior before the HTTP adapter -correctness work in version 0.8.0. Known defects use strict xfail markers. +These tests protect MlbResult constructor behavior for version 0.8.0, +including independent data dictionaries and caller-owned dictionary safety. """ -import pytest - from mlbstatsapi import MlbResult @@ -68,33 +66,28 @@ def test_accepts_explicitly_supplied_dictionary(): assert result.data == {"teams": [{"id": 133}]} -@pytest.mark.xfail( - strict=True, - reason=( - "MlbResult uses a shared mutable default for data; " - "fix/http-adapter-correctness should give each instance its own dict" - ), -) 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 -@pytest.mark.xfail( - strict=True, - reason=( - "MlbResult deletes copyright from the caller-owned dictionary; " - "fix/http-adapter-correctness should leave the input unchanged" - ), -) def test_does_not_mutate_caller_owned_dictionary(): payload = { "copyright": "MLB", "sports": [], } - MlbResult(200, "OK", payload) + result = MlbResult(200, "OK", payload) - assert "copyright" in payload + assert payload == { + "copyright": "MLB", + "sports": [], + } + assert result.data == { + "sports": [], + } From cac49c37103268d3cfd1570e1eba830bb4779975 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 05:48:28 +0000 Subject: [PATCH 4/9] feat: add shared HTTP sessions and timeouts Co-authored-by: Matthew Spah --- mlbstatsapi/mlb_api.py | 49 ++++- mlbstatsapi/mlb_dataadapter.py | 34 ++- tests/test_mlb_session.py | 387 +++++++++++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 tests/test_mlb_session.py diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 40657ba..0f6de37 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,7 @@ 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 # from .exceptions import TheMlbStatsApiException from . import mlb_module @@ -38,12 +40,51 @@ 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. + self._owns_session = session is None + self._session = session if session is not None else requests.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 8c3aa4e..5a7cafd 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -1,9 +1,15 @@ from typing import Dict + from .exceptions import TheMlbStatsApiException import requests import logging +# 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] + + class MlbResult: """ A class that holds data, status_code, and message returned from statsapi.mlb.com @@ -46,9 +52,20 @@ 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._timeout = timeout + self._owns_session = session is None + self._session = session if session is not None else requests.Session() + self._closed = False def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult: """ @@ -74,7 +91,11 @@ 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) + response = self._session.get( + url=full_url, + params=ep_params, + timeout=self._timeout, + ) except requests.exceptions.RequestException as e: self._logger.error(msg=(str(e))) @@ -134,3 +155,12 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe 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/tests/test_mlb_session.py b/tests/test_mlb_session.py new file mode 100644 index 0000000..c3ebb71 --- /dev/null +++ b/tests/test_mlb_session.py @@ -0,0 +1,387 @@ +"""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, 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(TheMlbStatsApiException, match=r"^500: Internal Server Error$"): + adapter.get(endpoint="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() From d01340798128baaf9f6043f2ef46ff4627c4ca63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:29:47 +0000 Subject: [PATCH 5/9] feat: add HTTP retries and structured errors Add bounded urllib3 Retry adapters for library-created Sessions and introduce structured transport, timeout, HTTP, and decode exceptions while preserving 4xx empty-result compatibility. Co-authored-by: Matthew Spah --- mlbstatsapi/__init__.py | 8 +- mlbstatsapi/exceptions.py | 32 +++- mlbstatsapi/mlb_api.py | 14 +- mlbstatsapi/mlb_dataadapter.py | 83 ++++++++-- tests/test_mlb_dataadapter.py | 34 ++-- tests/test_mlb_exceptions.py | 171 ++++++++++++++++++++ tests/test_mlb_retries.py | 278 +++++++++++++++++++++++++++++++++ tests/test_mlb_session.py | 9 +- 8 files changed, 603 insertions(+), 26 deletions(-) create mode 100644 tests/test_mlb_exceptions.py create mode 100644 tests/test_mlb_retries.py 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 0f6de37..26921e6 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -22,7 +22,12 @@ from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.standings import Standings -from .mlb_dataadapter import DEFAULT_TIMEOUT, MlbDataAdapter, TimeoutType +from .mlb_dataadapter import ( + DEFAULT_TIMEOUT, + MlbDataAdapter, + TimeoutType, + _configure_retry_adapters, +) # from .exceptions import TheMlbStatsApiException from . import mlb_module @@ -49,8 +54,13 @@ def __init__( ): # 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 - self._session = session if session is not None else requests.Session() + 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( diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index 5a7cafd..f9a416c 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -1,15 +1,65 @@ 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: """ A class that holds data, status_code, and message returned from statsapi.mlb.com @@ -64,7 +114,11 @@ def __init__( self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._owns_session = session is None - self._session = session if session is not None else requests.Session() + 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: @@ -97,9 +151,12 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe timeout=self._timeout, ) - except requests.exceptions.RequestException as e: - self._logger.error(msg=(str(e))) - raise TheMlbStatsApiException('Request failed') from e + 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 @@ -123,13 +180,17 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe response.reason, response.url, )) - raise TheMlbStatsApiException( - f"{status_code}: {response.reason}" + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, ) if not 200 <= status_code <= 299: - raise TheMlbStatsApiException( - f"{status_code}: {response.reason}" + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, ) self._logger.debug(msg=logline_post.format( @@ -146,7 +207,7 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe response_data = response.json() except (ValueError, requests.JSONDecodeError) as exc: self._logger.error(msg=(str(exc))) - raise TheMlbStatsApiException( + raise MlbDecodeError( "Bad JSON in response" ) from exc diff --git a/tests/test_mlb_dataadapter.py b/tests/test_mlb_dataadapter.py index d4af196..521eb6f 100644 --- a/tests/test_mlb_dataadapter.py +++ b/tests/test_mlb_dataadapter.py @@ -10,7 +10,14 @@ import requests import pytest -from mlbstatsapi import MlbDataAdapter, MlbResult, TheMlbStatsApiException +from mlbstatsapi import ( + MlbDataAdapter, + MlbDecodeError, + MlbHttpError, + MlbResult, + MlbTransportError, + TheMlbStatsApiException, +) BASE_URL = "https://statsapi.mlb.com/api/v1/" @@ -172,7 +179,7 @@ def test_non_json_404_returns_empty_data(adapter, requests_mock): assert result.data == {} -def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): +def test_json_500_raises_mlb_http_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}teams/133/stats", json={ @@ -185,16 +192,19 @@ def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): reason="Internal Server Error", ) - with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$") as exc_info: + 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_the_mlb_stats_api_exception(adapter, requests_mock): +def test_html_502_raises_mlb_http_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}sports", text="Bad Gateway", @@ -203,26 +213,30 @@ def test_html_502_raises_the_mlb_stats_api_exception(adapter, requests_mock): headers={"Content-Type": "text/html"}, ) - with pytest.raises(TheMlbStatsApiException, match=r"^502: Bad Gateway$") as exc_info: + 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_request_failed(adapter, requests_mock): +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(TheMlbStatsApiException, match=r"^Request failed$") as exc_info: + 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_bad_json(adapter, requests_mock): +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', @@ -231,13 +245,15 @@ def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_m headers={"Content-Type": "application/json"}, ) - with pytest.raises(TheMlbStatsApiException, match=r"^Bad JSON in response$") as exc_info: + 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(): 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_retries.py b/tests/test_mlb_retries.py new file mode 100644 index 0000000..a9a8b99 --- /dev/null +++ b/tests/test_mlb_retries.py @@ -0,0 +1,278 @@ +"""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(scripted_http_server, no_retry_sleep): + configure, port = scripted_http_server + + 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) + + # Replace handler class on a dedicated server for this case. + 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 index c3ebb71..6f0c6fc 100644 --- a/tests/test_mlb_session.py +++ b/tests/test_mlb_session.py @@ -9,7 +9,7 @@ import pytest import requests -from mlbstatsapi import Mlb, MlbDataAdapter, TheMlbStatsApiException +from mlbstatsapi import Mlb, MlbDataAdapter, MlbHttpError, TheMlbStatsApiException from mlbstatsapi.mlb_dataadapter import DEFAULT_TIMEOUT @@ -144,9 +144,14 @@ def test_adapter_500_behavior_with_session(): ) adapter = MlbDataAdapter(session=session) - with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$"): + 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 --- From 50e6d9d803277ffb7756c343932ba99d1fb21243 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:29:56 +0000 Subject: [PATCH 6/9] test: simplify invalid JSON no-retry coverage Remove an unused local-server fixture dependency from the decode no-retry test. Co-authored-by: Matthew Spah --- tests/test_mlb_retries.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_mlb_retries.py b/tests/test_mlb_retries.py index a9a8b99..0bae978 100644 --- a/tests/test_mlb_retries.py +++ b/tests/test_mlb_retries.py @@ -243,9 +243,7 @@ def test_final_429_returns_empty_mlb_result( assert _ScriptedHandler.request_count == 4 -def test_invalid_json_is_not_retried(scripted_http_server, no_retry_sleep): - configure, port = scripted_http_server - +def test_invalid_json_is_not_retried(no_retry_sleep): class BadJsonHandler(_ScriptedHandler): def do_GET(self) -> None: # noqa: N802 with self.lock: @@ -257,7 +255,6 @@ def do_GET(self) -> None: # noqa: N802 self.end_headers() self.wfile.write(body) - # Replace handler class on a dedicated server for this case. server = ThreadingHTTPServer(("127.0.0.1", 0), BadJsonHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() From c2e3d004eaf110a1f951a785dffa2ab7f2f1315b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:38:52 +0000 Subject: [PATCH 7/9] ci: separate validation and document HTTP transport Make deterministic offline tests the normal CI gate, move live MLB API tests to a manual/scheduled workflow, remove automatic publishing workflows, and document HTTP transport behavior for 0.8.0. Co-authored-by: Matthew Spah --- .../build-and-test-mlbstatsapi-prd.yml | 35 --- .../build-and-test-mlbstatsapi-test.yml | 36 --- .github/workflows/build-and-test.yml | 54 +++- .github/workflows/external-tests.yml | 34 +++ README.md | 61 +++- docs/http-transport.md | 277 ++++++++++++++++++ 6 files changed, 404 insertions(+), 93 deletions(-) delete mode 100644 .github/workflows/build-and-test-mlbstatsapi-prd.yml delete mode 100644 .github/workflows/build-and-test-mlbstatsapi-test.yml create mode 100644 .github/workflows/external-tests.yml create mode 100644 docs/http-transport.md 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..8ab625d 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,29 @@ Ty France Seattle Mariners Seattle ``` +## HTTP Reliability and Configuration + +The client remains synchronous. Sessions and retries are handled automatically for ordinary users, and existing `Mlb()` construction remains valid. + +Prefer a context manager when you want automatic cleanup: + +```python +import mlbstatsapi + +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +``` + +Customize connect and read timeouts when needed: + +```python +mlb = mlbstatsapi.Mlb( + timeout=(5.0, 60.0), +) +``` + +For shared Sessions, Session injection, retries, and structured exceptions, see [HTTP Transport](docs/http-transport.md). + ## Working with Pydantic Models All returned objects are Pydantic models, giving you access to powerful serialization and validation features. @@ -180,17 +203,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 -# Run tests -poetry run pytest +poetry run pytest \ + tests/ \ + --ignore=tests/external_tests +``` + +External tests contact the live MLB API. They require internet access and are separate from normal offline CI: + +```bash +poetry run pytest \ + tests/external_tests/ +``` + +These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. -# Run external tests (requires internet) -poetry run pytest tests/external_tests/ +Full local validation: + +```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 +248,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/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. From 65c0c57660c81075d061b5509c636ede9963e939 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 1 Aug 2026 00:48:43 -0700 Subject: [PATCH 8/9] docs: add README HTTP session, timeout, and retry examples (#263) Expand the Quick Start area with practical copy-and-paste examples for shared Sessions, timeouts, Session injection, retries, and structured exceptions while linking to the detailed transport document. Co-authored-by: Cursor Agent --- README.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8ab625d..0d4998a 100644 --- a/README.md +++ b/README.md @@ -65,28 +65,180 @@ Ty France Seattle Mariners Seattle ``` -## HTTP Reliability and Configuration +## HTTP Sessions, Timeouts, and Retries -The client remains synchronous. Sessions and retries are handled automatically for ordinary users, and existing `Mlb()` construction remains valid. +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. -Prefer a context manager when you want automatic cleanup: +### 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) ``` -Customize connect and read timeouts when needed: +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 -mlb = mlbstatsapi.Mlb( +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) ``` -For shared Sessions, Session injection, retries, and structured exceptions, see [HTTP Transport](docs/http-transport.md). +```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 From 66440a86d393d8a019f08e322cebcc2cca81241a Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 1 Aug 2026 03:59:56 -0700 Subject: [PATCH 9/9] doc: updated release notes and version bumped pyproject --- RELEASE_NOTES_v0.7.1.md | 122 -------- docs/releases/0.7.1.md | 288 ++++++++++++++++++ docs/releases/0.8.0.md | 654 ++++++---------------------------------- pyproject.toml | 2 +- 4 files changed, 373 insertions(+), 693 deletions(-) delete mode 100644 RELEASE_NOTES_v0.7.1.md create mode 100644 docs/releases/0.7.1.md 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/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 index 96158b1..8866341 100644 --- a/docs/releases/0.8.0.md +++ b/docs/releases/0.8.0.md @@ -1,335 +1,68 @@ -# Version 0.8.0 Release Plan - -## Release theme +# python-mlb-statsapi 0.8.0 Version 0.8.0 is the HTTP reliability release. -The purpose of this release is to modernize the network layer without breaking the existing public API or changing how users access MLB data. +This release modernizes the package’s network layer while preserving the existing synchronous public API, endpoint return types, and endpoint-specific 404 behavior. -The release will focus on: +## Highlights -* Shared HTTP sessions -* Explicit timeouts -* Bounded retries -* Better exception types -* Dependency injection for tests -* Session cleanup -* Deterministic offline transport tests -* Correct HTTP response handling -* Safer release automation +### Shared HTTP Sessions -This release will not redesign the endpoint API or convert the package to async. +Each `Mlb` client now uses one shared `requests.Session` for its v1 and v1.1 adapters. -## Primary objective +This allows repeated requests to reuse pooled network connections. -Existing code should continue working without modification: +Library-created Sessions are closed automatically when using the client as a context manager: ```python import mlbstatsapi -mlb = mlbstatsapi.Mlb() -player = mlb.get_person(664034) -``` - -Users should gain better reliability without needing to learn a new interface. - -## Compatibility requirements - -Version 0.8.0 must preserve: - -* The synchronous `Mlb` client -* Existing method names -* Existing positional constructor arguments -* Existing endpoint return types -* `MlbResult.status_code` -* `MlbResult.message` -* `MlbResult.data` -* `TheMlbStatsApiException` -* Current not-found behavior - -Many endpoint methods currently translate 404 responses into: - -* `None` -* `[]` -* `{}` - -That behavior must remain unchanged in version 0.8.0. - -Any new package exception must inherit from: - -```python -TheMlbStatsApiException -``` - -Stricter handling of client errors may be considered for a later release. - -## Branch strategy - -Development will follow this flow: - -```text -feature branch - ↓ -release/0.8.0 - ↓ -main - ↓ -v0.8.0 -``` - -The existing `development` branch will not be used for this release. - -The release branch should be created from an updated `main` after the prerequisite model and test work is merged. - -```bash -git switch main -git pull origin main - -git switch -c release/0.8.0 -git push -u origin release/0.8.0 -``` - -Each version 0.8.0 feature branch must: - -1. Start from `release/0.8.0` -2. Target `release/0.8.0` -3. Contain one focused change -4. Pass relevant offline tests -5. Preserve the compatibility requirements - -Individual feature pull requests should normally be squash-merged. - -The final pull request from `release/0.8.0` to `main` should use a merge commit so the release commits remain visible. - -## Prerequisite work - -Before starting the transport changes: - -* Merge or otherwise resolve PR #249 -* Confirm the full offline test suite runs in CI -* Confirm model alias regression tests are active -* Create `AGENTS.md` on `main` -* Create this release plan on `main` -* Create `release/0.8.0` from the updated `main` - -## Planned pull requests - -## PR 1: Establish the HTTP adapter contract - -Suggested branch: - -```text -test/http-adapter-contract -``` - -### Purpose - -Capture the current HTTP adapter behavior before changing its implementation. - -### Planned changes - -Move mocked transport tests out of `tests/external_tests/`. - -Only tests that intentionally contact the live MLB API should remain in the external test directory. - -Add deterministic offline tests for: - -* Successful 200 responses -* Other valid 2xx responses -* Query parameter forwarding -* 404 responses -* 500 responses -* Connection failures -* Timeouts -* Invalid JSON -* Empty responses -* Copyright removal -* Independent `MlbResult` data dictionaries -* Response reason preservation - -### Acceptance criteria - -* No production behavior changes -* No live network dependency for transport unit tests -* Current 404 behavior is documented by tests -* Current exception behavior is documented by tests -* Offline tests are suitable for required CI checks - -## PR 2: Correct HTTP adapter behavior - -Suggested branch: - -```text -fix/http-adapter-correctness -``` - -### Purpose - -Fix implementation problems that do not require changing the public API. - -### Planned changes - -Correct the success status check. - -Current logic only reliably handles status code 200. It should support the full successful range: - -```python -if 200 <= response.status_code <= 299: +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) ``` -Remove mutable default arguments from `MlbResult`. - -Avoid mutating the original response dictionary when removing the MLB copyright field. - -Handle status codes before decoding error response bodies. - -A server returning HTML with a 500 or 502 status must be treated as an HTTP failure rather than only as invalid JSON. - -Handle valid empty responses safely. +Callers may also inject their own Session. Injected Sessions remain caller-owned and are not closed or reconfigured by the library. -Stop changing the consuming application’s logger level. +### Explicit timeouts -### Acceptance criteria +Every request now has an explicit timeout. -* All valid 2xx responses are handled correctly -* A successful malformed JSON response raises a decoding exception -* A failed HTML response is classified as an HTTP failure -* 404 responses still return empty data -* Existing endpoint behavior remains compatible -* No shared mutable dictionaries remain - -## PR 3: Add shared sessions and timeouts - -Suggested branch: +The default is: ```text -feat/shared-http-session +Connect timeout: 3.05 seconds +Read timeout: 30 seconds ``` -### Purpose - -Reuse HTTP connections and ensure every request has a bounded duration. - -### Proposed public configuration - -```python -mlb = mlbstatsapi.Mlb( - timeout=(3.05, 30.0), - session=None, -) -``` - -The existing positional constructor arguments must remain valid. - -### Planned changes - -* Replace direct `requests.get()` calls with `Session.get()` -* Create one session inside `Mlb` -* Share the session between the v1 and v1.1 adapters -* Allow callers to inject their own session -* Add a default timeout -* Pass the timeout to every request -* Add `close()` -* Add context-manager support -* Track ownership of the session - -### Resource ownership - -If the library creates the session, the library closes it. - -If the caller injects the session, the caller owns it and the library must not close it. - -### Example usage +Custom scalar or connect/read timeout values may be supplied: ```python -with mlbstatsapi.Mlb() as mlb: +with mlbstatsapi.Mlb(timeout=(5.0, 60.0)) as mlb: player = mlb.get_person(664034) ``` -Custom timeout: - -```python -mlb = mlbstatsapi.Mlb(timeout=(5.0, 60.0)) -``` - -Injected session: - -```python -import requests -import mlbstatsapi - -session = requests.Session() -mlb = mlbstatsapi.Mlb(session=session) -``` - -### Acceptance criteria +### Bounded retries -* Every request receives a timeout -* The v1 and v1.1 adapters share the same session -* Internally created sessions are closed -* Injected sessions are not closed -* Existing constructor usage remains valid -* Existing endpoint tests continue passing - -## PR 4: Add retries and structured exceptions - -Suggested branch: +Library-created Sessions automatically retry temporary GET failures for: ```text -feat/http-retries-errors +429 +500 +502 +503 +504 ``` -### Purpose - -Recover automatically from temporary MLB or network failures while giving applications more precise error handling. +The initial request may be followed by up to three retries. Retries use exponential backoff and respect `Retry-After` headers. -### Default retry policy +Ordinary client errors such as 400, 401, 403, and 404 are not retried. -Retries should apply only to safe GET requests. +Caller-injected Sessions retain their existing retry and adapter configuration. -Retry temporary failures such as: +### Structured exceptions -* Connection failures -* Temporary read failures -* HTTP 429 -* HTTP 500 -* HTTP 502 -* HTTP 503 -* HTTP 504 - -Do not retry: - -* HTTP 400 -* HTTP 401 -* HTTP 403 -* HTTP 404 -* JSON decoding failures -* Pydantic validation failures - -Retries must: - -* Be bounded -* Use exponential backoff -* Respect `Retry-After` -* Avoid retrying unsafe methods - -Suggested policy: - -```python -Retry( - total=3, - connect=3, - read=2, - status=3, - backoff_factor=0.5, - status_forcelist=(429, 500, 502, 503, 504), - allowed_methods={"GET"}, - respect_retry_after_header=True, - raise_on_status=False, -) -``` - -### Exception hierarchy +Version 0.8.0 introduces: ```text TheMlbStatsApiException @@ -339,302 +72,83 @@ TheMlbStatsApiException └── MlbDecodeError ``` -### Exception responsibilities - -`MlbTransportError` - -* Connection failures -* DNS failures -* Other request transport failures - -`MlbTimeoutError` - -* Connection timeout -* Read timeout -* Total request timeout behavior exposed by Requests - -`MlbHttpError` - -* Unexpected HTTP status after retries -* Should preserve useful status and request context - -`MlbDecodeError` - -* Successful response containing invalid JSON - -### Compatibility - -Existing code must continue working: - -```python -except TheMlbStatsApiException: - ... -``` - -Applications may optionally use more precise handling: - -```python -except MlbTimeoutError: - ... -except MlbHttpError as exc: - print(exc.status_code) -``` - -A 404 must still not raise in version 0.8.0. - -### Acceptance criteria - -* New exceptions inherit from the existing base exception -* Timeout failures use `MlbTimeoutError` -* Connection failures use `MlbTransportError` -* Invalid successful JSON uses `MlbDecodeError` -* Final server failure after retries uses `MlbHttpError` -* 404 behavior remains unchanged -* Retry counts are covered by deterministic tests - -## PR 5: Update CI and documentation - -Suggested branch: - -```text -docs/v0.8-http-transport -``` - -### Purpose - -Separate deterministic validation from live MLB API validation and document the new HTTP configuration. - -### CI plan - -Required pull request checks should run offline tests on: - -* Python 3.10 -* Python 3.11 -* Python 3.12 - -Suggested command: - -```bash -poetry run pytest tests/ --ignore=tests/external_tests -``` - -Live MLB API tests should run separately: - -```bash -poetry run pytest tests/external_tests/ -``` - -External tests may run: - -* On a schedule -* Manually -* Before a release -* On one supported Python version - -Live MLB availability should not be the only test gate for ordinary pull requests. +All new exceptions inherit from `TheMlbStatsApiException`, preserving compatibility with applications that already catch the package’s base exception. -### Documentation plan - -Document: - -* Default timeout behavior -* Timeout customization -* Shared session behavior -* Session injection -* Session ownership -* Context-manager usage -* Retry status codes -* Retry limits -* Exception hierarchy -* Preserved 404 behavior -* Lack of default caching -* Continued synchronous API - -### Acceptance criteria - -* Offline CI runs on all supported Python versions -* External tests remain available -* Public configuration examples are documented -* Compatibility behavior is documented -* Publishing is separated from normal test workflows - -## Final release preparation - -Suggested branch: +`MlbHttpError` exposes: ```text -release/0.8.0-version +status_code +reason +url ``` -This branch should start from and target `release/0.8.0`. - -### Planned changes - -* Bump package version to `0.8.0` -* Add release notes -* Add changelog entry -* Update README examples if necessary -* Confirm package exports -* Confirm supported Python versions -* Confirm release documentation +### HTTP response correctness -### Acceptance criteria +The HTTP adapter now: -* Package metadata reports version 0.8.0 -* Documentation matches actual behavior -* No unfinished feature flags remain -* Release notes clearly describe compatibility -* Build succeeds +* 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 -## Publishing workflow +### Preserved compatibility -Normal pushes and pull requests should: +This release preserves: -* Install dependencies -* Run tests -* Build the package - -They should not automatically publish a release. - -Production publishing should occur only after an explicit release action or version tag. - -Suggested tags: - -```text -v0.8.0rc1 -v0.8.0rc2 -v0.8.0 -``` - -Release candidates may publish to TestPyPI. - -The final `v0.8.0` tag may publish to production PyPI. - -Do not publish or create tags without explicit authorization. +* 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 `{}` -## Integrated release testing +A final 429 continues through the package’s existing 4xx compatibility behavior. Final 5xx responses raise `MlbHttpError`. -After all feature pull requests are merged into `release/0.8.0`: +### Testing and CI -```bash -git switch release/0.8.0 -git pull origin release/0.8.0 +The release adds deterministic offline transport coverage for: -poetry install -poetry run pytest tests/ -poetry build -``` +* Sessions and ownership +* Timeout forwarding +* Retry behavior +* HTTP status handling +* Empty and malformed responses +* Structured exceptions +* Context-manager cleanup -Install the built wheel into a clean environment: +Offline CI now runs on Python 3.10, 3.11, and 3.12. -```bash -python -m venv /tmp/mlbstatsapi-080 -source /tmp/mlbstatsapi-080/bin/activate +Live MLB API tests run separately through a manual and scheduled workflow. Normal pushes no longer publish automatically to TestPyPI or PyPI. -python -m pip install --upgrade pip -python -m pip install dist/*.whl -``` +The final release package was validated by: -Run a public API smoke test: +* The deterministic offline test suite +* The live MLB API test suite +* A clean wheel installation +* Public import checks +* Live README example execution -```python -import mlbstatsapi +## Documentation -with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) - print(player.full_name) +The README now includes practical examples for: - team = mlb.get_team(136) - print(team.name) -``` +* Context-manager usage +* Shared Sessions +* Custom timeouts +* Injected Sessions +* Retry behavior +* Structured exceptions -## Required smoke coverage - -Test at least: - -* `get_person` -* `get_team` -* `get_schedule` -* `get_player_stats` -* `get_game` -* A normal 200 response -* A 404 response -* A timeout -* A connection failure -* A retried 500 response -* Invalid JSON -* An injected session -* Context-manager cleanup -* Package imports from the built wheel +See `docs/http-transport.md` for the complete HTTP transport documentation. -## Out of scope +## Not included -Version 0.8.0 will not include: +Version 0.8.0 does not add: -* An asynchronous client -* Default response caching -* Persistent storage -* Hardcoded global rate limiting +* Async support +* Response caching +* New MLB endpoints * Strict exceptions for every 4xx response -* A redesign of public endpoint methods -* A major decomposition of the `Mlb` class -* New analytics features -* New MLB endpoints unrelated to transport reliability -* Issue #245 schedule behavior -* Broad stat type conversions -* Unrelated model cleanup - -These changes should be handled in separate releases or pull requests. - -## Definition of done - -Version 0.8.0 is complete when: - -* Offline tests pass on Python 3.10, 3.11, and 3.12 -* Relevant external MLB tests pass -* Existing public usage remains compatible -* Current 404 behavior remains unchanged -* Every request has an explicit timeout -* Internally created sessions reuse connections -* Temporary failures are retried safely -* Retries are bounded -* Injected sessions are supported -* Caller-owned sessions are not closed -* Library-owned sessions are closed -* New exceptions inherit from `TheMlbStatsApiException` -* Transport failures preserve useful context -* The package builds successfully -* The built wheel installs in a clean environment -* Public API smoke tests pass -* Documentation is complete -* Release notes are accurate -* Publishing requires an explicit release action - -## Final release process - -1. Confirm all planned feature pull requests are merged into `release/0.8.0`. -2. Run the complete test suite. -3. Run the external MLB API tests. -4. Build the package. -5. Install and test the built wheel. -6. Create and test `0.8.0rc1` through TestPyPI if needed. -7. Open the final pull request from `release/0.8.0` to `main`. -8. Review the complete release diff. -9. Confirm CI passes. -10. Merge using a merge commit. -11. Tag the exact merge commit as `v0.8.0`. -12. Publish the GitHub release. -13. Publish the package to PyPI. -14. Confirm the production package installs correctly. -15. Delete the release branch after successful verification. - -## First development task - -The first implementation task is: - -```text -test/http-adapter-contract -``` - -The adapter’s current behavior must be protected by deterministic offline tests before the production transport is refactored. - +* Global rate limiting 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 ",