From 59a18e0058a7a8c7fcaa818a8ea04ff61e5227e8 Mon Sep 17 00:00:00 2001 From: Jocelyn Legault Date: Mon, 27 Oct 2025 21:37:25 -0400 Subject: [PATCH 1/6] Accept BaseException in add_exception - widen the type hint so asyncio.CancelledError (and other BaseException subclasses) pass type checking - keep runtime behavior unchanged while satisfying Pyright --- pytest_httpx/_httpx_mock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index f51d15b..6667f21 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -120,7 +120,7 @@ def add_callback( """ self._callbacks.append((_RequestMatcher(self._options, **matchers), callback)) - def add_exception(self, exception: Exception, **matchers: Any) -> None: + def add_exception(self, exception: BaseException, **matchers: Any) -> None: """ Raise an exception if a request match. From 786b86193071a25c7279260bb2dfb1f8e6a6400b Mon Sep 17 00:00:00 2001 From: Jocelyn Legault Date: Tue, 28 Oct 2025 07:34:12 -0400 Subject: [PATCH 2/6] Add test for asyncio.CancelledError support --- tests/test_httpx_async.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 663ae52..f13468d 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -914,6 +914,18 @@ async def test_non_request_exception_raising(httpx_mock: HTTPXMock) -> None: assert str(exception_info.value) == "Unable to read within 5.0" +@pytest.mark.asyncio +async def test_request_cancelled_exception_raising(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_exception( + asyncio.CancelledError("Request was cancelled"), url="https://test_url" + ) + + async with httpx.AsyncClient() as client: + with pytest.raises(asyncio.CancelledError) as exception_info: + await client.get("https://test_url") + assert str(exception_info.value) == "Request was cancelled" + + @pytest.mark.asyncio async def test_callback_returning_response(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: From 7108d067cd9fda5a5d832bb6e9d9af1c085c601f Mon Sep 17 00:00:00 2001 From: Jocelyn Legault Date: Tue, 28 Oct 2025 20:12:17 -0400 Subject: [PATCH 3/6] Fix SonarCloud issues --- tests/test_httpx_async.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index f13468d..fca4487 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -577,10 +577,10 @@ def instant_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_with_pattern_in_url(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"url": str(request.url)}) - async def custom_response2(request: httpx.Request) -> httpx.Response: + def custom_response2(request: httpx.Request) -> httpx.Response: return httpx.Response( status_code=200, extensions={"http_version": b"HTTP/2.0"}, @@ -941,7 +941,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_returning_response(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"url": str(request.url)}) httpx_mock.add_callback(custom_response, url="https://test_url") @@ -971,7 +971,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_executed_twice(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) httpx_mock.add_callback(custom_response, is_reusable=True) @@ -1011,7 +1011,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content2"]) httpx_mock.add_response(json=["content1"]) @@ -1057,7 +1057,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_response_registered_after_async_callback(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content1"]) httpx_mock.add_callback(custom_response) @@ -1097,7 +1097,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_matching_method(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) httpx_mock.add_callback(custom_response, method="GET", is_reusable=True) @@ -2060,7 +2060,7 @@ async def test_elapsed_when_add_callback(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio async def test_elapsed_when_add_async_callback(httpx_mock: HTTPXMock) -> None: - async def custom_response(request: httpx.Request) -> httpx.Response: + def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"foo": "bar"}) httpx_mock.add_callback(custom_response) From 9c1cb67dc679a49ceedfb172acf0508155566ea8 Mon Sep 17 00:00:00 2001 From: Jocelyn Legault Date: Tue, 28 Oct 2025 20:27:36 -0400 Subject: [PATCH 4/6] Parametrize tests to minimize dups and please SonarCloud. --- tests/test_httpx_async.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index fca4487..8c89b92 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -903,27 +903,28 @@ async def test_request_exception_raising(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio -async def test_non_request_exception_raising(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_exception( - httpx.HTTPError("Unable to read within 5.0"), url="https://test_url" - ) - - async with httpx.AsyncClient() as client: - with pytest.raises(httpx.HTTPError) as exception_info: - await client.get("https://test_url") - assert str(exception_info.value) == "Unable to read within 5.0" - - -@pytest.mark.asyncio -async def test_request_cancelled_exception_raising(httpx_mock: HTTPXMock) -> None: - httpx_mock.add_exception( - asyncio.CancelledError("Request was cancelled"), url="https://test_url" - ) +@pytest.mark.parametrize( + ("exception_type", "message"), + [ + pytest.param( + httpx.HTTPError, "Unable to read within 5.0", id="non_request_exception" + ), + pytest.param( + asyncio.CancelledError, + "Request was cancelled", + id="cancelled_exception", + ), + ], +) +async def test_exception_raising( + httpx_mock: HTTPXMock, exception_type: type, message: str +) -> None: + httpx_mock.add_exception(exception_type(message), url="https://test_url") async with httpx.AsyncClient() as client: - with pytest.raises(asyncio.CancelledError) as exception_info: + with pytest.raises(exception_type) as exception_info: await client.get("https://test_url") - assert str(exception_info.value) == "Request was cancelled" + assert str(exception_info.value) == message @pytest.mark.asyncio From ced1c3a21be2a2496c83f6ec0dc70e2e1f3ba57b Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 17:01:52 +0100 Subject: [PATCH 5/6] Document the fact that add_exception can receive BaseException derived exception --- CHANGELOG.md | 1 + tests/test_httpx_async.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4e08d..e74c739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - URL with more than one value for the same parameter were not matched properly (matching was performed on the first value). +- `httpx_mock.add_exception` is now properly documented (accepts `BaseException` instead of `Exception`). ## [0.35.0] - 2024-11-28 ### Changed diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index f83abbb..3f1ab89 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -726,10 +726,10 @@ def instant_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_with_pattern_in_url(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"url": str(request.url)}) - def custom_response2(request: httpx.Request) -> httpx.Response: + async def custom_response2(request: httpx.Request) -> httpx.Response: return httpx.Response( status_code=200, extensions={"http_version": b"HTTP/2.0"}, @@ -1055,9 +1055,11 @@ async def test_request_exception_raising(httpx_mock: HTTPXMock) -> None: @pytest.mark.parametrize( ("exception_type", "message"), [ + # httpx exception without request context pytest.param( httpx.HTTPError, "Unable to read within 5.0", id="non_request_exception" ), + # BaseException derived exception pytest.param( asyncio.CancelledError, "Request was cancelled", @@ -1065,7 +1067,7 @@ async def test_request_exception_raising(httpx_mock: HTTPXMock) -> None: ), ], ) -async def test_exception_raising( +async def test_non_request_exception_raising( httpx_mock: HTTPXMock, exception_type: type, message: str ) -> None: httpx_mock.add_exception(exception_type(message), url="https://test_url") @@ -1091,7 +1093,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_returning_response(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"url": str(request.url)}) httpx_mock.add_callback(custom_response, url="https://test_url") @@ -1121,7 +1123,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_executed_twice(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) httpx_mock.add_callback(custom_response, is_reusable=True) @@ -1161,7 +1163,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content2"]) httpx_mock.add_response(json=["content1"]) @@ -1207,7 +1209,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_response_registered_after_async_callback(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content1"]) httpx_mock.add_callback(custom_response) @@ -1247,7 +1249,7 @@ def custom_response(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_async_callback_matching_method(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) httpx_mock.add_callback(custom_response, method="GET", is_reusable=True) @@ -2210,7 +2212,7 @@ async def test_elapsed_when_add_callback(httpx_mock: HTTPXMock) -> None: @pytest.mark.asyncio async def test_elapsed_when_add_async_callback(httpx_mock: HTTPXMock) -> None: - def custom_response(request: httpx.Request) -> httpx.Response: + async def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json={"foo": "bar"}) httpx_mock.add_callback(custom_response) From 7b612dde58ea5e54fc8c2b2459434870d4d666c3 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Tue, 2 Dec 2025 17:05:38 +0100 Subject: [PATCH 6/6] Keep the number of test cases up to date --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cd30ce..6d97041 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