Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aa9a9b6
Add a compatibility layer that allows using httpx2
Klavionik Aug 6, 2026
fc52b8d
Add a changelog entry
Klavionik Aug 6, 2026
dfb4413
Move httpx and httpx2 dependencies to the extras
Klavionik Sep 8, 2026
8f80b92
Make IteratorStream inherit from public classes
Klavionik Oct 8, 2026
86e6235
Collect httpx backends for mocking
Klavionik Oct 8, 2026
24a4102
Mock every installed backend
Klavionik Oct 8, 2026
be54b13
Inject backends throughout the mocking machinery
Klavionik Oct 9, 2026
6100c00
Rename HttpxBackend -> HTTPBackend
Klavionik Oct 9, 2026
70a1905
Sync and async HTTPX tests pass
Klavionik Oct 12, 2026
7c4c734
Plugin tests pass
Klavionik Oct 12, 2026
7e6d9b4
Update number of outcomes with the backends count
Klavionik Oct 12, 2026
2b7c537
Make error messages use the correct backend name
Klavionik Oct 12, 2026
ec9730c
Add tests covering the case of both backends installed
Klavionik Oct 12, 2026
9c0de86
Add tox config
Klavionik Oct 12, 2026
3c4c9c7
Refactor _RequestMatcher to drop the runtime httpx.URL dependency
Klavionik Oct 13, 2026
71f9887
Update pyproject to include dev extra, update the contriburing guide
Klavionik Oct 13, 2026
7368f00
Add a changlelog entry and update docs
Klavionik Oct 13, 2026
11aabfd
Use tox in CI
Klavionik Oct 13, 2026
e81ece1
Revert using httpx2 in type hints
Klavionik Oct 13, 2026
3069009
Update tox config
Klavionik Oct 14, 2026
7e11b1f
Remove stale code
Klavionik Oct 14, 2026
c9cf282
Consistent type hints of transports
Klavionik Oct 14, 2026
e35d301
Move get_backend/set_backend helpers to the compat module
Klavionik Oct 14, 2026
9c1ea19
Improve comment
Klavionik Oct 14, 2026
fb3d87e
Revert unnecessary change in README
Klavionik Oct 14, 2026
99c0b1f
More pragma: no cover on httpx imports
Klavionik Oct 14, 2026
7e9d2de
Update the public API to use both httpx and httpx2 type hints
Klavionik Oct 19, 2026
6b7579f
Fix Callable type hint
Klavionik Oct 19, 2026
1d2b13e
Merge branch 'develop' into add_httpx2_support
Klavionik Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
pre-commit run --all-files
- name: Test
run: |
pytest --cov-report=xml --junitxml=junit.xml -o junit_family=legacy
tox -- --cov-report=xml --junitxml=junit.xml -o junit_family=legacy
- name: Upload test results to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `match_params` is now handling `bool`, `int` and `float` values in addition to `str`. Only str values were previously expected.
- A meaningful error will now be returned when a callback that does not return an `httpx.Response` is called.

### Added
- Support for httpx2 is implemented. When both `httpx` and `httpx2` are installed, the `httpx_mock`
fixture now mocks both of them within the same test session.

### Changed
- `httpx` is not installed automatically anymore. You now have to require it explicitly through the `httpx` or `httpx2` extra.
Both extras can be installed at the same time to mock both libraries simultaneously.

