From 168abfdd6848970b522bc692a11cbb78eba8c429 Mon Sep 17 00:00:00 2001 From: elvis kahoro Date: Mon, 26 Oct 2026 04:41:40 +0000 Subject: [PATCH] Add httpx2 support --- CHANGELOG.md | 7 +++++++ respx/_compat.py | 39 ++++++++++++++++++++++++++++++++++++++ respx/handlers.py | 2 +- respx/mocks.py | 21 ++++++++++---------- respx/models.py | 2 +- respx/patterns.py | 2 +- respx/router.py | 2 +- respx/transports.py | 6 ++++-- respx/types.py | 2 +- respx/utils.py | 2 +- setup.py | 1 + tests/conftest.py | 2 +- tests/test_api.py | 4 ++-- tests/test_compat.py | 41 ++++++++++++++++++++++++++++++++++++++++ tests/test_mock.py | 14 +++++++------- tests/test_patterns.py | 2 +- tests/test_plugin.py | 2 +- tests/test_router.py | 4 ++-- tests/test_stats.py | 2 +- tests/test_transports.py | 2 +- 20 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 respx/_compat.py create mode 100644 tests/test_compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ae883..56af0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Support mocking `httpx2` (and `httpcore2`). `httpx` is used when installed, otherwise + `httpx2`; set `RESPX_HTTPX_PACKAGE=httpx2` to force it. Install with `respx[httpx2]`. + ## [0.23.1] - 2026-04-08 ### Fixed diff --git a/respx/_compat.py b/respx/_compat.py new file mode 100644 index 0000000..f29f2de --- /dev/null +++ b/respx/_compat.py @@ -0,0 +1,39 @@ +""" +Select the HTTPX flavour (`httpx` or its fork `httpx2`) that RESPX mocks. + +`httpx` is preferred when installed. `httpx2` (and its `httpcore2` dependency) +is used when `httpx` is missing, or when explicitly requested through the +`RESPX_HTTPX_PACKAGE=httpx2` environment variable. +""" + +import importlib +import os +from types import ModuleType +from typing import Tuple + +_PACKAGES = {"httpx": "httpcore", "httpx2": "httpcore2"} + + +def _select() -> Tuple[str, str]: + requested = os.environ.get("RESPX_HTTPX_PACKAGE") + if requested: + if requested not in _PACKAGES: + raise ImportError( + f"RESPX_HTTPX_PACKAGE must be one of {sorted(_PACKAGES)}, " + f"got {requested!r}" + ) + return requested, _PACKAGES[requested] + + for name, core in _PACKAGES.items(): + try: + importlib.import_module(name) + except ImportError: + continue + return name, core + + raise ImportError("RESPX requires either `httpx` or `httpx2` to be installed") + + +httpx_name, httpcore_name = _select() +httpx: ModuleType = importlib.import_module(httpx_name) +httpcore: ModuleType = importlib.import_module(httpcore_name) diff --git a/respx/handlers.py b/respx/handlers.py index f383678..2e915df 100644 --- a/respx/handlers.py +++ b/respx/handlers.py @@ -1,6 +1,6 @@ from typing import Any, Callable -import httpx +from respx._compat import httpx class TransportHandler: diff --git a/respx/mocks.py b/respx/mocks.py index ae0dad7..5b5ddb9 100644 --- a/respx/mocks.py +++ b/respx/mocks.py @@ -4,8 +4,7 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Type from unittest import mock -import httpcore -import httpx +from respx._compat import httpcore, httpcore_name, httpx, httpx_name from respx.patterns import parse_url @@ -142,10 +141,10 @@ def mock(cls, spec): class HTTPXMocker(Mocker): - name = "httpx" + name = "httpx" # registry key, regardless of the selected flavour targets = [ - "httpx._client.Client", - "httpx._client.AsyncClient", + f"{httpx_name}._client.Client", + f"{httpx_name}._client.AsyncClient", ] target_methods = ["_transport_for_url"] @@ -262,12 +261,12 @@ async def from_async_httpx_response(cls, httpx_response, target, **kwargs): class HTTPCoreMocker(AbstractRequestMocker): name = "httpcore" targets = [ - "httpcore._sync.connection.HTTPConnection", - "httpcore._sync.connection_pool.ConnectionPool", - "httpcore._sync.http_proxy.HTTPProxy", - "httpcore._async.connection.AsyncHTTPConnection", - "httpcore._async.connection_pool.AsyncConnectionPool", - "httpcore._async.http_proxy.AsyncHTTPProxy", + f"{httpcore_name}._sync.connection.HTTPConnection", + f"{httpcore_name}._sync.connection_pool.ConnectionPool", + f"{httpcore_name}._sync.http_proxy.HTTPProxy", + f"{httpcore_name}._async.connection.AsyncHTTPConnection", + f"{httpcore_name}._async.connection_pool.AsyncConnectionPool", + f"{httpcore_name}._async.http_proxy.AsyncHTTPProxy", ] target_methods = ["handle_request", "handle_async_request"] diff --git a/respx/models.py b/respx/models.py index 7585171..feaf844 100644 --- a/respx/models.py +++ b/respx/models.py @@ -14,7 +14,7 @@ from unittest import mock from warnings import warn -import httpx +from respx._compat import httpx from respx.utils import SetCookie diff --git a/respx/patterns.py b/respx/patterns.py index 0bd6199..bf21b84 100644 --- a/respx/patterns.py +++ b/respx/patterns.py @@ -25,7 +25,7 @@ ) from unittest.mock import ANY -import httpx +from respx._compat import httpx from respx.utils import MultiItems, decode_data diff --git a/respx/router.py b/respx/router.py index 449a5a4..5c15377 100644 --- a/respx/router.py +++ b/respx/router.py @@ -17,7 +17,7 @@ overload, ) -import httpx +from respx._compat import httpx from .mocks import Mocker from .models import ( diff --git a/respx/transports.py b/respx/transports.py index 4b34a15..b65d93f 100644 --- a/respx/transports.py +++ b/respx/transports.py @@ -12,11 +12,13 @@ ) from warnings import warn -import httpx -from httpx import AsyncBaseTransport, BaseTransport +from respx._compat import httpx from .models import PassThrough +AsyncBaseTransport = httpx.AsyncBaseTransport +BaseTransport = httpx.BaseTransport + if TYPE_CHECKING: from .router import Router # pragma: nocover diff --git a/respx/types.py b/respx/types.py index 1cf3e0c..6d11e32 100644 --- a/respx/types.py +++ b/respx/types.py @@ -18,7 +18,7 @@ Union, ) -import httpx +from respx._compat import httpx URL = Tuple[ bytes, # scheme diff --git a/respx/utils.py b/respx/utils.py index aa59488..80910a8 100644 --- a/respx/utils.py +++ b/respx/utils.py @@ -18,7 +18,7 @@ ) from urllib.parse import parse_qsl -import httpx +from respx._compat import httpx class MultiItems(defaultdict): diff --git a/setup.py b/setup.py index c8e1619..1315c83 100644 --- a/setup.py +++ b/setup.py @@ -43,4 +43,5 @@ zip_safe=False, python_requires=">=3.8", install_requires=["httpx>=0.25.0"], + extras_require={"httpx2": ["httpx2>=2.0.0"]}, ) diff --git a/tests/conftest.py b/tests/conftest.py index 1e40197..6f1a2de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -import httpx +from respx._compat import httpx import pytest import respx diff --git a/tests/test_api.py b/tests/test_api.py index 8aa9903..bf7de87 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,8 +4,8 @@ import socket from unittest import mock -import httpcore -import httpx +from respx._compat import httpcore +from respx._compat import httpx import pytest import respx diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..dcfcd75 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,41 @@ +import importlib +import sys + +import pytest + +import respx._compat as compat + + +@pytest.fixture(autouse=True) +def restore_compat(): + yield + importlib.reload(compat) + + +def test_invalid_package_env(monkeypatch): + monkeypatch.setenv("RESPX_HTTPX_PACKAGE", "requests") + with pytest.raises(ImportError, match="RESPX_HTTPX_PACKAGE"): + importlib.reload(compat) + + +@pytest.mark.parametrize("package", ["httpx", "httpx2"]) +def test_package_env(monkeypatch, package): + monkeypatch.setenv("RESPX_HTTPX_PACKAGE", package) + importlib.reload(compat) + assert compat.httpx.__name__ == package + + +def test_fallback_to_httpx2(monkeypatch): + monkeypatch.delenv("RESPX_HTTPX_PACKAGE", raising=False) + monkeypatch.setitem(sys.modules, "httpx", None) + importlib.reload(compat) + assert compat.httpx_name == "httpx2" + assert compat.httpcore_name == "httpcore2" + + +def test_none_installed(monkeypatch): + monkeypatch.delenv("RESPX_HTTPX_PACKAGE", raising=False) + monkeypatch.setitem(sys.modules, "httpx", None) + monkeypatch.setitem(sys.modules, "httpx2", None) + with pytest.raises(ImportError, match="httpx2"): + importlib.reload(compat) diff --git a/tests/test_mock.py b/tests/test_mock.py index 6f1118c..facb9c7 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -2,8 +2,8 @@ from contextlib import ExitStack as does_not_raise from unittest import mock -import httpcore -import httpx +from respx._compat import httpcore +from respx._compat import httpx import pytest import respx @@ -452,7 +452,7 @@ async def test_assert_all_mocked(client, assert_all_mocked, raises): def test_add_remove_targets(): from respx.mocks import HTTPCoreMocker - target = "httpcore._sync.connection.HTTPConnection" + target = f"{httpcore.__name__}._sync.connection.HTTPConnection" assert HTTPCoreMocker.targets.count(target) == 1 HTTPCoreMocker.add_targets(target) assert HTTPCoreMocker.targets.count(target) == 1 @@ -460,8 +460,8 @@ def test_add_remove_targets(): pre_add_count = len(HTTPCoreMocker.targets) try: HTTPCoreMocker.add_targets( - "httpx._transports.asgi.ASGITransport", - "httpx._transports.wsgi.WSGITransport", + f"{httpx.__name__}._transports.asgi.ASGITransport", + f"{httpx.__name__}._transports.wsgi.WSGITransport", ) assert len(HTTPCoreMocker.targets) == pre_add_count + 2 @@ -469,8 +469,8 @@ def test_add_remove_targets(): assert len(HTTPCoreMocker.targets) == pre_add_count + 2 finally: HTTPCoreMocker.remove_targets( - "httpx._transports.asgi.ASGITransport", - "httpx._transports.wsgi.WSGITransport", + f"{httpx.__name__}._transports.asgi.ASGITransport", + f"{httpx.__name__}._transports.wsgi.WSGITransport", ) assert len(HTTPCoreMocker.targets) == pre_add_count diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 178a0e5..f6fe838 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -2,7 +2,7 @@ import re from unittest.mock import ANY -import httpx +from respx._compat import httpx import pytest from respx.patterns import ( diff --git a/tests/test_plugin.py b/tests/test_plugin.py index df42118..34557d8 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,7 +4,7 @@ def test_respx_mock_fixture(testdir): testdir.makepyfile( """ - import httpx + from respx._compat import httpx import pytest @pytest.fixture diff --git a/tests/test_router.py b/tests/test_router.py index e820407..6eb964a 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,7 +1,7 @@ import warnings -import httpcore -import httpx +from respx._compat import httpcore +from respx._compat import httpx import pytest from respx import Route, Router diff --git a/tests/test_stats.py b/tests/test_stats.py index 9cf8099..548f4e6 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,6 +1,6 @@ import re -import httpx +from respx._compat import httpx import pytest import respx diff --git a/tests/test_transports.py b/tests/test_transports.py index 1a12691..36652fe 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -1,6 +1,6 @@ import warnings -import httpx +from respx._compat import httpx import pytest from respx.models import PassThrough