From 8ecb8c6c2c3adaaf14a3937a5acad98be930c678 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 12:18:01 -0700 Subject: [PATCH 1/3] fix: honor environment proxies for library-created async clients PR #323 moved async retries into MlbAsyncRetryTransport, mounted via AsyncClient(transport=...). HTTPX only builds its own env-proxy mounts when the caller leaves transport=None (allow_env_proxies = trust_env and transport is None in Client.__init__), so passing a transport silently disabled HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY support, leaving callers behind a proxy with an unexplained hang. create_library_async_client() now rebuilds that proxy discovery from the stdlib (mlbstatsapi/_env_proxies.py, no private httpx APIs) and passes it through HTTPX's public mounts= argument, wrapping every proxy transport in the same retry transport used for direct requests so retries still apply behind a proxy. A caller-injected client is untouched. Fixes #324. --- docs/async.md | 16 ++ docs/http-transport.md | 29 ++- docs/releases/1.1.0.md | 47 +++++ mlbstatsapi/_async_transport.py | 41 ++++- mlbstatsapi/_env_proxies.py | 77 ++++++++ tests/test_env_proxies.py | 307 +++++++++++++++++++++++++++++++ tests/test_release_validation.py | 6 + 7 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 docs/releases/1.1.0.md create mode 100644 mlbstatsapi/_env_proxies.py create mode 100644 tests/test_env_proxies.py diff --git a/docs/async.md b/docs/async.md index 17b647fa..0103a069 100644 --- a/docs/async.md +++ b/docs/async.md @@ -207,6 +207,22 @@ real application the client is typically created once, reused across calls, and closed by whatever code owns its lifecycle — the examples above show a few ways to run this, not the required shape of your application. +## Environment proxies + +A library-created client (the default — no `client=` passed) honors +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the environment, +the same variables a plain `httpx.AsyncClient()` discovers on its own. + +An injected client keeps whatever proxy configuration its caller gave it — +`httpx.AsyncClient()` reads those variables itself by default, or a caller +may pass `trust_env=False` or an explicit `proxy=`/`mounts=` to opt out or +override. The library does not add or remove proxy configuration on an +injected client. + +See [HTTP transport: async client environment +proxies](http-transport.md#async-client-environment-proxies) for the full +behavior. + ## Documentation boundaries - [README](../README.md) — installation and quick-start examples diff --git a/docs/http-transport.md b/docs/http-transport.md index 4b246953..999e3de3 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -710,8 +710,31 @@ bodies. The client has no default response cache. -## No async support +## Async client environment proxies -The client remains synchronous. +Library-created `AsyncMlb` / `AsyncMlbDataAdapter` clients honor +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` (any case), the same +environment variables HTTPX itself discovers for a plain `httpx.AsyncClient()`. -Async support is not part of version 1.0.0. +```text +Library-created async client + Reads HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY from the environment + Routes matching requests through the proxy + Applies the library retry policy to proxied and direct requests alike + +Caller-injected async client + Keeps exactly whatever transport and mounts its caller configured + The library never reads proxy environment variables for it +``` + +This mirrors [Session ownership](#session-ownership) on the sync side: the +library only ever configures a client it created itself. See +[async.md](async.md#custom-httpx-client) for injecting a client, including one +configured with its own proxy settings. + +## No async support in this section + +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. diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 00000000..51a2675a --- /dev/null +++ b/docs/releases/1.1.0.md @@ -0,0 +1,47 @@ +# python-mlb-statsapi 1.1.0 + +Version 1.1.0 adds an asynchronous client, `AsyncMlb` (and the underlying +`AsyncMlbDataAdapter`), behind the optional `async` extra. See +[async.md](../async.md) for usage and [public-api.md](../public-api.md) for +the supported async surface. + +## Fixed + +* Environment proxies (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / + `NO_PROXY`) are honored for async clients the library creates. A custom + transport installed for async retries (added ahead of this release) had the + side effect of disabling HTTPX's own environment-proxy discovery, since + HTTPX only builds it when no transport is passed in. Library-created async + clients now rebuild that discovery themselves and mount it explicitly, so + callers behind a corporate or `NO_PROXY`-configured proxy get correct + behavior instead of a silent hang. A caller-injected `httpx.AsyncClient` + keeps whatever transport and proxy configuration its caller mounted; the + library never touches it. + +No code changes are required to pick up the fix; a library-created client +already reads the environment on every construction: + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY, if set, are honored here. + async with AsyncMlb() as mlb: + return await mlb.get_person(664034) + + +asyncio.run(main()) +``` + +## Python support + +python-mlb-statsapi requires Python >=3.10. + +CI validates Python 3.10, 3.11, 3.12, 3.13, and 3.14. + +## Related issue + +Fixes #324. diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py index 816e34e4..5f5252eb 100644 --- a/mlbstatsapi/_async_transport.py +++ b/mlbstatsapi/_async_transport.py @@ -25,6 +25,7 @@ import asyncio from ._async_support import import_httpx +from ._env_proxies import environment_proxy_map from .mlb_dataadapter import _build_user_agent, create_retry_policy httpx = import_httpx() @@ -151,15 +152,51 @@ async def aclose(self) -> None: await self._inner.aclose() -def create_library_async_client() -> httpx.AsyncClient: +def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: """Build the async client the library creates and owns. The counterpart of ``_configure_library_session()`` on the sync side: library defaults are applied here, at creation, and only to clients the library creates. Passing headers to the constructor replaces just the User-Agent, so HTTPX's other default headers survive. + + HTTPX only builds its own environment-proxy mounts when the caller leaves + ``transport=None`` (``allow_env_proxies = trust_env and transport is + None`` in ``httpx.Client.__init__``). Passing ``transport=`` here, which + is required to install the retry transport, would otherwise silently + disable ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` + support for every library-created async client (issue #324). This + rebuilds that discovery from the stdlib (see ``_env_proxies.py``) and + passes it through HTTPX's public ``mounts=`` argument instead, wrapping + every proxy transport in the same retry transport the direct path uses, + so a request routed through a proxy still gets library retries. + + One retry policy instance is shared by the direct transport and every + proxy transport, mirroring the sync side sharing one Session across the + v1 and v1.1 adapters: retries are a property of the client, not of any + one transport within it. """ + retry_policy = create_retry_policy() + direct = MlbAsyncRetryTransport( + httpx.AsyncHTTPTransport(), retry_policy=retry_policy + ) + + mounts: dict[str, httpx.AsyncBaseTransport | None] = {} + for pattern, proxy in environment_proxy_map(trust_env=trust_env).items(): + if proxy is None: + # None tells HTTPX to fall back to client._transport for this + # pattern (see AsyncClient._transport_for_url), i.e. bypass the + # proxy rather than route through a second transport instance. + # aclose() also skips a None mount, so this never gets closed + # twice via both the direct transport and a mount entry. + mounts[pattern] = None + else: + mounts[pattern] = MlbAsyncRetryTransport( + httpx.AsyncHTTPTransport(proxy=proxy), retry_policy=retry_policy + ) + return httpx.AsyncClient( headers={"User-Agent": _build_user_agent()}, - transport=MlbAsyncRetryTransport(), + transport=direct, + mounts=mounts, ) diff --git a/mlbstatsapi/_env_proxies.py b/mlbstatsapi/_env_proxies.py new file mode 100644 index 00000000..4da4b87a --- /dev/null +++ b/mlbstatsapi/_env_proxies.py @@ -0,0 +1,77 @@ +"""Build an HTTPX-compatible proxy mount map from the environment. + +HTTPX only discovers ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / +``NO_PROXY`` for itself when it builds its own transport, which happens only +when the caller does not pass ``transport=`` (see ``allow_env_proxies = +trust_env and transport is None`` in ``httpx.Client.__init__``). The async +retry transport (``_async_transport.py``) always passes ``transport=``, so +that discovery never runs, and environment proxy support silently disappears +for library-created async clients (issue #324). + +This module reimplements that discovery from the stdlib and hands the result +to HTTPX's public ``mounts=`` argument instead, so the library stays off +HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here +intentionally mirrors that private function's semantics, verified against +installed httpx 0.28.1, so the two must be updated together if a manual +recheck against a newer httpx ever turns up drift. + +No httpx import here: environment variables in, a plain ``dict`` out. +""" + +from __future__ import annotations + +import ipaddress +from urllib.request import getproxies + + +def environment_proxy_map(*, trust_env: bool = True) -> dict[str, str | None]: + """Return an HTTPX ``mounts=``-shaped map of proxies from the environment. + + Keys are URL patterns such as ``"https://"`` or ``"all://*mlb.com"``; a + ``None`` value means "bypass the proxy for this pattern" and is meaningful + only when a broader pattern (from ``ALL_PROXY``) would otherwise match. + """ + if not trust_env: + return {} + + proxy_info = getproxies() + mounts: dict[str, str | None] = {} + + for scheme in ("http", "https", "all"): + value = proxy_info.get(scheme) + if value: + mounts[f"{scheme}://"] = value if "://" in value else f"http://{value}" + + no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")] + for hostname in no_proxy_hosts: + if hostname == "*": + return {} + elif hostname: + if "://" in hostname: + mounts[hostname] = None + elif _is_ipv4(hostname): + mounts[f"all://{hostname}"] = None + elif _is_ipv6(hostname): + mounts[f"all://[{hostname}]"] = None + elif hostname.lower() == "localhost": + mounts[f"all://{hostname}"] = None + else: + mounts[f"all://*{hostname}"] = None + + return mounts + + +def _is_ipv4(hostname: str) -> bool: + try: + ipaddress.IPv4Address(hostname.split("/")[0]) + except ValueError: + return False + return True + + +def _is_ipv6(hostname: str) -> bool: + try: + ipaddress.IPv6Address(hostname.split("/")[0]) + except ValueError: + return False + return True diff --git a/tests/test_env_proxies.py b/tests/test_env_proxies.py new file mode 100644 index 00000000..b4834900 --- /dev/null +++ b/tests/test_env_proxies.py @@ -0,0 +1,307 @@ +"""Tests for environment proxy support in the async transport (issue #324). + +PR #323 moved async retries into a custom HTTPX transport. HTTPX only builds +its own environment-proxy mounts when the caller leaves ``transport=None`` +(``allow_env_proxies = trust_env and transport is None`` in +``httpx.Client.__init__``), so passing a transport to install retries +silently disabled ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / +``NO_PROXY`` support for every library-created async client. + +Two layers are covered: + +* ``mlbstatsapi._env_proxies.environment_proxy_map`` is pure stdlib parsing, + tested directly against fixtures for every documented ``NO_PROXY`` form. +* ``create_library_async_client`` wires that map into HTTPX's public + ``mounts=`` argument. The differential test at the bottom pins that wiring + to HTTPX's own environment-proxy discovery, so a semantic change in a + future HTTPX release (a patch bump is allowed by the ``httpx>=0.28.1,<1.0`` + pin) shows up as a failing test instead of silent drift. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from mlbstatsapi._env_proxies import environment_proxy_map + +# Every test below that touches HTTPX skips as a unit when the optional +# ``async`` extra is not installed, matching the guard used throughout the +# async test suite (see tests/test_async_optional_dependency.py). +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 + +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" + +# Both cases of every proxy variable urllib.request.getproxies() reads, so a +# proxy set in the developer's own shell can never leak into a fixture. +_PROXY_ENV_VARS = ( + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +) + + +def _clear_proxy_env(monkeypatch) -> None: + for name in _PROXY_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _set_env(monkeypatch, env: dict) -> None: + _clear_proxy_env(monkeypatch) + for key, value in env.items(): + monkeypatch.setenv(key, value) + + +# --------------------------------------------------------------------------- +# environment_proxy_map(): pure stdlib parsing +# --------------------------------------------------------------------------- + + +def test_https_proxy_only(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + assert environment_proxy_map() == {"https://": "http://corp:8080"} + + +def test_http_proxy_only(monkeypatch): + _set_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) + assert environment_proxy_map() == {"http://": "http://corp:8080"} + + +def test_all_proxy(monkeypatch): + _set_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) + assert environment_proxy_map() == {"all://": "http://corp:9"} + + +def test_bare_host_port_normalizes_to_http(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) + assert environment_proxy_map() == {"https://": "http://corp:8080"} + + +def test_no_proxy_subdomain_wildcard(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"}) + assert environment_proxy_map() == { + "https://": "http://corp:8080", + "all://*mlb.com": None, + } + + +def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "localhost,127.0.0.1,::1"}, + ) + assert environment_proxy_map() == { + "https://": "http://corp:8080", + "all://localhost": None, + "all://127.0.0.1": None, + "all://[::1]": None, + } + + +def test_no_proxy_star_disables_every_proxy(monkeypatch): + _set_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) + assert environment_proxy_map() == {} + + +def test_empty_env(monkeypatch): + _set_env(monkeypatch, {}) + assert environment_proxy_map() == {} + + +def test_trust_env_false_ignores_everything(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + assert environment_proxy_map(trust_env=False) == {} + + +# --------------------------------------------------------------------------- +# create_library_async_client(): wiring the map into HTTPX +# --------------------------------------------------------------------------- + + +def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + client = create_library_async_client() + try: + transports = [client._transport] + [ + mount for mount in client._mounts.values() if mount is not None + ] + assert len(transports) == 3 + assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) + + policy = transports[0]._retry_policy + assert all(t._retry_policy is policy for t in transports) + finally: + await client.aclose() + + asyncio.run(scenario()) + + +def test_aclose_closes_every_proxy_transport(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + with patch.object( + httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock + ) as mock_aclose: + client = create_library_async_client() + proxy_mounts = [m for m in client._mounts.values() if m is not None] + assert len(proxy_mounts) == 2 + + await client.aclose() + + # The direct transport plus every proxy transport, none skipped and + # none closed twice. + assert mock_aclose.call_count == 1 + len(proxy_mounts) + + asyncio.run(scenario()) + + +def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + client = httpx.AsyncClient(transport=transport) + original_mounts = dict(client._mounts) + + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._owns_client is False + assert adapter._client is client + assert adapter._client._transport is transport + assert adapter._client._mounts == original_mounts + + +def test_retry_fires_through_a_proxied_transport(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + return httpx.Response(503) if call_count == 1 else httpx.Response(200) + + async def scenario(): + with ( + patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)), + patch(SLEEP_TARGET, new_callable=AsyncMock), + ): + client = create_library_async_client() + try: + return await client.get("https://statsapi.mlb.com/api/v1/sports") + finally: + await client.aclose() + + response = asyncio.run(scenario()) + assert response.status_code == 200 + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# Differential test: pin our wiring to HTTPX's own env-proxy discovery. +# +# Each case was hand-verified against stock httpx 0.28.1 discovery +# (httpx.AsyncClient() with no transport=). If this starts failing against a +# newer 0.x httpx, treat it as a signal that NO_PROXY / proxy semantics moved +# out from under us, not as a test to loosen. +# --------------------------------------------------------------------------- + + +def proxy_target(transport): + inner = getattr(transport, "_inner", transport) + pool = getattr(inner, "_pool", None) + url = getattr(pool, "_proxy_url", None) + return str(url) if url is not None else None + + +DIFFERENTIAL_CASES = [ + ( + {"HTTPS_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"HTTP_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"ALL_PROXY": "http://corp:9"}, + ["https://statsapi.mlb.com/api", "http://anything.test/"], + ), + ( + {"HTTPS_PROXY": "corp:8080"}, + ["https://statsapi.mlb.com/api"], + ), + ( + { + "HTTPS_PROXY": "http://corp:8080", + "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", + }, + [ + "https://statsapi.mlb.com/api", + "https://mlb.com/", + "https://other.test/", + "http://localhost:8000/", + "https://127.0.0.1/", + "https://[::1]/", + ], + ), + ( + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, + ["https://statsapi.mlb.com/api"], + ), + ( + {}, + ["https://statsapi.mlb.com/api"], + ), +] + + +@pytest.mark.parametrize( + "env, urls", + DIFFERENTIAL_CASES, + ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], +) +def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): + _set_env(monkeypatch, env) + + async def scenario(): + stock = httpx.AsyncClient() + ours = create_library_async_client() + try: + for url in urls: + stock_transport = stock._transport_for_url(httpx.URL(url)) + our_transport = ours._transport_for_url(httpx.URL(url)) + + assert isinstance(our_transport, MlbAsyncRetryTransport), url + assert proxy_target(our_transport) == proxy_target( + stock_transport + ), url + finally: + await stock.aclose() + await ours.aclose() + + asyncio.run(scenario()) diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a37cd41e..a20aaac3 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -44,12 +44,18 @@ # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. +# +# 1.1.0.md documents a release that has landed on this branch but is not yet +# the pyproject-declared version (that bump is the separate issue referenced +# above), so it is validated the same way as an already-shipped release +# rather than promoted to CURRENT_RELEASE_NOTES. HISTORICAL_RELEASE_NOTES = ( RELEASE_NOTES_DIR / "0.7.1.md", 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.1.0.md", ) # Deterministic CI contract for the 1.0 release. From 648b29784e79c0c0aa64044c4dc46d22846dd106 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 12:48:46 -0700 Subject: [PATCH 2/3] test: address PR #331 review feedback on env-proxy tests Test and docs fixes only, no changes to _env_proxies.py logic or create_library_async_client() wiring: - The aclose() test's fixture had no NO_PROXY entry, so its mount map had no None value and the "none closed twice" assertion held regardless of whether the bypass branch mounted None or reused `direct`. Added a NO_PROXY fixture and pinned proxy_mounts to an explicit length so the test now fails if that branch regresses (verified locally, then reverted). - The proxied-retry test asserted nothing about which transport actually served the request; both the direct transport and the https:// mount wrapped the same MockTransport, so resolution could have silently fallen back to direct. Added an explicit _transport_for_url() assertion before the request. - Split the module: tests/test_env_proxies.py now covers only environment_proxy_map()'s pure stdlib parsing and carries no httpx import, so it runs in the no-httpx CI job instead of skipping with everything else. tests/test_async_env_proxies.py keeps the client-wiring, cleanup, and differential tests behind the module-level httpx importorskip guard. - Renamed the "No async support in this section" heading in docs/http-transport.md to "Scope of this document". - Reworded the _env_proxies.py docstring to point at the differential test as the automated drift check, rather than implying a manual recheck is needed. - Closed the httpx.AsyncClient left open in the injected-client test. --- docs/http-transport.md | 2 +- mlbstatsapi/_env_proxies.py | 8 +- tests/test_async_env_proxies.py | 253 +++++++++++++++++++++++++++++++ tests/test_env_proxies.py | 260 ++++---------------------------- 4 files changed, 288 insertions(+), 235 deletions(-) create mode 100644 tests/test_async_env_proxies.py diff --git a/docs/http-transport.md b/docs/http-transport.md index 999e3de3..26884c80 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -732,7 +732,7 @@ library only ever configures a client it created itself. See [async.md](async.md#custom-httpx-client) for injecting a client, including one configured with its own proxy settings. -## No async support in this section +## 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 diff --git a/mlbstatsapi/_env_proxies.py b/mlbstatsapi/_env_proxies.py index 4da4b87a..70d411d4 100644 --- a/mlbstatsapi/_env_proxies.py +++ b/mlbstatsapi/_env_proxies.py @@ -12,8 +12,12 @@ to HTTPX's public ``mounts=`` argument instead, so the library stays off HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here intentionally mirrors that private function's semantics, verified against -installed httpx 0.28.1, so the two must be updated together if a manual -recheck against a newer httpx ever turns up drift. +installed httpx 0.28.1. The differential test in +``tests/test_async_env_proxies.py`` (``test_matches_stock_httpx_env_proxy_resolution``) +is the drift alarm: it resolves the same URLs against a stock +``httpx.AsyncClient()`` and against this module's output on every run, so a +future httpx release changing ``NO_PROXY`` or proxy semantics fails that test +instead of silently diverging. No httpx import here: environment variables in, a plain ``dict`` out. """ diff --git a/tests/test_async_env_proxies.py b/tests/test_async_env_proxies.py new file mode 100644 index 00000000..5744b2b7 --- /dev/null +++ b/tests/test_async_env_proxies.py @@ -0,0 +1,253 @@ +"""Tests for async client env-proxy wiring in _async_transport.py (issue #324). + +PR #323 moved async retries into a custom HTTPX transport, mounted onto +library-created clients via ``AsyncClient(transport=...)``. httpx 0.28.1 only +builds its own environment-proxy mounts when the caller leaves +``transport=None`` (``allow_env_proxies = trust_env and transport is None`` in +``httpx.Client.__init__``), so passing a transport silently disabled +``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` support for +every library-created async client. + +``create_library_async_client`` (in ``mlbstatsapi/_async_transport.py``) +rebuilds that discovery via ``mlbstatsapi._env_proxies.environment_proxy_map`` +and wires it through HTTPX's public ``mounts=`` argument instead. This module +covers that wiring: shared retry policy, transport cleanup, injected-client +isolation, retries through a proxy, and — at the bottom — a differential test +against HTTPX's own env-proxy discovery. The pure parsing behind the map is +covered separately in tests/test_env_proxies.py, which has no HTTPX +dependency and runs even without the ``async`` extra. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +# The whole module needs a real HTTPX-backed client, so it skips as a unit +# when the optional ``async`` extra is not installed, matching the guard used +# throughout the async test suite (see tests/test_async_optional_dependency.py). +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 + +from test_env_proxies import set_proxy_env # noqa: E402 + +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" + + +# --------------------------------------------------------------------------- +# create_library_async_client(): wiring the map into HTTPX +# --------------------------------------------------------------------------- + + +def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): + set_proxy_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + client = create_library_async_client() + try: + transports = [client._transport] + [ + mount for mount in client._mounts.values() if mount is not None + ] + assert len(transports) == 3 + assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) + + policy = transports[0]._retry_policy + assert all(t._retry_policy is policy for t in transports) + finally: + await client.aclose() + + asyncio.run(scenario()) + + +def test_aclose_closes_every_proxy_transport(monkeypatch): + # NO_PROXY adds a bypass mount. That is what makes this a real regression + # test: the bypass branch mounts None specifically so HTTPX falls back to + # client._transport for that pattern instead of routing through (and + # later double-closing) a second reference to the same `direct` object. + # Without a bypass entry in the fixture, `len(proxy_mounts) == 2` below + # would still hold even if the bypass branch mounted `direct` instead of + # None, since there would be nothing to tell the two apart. + set_proxy_env( + monkeypatch, + { + "HTTPS_PROXY": "http://corp:8080", + "HTTP_PROXY": "http://corp:9090", + "NO_PROXY": "mlb.com", + }, + ) + + async def scenario(): + with patch.object( + httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock + ) as mock_aclose: + client = create_library_async_client() + assert len(client._mounts) == 3 + + proxy_mounts = [m for m in client._mounts.values() if m is not None] + # Pinned to 2, not derived after the fact: if the bypass branch + # ever mounts `direct` instead of None, this becomes 3 and fails + # here, before the tautological count below could paper over it. + assert len(proxy_mounts) == 2 + + await client.aclose() + + # The direct transport plus the two real proxy transports; the + # NO_PROXY bypass mount is None and contributes no separate close. + assert mock_aclose.call_count == 1 + len(proxy_mounts) + + asyncio.run(scenario()) + + +def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + client = httpx.AsyncClient(transport=transport) + original_mounts = dict(client._mounts) + + async def scenario(): + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._owns_client is False + assert adapter._client is client + assert adapter._client._transport is transport + assert adapter._client._mounts == original_mounts + + await client.aclose() + + asyncio.run(scenario()) + + +def test_retry_fires_through_a_proxied_transport(monkeypatch): + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + return httpx.Response(503) if call_count == 1 else httpx.Response(200) + + async def scenario(): + with ( + patch( + INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler) + ), + patch(SLEEP_TARGET, new_callable=AsyncMock), + ): + client = create_library_async_client() + try: + # Pin resolution to the proxy mount rather than the direct + # fallback, so a request to statsapi.mlb.com with HTTPS_PROXY + # set is guaranteed to exercise the proxied transport below, + # not just happen to because both wrap the same handler. + target = httpx.URL("https://statsapi.mlb.com/api/v1/sports") + assert client._transport_for_url(target) is not client._transport + + return await client.get(str(target)) + finally: + await client.aclose() + + response = asyncio.run(scenario()) + assert response.status_code == 200 + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# Differential test: pin our wiring to HTTPX's own env-proxy discovery. +# +# Each case was hand-verified against stock httpx 0.28.1 discovery +# (httpx.AsyncClient() with no transport=). If this starts failing against a +# newer 0.x httpx, that is the drift alarm: it means NO_PROXY / proxy +# semantics moved out from under environment_proxy_map's stdlib +# reimplementation, and the two need to be reconciled, not the test loosened. +# --------------------------------------------------------------------------- + + +def proxy_target(transport): + inner = getattr(transport, "_inner", transport) + pool = getattr(inner, "_pool", None) + url = getattr(pool, "_proxy_url", None) + return str(url) if url is not None else None + + +DIFFERENTIAL_CASES = [ + ( + {"HTTPS_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"HTTP_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"ALL_PROXY": "http://corp:9"}, + ["https://statsapi.mlb.com/api", "http://anything.test/"], + ), + ( + {"HTTPS_PROXY": "corp:8080"}, + ["https://statsapi.mlb.com/api"], + ), + ( + { + "HTTPS_PROXY": "http://corp:8080", + "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", + }, + [ + "https://statsapi.mlb.com/api", + "https://mlb.com/", + "https://other.test/", + "http://localhost:8000/", + "https://127.0.0.1/", + "https://[::1]/", + ], + ), + ( + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, + ["https://statsapi.mlb.com/api"], + ), + ( + {}, + ["https://statsapi.mlb.com/api"], + ), +] + + +@pytest.mark.parametrize( + "env, urls", + DIFFERENTIAL_CASES, + ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], +) +def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): + set_proxy_env(monkeypatch, env) + + async def scenario(): + stock = httpx.AsyncClient() + ours = create_library_async_client() + try: + for url in urls: + stock_transport = stock._transport_for_url(httpx.URL(url)) + our_transport = ours._transport_for_url(httpx.URL(url)) + + assert isinstance(our_transport, MlbAsyncRetryTransport), url + assert proxy_target(our_transport) == proxy_target( + stock_transport + ), url + finally: + await stock.aclose() + await ours.aclose() + + asyncio.run(scenario()) diff --git a/tests/test_env_proxies.py b/tests/test_env_proxies.py index b4834900..c6dfbc55 100644 --- a/tests/test_env_proxies.py +++ b/tests/test_env_proxies.py @@ -1,51 +1,28 @@ -"""Tests for environment proxy support in the async transport (issue #324). +"""Tests for ``mlbstatsapi._env_proxies.environment_proxy_map`` (issue #324). -PR #323 moved async retries into a custom HTTPX transport. HTTPX only builds -its own environment-proxy mounts when the caller leaves ``transport=None`` -(``allow_env_proxies = trust_env and transport is None`` in -``httpx.Client.__init__``), so passing a transport to install retries -silently disabled ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / -``NO_PROXY`` support for every library-created async client. +PR #323 moved async retries into a custom HTTPX transport, which had the side +effect of disabling HTTPX's own environment-proxy discovery for library-created +async clients (see ``mlbstatsapi/_env_proxies.py`` for the full story). +``environment_proxy_map`` is the stdlib-only replacement for that discovery. -Two layers are covered: - -* ``mlbstatsapi._env_proxies.environment_proxy_map`` is pure stdlib parsing, - tested directly against fixtures for every documented ``NO_PROXY`` form. -* ``create_library_async_client`` wires that map into HTTPX's public - ``mounts=`` argument. The differential test at the bottom pins that wiring - to HTTPX's own environment-proxy discovery, so a semantic change in a - future HTTPX release (a patch bump is allowed by the ``httpx>=0.28.1,<1.0`` - pin) shows up as a failing test instead of silent drift. +This module covers only the pure parsing in ``environment_proxy_map`` itself +and imports nothing from HTTPX, so it runs — and is meant to run — in the +no-httpx CI job: a stdlib-only helper is exactly where that job's coverage +matters most. The tests that exercise how the map is wired into an HTTPX +client (``create_library_async_client``) live in +tests/test_async_env_proxies.py, which skips as a whole without the ``async`` +extra. These tests must not contact the live MLB API. """ from __future__ import annotations -import asyncio -from unittest.mock import AsyncMock, patch - -import pytest - from mlbstatsapi._env_proxies import environment_proxy_map -# Every test below that touches HTTPX skips as a unit when the optional -# ``async`` extra is not installed, matching the guard used throughout the -# async test suite (see tests/test_async_optional_dependency.py). -httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") - -from mlbstatsapi._async_transport import ( # noqa: E402 - MlbAsyncRetryTransport, - create_library_async_client, -) -from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 - -SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" -INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" - # Both cases of every proxy variable urllib.request.getproxies() reads, so a # proxy set in the developer's own shell can never leak into a fixture. -_PROXY_ENV_VARS = ( +PROXY_ENV_VARS = ( "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", @@ -57,44 +34,41 @@ ) -def _clear_proxy_env(monkeypatch) -> None: - for name in _PROXY_ENV_VARS: +def clear_proxy_env(monkeypatch) -> None: + for name in PROXY_ENV_VARS: monkeypatch.delenv(name, raising=False) -def _set_env(monkeypatch, env: dict) -> None: - _clear_proxy_env(monkeypatch) +def set_proxy_env(monkeypatch, env: dict) -> None: + clear_proxy_env(monkeypatch) for key, value in env.items(): monkeypatch.setenv(key, value) -# --------------------------------------------------------------------------- -# environment_proxy_map(): pure stdlib parsing -# --------------------------------------------------------------------------- - - def test_https_proxy_only(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) assert environment_proxy_map() == {"https://": "http://corp:8080"} def test_http_proxy_only(monkeypatch): - _set_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) assert environment_proxy_map() == {"http://": "http://corp:8080"} def test_all_proxy(monkeypatch): - _set_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) + set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) assert environment_proxy_map() == {"all://": "http://corp:9"} def test_bare_host_port_normalizes_to_http(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) assert environment_proxy_map() == {"https://": "http://corp:8080"} def test_no_proxy_subdomain_wildcard(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"}) + set_proxy_env( + monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"} + ) assert environment_proxy_map() == { "https://": "http://corp:8080", "all://*mlb.com": None, @@ -102,7 +76,7 @@ def test_no_proxy_subdomain_wildcard(monkeypatch): def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): - _set_env( + set_proxy_env( monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "localhost,127.0.0.1,::1"}, ) @@ -115,193 +89,15 @@ def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): def test_no_proxy_star_disables_every_proxy(monkeypatch): - _set_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) + set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) assert environment_proxy_map() == {} def test_empty_env(monkeypatch): - _set_env(monkeypatch, {}) + set_proxy_env(monkeypatch, {}) assert environment_proxy_map() == {} def test_trust_env_false_ignores_everything(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) assert environment_proxy_map(trust_env=False) == {} - - -# --------------------------------------------------------------------------- -# create_library_async_client(): wiring the map into HTTPX -# --------------------------------------------------------------------------- - - -def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): - _set_env( - monkeypatch, - {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, - ) - - async def scenario(): - client = create_library_async_client() - try: - transports = [client._transport] + [ - mount for mount in client._mounts.values() if mount is not None - ] - assert len(transports) == 3 - assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) - - policy = transports[0]._retry_policy - assert all(t._retry_policy is policy for t in transports) - finally: - await client.aclose() - - asyncio.run(scenario()) - - -def test_aclose_closes_every_proxy_transport(monkeypatch): - _set_env( - monkeypatch, - {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, - ) - - async def scenario(): - with patch.object( - httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock - ) as mock_aclose: - client = create_library_async_client() - proxy_mounts = [m for m in client._mounts.values() if m is not None] - assert len(proxy_mounts) == 2 - - await client.aclose() - - # The direct transport plus every proxy transport, none skipped and - # none closed twice. - assert mock_aclose.call_count == 1 + len(proxy_mounts) - - asyncio.run(scenario()) - - -def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) - - transport = httpx.MockTransport(lambda request: httpx.Response(200)) - client = httpx.AsyncClient(transport=transport) - original_mounts = dict(client._mounts) - - adapter = AsyncMlbDataAdapter(client=client) - - assert adapter._owns_client is False - assert adapter._client is client - assert adapter._client._transport is transport - assert adapter._client._mounts == original_mounts - - -def test_retry_fires_through_a_proxied_transport(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(503) if call_count == 1 else httpx.Response(200) - - async def scenario(): - with ( - patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)), - patch(SLEEP_TARGET, new_callable=AsyncMock), - ): - client = create_library_async_client() - try: - return await client.get("https://statsapi.mlb.com/api/v1/sports") - finally: - await client.aclose() - - response = asyncio.run(scenario()) - assert response.status_code == 200 - assert call_count == 2 - - -# --------------------------------------------------------------------------- -# Differential test: pin our wiring to HTTPX's own env-proxy discovery. -# -# Each case was hand-verified against stock httpx 0.28.1 discovery -# (httpx.AsyncClient() with no transport=). If this starts failing against a -# newer 0.x httpx, treat it as a signal that NO_PROXY / proxy semantics moved -# out from under us, not as a test to loosen. -# --------------------------------------------------------------------------- - - -def proxy_target(transport): - inner = getattr(transport, "_inner", transport) - pool = getattr(inner, "_pool", None) - url = getattr(pool, "_proxy_url", None) - return str(url) if url is not None else None - - -DIFFERENTIAL_CASES = [ - ( - {"HTTPS_PROXY": "http://corp:8080"}, - ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], - ), - ( - {"HTTP_PROXY": "http://corp:8080"}, - ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], - ), - ( - {"ALL_PROXY": "http://corp:9"}, - ["https://statsapi.mlb.com/api", "http://anything.test/"], - ), - ( - {"HTTPS_PROXY": "corp:8080"}, - ["https://statsapi.mlb.com/api"], - ), - ( - { - "HTTPS_PROXY": "http://corp:8080", - "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", - }, - [ - "https://statsapi.mlb.com/api", - "https://mlb.com/", - "https://other.test/", - "http://localhost:8000/", - "https://127.0.0.1/", - "https://[::1]/", - ], - ), - ( - {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, - ["https://statsapi.mlb.com/api"], - ), - ( - {}, - ["https://statsapi.mlb.com/api"], - ), -] - - -@pytest.mark.parametrize( - "env, urls", - DIFFERENTIAL_CASES, - ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], -) -def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): - _set_env(monkeypatch, env) - - async def scenario(): - stock = httpx.AsyncClient() - ours = create_library_async_client() - try: - for url in urls: - stock_transport = stock._transport_for_url(httpx.URL(url)) - our_transport = ours._transport_for_url(httpx.URL(url)) - - assert isinstance(our_transport, MlbAsyncRetryTransport), url - assert proxy_target(our_transport) == proxy_target( - stock_transport - ), url - finally: - await stock.aclose() - await ours.aclose() - - asyncio.run(scenario()) From ca0ef5f7898b318395fd5935060f279aa28d1b26 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 19:05:37 -0700 Subject: [PATCH 3/3] cleanup: drop 1.1.0 release notes and trust_env param from PR #331 Two review findings from #331, no proxy logic or test coverage changed: - docs/releases/1.1.0.md and its release-validation classification are #307's responsibility (version bump, release notes, final release validation), not #324's. Removed the file and reverted tests/test_release_validation.py to its release/1.1.0 state so 1.1.0 is not prematurely classified as historical release notes. - create_library_async_client(*, trust_env=True) only threaded trust_env into environment_proxy_map(); it never reached AsyncClient or AsyncHTTPTransport, so it did not represent full HTTPX trust_env semantics and wasn't exposed by any public constructor. Removed the parameter; the factory now always runs environment discovery, matching the trust_env=True default a caller gets from a plain httpx.AsyncClient(). environment_proxy_map() keeps its own trust_env parameter and test, since it is a pure helper. --- docs/releases/1.1.0.md | 47 -------------------------------- mlbstatsapi/_async_transport.py | 11 ++++++-- tests/test_release_validation.py | 6 ---- 3 files changed, 9 insertions(+), 55 deletions(-) delete mode 100644 docs/releases/1.1.0.md diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md deleted file mode 100644 index 51a2675a..00000000 --- a/docs/releases/1.1.0.md +++ /dev/null @@ -1,47 +0,0 @@ -# python-mlb-statsapi 1.1.0 - -Version 1.1.0 adds an asynchronous client, `AsyncMlb` (and the underlying -`AsyncMlbDataAdapter`), behind the optional `async` extra. See -[async.md](../async.md) for usage and [public-api.md](../public-api.md) for -the supported async surface. - -## Fixed - -* Environment proxies (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / - `NO_PROXY`) are honored for async clients the library creates. A custom - transport installed for async retries (added ahead of this release) had the - side effect of disabling HTTPX's own environment-proxy discovery, since - HTTPX only builds it when no transport is passed in. Library-created async - clients now rebuild that discovery themselves and mount it explicitly, so - callers behind a corporate or `NO_PROXY`-configured proxy get correct - behavior instead of a silent hang. A caller-injected `httpx.AsyncClient` - keeps whatever transport and proxy configuration its caller mounted; the - library never touches it. - -No code changes are required to pick up the fix; a library-created client -already reads the environment on every construction: - -```python -import asyncio - -from mlbstatsapi import AsyncMlb - - -async def main(): - # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY, if set, are honored here. - async with AsyncMlb() as mlb: - return await mlb.get_person(664034) - - -asyncio.run(main()) -``` - -## Python support - -python-mlb-statsapi requires Python >=3.10. - -CI validates Python 3.10, 3.11, 3.12, 3.13, and 3.14. - -## Related issue - -Fixes #324. diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py index 5f5252eb..64a954ff 100644 --- a/mlbstatsapi/_async_transport.py +++ b/mlbstatsapi/_async_transport.py @@ -152,7 +152,7 @@ async def aclose(self) -> None: await self._inner.aclose() -def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: +def create_library_async_client() -> httpx.AsyncClient: """Build the async client the library creates and owns. The counterpart of ``_configure_library_session()`` on the sync side: @@ -171,6 +171,13 @@ def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: every proxy transport in the same retry transport the direct path uses, so a request routed through a proxy still gets library retries. + Environment discovery always runs here, matching the ``trust_env=True`` + default a caller gets from a plain ``httpx.AsyncClient()``. Neither + ``AsyncMlb`` nor ``AsyncMlbDataAdapter`` exposes a ``trust_env`` toggle; + a caller who needs one injects their own client instead, the same way + they would opt into any other HTTPX-level setting this factory does not + surface. + One retry policy instance is shared by the direct transport and every proxy transport, mirroring the sync side sharing one Session across the v1 and v1.1 adapters: retries are a property of the client, not of any @@ -182,7 +189,7 @@ def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: ) mounts: dict[str, httpx.AsyncBaseTransport | None] = {} - for pattern, proxy in environment_proxy_map(trust_env=trust_env).items(): + for pattern, proxy in environment_proxy_map().items(): if proxy is None: # None tells HTTPX to fall back to client._transport for this # pattern (see AsyncClient._transport_for_url), i.e. bypass the diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a20aaac3..a37cd41e 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -44,18 +44,12 @@ # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. -# -# 1.1.0.md documents a release that has landed on this branch but is not yet -# the pyproject-declared version (that bump is the separate issue referenced -# above), so it is validated the same way as an already-shipped release -# rather than promoted to CURRENT_RELEASE_NOTES. HISTORICAL_RELEASE_NOTES = ( RELEASE_NOTES_DIR / "0.7.1.md", 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.1.0.md", ) # Deterministic CI contract for the 1.0 release.