|
| 1 | +"""Build an HTTPX-compatible proxy mount map from the environment. |
| 2 | +
|
| 3 | +HTTPX only discovers ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / |
| 4 | +``NO_PROXY`` for itself when it builds its own transport, which happens only |
| 5 | +when the caller does not pass ``transport=`` (see ``allow_env_proxies = |
| 6 | +trust_env and transport is None`` in ``httpx.Client.__init__``). The async |
| 7 | +retry transport (``_async_transport.py``) always passes ``transport=``, so |
| 8 | +that discovery never runs, and environment proxy support silently disappears |
| 9 | +for library-created async clients (issue #324). |
| 10 | +
|
| 11 | +This module reimplements that discovery from the stdlib and hands the result |
| 12 | +to HTTPX's public ``mounts=`` argument instead, so the library stays off |
| 13 | +HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here |
| 14 | +intentionally mirrors that private function's semantics, verified against |
| 15 | +installed httpx 0.28.1. The differential test in |
| 16 | +``tests/test_async_env_proxies.py`` (``test_matches_stock_httpx_env_proxy_resolution``) |
| 17 | +is the drift alarm: it resolves the same URLs against a stock |
| 18 | +``httpx.AsyncClient()`` and against this module's output on every run, so a |
| 19 | +future httpx release changing ``NO_PROXY`` or proxy semantics fails that test |
| 20 | +instead of silently diverging. |
| 21 | +
|
| 22 | +No httpx import here: environment variables in, a plain ``dict`` out. |
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import ipaddress |
| 28 | +from urllib.request import getproxies |
| 29 | + |
| 30 | + |
| 31 | +def environment_proxy_map(*, trust_env: bool = True) -> dict[str, str | None]: |
| 32 | + """Return an HTTPX ``mounts=``-shaped map of proxies from the environment. |
| 33 | +
|
| 34 | + Keys are URL patterns such as ``"https://"`` or ``"all://*mlb.com"``; a |
| 35 | + ``None`` value means "bypass the proxy for this pattern" and is meaningful |
| 36 | + only when a broader pattern (from ``ALL_PROXY``) would otherwise match. |
| 37 | + """ |
| 38 | + if not trust_env: |
| 39 | + return {} |
| 40 | + |
| 41 | + proxy_info = getproxies() |
| 42 | + mounts: dict[str, str | None] = {} |
| 43 | + |
| 44 | + for scheme in ("http", "https", "all"): |
| 45 | + value = proxy_info.get(scheme) |
| 46 | + if value: |
| 47 | + mounts[f"{scheme}://"] = value if "://" in value else f"http://{value}" |
| 48 | + |
| 49 | + no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")] |
| 50 | + for hostname in no_proxy_hosts: |
| 51 | + if hostname == "*": |
| 52 | + return {} |
| 53 | + elif hostname: |
| 54 | + if "://" in hostname: |
| 55 | + mounts[hostname] = None |
| 56 | + elif _is_ipv4(hostname): |
| 57 | + mounts[f"all://{hostname}"] = None |
| 58 | + elif _is_ipv6(hostname): |
| 59 | + mounts[f"all://[{hostname}]"] = None |
| 60 | + elif hostname.lower() == "localhost": |
| 61 | + mounts[f"all://{hostname}"] = None |
| 62 | + else: |
| 63 | + mounts[f"all://*{hostname}"] = None |
| 64 | + |
| 65 | + return mounts |
| 66 | + |
| 67 | + |
| 68 | +def _is_ipv4(hostname: str) -> bool: |
| 69 | + try: |
| 70 | + ipaddress.IPv4Address(hostname.split("/")[0]) |
| 71 | + except ValueError: |
| 72 | + return False |
| 73 | + return True |
| 74 | + |
| 75 | + |
| 76 | +def _is_ipv6(hostname: str) -> bool: |
| 77 | + try: |
| 78 | + ipaddress.IPv6Address(hostname.split("/")[0]) |
| 79 | + except ValueError: |
| 80 | + return False |
| 81 | + return True |
0 commit comments