### Removed
- python `3.10` is not supported anymore.

Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ Title should be a small sentence describing the request.
1) Create a new branch based on `develop` branch.
2) Fetch all dependencies using [`pip`](https://pip.pypa.io/en/stable/).
```shell
python -m pip install .[testing]
python -m pip install .[dev]
```
3) Ensure tests are ok by running them using [`pytest`](https://doc.pytest.org/en/latest/index.html).
3) Ensure tests are ok by running them using [`tox`](https://tox.wiki/en/4.65.4/index.html).
```shell
pytest
tox
```
You can also run tests directly using [`pytest`](https://doc.pytest.org/en/latest/index.html).
4) Install [pre-commit](https://pre-commit.com) hook to ensure new code will follow guidelines.
```shell
python -m pip install pre-commit
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@
>
> However, current state can be considered as stable.

Once installed, `httpx_mock` [`pytest`](https://docs.pytest.org/en/latest/) fixture will make sure every [`httpx`](https://www.python-httpx.org) request will be replied to with user provided responses ([unless some hosts are explicitly skipped](#do-not-mock-some-requests)).
Since `pytest-httpx` supports both `httpx` and `httpx2`, it doesn't install any of them by itself,
you have to pick which one (or both) you are using:

```bash
# For httpx.
pip install pytest-httpx[httpx]
# For httpx2.
pip install pytest-httpx[httpx2]
# For both.
pip install pytest-httpx[httpx,httpx2]
```

Once installed, `httpx_mock` [`pytest`](https://docs.pytest.org/en/latest/) fixture will make sure every [`httpx`](https://www.python-httpx.org) or [`httpx2`](https://pydantic.dev/docs/httpx2/get-started/)
request will be replied to with user provided responses ([unless some hosts are explicitly skipped](#do-not-mock-some-requests)).

For brevity, both httpx and httpx2 will be further referred to as httpx. All the examples using httpx
should stay intact when using httpx2.

- [Add responses](#add-responses)
- [JSON body](#add-json-response)
Expand All @@ -37,7 +53,7 @@ Once installed, `httpx_mock` [`pytest`](https://docs.pytest.org/en/latest/) fixt

## Add responses

You can register responses for both sync and async [`HTTPX`](https://www.python-httpx.org) requests.
You can register responses for both sync and async ([`HTTPX`](https://www.python-httpx.org)) requests.

```python
import pytest
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"httpx==0.28.*",
"pytest==9.*",
]
dynamic = ["version"]
Expand All @@ -51,12 +50,18 @@ changelog = "https://github.com/Colin-b/pytest_httpx/blob/master/CHANGELOG.md"
issues = "https://github.com/Colin-b/pytest_httpx/issues"

[project.optional-dependencies]
httpx = ["httpx==0.28.*"]
httpx2 = ["httpx2==2.*"]
testing = [
# Used to check coverage
"pytest-cov==7.*",
# Used to run async tests
"pytest-asyncio==1.*",
]
dev = [
"pytest-httpx[testing,httpx,httpx2]",
"tox==4.*"
]

[project.entry-points.pytest11]
pytest_httpx = "pytest_httpx"
Expand Down
61 changes: 36 additions & 25 deletions pytest_httpx/__init__.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,78 @@
from collections.abc import Generator
from operator import methodcaller

import httpx
import pytest
from pytest import Config, FixtureRequest, MonkeyPatch

from pytest_httpx._httpx_compat import HTTPXBackend, get_backends
from pytest_httpx._httpx_internals import IteratorStream
from pytest_httpx._httpx_mock import HTTPXMock
from pytest_httpx._options import _HTTPXMockOptions
from pytest_httpx.version import __version__

__all__ = (
"HTTPXBackend",
"HTTPXMock",
"IteratorStream",
"__version__",
"get_backends",
)


@pytest.fixture
def httpx_mock(
def _mock_backend(
monkeypatch: MonkeyPatch,
request: FixtureRequest,
) -> Generator[HTTPXMock, None, None]:
httpx_mock_markers: dict = {}
for marker in request.node.iter_markers("httpx_mock"):
httpx_mock_markers = marker.kwargs | httpx_mock_markers
__tracebackhide__ = methodcaller("errisinstance", TypeError)
options = _HTTPXMockOptions(**httpx_mock_markers)

mock = HTTPXMock(options)

options: _HTTPXMockOptions,
mock: HTTPXMock,
backend: HTTPXBackend,
) -> None:
# Mock synchronous requests
real_handle_request = httpx.HTTPTransport.handle_request
real_handle_request = backend.httpx.HTTPTransport.handle_request

def mocked_handle_request(
transport: httpx.HTTPTransport, request: httpx.Request
) -> httpx.Response:
def mocked_handle_request(transport, request): # type: ignore[no-untyped-def]
if options.should_mock(request):
return mock._handle_request(transport, request)
return mock._handle_request(backend, transport, request)
return real_handle_request(transport, request)

monkeypatch.setattr(
httpx.HTTPTransport,
backend.httpx.HTTPTransport,
"handle_request",
mocked_handle_request,
)

# Mock asynchronous requests
real_handle_async_request = httpx.AsyncHTTPTransport.handle_async_request
real_handle_async_request = backend.httpx.AsyncHTTPTransport.handle_async_request

async def mocked_handle_async_request(
transport: httpx.AsyncHTTPTransport, request: httpx.Request
) -> httpx.Response:
async def mocked_handle_async_request(transport, request): # type: ignore[no-untyped-def]
if options.should_mock(request):
return await mock._handle_async_request(transport, request)
return await mock._handle_async_request(backend, transport, request)
return await real_handle_async_request(transport, request)

monkeypatch.setattr(
httpx.AsyncHTTPTransport,
backend.httpx.AsyncHTTPTransport,
"handle_async_request",
mocked_handle_async_request,
)


@pytest.fixture
def httpx_mock(
monkeypatch: MonkeyPatch,
request: FixtureRequest,
) -> Generator[HTTPXMock, None, None]:
httpx_mock_markers: dict = {}

for marker in request.node.iter_markers("httpx_mock"):
httpx_mock_markers = marker.kwargs | httpx_mock_markers

__tracebackhide__ = methodcaller("errisinstance", TypeError)
options = _HTTPXMockOptions(**httpx_mock_markers)

mock = HTTPXMock(options)

# Mock sync and async transports of every installed httpx backend.
for backend in get_backends():
_mock_backend(monkeypatch, options, mock, backend)

yield mock
try:
mock._assert_options()
Expand Down
65 changes: 65 additions & 0 deletions pytest_httpx/_httpx_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from dataclasses import dataclass
from functools import cache
from types import ModuleType
from typing import TYPE_CHECKING, Literal


@dataclass(frozen=True)
class HTTPXBackend:
name: Literal["httpx", "httpx2"]
httpx: ModuleType
httpcore: ModuleType


@cache
def get_backends() -> list[HTTPXBackend]:
"""Return every httpx backend found in the environment."""

backends = []

try:
import httpcore2 as httpcore2_ # pragma: no cover
import httpx2 as httpx2_ # pragma: no cover
except ModuleNotFoundError: # pragma: no cover
pass
else:
backends.append(HTTPXBackend("httpx2", httpx2_, httpcore2_)) # pragma: no cover

try:
import httpcore as httpcore_ # pragma: no cover
import httpx as httpx_ # pragma: no cover
except ModuleNotFoundError: # pragma: no cover
pass
else:
backends.append(HTTPXBackend("httpx", httpx_, httpcore_)) # pragma: no cover

if not backends: # pragma: no cover
raise RuntimeError(
"pytest-httpx requires either httpx or httpx2 to be installed.\n"
"They are now optional dependencies, install the matching extra with:\n"
" $ pip install pytest-httpx[httpx2]\n"
"or:\n"
" $ pip install pytest-httpx[httpx]\n"
)

return backends


_BACKEND_ATTRIBUTE = "__pytest_httpx_backend"


def set_backend(request: "httpx.Request", backend: HTTPXBackend) -> None:
# Store the intercepting backend on the request so that backend-agnostic
# callbacks (e.g. the one built by `add_response`) can build a response
# with the matching library.
setattr(request, _BACKEND_ATTRIBUTE, backend)


def get_backend(request: "httpx.Request") -> HTTPXBackend:
return getattr(request, _BACKEND_ATTRIBUTE)


if TYPE_CHECKING:
import httpcore # noqa: F401
import httpx
import httpx2 # noqa: F401
72 changes: 43 additions & 29 deletions pytest_httpx/_httpx_internals.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,62 @@
import base64
from collections.abc import AsyncIterator, Iterable, Iterator, Sequence
from typing import TypeAlias
from typing import TYPE_CHECKING, TypeAlias, Union

import httpcore
import httpx
from pytest_httpx._httpx_compat import HTTPXBackend, get_backends

# TODO Get rid of this internal import
from httpx._content import AsyncIteratorByteStream, IteratorByteStream
if TYPE_CHECKING:
from pytest_httpx._httpx_compat import httpcore, httpx, httpx2

# Those types are internally defined within httpx._types
HeaderTypes: TypeAlias = (
httpx.Headers
| dict[str, str]
| dict[bytes, bytes]
| Sequence[tuple[str, str]]
| Sequence[tuple[bytes, bytes]]
)
PrimitiveData: TypeAlias = str | int | float | bool | None
HeaderTypes: TypeAlias = Union[
"httpx.Headers",
"httpx2.Headers",
dict[str, str],
dict[bytes, bytes],
Sequence[tuple[str, str]],
Sequence[tuple[bytes, bytes]],
]
PrimitiveData: TypeAlias = str | int | float | bool


class IteratorStream(AsyncIteratorByteStream, IteratorByteStream):
def _iterator_stream_bases() -> list[type]:
bases: list[type] = []

for backend in get_backends():
bases.append(backend.httpx.SyncByteStream)
bases.append(backend.httpx.AsyncByteStream)

return bases


class IteratorStream(*_iterator_stream_bases()): # type: ignore[misc]
def __init__(self, stream: Iterable[bytes]):
class Stream:
def __iter__(self) -> Iterator[bytes]:
yield from stream
self._stream = stream

async def __aiter__(self) -> AsyncIterator[bytes]:
for chunk in stream:
yield chunk
def __iter__(self) -> Iterator[bytes]:
yield from self._stream

AsyncIteratorByteStream.__init__(self, stream=Stream())
IteratorByteStream.__init__(self, stream=Stream())
async def __aiter__(self) -> AsyncIterator[bytes]:
for chunk in self._stream:
yield chunk


def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> httpx.URL:
def _to_httpx_url(
backend: HTTPXBackend,
url: "httpcore.URL",
headers: list[tuple[bytes, bytes]],
) -> "httpx.URL":
for name, value in headers:
if b"Proxy-Authorization" == name:
return httpx.URL(
return backend.httpx.URL(
scheme=url.scheme.decode(),
host=url.host.decode(),
port=url.port,
raw_path=url.target,
userinfo=base64.b64decode(value[6:]),
)

return httpx.URL(
return backend.httpx.URL(
scheme=url.scheme.decode(),
host=url.host.decode(),
port=url.port,
Expand All @@ -53,10 +65,12 @@ def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> http


def _proxy_url(
real_transport: httpx.HTTPTransport | httpx.AsyncHTTPTransport,
) -> httpx.URL | None:
backend: HTTPXBackend,
real_transport: "httpx.HTTPTransport | httpx.AsyncHTTPTransport",
) -> "httpx.URL | None":
if isinstance(
real_pool := real_transport._pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy)
real_pool := real_transport._pool,
(backend.httpcore.HTTPProxy, backend.httpcore.AsyncHTTPProxy),
):
return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers)
return _to_httpx_url(backend, real_pool._proxy_url, real_pool._proxy_headers)
return None
Loading