From 77fc926ed10b4043bbdfdbeb00e9f2f244a5d920 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 13 Nov 2023 19:34:58 +0100 Subject: [PATCH 1/4] Reproduce the fact that custom transport is not used --- tests/test_httpx_async.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index e9ad034..584d5c4 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -5,6 +5,7 @@ import httpx import pytest +from httpx import Request, Response from pytest import Testdir from unittest.mock import ANY @@ -2015,3 +2016,26 @@ async def test_streams_are_not_cascading_resulting_in_maximum_recursion( tasks = [client.get("https://example.com/") for _ in range(950)] await asyncio.gather(*tasks) # No need to assert anything, this test case ensure that no error was raised by the gather + + +@pytest.mark.asyncio +async def test_custom_transport(httpx_mock: HTTPXMock) -> None: + class CustomTransport(httpx.AsyncHTTPTransport): + def __init__(self, prefix: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = prefix + + async def handle_async_request( + self, + request: Request, + ) -> Response: + httpx_response = await super().handle_async_request(request) + httpx_response.headers["x-prefix"] = self.prefix + return httpx_response + + httpx_mock.add_response() + + async with httpx.AsyncClient(transport=CustomTransport(prefix="test")) as client: + response = await client.post("https://test_url", content=b"This is the body") + assert response.read() == b"" + assert response.headers["x-prefix"] == "test" From 7ac04bc8fbc2edb69a20032912d528bbcd6ccdac Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 13 Nov 2023 20:12:07 +0100 Subject: [PATCH 2/4] Handle custom transport --- CHANGELOG.md | 5 ++++ pytest_httpx/__init__.py | 44 ++++++++++++++++++++------------ pytest_httpx/_httpx_internals.py | 13 ++++------ pytest_httpx/_httpx_mock.py | 26 +++---------------- pytest_httpx/_request_matcher.py | 4 +-- tests/test_httpx_async.py | 5 ++-- tests/test_httpx_sync.py | 23 ++++++++++++++++- 7 files changed, 67 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf57b10..b2088ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Custom HTTP transport are now handled (parent call to `handle_async_request` or `handle_request`). + +### Changed +- Only HTTP transport are now mocked, this should not have any impact, however if it does, please feel free to open an issue describing your use case. ## [0.26.0] - 2023-09-18 ### Added diff --git a/pytest_httpx/__init__.py b/pytest_httpx/__init__.py index b579fa9..3fe8071 100644 --- a/pytest_httpx/__init__.py +++ b/pytest_httpx/__init__.py @@ -5,11 +5,7 @@ import pytest from pytest import MonkeyPatch -from pytest_httpx._httpx_mock import ( - HTTPXMock, - _PytestSyncTransport, - _PytestAsyncTransport, -) +from pytest_httpx._httpx_mock import HTTPXMock from pytest_httpx._httpx_internals import IteratorStream from pytest_httpx.version import __version__ @@ -45,22 +41,36 @@ def httpx_mock( mock = HTTPXMock() # Mock synchronous requests - real_sync_transport = httpx.Client._transport_for_url + real_handle_request = httpx.HTTPTransport.handle_request + + def mocked_handle_request( + transport: httpx.HTTPTransport, request: httpx.Request + ) -> httpx.Response: + if request.url.host in non_mocked_hosts: + return real_handle_request(transport, request) + return mock._handle_request(transport, request) + monkeypatch.setattr( - httpx.Client, - "_transport_for_url", - lambda self, url: real_sync_transport(self, url) - if url.host in non_mocked_hosts - else _PytestSyncTransport(real_sync_transport(self, url), mock), + httpx.HTTPTransport, + "handle_request", + mocked_handle_request, ) + # Mock asynchronous requests - real_async_transport = httpx.AsyncClient._transport_for_url + real_handle_async_request = httpx.AsyncHTTPTransport.handle_async_request + + async def mocked_handle_async_request( + transport: httpx.AsyncHTTPTransport, request: httpx.Request + ) -> httpx.Response: + if request.url.host in non_mocked_hosts: + return await real_handle_async_request(transport, request) + return await mock._handle_async_request(transport, request) + monkeypatch.setattr( - httpx.AsyncClient, - "_transport_for_url", - lambda self, url: real_async_transport(self, url) - if url.host in non_mocked_hosts - else _PytestAsyncTransport(real_async_transport(self, url), mock), + httpx.AsyncHTTPTransport, + "handle_async_request", + mocked_handle_async_request, ) + yield mock mock.reset(assert_all_responses_were_requested) diff --git a/pytest_httpx/_httpx_internals.py b/pytest_httpx/_httpx_internals.py index e12b933..da21797 100644 --- a/pytest_httpx/_httpx_internals.py +++ b/pytest_httpx/_httpx_internals.py @@ -61,12 +61,9 @@ def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> http def _proxy_url( - real_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport] + real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport] ) -> Optional[httpx.URL]: - if isinstance(real_transport, httpx.HTTPTransport): - if isinstance(real_pool := real_transport._pool, httpcore.HTTPProxy): - return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers) - - if isinstance(real_transport, httpx.AsyncHTTPTransport): - if isinstance(real_pool := real_transport._pool, httpcore.AsyncHTTPProxy): - return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers) + if isinstance( + real_pool := real_transport._pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy) + ): + return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index 425f420..941b050 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -12,7 +12,7 @@ class HTTPXMock: def __init__(self) -> None: self._requests: list[ - tuple[Union[httpx.BaseTransport, httpx.AsyncBaseTransport], httpx.Request] + tuple[Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], httpx.Request] ] = [] self._callbacks: list[ tuple[ @@ -123,7 +123,7 @@ def exception_callback(request: httpx.Request) -> None: def _handle_request( self, - real_transport: httpx.BaseTransport, + real_transport: httpx.HTTPTransport, request: httpx.Request, ) -> httpx.Response: self._requests.append((real_transport, request)) @@ -142,7 +142,7 @@ def _handle_request( async def _handle_async_request( self, - real_transport: httpx.AsyncBaseTransport, + real_transport: httpx.AsyncHTTPTransport, request: httpx.Request, ) -> httpx.Response: self._requests.append((real_transport, request)) @@ -178,7 +178,7 @@ def _explain_that_no_response_was_found( def _get_callback( self, - real_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport], + real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], request: httpx.Request, ) -> Optional[ Callable[ @@ -266,24 +266,6 @@ def _reset_callbacks(self) -> list[_RequestMatcher]: return callbacks_not_executed -class _PytestSyncTransport(httpx.BaseTransport): - def __init__(self, real_transport: httpx.BaseTransport, mock: HTTPXMock): - self._real_transport = real_transport - self._mock = mock - - def handle_request(self, request: httpx.Request) -> httpx.Response: - return self._mock._handle_request(self._real_transport, request) - - -class _PytestAsyncTransport(httpx.AsyncBaseTransport): - def __init__(self, real_transport: httpx.AsyncBaseTransport, mock: HTTPXMock): - self._real_transport = real_transport - self._mock = mock - - async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - return await self._mock._handle_async_request(self._real_transport, request) - - def _unread(response: httpx.Response) -> httpx.Response: # Allow to read the response on client side response.is_stream_consumed = False diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py index e80df4b..1bb590c 100644 --- a/pytest_httpx/_request_matcher.py +++ b/pytest_httpx/_request_matcher.py @@ -52,7 +52,7 @@ def __init__( def match( self, - real_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport], + real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport], request: httpx.Request, ) -> bool: return ( @@ -106,7 +106,7 @@ def _content_match(self, request: httpx.Request) -> bool: return False def _proxy_match( - self, real_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport] + self, real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport] ) -> bool: if not self.proxy_url: return True diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 584d5c4..54eb82a 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -5,7 +5,6 @@ import httpx import pytest -from httpx import Request, Response from pytest import Testdir from unittest.mock import ANY @@ -2027,8 +2026,8 @@ def __init__(self, prefix: str, *args, **kwargs): async def handle_async_request( self, - request: Request, - ) -> Response: + request: httpx.Request, + ) -> httpx.Response: httpx_response = await super().handle_async_request(request) httpx_response.headers["x-prefix"] = self.prefix return httpx_response diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 00431c5..e1e4938 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -1,5 +1,4 @@ import re -from typing import Any from unittest.mock import ANY import httpx @@ -1706,3 +1705,25 @@ def test_mutating_json(httpx_mock: HTTPXMock) -> None: response = client.get("https://test_url") assert response.json() == {"content": "request 2"} + + +def test_custom_transport(httpx_mock: HTTPXMock) -> None: + class CustomTransport(httpx.HTTPTransport): + def __init__(self, prefix: str, *args, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = prefix + + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + httpx_response = super().handle_request(request) + httpx_response.headers["x-prefix"] = self.prefix + return httpx_response + + httpx_mock.add_response() + + with httpx.Client(transport=CustomTransport(prefix="test")) as client: + response = client.post("https://test_url", content=b"This is the body") + assert response.read() == b"" + assert response.headers["x-prefix"] == "test" From d8f952d3c3ef224e9d33f8e6536b2188520ee94e Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 13 Nov 2023 20:19:14 +0100 Subject: [PATCH 3/4] Handle python 3.12 --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 3 +++ README.md | 2 +- setup.py | 1 + 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 087ee82..ab4a5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81c921d..8cac5ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be9aceb..3ec3b4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.11.0 hooks: - id: black \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b2088ce..6d8c4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Explicit support for python `3.12`. + ### Fixed - Custom HTTP transport are now handled (parent call to `handle_async_request` or `handle_request`). diff --git a/README.md b/README.md index 006123d..8fc751c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

diff --git a/setup.py b/setup.py index e10c5ab..5e17eea 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Build Tools", "Topic :: Internet :: WWW/HTTP", "Framework :: Pytest", From 68a3a32e0bc2c1c97201e275d17924e9243ba51e Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 13 Nov 2023 20:24:24 +0100 Subject: [PATCH 4/4] Release version 0.27.0 today --- CHANGELOG.md | 5 ++++- pytest_httpx/version.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8c4cf..84c4b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.27.0] - 2023-11-13 ### Added - Explicit support for python `3.12`. @@ -295,7 +297,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - First release, should be considered as unstable for now as design might change. -[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.26.0...HEAD +[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.27.0...HEAD +[0.27.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.26.0...v0.27.0 [0.26.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.23.1...v0.24.0 diff --git a/pytest_httpx/version.py b/pytest_httpx/version.py index 2773c8d..5921900 100644 --- a/pytest_httpx/version.py +++ b/pytest_httpx/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "0.26.0" +__version__ = "0.27.0"