diff --git a/README.md b/README.md index 12030909..34b2c15a 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ See the [method reference](docs/methods.md) for the full method documentation th Both clients use explicit timeouts, structured exceptions, and pooled HTTP connections. `strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. -Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.0.1`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract. +Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.1.0`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract. The main transport exceptions are: diff --git a/docs/http-transport.md b/docs/http-transport.md index 763de152..5760613c 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -1,18 +1,19 @@ # HTTP Transport This document describes the HTTP transport behavior of the current release, -version 1.0.0. +version 1.1.0. Version 0.8.0 introduced shared Sessions, explicit timeouts, bounded retries, and structured exceptions. Version 0.9.0 introduced configurable strict behavior and compatibility warnings. Version 1.0.0 makes strict handling the default and defines the stable public contract. -The public client remains synchronous. Ordinary usage does not need to -configure sessions or retries. +Version 1.1.0 adds the optional asynchronous `AsyncMlb` and +`AsyncMlbDataAdapter` clients while preserving the existing synchronous API. +Ordinary usage does not need to configure sessions, clients, or retries. -See [the 1.0.0 release notes](releases/1.0.0.md) for a shorter summary of what -changed. For the authoritative public API boundary see +See [the 1.1.0 release notes](releases/1.1.0.md) for a shorter summary of what +changed in the current release. For the authoritative public API boundary see [the public API contract](public-api.md). ## Public transport API @@ -21,6 +22,8 @@ Everything this document describes is reachable from the package root: ```python from mlbstatsapi import ( + AsyncMlb, + AsyncMlbDataAdapter, Mlb, MlbDataAdapter, MlbDecodeError, @@ -33,6 +36,9 @@ from mlbstatsapi import ( ) ``` +The async symbols require the optional `async` installation extra. The +synchronous symbols remain available without HTTPX. + Names that are not exported from `mlbstatsapi` are internal and may change without a deprecation cycle. See [public-api.md](public-api.md) for the complete stability classification. @@ -48,8 +54,10 @@ mlb = mlbstatsapi.Mlb() player = mlb.get_person(664034) ``` -In version 1.0.0 that construction uses strict HTTP handling by default. The -client remains synchronous. Async support is not part of version 1.0.0. +Version 1.0.0 made strict HTTP handling the default for this construction. +That synchronous behavior is unchanged in 1.1.0, and existing synchronous +users require no code changes. Version 1.1.0 also provides the optional +`AsyncMlb` client; see [Async usage](async.md). ## Context manager @@ -189,7 +197,7 @@ to the library's tested retry policy. ## User-Agent -Library-created Sessions send a package-specific User-Agent: +Library-created Sessions and async clients send a package-specific User-Agent: ```text python-mlb-statsapi/ @@ -198,7 +206,7 @@ python-mlb-statsapi/ With the package version currently declared in project metadata that resolves to: ```text -python-mlb-statsapi/1.0.1 +python-mlb-statsapi/1.1.0 ``` The version comes from the installed package metadata, so it always matches @@ -207,10 +215,10 @@ the installed release without a separately maintained version string. Notes: * The header helps identify package traffic while debugging -* Other Requests default headers such as `Accept-Encoding`, `Accept`, and `Connection` remain intact +* Other transport default headers remain intact * Only `User-Agent` is set; the full header mapping is never replaced -* Caller-injected Sessions are never modified -* Applications using an injected Session may set their own User-Agent +* Caller-injected Sessions and HTTPX clients are never modified +* Applications using an injected Session or client may set their own User-Agent * The header contains no machine identifiers, installation identifiers, hostnames, or user tracking data * This is not telemetry and sends no analytics @@ -243,12 +251,12 @@ finally: ## Default retry policy -Library-created Sessions mount a bounded retry policy for GET requests -automatically. +Library-created Sessions and async clients use a bounded retry policy for GET +requests automatically. -Caller-injected Sessions are never automatically reconfigured. Retry settings -on an injected Session remain under the caller's control unless the caller -opts in. +Caller-injected Sessions and HTTPX clients are never automatically +reconfigured. Retry settings on injected Sessions and clients remain under +the caller's control. ```text Initial request: 1 @@ -638,7 +646,7 @@ Notes: * `MlbTimeoutError` is a subtype of `MlbTransportError` * All new errors inherit from `TheMlbStatsApiException` * Existing broad exception handling remains valid -* Original Requests or JSON decoding failures are preserved through exception chaining +* Original Requests, HTTPX, or JSON decoding failures are preserved through exception chaining ## HTTP exception attributes @@ -735,7 +743,8 @@ configured with its own proxy settings. ## Scope of this document -The retry, timeout, User-Agent, and strict-HTTP behavior documented above -apply to the synchronous `Mlb` client. For the asynchronous client, see -[async.md](async.md); it shares this document's retry, timeout, and -error-handling contract except where noted above. +The retry, timeout, User-Agent, strict-HTTP, and error-handling contract applies +to both `Mlb` and `AsyncMlb`. Session-specific sections describe the +synchronous Requests transport; `AsyncMlb` uses a caller-owned or +library-created HTTPX client with the corresponding ownership rules. See +[async.md](async.md) for async lifecycle, concurrency, and client injection. diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 00000000..de51ffee --- /dev/null +++ b/docs/releases/1.1.0.md @@ -0,0 +1,69 @@ +# python-mlb-statsapi 1.1.0 + +Version 1.1.0 adds first-class asynchronous access to the MLB Stats API while +preserving the existing synchronous API. Applications upgrading from 1.0.x +that use `Mlb` or `MlbDataAdapter` require no code changes. + +## Async support + +Install the optional `async` extra to add HTTPX, the asynchronous transport +dependency: + +```bash +python3 -m pip install "python-mlb-statsapi[async]" +``` + +The extra provides the public `AsyncMlb` and `AsyncMlbDataAdapter` classes. +`AsyncMlb` covers the full endpoint surface exposed by `Mlb`. Sync and async +endpoints share the same parsing functions and return matching public Pydantic +models, values, and endpoint-specific empty-result shapes. + +```python +from mlbstatsapi import AsyncMlb + + +async def get_player(person_id: int): + async with AsyncMlb() as mlb: + return await mlb.get_person(person_id) +``` + +`async with AsyncMlb(...)` returns the client and closes library-owned HTTPX +resources when the block exits. Directly constructed clients support explicit +`await mlb.aclose()`, and repeated `aclose()` calls are safe. An injected +`httpx.AsyncClient` remains caller-owned and is never closed or reconfigured by +`AsyncMlb`. + +One `AsyncMlb` instance supports caller-controlled concurrent requests on the +same event loop. It does not create hidden request fanout or background tasks, +and cross-event-loop use is not promised. Caller cancellation propagates +without blocking unrelated concurrent requests. + +Library-created HTTPX clients honor `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, +and `NO_PROXY` from the environment while retaining the library's bounded +retry policy. Injected clients keep their caller-provided proxy and transport +configuration. + +## HTTP compatibility + +`strict_http=True` remains the default for both synchronous and asynchronous +clients. New code should use `strict_http=True` and handle `MlbHttpError`. + +`strict_http=False` remains supported throughout the 1.x release series and +may be removed in 2.0. It continues to provide the documented compatibility +path for final non-404 4xx responses; it is not removed or deprecated in +1.1.0. + +The base installation remains synchronous-only and does not require HTTPX. +Existing 1.0.x synchronous users require zero code changes for 1.1.0. + +## Python and release validation + +python-mlb-statsapi requires Python >=3.10. CI validates Python 3.10 through 3.14 +(`3.10`, `3.11`, `3.12`, `3.13`, and `3.14`) for the deterministic offline sync +and async suites. + +Release validation now checks both wheel and source-distribution installs in +separate clean environments. Each artifact retains its existing synchronous +smoke validation and is also installed with the `async` extra to verify the +public async imports, lifecycle, ownership, strict/compatibility behavior, and +versioned User-Agent without contacting the live MLB API. diff --git a/pyproject.toml b/pyproject.toml index 42cb075a..dd6458c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-mlb-statsapi" -version = "1.0.1" +version = "1.1.0" description = "mlbstatsapi python wrapper" authors = [ "Matthew Spah ", diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 956d8ae2..166d1ec9 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -1,8 +1,8 @@ """Validate the built python-mlb-statsapi distributions before a release. Checks the artifacts in ``dist/``, then clean-installs each distribution -artifact into its own throwaway virtual environment and runs a public-API -smoke test against the *installed* package. +artifact into throwaway virtual environments and runs synchronous and async +public-API smoke tests against the *installed* package. Both the wheel and the source distribution are installed separately so a broken sdist build, a missing runtime dependency, or an omitted package file @@ -12,12 +12,12 @@ checkout cannot shadow the installed distribution artifact. Nothing here contacts the MLB API. Every HTTP response exercised by the smoke -test is produced by an injected fake Session. +tests is produced by an injected fake Session or HTTPX MockTransport. Usage:: python scripts/validate_release.py - python scripts/validate_release.py --expected-version 1.0.0 + python scripts/validate_release.py --expected-version 1.1.0 python scripts/validate_release.py --dist dist Without ``--expected-version`` the expected artifact version is read from the @@ -57,6 +57,12 @@ "mlbstatsapi/__init__.py", "mlbstatsapi/exceptions.py", "mlbstatsapi/warnings.py", + "mlbstatsapi/_async_support.py", + "mlbstatsapi/_async_transport.py", + "mlbstatsapi/_env_proxies.py", + "mlbstatsapi/_http.py", + "mlbstatsapi/async_mlb.py", + "mlbstatsapi/async_mlb_dataadapter.py", "mlbstatsapi/mlb_api.py", "mlbstatsapi/mlb_dataadapter.py", "mlbstatsapi/mlb_module.py", @@ -70,6 +76,12 @@ ADAPTER_STRICT_DEFAULT_MESSAGE = ( "MlbDataAdapter.strict_http must default to True for the 1.0 contract" ) +ASYNC_MLB_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" +) +ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" +) SMOKE_TEST_SOURCE = ''' """Public API smoke test for an installed python-mlb-statsapi artifact. @@ -504,6 +516,262 @@ def close(self): ''' +ASYNC_SMOKE_TEST_SOURCE = ''' +"""Async public API smoke test for an installed artifact with its async extra. + +Runs inside a throwaway virtual environment against the installed +distribution, never against a repository checkout. Every exercised HTTP +response comes from HTTPX MockTransport, so this test performs no network I/O +and never reaches the MLB API. +""" + +import asyncio +import importlib.metadata +import inspect +import logging +import sys +import sysconfig +import warnings +from pathlib import Path + +import httpx + +import mlbstatsapi +from mlbstatsapi import ( + AsyncMlb, + AsyncMlbDataAdapter, + MlbHttpCompatibilityWarning, + MlbHttpError, +) + +expected_version = sys.argv[1] + +# Final 403 responses exercise both strict and 1.x compatibility behavior +# without contacting the live service. +FORBIDDEN_PAYLOAD = {"messageNumber": 403, "message": "Forbidden"} +SPORTS_URL = "https://statsapi.mlb.com/api/v1/sports" +ASYNC_MLB_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" +) +ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" +) + +# Expected final 403s are logged by the adapter. Keep release output concise +# without configuring logging from inside the installed package. +package_logger = logging.getLogger("mlbstatsapi") +package_logger.addHandler(logging.NullHandler()) +package_logger.propagate = False + + +# --- The installed artifact and its optional dependency --- + +assert sys.prefix != sys.base_prefix, ( + "the async smoke test must run inside the throwaway virtual environment" +) + +site_packages = Path(sysconfig.get_paths()["purelib"]).resolve() +package_file = Path(mlbstatsapi.__file__).resolve() +assert package_file.is_relative_to(site_packages), ( + f"mlbstatsapi was imported from {package_file}, not from the installed " + f"distribution artifact under {site_packages}" +) + +installed_version = importlib.metadata.version("python-mlb-statsapi") +assert installed_version == expected_version, ( + f"installed metadata reports {installed_version}, expected {expected_version}" +) + +# In this otherwise-clean environment, importing HTTPX and reading its +# distribution metadata proves that installing the local artifact's [async] +# extra installed the optional transport dependency. +installed_httpx_version = importlib.metadata.version("httpx") +assert installed_httpx_version, "the async extra did not install HTTPX metadata" +assert httpx.__version__ == installed_httpx_version + +for name in ("AsyncMlb", "AsyncMlbDataAdapter"): + assert hasattr(mlbstatsapi, name), f"mlbstatsapi.{name} is not importable" + assert getattr(mlbstatsapi, name) is not None, f"mlbstatsapi.{name} is None" + + +# --- Public constructor and lifecycle contracts --- + +async_mlb_init = inspect.signature(AsyncMlb.__init__).parameters +async_adapter_init = inspect.signature(AsyncMlbDataAdapter.__init__).parameters + +assert list(async_mlb_init) == [ + "self", + "hostname", + "logger", + "timeout", + "client", + "strict_http", +] +assert async_mlb_init["hostname"].default == "statsapi.mlb.com" +assert async_mlb_init["logger"].default is None +assert async_mlb_init["timeout"].default == (3.05, 30.0) +assert async_mlb_init["client"].default is None +assert async_mlb_init["strict_http"].default is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE +) +assert async_mlb_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + +assert list(async_adapter_init) == [ + "self", + "hostname", + "ver", + "logger", + "timeout", + "client", + "strict_http", +] +assert async_adapter_init["hostname"].default == "statsapi.mlb.com" +assert async_adapter_init["ver"].default == "v1" +assert async_adapter_init["logger"].default is None +assert async_adapter_init["timeout"].default == (3.05, 30.0) +assert async_adapter_init["client"].default is None +assert async_adapter_init["strict_http"].default is True, ( + ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE +) +assert async_adapter_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY +assert inspect.iscoroutinefunction(AsyncMlb.aclose) +assert inspect.iscoroutinefunction(AsyncMlbDataAdapter.aclose) + + +def forbidden_response(request: httpx.Request) -> httpx.Response: + """Return one deterministic final 403 through HTTPX's fake transport.""" + return httpx.Response( + 403, + headers={"Content-Type": "application/json"}, + json=FORBIDDEN_PAYLOAD, + request=request, + ) + + +def assert_forbidden_error(exc: MlbHttpError, *, label: str) -> None: + assert exc.status_code == 403, f"{label}: status_code={exc.status_code}" + assert exc.reason == "Forbidden", f"{label}: reason={exc.reason!r}" + assert exc.method == "GET", f"{label}: method={exc.method!r}" + assert exc.url == SPORTS_URL, f"{label}: url={exc.url!r}" + assert isinstance(exc.response_data, dict), ( + f"{label}: response_data={exc.response_data!r}" + ) + for key, value in FORBIDDEN_PAYLOAD.items(): + assert exc.response_data.get(key) == value, ( + f"{label}: response_data={exc.response_data!r}" + ) + + +def compatibility_warnings(caught): + return [ + record + for record in caught + if issubclass(record.category, MlbHttpCompatibilityWarning) + ] + + +async def check_library_owned_lifecycle_and_user_agent() -> None: + expected_user_agent = f"python-mlb-statsapi/{expected_version}" + + # Construction plus async context-manager cleanup. No request is made with + # this library-created client; its configuration is inspected directly. + client = AsyncMlb() + owned_httpx_client = client._client + assert owned_httpx_client.headers["User-Agent"] == expected_user_agent + assert client._mlb_adapter_v1._strict_http is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE + ) + async with client as entered: + assert entered is client + assert owned_httpx_client.is_closed is False + assert owned_httpx_client.is_closed is True + + # Explicit cleanup is supported and idempotent for a library-owned client. + explicitly_closed = AsyncMlb() + explicitly_owned_httpx_client = explicitly_closed._client + await explicitly_closed.aclose() + assert explicitly_owned_httpx_client.is_closed is True + await explicitly_closed.aclose() + assert explicitly_owned_httpx_client.is_closed is True + + +async def check_strict_http_and_caller_ownership() -> None: + transport = httpx.MockTransport(forbidden_response) + caller_client = httpx.AsyncClient( + transport=transport, + headers={ + "User-Agent": "release-async-smoke-test/1.0", + "X-Release-Test": "preserved", + }, + ) + headers_before = dict(caller_client.headers) + + try: + # Omitting strict_http exercises the real True default. The context + # manager must leave the injected HTTPX client caller-owned and open. + async with AsyncMlb(client=caller_client) as strict_client: + assert strict_client._client is caller_client + assert strict_client._mlb_adapter_v1._strict_http is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE + ) + try: + await strict_client.get_sports() + except MlbHttpError as exc: + assert_forbidden_error( + exc, + label="AsyncMlb(strict_http=True).get_sports()", + ) + else: + raise AssertionError( + "AsyncMlb strict_http=True did not raise MlbHttpError" + ) + + assert caller_client.is_closed is False, ( + "AsyncMlb must not close a caller-injected httpx.AsyncClient" + ) + assert dict(caller_client.headers) == headers_before + + # Compatibility mode remains available through 1.x and returns the + # endpoint's historical empty result with its compatibility warning. + async with AsyncMlb( + client=caller_client, + strict_http=False, + ) as compatibility_client: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sports = await compatibility_client.get_sports() + + assert sports == [], ( + f"AsyncMlb(strict_http=False).get_sports() returned {sports!r}" + ) + captured = compatibility_warnings(caught) + assert len(captured) == 1, ( + "AsyncMlb(strict_http=False) expected exactly one " + f"MlbHttpCompatibilityWarning, captured {captured!r}" + ) + assert "strict_http=False" in str(captured[0].message) + assert caller_client.is_closed is False, ( + "compatibility mode closed a caller-injected httpx.AsyncClient" + ) + finally: + await caller_client.aclose() + + assert caller_client.is_closed is True + + +async def main() -> None: + await check_library_owned_lifecycle_and_user_agent() + await check_strict_http_and_caller_ownership() + + +asyncio.run(main()) +print( + f"async smoke test passed for python-mlb-statsapi {installed_version} " + f"with HTTPX {installed_httpx_version}" +) +''' + + class ValidationError(Exception): """A release validation check failed.""" @@ -648,7 +916,7 @@ def _create_clean_environment(venv_dir: Path) -> Path: def _check_clean_install(artifact: Path, expected_version: str, *, label: str) -> None: - """Clean-install one distribution artifact and smoke test the result. + """Clean-install one distribution artifact and smoke test the sync result. Each artifact gets its own virtual environment so the wheel and the source distribution are never validated against a shared install. @@ -686,6 +954,66 @@ def _check_clean_install(artifact: Path, expected_version: str, *, label: str) - ) +def _check_async_clean_install( + artifact: Path, + expected_version: str, + *, + label: str, +) -> None: + """Clean-install one artifact with ``[async]`` and smoke test the result. + + This environment is separate from both sync artifact environments. That + separation proves HTTPX arrives through the exact local wheel or sdist's + optional extra rather than being left over from another validation phase. + """ + with tempfile.TemporaryDirectory( + prefix="python-mlb-statsapi-release-async-" + ) as tmp: + workspace = Path(tmp) + venv_dir = workspace / "venv" + + _log( + f" creating clean async virtual environment for the {label} " + f"in {venv_dir}" + ) + python = _create_clean_environment(venv_dir) + + _run( + [str(python), "-m", "pip", "install", "--upgrade", "--quiet", "pip"], + cwd=workspace, + label=f"pip upgrade for the {label} async environment", + ) + + artifact_with_extra = f"{artifact.resolve()}[async]" + _log(f" installing {label} with async extra: {artifact.name}") + _run( + [ + str(python), + "-m", + "pip", + "install", + "--quiet", + artifact_with_extra, + ], + cwd=workspace, + label=f"{label} async-extra installation of {artifact.name}", + ) + + smoke_test = workspace / "release_async_smoke_test.py" + smoke_test.write_text(ASYNC_SMOKE_TEST_SOURCE, encoding="utf-8") + + # Running from the temporary workspace keeps the repository checkout + # off sys.path, exactly as the sync installed-artifact phase does. + _log( + f" running {label} async smoke test against the installed artifact" + ) + _run( + [str(python), str(smoke_test), expected_version], + cwd=workspace, + label=f"{label} async smoke test for {artifact.name}", + ) + + def validate(dist_dir: Path, expected_version: str) -> None: _log(f"Validating release {expected_version} in {dist_dir}") @@ -711,6 +1039,11 @@ def validate(dist_dir: Path, expected_version: str) -> None: _check_clean_install(wheel, expected_version, label=WHEEL_LABEL) _check_clean_install(sdist, expected_version, label=SDIST_LABEL) + # The optional dependency must be resolved from each exact artifact in a + # fresh environment; a working wheel must not mask a broken sdist extra. + _check_async_clean_install(wheel, expected_version, label=WHEEL_LABEL) + _check_async_clean_install(sdist, expected_version, label=SDIST_LABEL) + _log(f"Release validation passed for {DISTRIBUTION_NAME} {expected_version}") diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a37cd41e..198e8dc9 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -38,9 +38,9 @@ EXTERNAL_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "external-tests.yml" # Release notes for the version this branch is preparing. Kept explicit so the -# current-document checks do not depend on the pyproject version bump, which is -# owned by a separate issue. -CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.0.1.md" +# current-document checks cannot silently classify an unreviewed notes file as +# the current release merely because the declared version changed. +CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.1.0.md" # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. @@ -50,9 +50,9 @@ RELEASE_NOTES_DIR / "0.8.0.md", RELEASE_NOTES_DIR / "0.9.0.md", RELEASE_NOTES_DIR / "1.0.0.md", + RELEASE_NOTES_DIR / "1.0.1.md", ) -# Deterministic CI contract for the 1.0 release. # Deterministic CI contract for maintained release branches. RELEASE_BRANCH_PATTERN = 'release/**' SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") @@ -320,12 +320,16 @@ def _write_sdist( def _classify_command(command) -> str: parts = [str(part) for part in command] joined = " ".join(parts) + if "release_async_smoke_test.py" in joined: + return "async-smoke" if "release_smoke_test.py" in joined: - return "smoke" + return "sync-smoke" if "--upgrade" in parts: return "pip-upgrade" if "install" in parts: - return "install" + if any(part.endswith("[async]") for part in parts): + return "async-install" + return "sync-install" return "other" @@ -337,10 +341,10 @@ def __init__(self, returncode: int): def _stub_clean_install(monkeypatch, *, failing: str | None = None) -> list[list[str]]: """Stub environment creation and subprocess execution for install tests. - ``failing`` selects the step that returns a non-zero exit code: ``install`` - for the artifact installation or ``smoke`` for the installed-package smoke - test. Only the validator's own ``subprocess`` reference is replaced, so no - real interpreter, environment, or download is involved. + ``failing`` selects the classified step that returns a non-zero exit code, + such as ``sync-install``, ``sync-smoke``, ``async-install``, or + ``async-smoke``. Only the validator's own ``subprocess`` reference is + replaced, so no real interpreter, environment, or download is involved. """ commands: list[list[str]] = [] @@ -576,12 +580,22 @@ def test_missing_required_source_distribution_path_is_reported( def test_required_source_distribution_paths_cover_the_package_entry_points() -> None: - """The required list must include the files needed to rebuild and import.""" + """The required list must cover both public clients and async support.""" required = set(validator.REQUIRED_SDIST_PATHS) assert {"README.md", "pyproject.toml", "mlbstatsapi/__init__.py"} <= required - assert "mlbstatsapi/mlb_api.py" in required - assert "mlbstatsapi/mlb_dataadapter.py" in required + assert { + "mlbstatsapi/mlb_api.py", + "mlbstatsapi/mlb_dataadapter.py", + "mlbstatsapi/async_mlb.py", + "mlbstatsapi/async_mlb_dataadapter.py", + } <= required + assert { + "mlbstatsapi/_async_support.py", + "mlbstatsapi/_async_transport.py", + "mlbstatsapi/_env_proxies.py", + "mlbstatsapi/_http.py", + } <= required # Tests, docs, and scripts are intentionally absent from the sdist. assert not any(path.startswith(("tests/", "docs/", "scripts/")) for path in required) @@ -596,7 +610,7 @@ def test_wheel_installation_failure_identifies_the_artifact( tmp_path: Path, ) -> None: wheel = _write_wheel(tmp_path) - _stub_clean_install(monkeypatch, failing="install") + _stub_clean_install(monkeypatch, failing="sync-install") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install( @@ -616,7 +630,7 @@ def test_source_distribution_installation_failure_identifies_the_artifact( tmp_path: Path, ) -> None: sdist = _write_sdist(tmp_path) - _stub_clean_install(monkeypatch, failing="install") + _stub_clean_install(monkeypatch, failing="sync-install") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install( @@ -645,7 +659,7 @@ def test_smoke_test_failure_identifies_the_artifact( if label == validator.WHEEL_LABEL else _write_sdist(tmp_path) ) - _stub_clean_install(monkeypatch, failing="smoke") + _stub_clean_install(monkeypatch, failing="sync-smoke") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install(artifact, SYNTHETIC_VERSION, label=label) @@ -655,6 +669,66 @@ def test_smoke_test_failure_identifies_the_artifact( assert "exit code 1" in message +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_extra_installation_failure_identifies_the_artifact_and_phase( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + _stub_clean_install(monkeypatch, failing="async-install") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + message = str(exc_info.value) + assert label in message + assert artifact.name in message + assert "async-extra installation" in message + assert "exit code 1" in message + + +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_smoke_failure_identifies_the_artifact_and_phase( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + _stub_clean_install(monkeypatch, failing="async-smoke") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + message = str(exc_info.value) + assert label in message + assert artifact.name in message + assert "async smoke test" in message + assert "exit code 1" in message + + def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( monkeypatch, tmp_path: Path, @@ -669,12 +743,12 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( ) steps = [_classify_command(command) for command in commands] - assert steps == ["pip-upgrade", "install", "smoke"] + assert steps == ["pip-upgrade", "sync-install", "sync-smoke"] - install_command = commands[steps.index("install")] + install_command = commands[steps.index("sync-install")] assert str(wheel.resolve()) in install_command - smoke_command = commands[steps.index("smoke")] + smoke_command = commands[steps.index("sync-smoke")] assert smoke_command[-1] == SYNTHETIC_VERSION smoke_script = Path(smoke_command[-2]) # The script is written into a throwaway workspace, never the checkout. @@ -682,6 +756,41 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( assert PROJECT_ROOT not in smoke_script.parents +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_clean_install_requests_the_local_artifact_extra( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + commands = _stub_clean_install(monkeypatch) + + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + steps = [_classify_command(command) for command in commands] + assert steps == ["pip-upgrade", "async-install", "async-smoke"] + + install_command = commands[steps.index("async-install")] + assert f"{artifact.resolve()}[async]" in install_command + + smoke_command = commands[steps.index("async-smoke")] + assert smoke_command[-1] == SYNTHETIC_VERSION + smoke_script = Path(smoke_command[-2]) + assert smoke_script.name == "release_async_smoke_test.py" + assert PROJECT_ROOT not in smoke_script.parents + + def test_each_artifact_is_installed_into_its_own_environment( monkeypatch, tmp_path: Path, @@ -711,28 +820,62 @@ def record_environment(venv_dir: Path) -> Path: SYNTHETIC_VERSION, label=validator.SDIST_LABEL, ) + validator._check_async_clean_install( + wheel, + SYNTHETIC_VERSION, + label=validator.WHEEL_LABEL, + ) + validator._check_async_clean_install( + sdist, + SYNTHETIC_VERSION, + label=validator.SDIST_LABEL, + ) - assert len(created) == 2 - assert created[0] != created[1] + assert len(created) == 4 + assert len(set(created)) == 4 -def test_validate_clean_installs_both_artifacts(monkeypatch, tmp_path: Path) -> None: - """validate() must clean-install the wheel and the source distribution.""" +def test_validate_runs_sync_and_async_clean_installs_for_both_artifacts( + monkeypatch, + tmp_path: Path, +) -> None: + """validate() must exercise both install modes for wheel and sdist.""" wheel = _write_wheel(tmp_path) sdist = _write_sdist(tmp_path) - installs: list[tuple[Path, str, str]] = [] - - def record_install(artifact: Path, expected_version: str, *, label: str) -> None: - installs.append((artifact, expected_version, label)) - - monkeypatch.setattr(validator, "_check_clean_install", record_install) + sync_installs: list[tuple[Path, str, str]] = [] + async_installs: list[tuple[Path, str, str]] = [] + + def record_sync_install( + artifact: Path, + expected_version: str, + *, + label: str, + ) -> None: + sync_installs.append((artifact, expected_version, label)) + + def record_async_install( + artifact: Path, + expected_version: str, + *, + label: str, + ) -> None: + async_installs.append((artifact, expected_version, label)) + + monkeypatch.setattr(validator, "_check_clean_install", record_sync_install) + monkeypatch.setattr( + validator, + "_check_async_clean_install", + record_async_install, + ) validator.validate(tmp_path, SYNTHETIC_VERSION) - assert installs == [ + expected = [ (wheel, SYNTHETIC_VERSION, validator.WHEEL_LABEL), (sdist, SYNTHETIC_VERSION, validator.SDIST_LABEL), ] + assert sync_installs == expected + assert async_installs == expected def test_validate_reports_success_for_both_artifacts( @@ -751,6 +894,10 @@ def test_validate_reports_success_for_both_artifacts( assert f"running {validator.WHEEL_LABEL} smoke test" in output assert f"installing {validator.SDIST_LABEL}" in output assert f"running {validator.SDIST_LABEL} smoke test" in output + assert f"installing {validator.WHEEL_LABEL} with async extra" in output + assert f"running {validator.WHEEL_LABEL} async smoke test" in output + assert f"installing {validator.SDIST_LABEL} with async extra" in output + assert f"running {validator.SDIST_LABEL} async smoke test" in output assert "Release validation passed" in output @@ -768,6 +915,14 @@ def test_smoke_test_source_is_valid_python() -> None: compile(validator.SMOKE_TEST_SOURCE, "release_smoke_test.py", "exec") +def test_async_smoke_test_source_is_valid_python() -> None: + compile( + validator.ASYNC_SMOKE_TEST_SOURCE, + "release_async_smoke_test.py", + "exec", + ) + + def test_smoke_test_labels_reverted_strict_defaults() -> None: """A reverted strict default must fail with an explanatory message. @@ -798,6 +953,24 @@ def test_smoke_test_labels_reverted_strict_defaults() -> None: assert message in source +def test_async_smoke_test_labels_reverted_strict_defaults() -> None: + assert validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE == ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" + ) + assert validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE == ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" + ) + + source = validator.ASYNC_SMOKE_TEST_SOURCE + assert 'async_mlb_init["strict_http"].default is True' in source + assert 'async_adapter_init["strict_http"].default is True' in source + for message in ( + validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE, + validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE, + ): + assert message in source + + def test_smoke_test_asserts_strict_http_default() -> None: """The installed-artifact smoke test must match the 1.0 strict default.""" text = VALIDATE_RELEASE.read_text(encoding="utf-8") @@ -846,6 +1019,48 @@ def test_smoke_test_checks_library_created_session_configuration() -> None: assert "create_retry_policy() must return a new Retry instance per call" in source +def test_async_smoke_test_checks_optional_public_surface_and_httpx_metadata() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "import httpx" in source + assert 'importlib.metadata.version("httpx")' in source + assert " AsyncMlb,\n" in source + assert " AsyncMlbDataAdapter,\n" in source + assert 'for name in ("AsyncMlb", "AsyncMlbDataAdapter")' in source + + +def test_async_smoke_test_checks_lifecycle_strict_modes_and_ownership() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "async with client as entered:" in source + assert "assert entered is client" in source + assert source.count("await explicitly_closed.aclose()") == 2 + assert "AsyncMlb(client=caller_client)" in source + assert "strict_http=False" in source + assert "MlbHttpError" in source + assert "MlbHttpCompatibilityWarning" in source + assert "AsyncMlb must not close a caller-injected httpx.AsyncClient" in source + assert 'f"python-mlb-statsapi/{expected_version}"' in source + + +def test_async_smoke_test_runs_against_the_installed_distribution() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "sys.prefix != sys.base_prefix" in source + assert 'sysconfig.get_paths()["purelib"]' in source + assert "is_relative_to(site_packages)" in source + assert 'importlib.metadata.version("python-mlb-statsapi")' in source + + +def test_async_smoke_test_makes_no_live_mlb_request() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "httpx.get(" not in source + assert "httpx.request(" not in source + assert "httpx.MockTransport(forbidden_response)" in source + assert "never reaches the MLB API" in source + + @pytest.mark.parametrize( "symbol", (