diff --git a/tests/http_contract_support.py b/tests/http_contract_support.py index df1de81..5b17318 100644 --- a/tests/http_contract_support.py +++ b/tests/http_contract_support.py @@ -8,6 +8,7 @@ from __future__ import annotations +import pytest from urllib3.util.retry import Retry @@ -19,6 +20,7 @@ 401, 403, 405, + 409, 422, 429, ) @@ -49,6 +51,7 @@ 403, 404, 405, + 409, 422, ) @@ -58,6 +61,7 @@ 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", + 409: "Conflict", 422: "Unprocessable Entity", 429: "Too Many Requests", 500: "Internal Server Error", @@ -85,3 +89,43 @@ def assert_library_retry_policy(retry: Retry) -> None: assert "POST" not in retry.allowed_methods assert "PATCH" not in retry.allowed_methods assert "DELETE" not in retry.allowed_methods + + +API_VERSIONS = ("v1", "v1.1") + +# Pending version 1.0 default strict HTTP behavior (#284). +XFAIL_PENDING_STRICT_DEFAULT = pytest.mark.xfail( + strict=True, + reason="Pending #284: strict HTTP behavior becomes the 1.0 default", +) + +# Pending compatibility warning caller location via public Mlb endpoints (#285). +XFAIL_PENDING_WARNING_CALL_SITE = pytest.mark.xfail( + strict=True, + reason="Pending #285: compatibility warning must point to the public caller", +) + + +def adapter_for_api_version(mlb, api_version: str): + """Return the internal MlbDataAdapter for v1 or v1.1.""" + if api_version == "v1": + return mlb._mlb_adapter_v1 + if api_version == "v1.1": + return mlb._mlb_adapter_v1_1 + raise ValueError(f"unsupported api_version: {api_version!r}") + + +def standalone_adapter_for_version( + session, + api_version: str, + *, + strict_http: bool = False, +) -> "MlbDataAdapter": + """Build a versioned MlbDataAdapter sharing a mocked session.""" + from mlbstatsapi import MlbDataAdapter + + return MlbDataAdapter( + session=session, + ver=api_version, + strict_http=strict_http, + ) diff --git a/tests/test_http_contract.py b/tests/test_http_contract.py index 2193d54..447dddb 100644 --- a/tests/test_http_contract.py +++ b/tests/test_http_contract.py @@ -1,7 +1,7 @@ -"""High-level offline HTTP compatibility and strict-mode contract for version 0.9.0. +"""Offline HTTP contract tests for version 1.0 strict defaults and compatibility mode. -Protects existing version 0.8.0 public compatibility behavior and the opt-in -strict HTTP mode introduced for version 0.9.0. +Documents the version 1.0 HTTP behavior in deterministic tests. Unimplemented +1.0 default wiring is marked xfail pending issue #284. """ from __future__ import annotations @@ -25,11 +25,15 @@ from mlbstatsapi.mlb_dataadapter import DEFAULT_TIMEOUT from http_contract_support import ( + API_VERSIONS, COMPATIBILITY_CLIENT_ERRORS, HTTP_REASON_BY_STATUS, NOT_FOUND_STATUS, SERVER_ERRORS, + XFAIL_PENDING_STRICT_DEFAULT, + adapter_for_api_version, assert_library_retry_policy, + standalone_adapter_for_version, ) # Non-404 client errors asserted under strict mode (final 429 covered in retries). @@ -99,32 +103,44 @@ def close(self): def test_status_matrices_document_compatibility_baseline(): - """Keep the shared status groups stable for later strict-mode tests.""" - assert COMPATIBILITY_CLIENT_ERRORS == (400, 401, 403, 405, 422, 429) - assert STRICT_NON_404_CLIENT_ERRORS == (400, 401, 403, 405, 422) + """Keep the shared status groups stable for contract and retry tests.""" + assert COMPATIBILITY_CLIENT_ERRORS == (400, 401, 403, 405, 409, 422, 429) + assert STRICT_NON_404_CLIENT_ERRORS == (400, 401, 403, 405, 409, 422) assert NOT_FOUND_STATUS == 404 assert SERVER_ERRORS == (500, 502, 503, 504) assert 404 not in COMPATIBILITY_CLIENT_ERRORS + assert 409 in COMPATIBILITY_CLIENT_ERRORS assert 429 in COMPATIBILITY_CLIENT_ERRORS assert set(SERVER_ERRORS).isdisjoint(COMPATIBILITY_CLIENT_ERRORS) -# --- Default / explicit mode wiring --- +# --- Version 1.0 default strict wiring (pending implementation #284) --- -def test_mlb_default_uses_compatibility_mode(): - """Mlb() defaults to compatibility mode on the client and both adapters.""" +@XFAIL_PENDING_STRICT_DEFAULT +def test_mlb_default_matches_explicit_strict_mode_wiring(): + """Mlb() must default to strict mode on the client and both adapters.""" mlb = Mlb() try: - assert mlb._strict_http is False - assert mlb._mlb_adapter_v1._strict_http is False - assert mlb._mlb_adapter_v1_1._strict_http is False + assert mlb._strict_http is True + assert mlb._mlb_adapter_v1._strict_http is True + assert mlb._mlb_adapter_v1_1._strict_http is True finally: mlb.close() -def test_mlb_explicit_compatibility_mode_matches_default(): - """Mlb(strict_http=False) matches the default compatibility wiring.""" +@XFAIL_PENDING_STRICT_DEFAULT +def test_mlb_data_adapter_default_is_strict(): + """MlbDataAdapter() must default to strict HTTP in version 1.0.""" + adapter = MlbDataAdapter() + try: + assert adapter._strict_http is True + finally: + adapter.close() + + +def test_mlb_explicit_compatibility_mode_wiring(): + """Mlb(strict_http=False) keeps compatibility mode on both adapters.""" mlb = Mlb(strict_http=False) try: assert mlb._strict_http is False @@ -145,40 +161,74 @@ def test_mlb_explicit_strict_mode_wires_both_adapters(): mlb.close() -# --- Default 4xx compatibility (empty MlbResult, no MlbHttpError) --- +@pytest.mark.parametrize("api_version", API_VERSIONS) +def test_explicit_strict_mode_wires_both_api_versions(api_version): + """strict_http=True applies to standalone v1 and v1.1 adapters.""" + adapter = MlbDataAdapter(ver=api_version, strict_http=True) + try: + assert adapter._strict_http is True + finally: + adapter.close() +@pytest.mark.parametrize("api_version", API_VERSIONS) +def test_explicit_compatibility_mode_wires_both_api_versions(api_version): + """strict_http=False applies to standalone v1 and v1.1 adapters.""" + adapter = MlbDataAdapter(ver=api_version, strict_http=False) + try: + assert adapter._strict_http is False + finally: + adapter.close() + + +# --- Explicit compatibility mode: empty MlbResult, no MlbHttpError --- + + +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", COMPATIBILITY_CLIENT_ERRORS) -def test_compatibility_client_errors_return_empty_mlb_result(status_code): - """Non-404 4xx responses return an empty MlbResult via the Mlb adapters.""" +def test_compatibility_client_errors_return_empty_mlb_result( + api_version, + status_code, +): + """Non-404 4xx responses return an empty MlbResult in compatibility mode.""" reason = HTTP_REASON_BY_STATUS[status_code] - url = "https://statsapi.mlb.com/api/v1/sports" + url = f"https://statsapi.mlb.com/api/{api_version}/sports" session = MagicMock() session.get.return_value = _response( status_code=status_code, reason=reason, url=url, ) - mlb = Mlb(session=session) + mlb = Mlb(session=session, strict_http=False) + adapter = adapter_for_api_version(mlb, api_version) - result = mlb._mlb_adapter_v1.get(endpoint="sports") + result = adapter.get(endpoint="sports") assert isinstance(result, MlbResult) assert result.status_code == status_code assert result.data == {} +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", COMPATIBILITY_CLIENT_ERRORS) -def test_compatibility_client_errors_do_not_raise_mlb_http_error(status_code): +def test_compatibility_client_errors_do_not_raise_mlb_http_error( + api_version, + status_code, +): """Compatibility-mode client errors must not raise MlbHttpError.""" reason = HTTP_REASON_BY_STATUS[status_code] + url = f"https://statsapi.mlb.com/api/{api_version}/sports" session = MagicMock() session.get.return_value = _response( status_code=status_code, reason=reason, - url="https://statsapi.mlb.com/api/v1/sports", + url=url, + ) + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=False, ) - adapter = MlbDataAdapter(session=session) result = adapter.get(endpoint="sports") @@ -186,36 +236,88 @@ def test_compatibility_client_errors_do_not_raise_mlb_http_error(status_code): assert result.data == {} +# --- Version 1.0 default: final non-404 4xx raises (pending #284) --- + + +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", COMPATIBILITY_CLIENT_ERRORS) -def test_explicit_compatibility_mode_client_errors_return_empty_mlb_result( +@XFAIL_PENDING_STRICT_DEFAULT +def test_default_adapter_raises_on_final_non_404_client_error( + api_version, status_code, ): - """Mlb(strict_http=False) preserves empty MlbResult for non-404 4xx.""" + """Default MlbDataAdapter() raises MlbHttpError for final non-404 4xx.""" + reason = HTTP_REASON_BY_STATUS[status_code] + url = f"https://statsapi.mlb.com/api/{api_version}/sports" + payload = {"error": reason} + body = json.dumps(payload).encode("utf-8") + session = MagicMock() + session.get.return_value = _response( + status_code=status_code, + reason=reason, + url=url, + content=body, + payload=payload, + ) + adapter = standalone_adapter_for_version(session, api_version) + + with pytest.raises(MlbHttpError) as exc_info: + adapter.get(endpoint="sports") + + exc = exc_info.value + assert isinstance(exc, TheMlbStatsApiException) + assert exc.status_code == status_code + assert exc.reason == reason + assert exc.url == url + assert exc.method == "GET" + assert exc.response_data == payload + assert exc.body_excerpt is not None + + +@pytest.mark.parametrize("status_code", COMPATIBILITY_CLIENT_ERRORS) +@XFAIL_PENDING_STRICT_DEFAULT +def test_default_mlb_raises_on_final_non_404_client_error(status_code): + """Default Mlb() raises MlbHttpError for final non-404 4xx.""" reason = HTTP_REASON_BY_STATUS[status_code] url = "https://statsapi.mlb.com/api/v1/sports" + payload = {"message": "client error", "code": status_code} + body = json.dumps(payload).encode("utf-8") session = MagicMock() session.get.return_value = _response( status_code=status_code, reason=reason, url=url, + content=body, + payload=payload, ) - mlb = Mlb(session=session, strict_http=False) + mlb = Mlb(session=session) - result = mlb._mlb_adapter_v1.get(endpoint="sports") + with pytest.raises(MlbHttpError) as exc_info: + mlb._mlb_adapter_v1.get(endpoint="sports") - assert isinstance(result, MlbResult) - assert result.status_code == status_code - assert result.data == {} + exc = exc_info.value + assert isinstance(exc, TheMlbStatsApiException) + assert exc.status_code == status_code + assert exc.reason == reason + assert exc.url == url + assert exc.method == "GET" + assert exc.response_data == payload + assert exc.body_excerpt is not None + assert "client error" in exc.body_excerpt # --- Strict non-404 4xx raises enriched MlbHttpError --- +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", STRICT_NON_404_CLIENT_ERRORS) -def test_strict_non_404_client_errors_raise_enriched_mlb_http_error(status_code): +def test_strict_non_404_client_errors_raise_enriched_mlb_http_error( + api_version, + status_code, +): """Strict mode raises MlbHttpError with richer context for non-404 4xx.""" reason = HTTP_REASON_BY_STATUS[status_code] - url = "https://statsapi.mlb.com/api/v1/sports" + url = f"https://statsapi.mlb.com/api/{api_version}/sports" payload = {"message": "client error", "code": status_code} body = json.dumps(payload).encode("utf-8") session = MagicMock() @@ -227,9 +329,10 @@ def test_strict_non_404_client_errors_raise_enriched_mlb_http_error(status_code) payload=payload, ) mlb = Mlb(session=session, strict_http=True) + adapter = adapter_for_api_version(mlb, api_version) with pytest.raises(MlbHttpError) as exc_info: - mlb._mlb_adapter_v1.get(endpoint="sports") + adapter.get(endpoint="sports") exc = exc_info.value assert isinstance(exc, TheMlbStatsApiException) @@ -242,11 +345,15 @@ def test_strict_non_404_client_errors_raise_enriched_mlb_http_error(status_code) assert "client error" in exc.body_excerpt +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", STRICT_NON_404_CLIENT_ERRORS) -def test_strict_adapter_non_404_client_errors_raise_mlb_http_error(status_code): +def test_strict_adapter_non_404_client_errors_raise_mlb_http_error( + api_version, + status_code, +): """Standalone adapters honor strict_http for non-404 4xx responses.""" reason = HTTP_REASON_BY_STATUS[status_code] - url = "https://statsapi.mlb.com/api/v1/sports" + url = f"https://statsapi.mlb.com/api/{api_version}/sports" payload = {"error": reason} body = json.dumps(payload).encode("utf-8") session = MagicMock() @@ -257,7 +364,11 @@ def test_strict_adapter_non_404_client_errors_raise_mlb_http_error(status_code): content=body, payload=payload, ) - adapter = MlbDataAdapter(session=session, strict_http=True) + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=True, + ) with pytest.raises(MlbHttpError) as exc_info: adapter.get(endpoint="sports") @@ -274,7 +385,14 @@ def test_strict_adapter_non_404_client_errors_raise_mlb_http_error(status_code): # --- Endpoint-specific 404 return shapes via public Mlb methods --- -def test_mlb_get_person_404_returns_none(): +def _mlb_for_strict_http(session, strict_http): + if strict_http == "default": + return Mlb(session=session) + return Mlb(session=session, strict_http=strict_http) + + +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_mlb_get_person_404_returns_none(strict_http): """Single-object endpoints keep returning None on 404.""" session = MagicMock() session.get.return_value = _response( @@ -282,12 +400,13 @@ def test_mlb_get_person_404_returns_none(): reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], url="https://statsapi.mlb.com/api/v1/people/999999", ) - mlb = Mlb(session=session) + mlb = _mlb_for_strict_http(session, strict_http) assert mlb.get_person(999999) is None -def test_mlb_get_teams_404_returns_empty_list(): +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_mlb_get_teams_404_returns_empty_list(strict_http): """Collection endpoints keep returning an empty list on 404.""" session = MagicMock() session.get.return_value = _response( @@ -295,12 +414,13 @@ def test_mlb_get_teams_404_returns_empty_list(): reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], url="https://statsapi.mlb.com/api/v1/teams", ) - mlb = Mlb(session=session) + mlb = _mlb_for_strict_http(session, strict_http) assert mlb.get_teams() == [] -def test_mlb_get_player_stats_404_returns_empty_mapping(): +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_mlb_get_player_stats_404_returns_empty_mapping(strict_http): """Mapping endpoints keep returning an empty dict on 404.""" session = MagicMock() session.get.return_value = _response( @@ -308,12 +428,13 @@ def test_mlb_get_player_stats_404_returns_empty_mapping(): reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], url="https://statsapi.mlb.com/api/v1/people/999999/stats", ) - mlb = Mlb(session=session) + mlb = _mlb_for_strict_http(session, strict_http) assert mlb.get_player_stats(999999, ["season"], ["hitting"]) == {} -def test_mlb_endpoint_404_does_not_raise_mlb_http_error(): +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_mlb_endpoint_404_does_not_raise_mlb_http_error(strict_http): """Public 404 handling stays domain-level empty results, not MlbHttpError.""" session = MagicMock() session.get.return_value = _response( @@ -321,21 +442,22 @@ def test_mlb_endpoint_404_does_not_raise_mlb_http_error(): reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], url="https://statsapi.mlb.com/api/v1/people/999999", ) - mlb = Mlb(session=session) + mlb = _mlb_for_strict_http(session, strict_http) assert mlb.get_person(999999) is None assert mlb.get_teams() == [] assert mlb.get_player_stats(999999, ["season"], ["hitting"]) == {} +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("strict_http", [False, True]) -def test_404_preserves_endpoint_shapes_in_both_modes(strict_http): +def test_404_preserves_endpoint_shapes_in_both_modes(api_version, strict_http): """404 keeps None / [] / {} endpoint shapes in compatibility and strict modes.""" session = MagicMock() session.get.return_value = _response( status_code=NOT_FOUND_STATUS, reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], - url="https://statsapi.mlb.com/api/v1/people/999999", + url=f"https://statsapi.mlb.com/api/{api_version}/people/999999", ) mlb = Mlb(session=session, strict_http=strict_http) @@ -344,17 +466,25 @@ def test_404_preserves_endpoint_shapes_in_both_modes(strict_http): assert mlb.get_player_stats(999999, ["season"], ["hitting"]) == {} -@pytest.mark.parametrize("strict_http", [False, True]) -def test_adapter_404_returns_mlb_result_in_both_modes(strict_http): - """Adapters return a 404 MlbResult rather than raising in either mode.""" - url = "https://statsapi.mlb.com/api/v1/people/999999" +@pytest.mark.parametrize("api_version", API_VERSIONS) +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_adapter_404_returns_mlb_result_in_all_modes(api_version, strict_http): + """Adapters return a 404 MlbResult rather than raising in any HTTP mode.""" + url = f"https://statsapi.mlb.com/api/{api_version}/people/999999" session = MagicMock() session.get.return_value = _response( status_code=NOT_FOUND_STATUS, reason=HTTP_REASON_BY_STATUS[NOT_FOUND_STATUS], url=url, ) - adapter = MlbDataAdapter(session=session, strict_http=strict_http) + if strict_http == "default": + adapter = standalone_adapter_for_version(session, api_version) + else: + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=strict_http, + ) result = adapter.get(endpoint="people/999999") @@ -394,14 +524,16 @@ def test_mlb_server_errors_raise_mlb_http_error_with_existing_attributes(status_ @pytest.mark.parametrize("status_code", [500, 502]) -@pytest.mark.parametrize("strict_http", [False, True]) -def test_server_errors_raise_enriched_mlb_http_error_in_both_modes( +@pytest.mark.parametrize("api_version", API_VERSIONS) +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_server_errors_raise_enriched_mlb_http_error_in_all_modes( status_code, + api_version, strict_http, ): - """Final 5xx responses raise enriched MlbHttpError in both HTTP modes.""" + """Final 5xx responses raise enriched MlbHttpError in every HTTP mode.""" reason = HTTP_REASON_BY_STATUS[status_code] - url = "https://statsapi.mlb.com/api/v1/sports" + url = f"https://statsapi.mlb.com/api/{api_version}/sports" payload = {"message": "server error", "status": status_code} body = json.dumps(payload).encode("utf-8") session = MagicMock() @@ -412,10 +544,14 @@ def test_server_errors_raise_enriched_mlb_http_error_in_both_modes( content=body, payload=payload, ) - mlb = Mlb(session=session, strict_http=strict_http) + if strict_http == "default": + mlb = Mlb(session=session) + else: + mlb = Mlb(session=session, strict_http=strict_http) + adapter = adapter_for_api_version(mlb, api_version) with pytest.raises(MlbHttpError) as exc_info: - mlb._mlb_adapter_v1.get(endpoint="sports") + adapter.get(endpoint="sports") exc = exc_info.value assert exc.status_code == status_code @@ -427,15 +563,24 @@ def test_server_errors_raise_enriched_mlb_http_error_in_both_modes( assert "server error" in exc.body_excerpt -# --- Transport and decode errors remain unchanged in strict mode --- +# --- Transport and decode errors are independent of compatibility mode --- -def test_strict_mode_timeout_still_raises_mlb_timeout_error(): - """Strict mode does not convert timeouts into MlbHttpError.""" +@pytest.mark.parametrize("api_version", API_VERSIONS) +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_timeout_raises_mlb_timeout_error(api_version, strict_http): + """Timeouts raise MlbTimeoutError in every HTTP mode.""" original = requests.exceptions.Timeout("timed out") session = MagicMock() session.get.side_effect = original - adapter = MlbDataAdapter(session=session, strict_http=True) + if strict_http == "default": + adapter = standalone_adapter_for_version(session, api_version) + else: + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=strict_http, + ) with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: adapter.get(endpoint="sports") @@ -445,12 +590,21 @@ def test_strict_mode_timeout_still_raises_mlb_timeout_error(): assert exc_info.value.__cause__ is original -def test_strict_mode_connection_failure_still_raises_mlb_transport_error(): - """Strict mode does not convert connection failures into MlbHttpError.""" +@pytest.mark.parametrize("api_version", API_VERSIONS) +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_connection_failure_raises_mlb_transport_error(api_version, strict_http): + """Connection failures raise MlbTransportError in every HTTP mode.""" original = requests.exceptions.ConnectionError("connection refused") session = MagicMock() session.get.side_effect = original - adapter = MlbDataAdapter(session=session, strict_http=True) + if strict_http == "default": + adapter = standalone_adapter_for_version(session, api_version) + else: + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=strict_http, + ) with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info: adapter.get(endpoint="sports") @@ -460,9 +614,11 @@ def test_strict_mode_connection_failure_still_raises_mlb_transport_error(): assert exc_info.value.__cause__ is original -def test_strict_mode_invalid_json_still_raises_mlb_decode_error(): - """Strict mode does not convert 2xx JSON decode failures into MlbHttpError.""" - url = "https://statsapi.mlb.com/api/v1/sports" +@pytest.mark.parametrize("api_version", API_VERSIONS) +@pytest.mark.parametrize("strict_http", ["default", False, True]) +def test_invalid_json_raises_mlb_decode_error(api_version, strict_http): + """Malformed successful JSON raises MlbDecodeError in every HTTP mode.""" + url = f"https://statsapi.mlb.com/api/{api_version}/sports" session = MagicMock() session.get.return_value = _response( status_code=200, @@ -471,9 +627,15 @@ def test_strict_mode_invalid_json_still_raises_mlb_decode_error(): content=b'{"bad": json', text='{"bad": json', ) - # Force json() to raise like a real Response with malformed body. session.get.return_value.json.side_effect = ValueError("Expecting value") - adapter = MlbDataAdapter(session=session, strict_http=True) + if strict_http == "default": + adapter = standalone_adapter_for_version(session, api_version) + else: + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=strict_http, + ) with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info: adapter.get(endpoint="sports") diff --git a/tests/test_http_warnings.py b/tests/test_http_warnings.py index f19e9bb..92343d6 100644 --- a/tests/test_http_warnings.py +++ b/tests/test_http_warnings.py @@ -26,10 +26,14 @@ ) from http_contract_support import ( + API_VERSIONS, COMPATIBILITY_CLIENT_ERRORS, HTTP_REASON_BY_STATUS, NOT_FOUND_STATUS, SERVER_ERRORS, + XFAIL_PENDING_WARNING_CALL_SITE, + adapter_for_api_version, + standalone_adapter_for_version, ) # Final 429 is retried first, so it is covered in tests/test_mlb_retries.py. @@ -129,11 +133,22 @@ def test_compatibility_warning_inherits_future_warning(): # --- Compatibility-mode non-404 4xx warns exactly once --- +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", WARNING_CLIENT_ERRORS) -def test_compatibility_client_errors_warn_once_and_return_empty_result(status_code): +def test_compatibility_client_errors_warn_once_and_return_empty_result( + api_version, + status_code, +): """Non-404 4xx warns once while preserving the historical empty result.""" - session = _session_for_status(status_code) - adapter = MlbDataAdapter(session=session) + session = _session_for_status( + status_code, + url=f"https://statsapi.mlb.com/api/{api_version}/sports", + ) + adapter = standalone_adapter_for_version( + session, + api_version, + strict_http=False, + ) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: result = adapter.get(endpoint="sports") @@ -148,7 +163,7 @@ def test_compatibility_client_errors_warn_once_and_return_empty_result(status_co def test_compatibility_warning_message_contains_migration_guidance(status_code): """The message carries status, URL, mode, migration, and version guidance.""" session = _session_for_status(status_code) - adapter = MlbDataAdapter(session=session) + adapter = MlbDataAdapter(session=session, strict_http=False) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: adapter.get(endpoint="sports") @@ -166,7 +181,7 @@ def test_compatibility_warning_excludes_response_body(): """Response bodies must never leak into the warning message.""" payload = {"message": "secret client error detail"} session = _session_for_status(403, payload=payload) - adapter = MlbDataAdapter(session=session) + adapter = MlbDataAdapter(session=session, strict_http=False) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: adapter.get(endpoint="sports") @@ -183,7 +198,7 @@ def test_compatibility_warning_falls_back_to_request_url(): reason=HTTP_REASON_BY_STATUS[403], url=None, ) - adapter = MlbDataAdapter(session=_session_returning(response)) + adapter = MlbDataAdapter(session=_session_returning(response), strict_http=False) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: adapter.get(endpoint="sports") @@ -191,30 +206,37 @@ def test_compatibility_warning_falls_back_to_request_url(): assert SPORTS_URL in str(warning_info[0].message) -# --- Client wiring: default and explicit compatibility mode --- +# --- Explicit compatibility mode on Mlb and adapters --- +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", WARNING_CLIENT_ERRORS) -def test_default_mlb_client_warns_for_non_404_client_errors(status_code): - """Mlb() stays in compatibility mode and receives the migration notice.""" - session = _session_for_status(status_code) - mlb = Mlb(session=session) +def test_explicit_compatibility_mlb_client_warns_for_non_404_client_errors( + api_version, + status_code, +): + """Mlb(strict_http=False) warns once for non-404 4xx.""" + session = _session_for_status( + status_code, + url=f"https://statsapi.mlb.com/api/{api_version}/sports", + ) + mlb = Mlb(session=session, strict_http=False) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: - result = mlb._mlb_adapter_v1.get(endpoint="sports") + result = adapter_for_api_version(mlb, api_version).get(endpoint="sports") assert result.status_code == status_code assert result.data == {} assert len(warning_info) == 1 -def test_default_mlb_client_public_endpoint_warns_and_keeps_return_shape(): +def test_explicit_compatibility_mlb_public_endpoint_warns_and_keeps_return_shape(): """A public endpoint keeps returning None for 4xx while warning once.""" session = _session_for_status( 403, url="https://statsapi.mlb.com/api/v1/people/664034", ) - mlb = Mlb(session=session) + mlb = Mlb(session=session, strict_http=False) with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: person = mlb.get_person(664034) @@ -241,21 +263,29 @@ def test_explicit_compatibility_mode_warns_and_preserves_result(status_code): # --- Strict mode raises instead of warning --- +@pytest.mark.parametrize("api_version", API_VERSIONS) @pytest.mark.parametrize("status_code", WARNING_CLIENT_ERRORS) -def test_strict_mode_raises_without_compatibility_warning(status_code): +def test_strict_mode_raises_without_compatibility_warning( + api_version, + status_code, +): """Strict mode raises MlbHttpError and emits no compatibility warning.""" payload = {"error": HTTP_REASON_BY_STATUS[status_code]} - session = _session_for_status(status_code, payload=payload) + session = _session_for_status( + status_code, + url=f"https://statsapi.mlb.com/api/{api_version}/sports", + payload=payload, + ) mlb = Mlb(session=session, strict_http=True) with _recorded_compatibility_warnings() as caught: with pytest.raises(MlbHttpError) as exc_info: - mlb._mlb_adapter_v1.get(endpoint="sports") + adapter_for_api_version(mlb, api_version).get(endpoint="sports") exc = exc_info.value assert exc.status_code == status_code assert exc.reason == HTTP_REASON_BY_STATUS[status_code] - assert exc.url == SPORTS_URL + assert exc.url.endswith(f"/api/{api_version}/sports") assert exc.method == "GET" assert exc.response_data == payload assert _compatibility_warnings(caught) == [] @@ -402,7 +432,7 @@ def test_decode_failure_does_not_warn(strict_http): def test_caller_can_turn_the_warning_into_an_exception(): """A caller may promote only this category to an error.""" session = _session_for_status(403) - adapter = MlbDataAdapter(session=session) + adapter = MlbDataAdapter(session=session, strict_http=False) with warnings.catch_warnings(): warnings.simplefilter("error", MlbHttpCompatibilityWarning) @@ -413,7 +443,7 @@ def test_caller_can_turn_the_warning_into_an_exception(): def test_caller_can_ignore_only_this_warning_category(): """Ignoring the category keeps the historical compatibility result.""" session = _session_for_status(403) - adapter = MlbDataAdapter(session=session) + adapter = MlbDataAdapter(session=session, strict_http=False) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") @@ -428,10 +458,58 @@ def test_caller_can_ignore_only_this_warning_category(): def test_each_request_emits_its_own_warning(): """Every final non-404 4xx warns; deduplication is left to warning filters.""" session = _session_for_status(429) - adapter = MlbDataAdapter(session=session) + adapter = MlbDataAdapter(session=session, strict_http=False) with _recorded_compatibility_warnings() as caught: adapter.get(endpoint="sports") adapter.get(endpoint="sports") assert len(_compatibility_warnings(caught)) == 2 + + +# --- Warning call-site location --- + + +def test_compatibility_warning_points_to_direct_adapter_caller_line(): + """Direct adapter.get() warnings must reference the test caller line.""" + import inspect + + session = _session_for_status(403) + adapter = MlbDataAdapter(session=session, strict_http=False) + this_file = __file__ + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MlbHttpCompatibilityWarning) + expected_lineno = inspect.currentframe().f_lineno + 1 + adapter.get(endpoint="sports") + + compatibility = _compatibility_warnings(caught) + assert len(compatibility) == 1 + warning = compatibility[0] + assert warning.filename == this_file + assert warning.lineno == expected_lineno + + +@XFAIL_PENDING_WARNING_CALL_SITE +def test_compatibility_warning_points_to_public_mlb_endpoint_caller_line(): + """Public Mlb endpoint warnings must reference the application caller line.""" + import inspect + + session = _session_for_status( + 403, + url="https://statsapi.mlb.com/api/v1/people/664034", + ) + mlb = Mlb(session=session, strict_http=False) + this_file = __file__ + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MlbHttpCompatibilityWarning) + expected_lineno = inspect.currentframe().f_lineno + 1 + person = mlb.get_person(664034) + + assert person is None + compatibility = _compatibility_warnings(caught) + assert len(compatibility) == 1 + warning = compatibility[0] + assert warning.filename == this_file + assert warning.lineno == expected_lineno diff --git a/tests/test_mlb_retries.py b/tests/test_mlb_retries.py index 6fab006..a66eccf 100644 --- a/tests/test_mlb_retries.py +++ b/tests/test_mlb_retries.py @@ -27,6 +27,7 @@ NON_RETRYABLE_CLIENT_ERRORS, RETRYABLE_STATUS_CODES, SERVER_ERRORS, + XFAIL_PENDING_STRICT_DEFAULT, assert_library_retry_policy, ) @@ -285,14 +286,14 @@ def test_bounded_persistent_server_errors_raise_after_four_attempts( assert _ScriptedHandler.request_count == 4 -def test_final_429_returns_empty_mlb_result( +def test_final_429_returns_empty_mlb_result_in_compatibility_mode( scripted_http_server, no_retry_sleep, ): - """After retry exhaustion, a final 429 still returns an empty MlbResult.""" + """After retry exhaustion, compatibility mode returns an empty MlbResult.""" configure, port = scripted_http_server configure([429, 429, 429, 429, 429, 429]) - adapter = _adapter_against_local_server(port) + adapter = _adapter_against_local_server(port, strict_http=False) try: result = adapter.get(endpoint="sports") @@ -305,16 +306,58 @@ def test_final_429_returns_empty_mlb_result( assert _ScriptedHandler.request_count == 4 -def test_final_429_warns_once_after_retry_exhaustion( +@XFAIL_PENDING_STRICT_DEFAULT +def test_final_429_raises_mlb_http_error_after_retry_exhaustion_default_adapter( scripted_http_server, no_retry_sleep, ): - """Compatibility mode warns only after the final 429, not per retry attempt.""" + """Default MlbDataAdapter() raises MlbHttpError after retry exhaustion.""" configure, port = scripted_http_server configure([429, 429, 429, 429, 429, 429]) - # Library-created Session keeps the mounted retry adapter; do not inject a mock. adapter = _adapter_against_local_server(port) + try: + with pytest.raises(MlbHttpError) as exc_info: + adapter.get(endpoint="sports") + finally: + adapter.close() + + assert _ScriptedHandler.request_count == 4 + assert exc_info.value.status_code == 429 + assert exc_info.value.method == "GET" + + +@XFAIL_PENDING_STRICT_DEFAULT +def test_final_429_raises_via_default_mlb_client_after_retry_exhaustion( + scripted_http_server, + no_retry_sleep, +): + """Default Mlb() raises MlbHttpError after retries when the final response is 429.""" + configure, port = scripted_http_server + configure([429, 429, 429, 429, 429, 429]) + mlb = Mlb() + mlb._mlb_adapter_v1.url = f"http://127.0.0.1:{port}/api/v1/" + + try: + with pytest.raises(MlbHttpError) as exc_info: + mlb._mlb_adapter_v1.get(endpoint="sports") + finally: + mlb.close() + + assert _ScriptedHandler.request_count == 4 + assert exc_info.value.status_code == 429 + assert exc_info.value.method == "GET" + + +def test_final_429_warns_once_after_retry_exhaustion_in_compatibility_mode( + scripted_http_server, + no_retry_sleep, +): + """Compatibility mode warns only after the final 429, not per retry attempt.""" + configure, port = scripted_http_server + configure([429, 429, 429, 429, 429, 429]) + adapter = _adapter_against_local_server(port, strict_http=False) + try: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", MlbHttpCompatibilityWarning)