From 48bed0cdba3d5f78613369578e08639162fdd99e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Peter=20B=C3=A1bics?=
Date: Tue, 19 Nov 2024 18:10:16 +0100
Subject: [PATCH 01/29] feat: add support for `match_params` allowing partial
params matching
---
pytest_httpx/_httpx_mock.py | 4 ++++
pytest_httpx/_request_matcher.py | 14 ++++++++++----
tests/test_httpx_async.py | 12 ++++++++++++
tests/test_httpx_sync.py | 12 ++++++++++++
4 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py
index e99646e..fe807ba 100644
--- a/pytest_httpx/_httpx_mock.py
+++ b/pytest_httpx/_httpx_mock.py
@@ -68,6 +68,8 @@ def add_response(
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
+ :param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
+ :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -112,6 +114,7 @@ def add_callback(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
+ :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this callback as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this callback even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -133,6 +136,7 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None:
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
+ :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
:param is_optional: True will mark this exception response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this exception response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index a2aaa13..92e07b9 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -10,19 +10,21 @@
def _url_match(
- url_to_match: Union[Pattern[str], httpx.URL], received: httpx.URL
+ url_to_match: Union[Pattern[str], httpx.URL], received: httpx.URL, params: Optional[dict[str, str]]
) -> bool:
if isinstance(url_to_match, re.Pattern):
return url_to_match.match(str(received)) is not None
# Compare query parameters apart as order of parameters should not matter
received_params = dict(received.params)
- params = dict(url_to_match.params)
+ if params is None:
+ params = dict(url_to_match.params)
# Remove the query parameters from the original URL to compare everything besides query parameters
received_url = received.copy_with(query=None)
url = url_to_match.copy_with(query=None)
+ print(received_params, params, (received_params == params), (url == received_url))
return (received_params == params) and (url == received_url)
@@ -39,6 +41,7 @@ def __init__(
match_data: Optional[dict[str, Any]] = None,
match_files: Optional[Any] = None,
match_extensions: Optional[dict[str, Any]] = None,
+ match_params: Optional[dict[str, str]] = None,
is_optional: Optional[bool] = None,
is_reusable: Optional[bool] = None,
):
@@ -51,6 +54,7 @@ def __init__(
self.json = match_json
self.data = match_data
self.files = match_files
+ self.params = match_params
self.proxy_url = (
httpx.URL(proxy_url)
if proxy_url and isinstance(proxy_url, str)
@@ -106,7 +110,7 @@ def _url_match(self, request: httpx.Request) -> bool:
if not self.url:
return True
- return _url_match(self.url, request.url)
+ return _url_match(self.url, request.url, self.params)
def _method_match(self, request: httpx.Request) -> bool:
if not self.method:
@@ -168,7 +172,7 @@ def _proxy_match(
return True
if real_proxy_url := _proxy_url(real_transport):
- return _url_match(self.proxy_url, real_proxy_url)
+ return _url_match(self.proxy_url, real_proxy_url, None)
return False
@@ -214,5 +218,7 @@ def _extra_description(self) -> str:
extra_description.append(f"{self.proxy_url} proxy URL")
if self.extensions:
extra_description.append(f"{self.extensions} extensions")
+ if self.params:
+ extra_description.append(f"{self.params} params")
return " and ".join(extra_description)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index 185ce7f..fdebd17 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -71,6 +71,18 @@ async def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
response = await client.get("https://test_url?b=2&a=1")
assert response.content == b""
+@pytest.mark.asyncio
+async def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url=httpx.URL("https://test_url"), match_params = {"a": "1", "b": ANY}, is_reusable=True)
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post("https://test_url?a=1&b=2")
+ assert response.content == b""
+
+ # Parameters order should not matter
+ response = await client.get("https://test_url?b=2&a=1")
+ assert response.content == b""
+
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index c71d918..a4d6955 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -64,6 +64,18 @@ def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url=httpx.URL("https://test_url"), match_params = {"a": "1", "b": ANY}, is_reusable=True)
+
+ with httpx.Client() as client:
+ response = client.post("https://test_url?a=1&b=2")
+ assert response.content == b""
+
+ # Parameters order should not matter
+ response = client.get("https://test_url?b=2&a=1")
+ assert response.content == b""
+
+
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
def test_url_not_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url="https://test_url", is_optional=True)
From 28dc48a36c92936ee71eaf5f043a22e225c44fd5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Peter=20B=C3=A1bics?=
Date: Tue, 19 Nov 2024 19:38:28 +0100
Subject: [PATCH 02/29] Update pytest_httpx/_request_matcher.py
---
pytest_httpx/_request_matcher.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 92e07b9..5d139fa 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -24,7 +24,6 @@ def _url_match(
received_url = received.copy_with(query=None)
url = url_to_match.copy_with(query=None)
- print(received_params, params, (received_params == params), (url == received_url))
return (received_params == params) and (url == received_url)
From 619b2aebdc36a4701fccf6725c77038860efcaf1 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Wed, 14 May 2025 22:58:54 +0200
Subject: [PATCH 03/29] Add more documentation on match_params
---
CHANGELOG.md | 3 +++
README.md | 20 ++++++++++++++++++++
pytest_httpx/_httpx_mock.py | 18 ++++++++++--------
pytest_httpx/_request_matcher.py | 19 +++++++++++++++----
4 files changed, 48 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c99098b..29be31f 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
+- `match_params` parameter is now available on responses and callbacks registration, as well as request(s) retrieval. Allowing to provide query parameters as a dict instead of being part of the matched URL.
+ - This parameter allows to perform partial query params matching ([refer to documentation](README.md#matching-on-query-parameters) for more information).
## [0.35.0] - 2024-11-28
### Changed
diff --git a/README.md b/README.md
index 007852c..93c6674 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,26 @@ def test_url_as_httpx_url(httpx_mock: HTTPXMock):
response = client.get("https://test_url?a=1&b=2")
```
+#### Matching on query parameters
+
+Use `match_params` to partially match query parameters without having to provide a regular expression as `url`.
+
+If this parameter is provided, `url` parameter must not contain any query parameter.
+
+All query parameters have to be provided. You can however use `unittest.mock.ANY` to do partial matching.
+
+```python
+import httpx
+from pytest_httpx import HTTPXMock
+from unittest.mock import ANY
+
+def test_partial_params_matching(httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url="https://test_url", match_params={"a": "1", "b": ANY})
+
+ with httpx.Client() as client:
+ response = client.get("https://test_url?a=1&b=2")
+```
+
#### Matching on HTTP method
Use `method` parameter to specify the HTTP method (POST, PUT, DELETE, PATCH, HEAD) to reply to.
diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py
index fe807ba..b252291 100644
--- a/pytest_httpx/_httpx_mock.py
+++ b/pytest_httpx/_httpx_mock.py
@@ -58,7 +58,7 @@ def add_response(
:param html: HTTP body of the response (as HTML string content).
:param stream: HTTP body of the response (as httpx.SyncByteStream or httpx.AsyncByteStream) as stream content.
:param json: HTTP body of the response (if JSON should be used as content type) if data is not provided.
- :param url: Full URL identifying the request(s) to match.
+ :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request(s) to match.
:param proxy_url: Full proxy URL identifying the request(s) to match.
@@ -69,7 +69,7 @@ def add_response(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
:param is_optional: True will mark this response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -103,7 +103,7 @@ def add_callback(
:param callback: The callable that will be called upon reception of the matched request.
It must expect one parameter, the received httpx.Request and should return a httpx.Response.
- :param url: Full URL identifying the request(s) to match.
+ :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request(s) to match.
:param proxy_url: Full proxy URL identifying the request(s) to match.
@@ -114,7 +114,7 @@ def add_callback(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
:param is_optional: True will mark this callback as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this callback even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -125,7 +125,7 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None:
Raise an exception if a request match.
:param exception: The exception that will be raised upon reception of the matched request.
- :param url: Full URL identifying the request(s) to match.
+ :param url: Full URL identifying the request(s) to match. Use in addition to match_params if you do not want to provide query parameters as part of the URL.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request(s) to match.
:param proxy_url: Full proxy URL identifying the request(s) to match.
@@ -136,7 +136,7 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None:
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: HTTP URL query string params of the request(s) to match. Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
:param is_optional: True will mark this exception response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this exception response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -265,7 +265,7 @@ def get_requests(self, **matchers: Any) -> list[httpx.Request]:
"""
Return all requests sent that match (empty list if no requests were matched).
- :param url: Full URL identifying the requests to retrieve.
+ :param url: Full URL identifying the requests to retrieve. Use in addition to match_params if you do not want to provide query parameters as part of the URL.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the requests to retrieve. Must be an upper-cased string value.
:param proxy_url: Full proxy URL identifying the requests to retrieve.
@@ -276,6 +276,7 @@ def get_requests(self, **matchers: Any) -> list[httpx.Request]:
:param match_data: Multipart data (excluding files) identifying the requests to retrieve. Must be a dictionary.
:param match_files: Multipart files identifying the requests to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the requests to retrieve. Must be a dictionary.
+ :param match_params: Query string parameters identifying the requests to retrieve (if not provided as part of URL already). Must be a dictionary.
"""
matcher = _RequestMatcher(self._options, **matchers)
return [
@@ -288,7 +289,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]:
"""
Return the single request that match (or None).
- :param url: Full URL identifying the request to retrieve.
+ :param url: Full URL identifying the request to retrieve. Use in addition to match_params if you do not want to provide query parameters as part of the URL.
Can be a str, a re.Pattern instance or a httpx.URL instance.
:param method: HTTP method identifying the request to retrieve. Must be an upper-cased string value.
:param proxy_url: Full proxy URL identifying the request to retrieve.
@@ -299,6 +300,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]:
:param match_data: Multipart data (excluding files) identifying the request to retrieve. Must be a dictionary.
:param match_files: Multipart files identifying the request to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request to retrieve. Must be a dictionary.
+ :param match_params: Query string parameters identifying the request to retrieve (if not provided as part of URL already). Must be a dictionary.
:raises AssertionError: in case more than one request match.
"""
requests = self.get_requests(**matchers)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 5d139fa..6284bca 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -10,8 +10,11 @@
def _url_match(
- url_to_match: Union[Pattern[str], httpx.URL], received: httpx.URL, params: Optional[dict[str, str]]
+ url_to_match: Union[Pattern[str], httpx.URL],
+ received: httpx.URL,
+ params: Optional[dict[str, str]],
) -> bool:
+ # TODO Allow to provide a regex in URL and params as a dict
if isinstance(url_to_match, re.Pattern):
return url_to_match.match(str(received)) is not None
@@ -60,8 +63,16 @@ def __init__(
else proxy_url
)
self.extensions = match_extensions
- self.is_optional = not options.assert_all_responses_were_requested if is_optional is None else is_optional
- self.is_reusable = options.can_send_already_matched_responses if is_reusable is None else is_reusable
+ self.is_optional = (
+ not options.assert_all_responses_were_requested
+ if is_optional is None
+ else is_optional
+ )
+ self.is_reusable = (
+ options.can_send_already_matched_responses
+ if is_reusable is None
+ else is_reusable
+ )
if self._is_matching_body_more_than_one_way():
raise ValueError(
"Only one way of matching against the body can be provided. "
@@ -171,7 +182,7 @@ def _proxy_match(
return True
if real_proxy_url := _proxy_url(real_transport):
- return _url_match(self.proxy_url, real_proxy_url, None)
+ return _url_match(self.proxy_url, real_proxy_url, params=None)
return False
From 1a22feb9619a03aeaea257173f778829b94f9d05 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Wed, 14 May 2025 22:59:31 +0200
Subject: [PATCH 04/29] Bump black to latest version
---
.pre-commit-config.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1c8e89d..1cfd9a7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,5 @@
repos:
- repo: https://github.com/psf/black
- rev: 24.10.0
+ rev: 25.1.0
hooks:
- id: black
\ No newline at end of file
From 607e411cdf24d4bbd9635e4838ccdfad9dfef797 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Wed, 14 May 2025 23:00:37 +0200
Subject: [PATCH 05/29] Bump black to latest version
---
pytest_httpx/_httpx_internals.py | 2 +-
tests/test_httpx_async.py | 7 ++++++-
tests/test_httpx_sync.py | 6 +++++-
3 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/pytest_httpx/_httpx_internals.py b/pytest_httpx/_httpx_internals.py
index 477d2bc..d761f17 100644
--- a/pytest_httpx/_httpx_internals.py
+++ b/pytest_httpx/_httpx_internals.py
@@ -52,7 +52,7 @@ def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> http
def _proxy_url(
- real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport]
+ real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport],
) -> Optional[httpx.URL]:
if isinstance(
real_pool := real_transport._pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index cda8011..a85df5e 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -71,9 +71,14 @@ async def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
response = await client.get("https://test_url?b=2&a=1")
assert response.content == b""
+
@pytest.mark.asyncio
async def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
- httpx_mock.add_response(url=httpx.URL("https://test_url"), match_params = {"a": "1", "b": ANY}, is_reusable=True)
+ httpx_mock.add_response(
+ url=httpx.URL("https://test_url"),
+ match_params={"a": "1", "b": ANY},
+ is_reusable=True,
+ )
async with httpx.AsyncClient() as client:
response = await client.post("https://test_url?a=1&b=2")
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 2eddd75..2dca47c 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -65,7 +65,11 @@ def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
- httpx_mock.add_response(url=httpx.URL("https://test_url"), match_params = {"a": "1", "b": ANY}, is_reusable=True)
+ httpx_mock.add_response(
+ url=httpx.URL("https://test_url"),
+ match_params={"a": "1", "b": ANY},
+ is_reusable=True,
+ )
with httpx.Client() as client:
response = client.post("https://test_url?a=1&b=2")
From 2ad98c529576590dfe852bdac2292b796d48b9d8 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Wed, 14 May 2025 23:01:00 +0200
Subject: [PATCH 06/29] Keep license year up to date
---
LICENSE | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/LICENSE b/LICENSE
index cab2d30..28edb58 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Colin Bounouar
+Copyright (c) 2025 Colin Bounouar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
From 632069df1a97c2c33b65178cb2cc1fe46af0d95c Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Thu, 15 May 2025 00:05:35 +0200
Subject: [PATCH 07/29] Fix match_params a bit more
---
CHANGELOG.md | 3 +++
README.md | 10 ++++++++--
pytest_httpx/_httpx_mock.py | 10 +++++-----
pytest_httpx/_request_matcher.py | 25 +++++++++++++++++++------
tests/test_httpx_async.py | 8 ++++----
tests/test_httpx_sync.py | 13 ++++++++-----
6 files changed, 47 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29be31f..4b4e08d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `match_params` parameter is now available on responses and callbacks registration, as well as request(s) retrieval. Allowing to provide query parameters as a dict instead of being part of the matched URL.
- This parameter allows to perform partial query params matching ([refer to documentation](README.md#matching-on-query-parameters) for more information).
+### Fixed
+- URL with more than one value for the same parameter were not matched properly (matching was performed on the first value).
+
## [0.35.0] - 2024-11-28
### Changed
- Requires [`httpx`](https://www.python-httpx.org)==0.28.\*
diff --git a/README.md b/README.md
index 93c6674..db6e3cc 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -113,7 +113,7 @@ Use `match_params` to partially match query parameters without having to provide
If this parameter is provided, `url` parameter must not contain any query parameter.
-All query parameters have to be provided. You can however use `unittest.mock.ANY` to do partial matching.
+All query parameters have to be provided (as `str`). You can however use `unittest.mock.ANY` to do partial matching.
```python
import httpx
@@ -125,6 +125,12 @@ def test_partial_params_matching(httpx_mock: HTTPXMock):
with httpx.Client() as client:
response = client.get("https://test_url?a=1&b=2")
+
+def test_partial_multi_params_matching(httpx_mock: HTTPXMock):
+ httpx_mock.add_response(url="https://test_url", match_params={"a": ["1", "3"], "b": ["2", ANY]})
+
+ with httpx.Client() as client:
+ response = client.get("https://test_url?a=1&b=2&a=3&b=4")
```
#### Matching on HTTP method
diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py
index b252291..f51d15b 100644
--- a/pytest_httpx/_httpx_mock.py
+++ b/pytest_httpx/_httpx_mock.py
@@ -69,7 +69,7 @@ def add_response(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once).
:param is_optional: True will mark this response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -114,7 +114,7 @@ def add_callback(
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once).
:param is_optional: True will mark this callback as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this callback even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -136,7 +136,7 @@ def add_exception(self, exception: Exception, **matchers: Any) -> None:
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
- :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary.
+ :param match_params: Query string parameters identifying the request(s) to match (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once).
:param is_optional: True will mark this exception response as optional, False will expect a request matching it. Must be a boolean. Default to the opposite of assert_all_responses_were_requested option value (itself defaulting to True, meaning this parameter default to False).
:param is_reusable: True will allow re-using this exception response even if it already matched, False prevent re-using it. Must be a boolean. Default to the can_send_already_matched_responses option value (itself defaulting to False).
"""
@@ -276,7 +276,7 @@ def get_requests(self, **matchers: Any) -> list[httpx.Request]:
:param match_data: Multipart data (excluding files) identifying the requests to retrieve. Must be a dictionary.
:param match_files: Multipart files identifying the requests to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the requests to retrieve. Must be a dictionary.
- :param match_params: Query string parameters identifying the requests to retrieve (if not provided as part of URL already). Must be a dictionary.
+ :param match_params: Query string parameters identifying the requests to retrieve (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once).
"""
matcher = _RequestMatcher(self._options, **matchers)
return [
@@ -300,7 +300,7 @@ def get_request(self, **matchers: Any) -> Optional[httpx.Request]:
:param match_data: Multipart data (excluding files) identifying the request to retrieve. Must be a dictionary.
:param match_files: Multipart files identifying the request to retrieve. Refer to httpx documentation for more information on supported values: https://www.python-httpx.org/advanced/clients/#multipart-file-encoding
:param match_extensions: Extensions identifying the request to retrieve. Must be a dictionary.
- :param match_params: Query string parameters identifying the request to retrieve (if not provided as part of URL already). Must be a dictionary.
+ :param match_params: Query string parameters identifying the request to retrieve (if not provided as part of URL already). Must be a dictionary with str keys (parameter name) and str values (or a list of str values if parameter is provided more than once).
:raises AssertionError: in case more than one request match.
"""
requests = self.get_requests(**matchers)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 6284bca..4c4b2f5 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -4,6 +4,7 @@
from re import Pattern
import httpx
+from httpx import QueryParams
from pytest_httpx._httpx_internals import _proxy_url
from pytest_httpx._options import _HTTPXMockOptions
@@ -12,16 +13,16 @@
def _url_match(
url_to_match: Union[Pattern[str], httpx.URL],
received: httpx.URL,
- params: Optional[dict[str, str]],
+ params: Optional[dict[str, Union[str | list[str]]]],
) -> bool:
# TODO Allow to provide a regex in URL and params as a dict
if isinstance(url_to_match, re.Pattern):
return url_to_match.match(str(received)) is not None
# Compare query parameters apart as order of parameters should not matter
- received_params = dict(received.params)
+ received_params = to_params_dict(received.params)
if params is None:
- params = dict(url_to_match.params)
+ params = to_params_dict(url_to_match.params)
# Remove the query parameters from the original URL to compare everything besides query parameters
received_url = received.copy_with(query=None)
@@ -30,6 +31,15 @@ def _url_match(
return (received_params == params) and (url == received_url)
+def to_params_dict(params: QueryParams) -> dict[str, Union[str | list[str]]]:
+ """Convert query parameters to a dict where the value is a string if the parameter has a single value and a list of string otherwise."""
+ d = {}
+ for key in params:
+ values = params.get_list(key)
+ d[key] = values if len(values) > 1 else values[0]
+ return d
+
+
class _RequestMatcher:
def __init__(
self,
@@ -43,7 +53,7 @@ def __init__(
match_data: Optional[dict[str, Any]] = None,
match_files: Optional[Any] = None,
match_extensions: Optional[dict[str, Any]] = None,
- match_params: Optional[dict[str, str]] = None,
+ match_params: Optional[dict[str, Union[str | list[str]]]] = None,
is_optional: Optional[bool] = None,
is_reusable: Optional[bool] = None,
):
@@ -85,6 +95,9 @@ def __init__(
"match_data is meant to be used for multipart matching (in conjunction with match_files)."
"Use match_content to match url encoded data."
)
+ # TODO Prevent match_params and params in URL
+ # TODO Prevent match_params with non str values / keys
+ # TODO Prevent match_params with list values of size < 2
def expect_body(self) -> bool:
matching_ways = [
@@ -214,6 +227,8 @@ def __str__(self) -> str:
def _extra_description(self) -> str:
extra_description = []
+ if self.params:
+ extra_description.append(f"{self.params} query parameters")
if self.headers:
extra_description.append(f"{self.headers} headers")
if self.content is not None:
@@ -228,7 +243,5 @@ def _extra_description(self) -> str:
extra_description.append(f"{self.proxy_url} proxy URL")
if self.extensions:
extra_description.append(f"{self.extensions} extensions")
- if self.params:
- extra_description.append(f"{self.params} params")
return " and ".join(extra_description)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index a85df5e..663ae52 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -73,19 +73,19 @@ async def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
@pytest.mark.asyncio
-async def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
+async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url=httpx.URL("https://test_url"),
- match_params={"a": "1", "b": ANY},
+ match_params={"a": ["1", "3"], "b": ANY, "c": "4", "d": ["5", ANY]},
is_reusable=True,
)
async with httpx.AsyncClient() as client:
- response = await client.post("https://test_url?a=1&b=2")
+ response = await client.post("https://test_url?a=1&b=2&a=3&c=4&d=5&d=6")
assert response.content == b""
# Parameters order should not matter
- response = await client.get("https://test_url?b=2&a=1")
+ response = await client.get("https://test_url?b=9&a=1&a=3&c=4&d=5&d=7")
assert response.content == b""
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 2dca47c..e1f5610 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -64,22 +64,25 @@ def test_url_query_string_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
-def test_url_query_string_partial_matching(httpx_mock: HTTPXMock) -> None:
+def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
- url=httpx.URL("https://test_url"),
- match_params={"a": "1", "b": ANY},
+ url="https://test_url",
+ match_params={"a": ["1", "3"], "b": ANY, "c": "4", "d": ["5", ANY]},
is_reusable=True,
)
with httpx.Client() as client:
- response = client.post("https://test_url?a=1&b=2")
+ response = client.post("https://test_url?a=1&b=2&a=3&c=4&d=5&d=6")
assert response.content == b""
# Parameters order should not matter
- response = client.get("https://test_url?b=2&a=1")
+ response = client.get("https://test_url?b=9&a=1&a=3&c=4&d=5&d=7")
assert response.content == b""
+# TODO Test URL matching with more than one value on the same param (sucess and failure)
+
+
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
def test_url_not_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url="https://test_url", is_optional=True)
From 835ac95433eff0f934110a7c38dc8b953de7b5d5 Mon Sep 17 00:00:00 2001
From: Emmanuel Ferdman
Date: Sat, 24 May 2025 07:21:26 -0700
Subject: [PATCH 08/29] Fix Set-Cookie header mdn reference
Signed-off-by: Emmanuel Ferdman
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index db6e3cc..6cd30ce 100644
--- a/README.md
+++ b/README.md
@@ -499,7 +499,7 @@ def test_headers_as_httpx_headers(httpx_mock: HTTPXMock):
Cookies are sent in the `set-cookie` HTTP header.
-You can then send cookies in the response by setting the `set-cookie` header with [the value following key=value format]((https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie)).
+You can then send cookies in the response by setting the `set-cookie` header with [the value following key=value format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie).
```python
import httpx
From 59a18e0058a7a8c7fcaa818a8ea04ff61e5227e8 Mon Sep 17 00:00:00 2001
From: Jocelyn Legault
Date: Mon, 27 Oct 2025 21:37:25 -0400
Subject: [PATCH 09/29] 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 10/29] 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 11/29] 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 12/29] 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 6b0106d81395677c1636111a6f735441a75dfae9 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Tue, 11 Nov 2025 08:26:14 +0100
Subject: [PATCH 13/29] Bump dependencies, update pytest config for pytest 9
---
pyproject.toml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 999ca59..303c697 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,7 @@ classifiers = [
]
dependencies = [
"httpx==0.28.*",
- "pytest==8.*",
+ "pytest==9.*",
]
dynamic = ["version"]
@@ -53,7 +53,7 @@ testing = [
# Used to check coverage
"pytest-cov==6.*",
# Used to run async tests
- "pytest-asyncio==0.24.*",
+ "pytest-asyncio==1.3.*",
]
[project.entry-points.pytest11]
@@ -62,6 +62,6 @@ pytest_httpx = "pytest_httpx"
[tool.setuptools.dynamic]
version = {attr = "pytest_httpx.version.__version__"}
-[tool.pytest.ini_options]
+[tool.pytest]
# Silence deprecation warnings about option "asyncio_default_fixture_loop_scope"
asyncio_default_fixture_loop_scope = "function"
From 9186f7cb14d9adcea9110574d676ffe29e18f112 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 08:25:03 +0100
Subject: [PATCH 14/29] Drop Python 3.9 support (EOL)
---
.github/workflows/test.yml | 2 +-
pyproject.toml | 3 +--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 20b1f19..e0748b6 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', '3.12', '3.13']
+ python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
diff --git a/pyproject.toml b/pyproject.toml
index 303c697..9a33fd3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "pytest-httpx"
description = "Send responses to httpx."
readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
license = {file = "LICENSE"}
authors = [
{ name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" },
@@ -27,7 +27,6 @@ classifiers = [
"Natural Language :: English",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
From 3dd8952233c697684b6e839bf46e7f60ce699b28 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 08:26:07 +0100
Subject: [PATCH 15/29] Relax version constraint of python-asyncio
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 9a33fd3..5bdbd46 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ testing = [
# Used to check coverage
"pytest-cov==6.*",
# Used to run async tests
- "pytest-asyncio==1.3.*",
+ "pytest-asyncio==1.*",
]
[project.entry-points.pytest11]
From c4cca6b81109df83319a5d10b5ad9a3b309c8789 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 08:26:55 +0100
Subject: [PATCH 16/29] Bump pytest-cov to current release
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 5bdbd46..d0299d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ issues = "https://github.com/Colin-b/pytest_httpx/issues"
[project.optional-dependencies]
testing = [
# Used to check coverage
- "pytest-cov==6.*",
+ "pytest-cov==7.*",
# Used to run async tests
"pytest-asyncio==1.*",
]
From 9def72cd746b164373b4b98f7d9170af4d23cfc0 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 09:17:21 +0100
Subject: [PATCH 17/29] Revert pytest-cov change for now, it caused issues
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index d0299d5..5bdbd46 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ issues = "https://github.com/Colin-b/pytest_httpx/issues"
[project.optional-dependencies]
testing = [
# Used to check coverage
- "pytest-cov==7.*",
+ "pytest-cov==6.*",
# Used to run async tests
"pytest-asyncio==1.*",
]
From 5d44bf43197967edb30b6f77105f3913e46278c3 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 09:17:48 +0100
Subject: [PATCH 18/29] Bump workflow action versions
---
.github/workflows/release.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 712c63d..5d02e32 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Create packages
From 546f245e52dc72b0b662655b401b4b62dcdbd820 Mon Sep 17 00:00:00 2001
From: Sebastian
Date: Fri, 14 Nov 2025 09:19:05 +0100
Subject: [PATCH 19/29] Include 3.14 in testing
---
.github/workflows/release.yml | 2 +-
.github/workflows/test.yml | 2 +-
pyproject.toml | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5d02e32..451f9f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
- python-version: '3.13'
+ python-version: '3.14'
- name: Create packages
run: |
python -m pip install build
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e0748b6..e5513fc 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.10', '3.11', '3.12', '3.13']
+ python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4
diff --git a/pyproject.toml b/pyproject.toml
index 5bdbd46..a343643 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Build Tools",
"Typing :: Typed",
From acce2971883c2111d13531aa8656a3488111a075 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 12:58:59 +0100
Subject: [PATCH 20/29] Test multi values in same param
---
tests/test_httpx_async.py | 54 +++++++++++++++++++++++++++++++++++++++
tests/test_httpx_sync.py | 49 ++++++++++++++++++++++++++++++++++-
2 files changed, 102 insertions(+), 1 deletion(-)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index 663ae52..c9e171c 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -89,6 +89,60 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+@pytest.mark.asyncio
+async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
+
+ async with httpx.AsyncClient() as client:
+ response = await client.get("https://test_url", params={"a": [1, 3]})
+ assert response.content == b""
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True)
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.get("https://test_url", params={"a": [1, 3]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3 amongst:
+- Match any request on https://test_url?a=2&a=3"""
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.get("https://test_url", params={"a": [1, 3, 4]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3&a=4 amongst:
+- Match any request on https://test_url?a=1&a=3"""
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True)
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.get("https://test_url", params={"a": [1, 3]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3 amongst:
+- Match any request on https://test_url?a=1&a=3&a=4"""
+ )
+
+
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
async def test_url_not_matching(httpx_mock: HTTPXMock) -> None:
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index e1f5610..92df78a 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -80,7 +80,54 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
-# TODO Test URL matching with more than one value on the same param (sucess and failure)
+def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
+
+ with httpx.Client() as client:
+ response = client.get("https://test_url", params={"a": [1, 3]})
+ assert response.content == b""
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True)
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.get("https://test_url", params={"a": [1, 3]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3 amongst:
+- Match any request on https://test_url?a=2&a=3"""
+ )
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.get("https://test_url", params={"a": [1, 3, 4]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3&a=4 amongst:
+- Match any request on https://test_url?a=1&a=3"""
+ )
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True)
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.get("https://test_url", params={"a": [1, 3]})
+ assert (
+ str(exception_info.value)
+ == """No response can be found for GET request on https://test_url?a=1&a=3 amongst:
+- Match any request on https://test_url?a=1&a=3&a=4"""
+ )
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
From 508927585c55303ba092a9b60d1ce6ba18f59c7d Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 13:55:16 +0100
Subject: [PATCH 21/29] Test params not matching
---
tests/test_httpx_async.py | 19 +++++++++++++++++++
tests/test_httpx_sync.py | 18 ++++++++++++++++++
2 files changed, 37 insertions(+)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index c9e171c..b7a9b2d 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -89,6 +89,25 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": "1"},
+ is_optional=True,
+ )
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.post("https://test_url?a=2")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=2 amongst:
+- Match any request on https://test_url with {'a': '1'} query parameters"""
+ )
+
+
@pytest.mark.asyncio
async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 92df78a..935ecb8 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -80,6 +80,24 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": "1"},
+ is_optional=True,
+ )
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.post("https://test_url?a=2")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=2 amongst:
+- Match any request on https://test_url with {'a': '1'} query parameters"""
+ )
+
+
def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
From 8295e1d532403748a8d0f3efa1122281a958980b Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 14:17:37 +0100
Subject: [PATCH 22/29] Prevent invalid match_params setup
---
pytest_httpx/_request_matcher.py | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 4c4b2f5..33d978b 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -67,6 +67,26 @@ def __init__(
self.data = match_data
self.files = match_files
self.params = match_params
+ if self.params:
+ # TODO Prevent match_params and params in URL
+ for name, values in self.params.items():
+ if not isinstance(name, str):
+ raise ValueError(
+ "match_params keys should only contain strings."
+ )
+ if isinstance(values, list):
+ if len(values) < 2:
+ raise ValueError(
+ "match_params values should only contain list of at least 2 elements, use the string value otherwise."
+ )
+ if not all(isinstance(value, str) for value in values):
+ raise ValueError(
+ "match_params values should only contain string or list of strings."
+ )
+ elif not isinstance(values, str):
+ raise ValueError(
+ "match_params values should only contain string or list of strings."
+ )
self.proxy_url = (
httpx.URL(proxy_url)
if proxy_url and isinstance(proxy_url, str)
@@ -95,9 +115,6 @@ def __init__(
"match_data is meant to be used for multipart matching (in conjunction with match_files)."
"Use match_content to match url encoded data."
)
- # TODO Prevent match_params and params in URL
- # TODO Prevent match_params with non str values / keys
- # TODO Prevent match_params with list values of size < 2
def expect_body(self) -> bool:
matching_ways = [
From 34b20cae7473ad4e738a9f2958a99de741eb1066 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 16:09:04 +0100
Subject: [PATCH 23/29] Prevent invalid match_params setup
---
pytest_httpx/_request_matcher.py | 21 +--------
tests/test_httpx_async.py | 76 ++++++++++++++++++++++++++++++++
tests/test_httpx_sync.py | 72 ++++++++++++++++++++++++++++++
3 files changed, 149 insertions(+), 20 deletions(-)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 33d978b..4530d3e 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -67,26 +67,7 @@ def __init__(
self.data = match_data
self.files = match_files
self.params = match_params
- if self.params:
- # TODO Prevent match_params and params in URL
- for name, values in self.params.items():
- if not isinstance(name, str):
- raise ValueError(
- "match_params keys should only contain strings."
- )
- if isinstance(values, list):
- if len(values) < 2:
- raise ValueError(
- "match_params values should only contain list of at least 2 elements, use the string value otherwise."
- )
- if not all(isinstance(value, str) for value in values):
- raise ValueError(
- "match_params values should only contain string or list of strings."
- )
- elif not isinstance(values, str):
- raise ValueError(
- "match_params values should only contain string or list of strings."
- )
+ # TODO Prevent match_params and params in URL
self.proxy_url = (
httpx.URL(proxy_url)
if proxy_url and isinstance(proxy_url, str)
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index b7a9b2d..ac4b3fb 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -89,6 +89,82 @@ async def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_query_params_with_single_value_list(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": ["1"]},
+ is_optional=True,
+ )
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.post("https://test_url?a=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1 amongst:
+- Match any request on https://test_url with {'a': ['1']} query parameters"""
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_query_params_with_non_str_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": 1},
+ is_optional=True,
+ )
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.post("https://test_url?a=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1 amongst:
+- Match any request on https://test_url with {'a': 1} query parameters"""
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_query_params_with_non_str_list_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": [1, "2"]},
+ is_optional=True,
+ )
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.post("https://test_url?a=1&a=2")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1&a=2 amongst:
+- Match any request on https://test_url with {'a': [1, '2']} query parameters"""
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+async def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={1: "1"},
+ is_optional=True,
+ )
+
+ async with httpx.AsyncClient() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ await client.post("https://test_url?1=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?1=1 amongst:
+- Match any request on https://test_url with {1: '1'} query parameters"""
+ )
+
+
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 935ecb8..784c8b8 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -80,6 +80,78 @@ def test_url_query_params_partial_matching(httpx_mock: HTTPXMock) -> None:
assert response.content == b""
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_query_params_with_single_value_list(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": ["1"]},
+ is_optional=True,
+ )
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.post("https://test_url?a=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1 amongst:
+- Match any request on https://test_url with {'a': ['1']} query parameters"""
+ )
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_query_params_with_non_str_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": 1},
+ is_optional=True,
+ )
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.post("https://test_url?a=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1 amongst:
+- Match any request on https://test_url with {'a': 1} query parameters"""
+ )
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_query_params_with_non_str_list_value(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={"a": [1, "2"]},
+ is_optional=True,
+ )
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.post("https://test_url?a=1&a=2")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?a=1&a=2 amongst:
+- Match any request on https://test_url with {'a': [1, '2']} query parameters"""
+ )
+
+
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None:
+ httpx_mock.add_response(
+ url="https://test_url",
+ match_params={1: "1"},
+ is_optional=True,
+ )
+
+ with httpx.Client() as client:
+ with pytest.raises(httpx.TimeoutException) as exception_info:
+ client.post("https://test_url?1=1")
+ assert (
+ str(exception_info.value)
+ == """No response can be found for POST request on https://test_url?1=1 amongst:
+- Match any request on https://test_url with {1: '1'} query parameters"""
+ )
+
+
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
From e5ec7b6e0c4215f227fc3e9d82342b618ae82a27 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 16:41:08 +0100
Subject: [PATCH 24/29] Prevent invalid match_params setup
---
pytest_httpx/_request_matcher.py | 22 ++++++++++++++++++++--
tests/test_httpx_sync.py | 21 +++++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index 4530d3e..c34033b 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -15,7 +15,6 @@ def _url_match(
received: httpx.URL,
params: Optional[dict[str, Union[str | list[str]]]],
) -> bool:
- # TODO Allow to provide a regex in URL and params as a dict
if isinstance(url_to_match, re.Pattern):
return url_to_match.match(str(received)) is not None
@@ -67,7 +66,6 @@ def __init__(
self.data = match_data
self.files = match_files
self.params = match_params
- # TODO Prevent match_params and params in URL
self.proxy_url = (
httpx.URL(proxy_url)
if proxy_url and isinstance(proxy_url, str)
@@ -91,6 +89,18 @@ def __init__(
"If you want to match against the multipart representation, use match_files (and match_data). "
"Otherwise, use match_content."
)
+ if self.params and not self.url:
+ raise ValueError(
+ "URL must be provided when match_params is used."
+ )
+ if self.params and isinstance(self.url, re.Pattern):
+ raise ValueError(
+ "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only"
+ )
+ if self._is_matching_params_more_than_one_way():
+ raise ValueError(
+ "Provided URL must not contain any query parameter when match_params is used."
+ )
if self.data and not self.files:
raise ValueError(
"match_data is meant to be used for multipart matching (in conjunction with match_files)."
@@ -113,6 +123,14 @@ def _is_matching_body_more_than_one_way(self) -> bool:
]
return sum(matching_ways) > 1
+ def _is_matching_params_more_than_one_way(self) -> bool:
+ url_has_params = bool(self.url.params) if (self.url and isinstance(self.url, httpx.URL)) else False
+ matching_ways = [
+ self.params is not None,
+ url_has_params,
+ ]
+ return sum(matching_ways) > 1
+
def match(
self,
real_transport: Union[httpx.HTTPTransport, httpx.AsyncHTTPTransport],
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 784c8b8..60e1d64 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -152,6 +152,27 @@ def test_url_query_params_with_non_str_name(httpx_mock: HTTPXMock) -> None:
)
+def test_match_params_without_url(httpx_mock: HTTPXMock) -> None:
+ with pytest.raises(ValueError) as exception_info:
+ httpx_mock.add_response(match_params={"a": "1"})
+
+ assert str(exception_info.value) == "URL must be provided when match_params is used."
+
+
+def test_query_params_in_both_url_and_match_params(httpx_mock: HTTPXMock) -> None:
+ with pytest.raises(ValueError) as exception_info:
+ httpx_mock.add_response(url="https://test_url?a=1", match_params={"a": "1"})
+
+ assert str(exception_info.value) == "Provided URL must not contain any query parameter when match_params is used."
+
+
+def test_regex_url_and_match_params(httpx_mock: HTTPXMock) -> None:
+ with pytest.raises(ValueError) as exception_info:
+ httpx_mock.add_response(url=re.compile(".*test.*"), match_params={"a": "1"})
+
+ assert str(exception_info.value) == "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only"
+
+
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
From ced1c3a21be2a2496c83f6ec0dc70e2e1f3ba57b Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 17:01:52 +0100
Subject: [PATCH 25/29] 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 26/29] 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 @@
-
+
From 4d1c417ca2e3c0a0c2c50f19ceb155cc02627bd3 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 17:14:52 +0100
Subject: [PATCH 27/29] Release version 0.35.1
---
.pre-commit-config.yaml | 2 +-
CHANGELOG.md | 5 ++++-
pyproject.toml | 2 +-
pytest_httpx/_request_matcher.py | 10 ++++++----
pytest_httpx/version.py | 2 +-
tests/test_httpx_async.py | 16 ++++++++++++----
tests/test_httpx_sync.py | 30 +++++++++++++++++++++++-------
7 files changed, 48 insertions(+), 19 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1cfd9a7..30bae24 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,5 @@
repos:
- repo: https://github.com/psf/black
- rev: 25.1.0
+ rev: 25.11.0
hooks:
- id: black
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e74c739..02f9de8 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.35.1] - 2025-12-02
### Added
- `match_params` parameter is now available on responses and callbacks registration, as well as request(s) retrieval. Allowing to provide query parameters as a dict instead of being part of the matched URL.
- This parameter allows to perform partial query params matching ([refer to documentation](README.md#matching-on-query-parameters) for more information).
@@ -415,7 +417,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.35.0...HEAD
+[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.1...HEAD
+[0.35.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.0...v0.35.1
[0.35.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.34.0...v0.35.0
[0.34.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0
[0.33.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.32.0...v0.33.0
diff --git a/pyproject.toml b/pyproject.toml
index a343643..e2fa967 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,7 +51,7 @@ issues = "https://github.com/Colin-b/pytest_httpx/issues"
[project.optional-dependencies]
testing = [
# Used to check coverage
- "pytest-cov==6.*",
+ "pytest-cov==7.*",
# Used to run async tests
"pytest-asyncio==1.*",
]
diff --git a/pytest_httpx/_request_matcher.py b/pytest_httpx/_request_matcher.py
index c34033b..504f47d 100644
--- a/pytest_httpx/_request_matcher.py
+++ b/pytest_httpx/_request_matcher.py
@@ -90,9 +90,7 @@ def __init__(
"Otherwise, use match_content."
)
if self.params and not self.url:
- raise ValueError(
- "URL must be provided when match_params is used."
- )
+ raise ValueError("URL must be provided when match_params is used.")
if self.params and isinstance(self.url, re.Pattern):
raise ValueError(
"match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only"
@@ -124,7 +122,11 @@ def _is_matching_body_more_than_one_way(self) -> bool:
return sum(matching_ways) > 1
def _is_matching_params_more_than_one_way(self) -> bool:
- url_has_params = bool(self.url.params) if (self.url and isinstance(self.url, httpx.URL)) else False
+ url_has_params = (
+ bool(self.url.params)
+ if (self.url and isinstance(self.url, httpx.URL))
+ else False
+ )
matching_ways = [
self.params is not None,
url_has_params,
diff --git a/pytest_httpx/version.py b/pytest_httpx/version.py
index 10a3596..f3ab3f5 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.35.0"
+__version__ = "0.35.1"
diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py
index 3f1ab89..b5ad27a 100644
--- a/tests/test_httpx_async.py
+++ b/tests/test_httpx_async.py
@@ -185,7 +185,9 @@ async def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
@pytest.mark.asyncio
-async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
+async def test_url_matching_with_more_than_one_value_on_same_param(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
async with httpx.AsyncClient() as client:
@@ -195,7 +197,9 @@ async def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: H
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-async def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None:
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True)
async with httpx.AsyncClient() as client:
@@ -210,7 +214,9 @@ async def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-async def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None:
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
async with httpx.AsyncClient() as client:
@@ -225,7 +231,9 @@ async def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_
@pytest.mark.asyncio
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-async def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None:
+async def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True)
async with httpx.AsyncClient() as client:
diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py
index 60e1d64..3ad9043 100644
--- a/tests/test_httpx_sync.py
+++ b/tests/test_httpx_sync.py
@@ -156,21 +156,29 @@ def test_match_params_without_url(httpx_mock: HTTPXMock) -> None:
with pytest.raises(ValueError) as exception_info:
httpx_mock.add_response(match_params={"a": "1"})
- assert str(exception_info.value) == "URL must be provided when match_params is used."
+ assert (
+ str(exception_info.value) == "URL must be provided when match_params is used."
+ )
def test_query_params_in_both_url_and_match_params(httpx_mock: HTTPXMock) -> None:
with pytest.raises(ValueError) as exception_info:
httpx_mock.add_response(url="https://test_url?a=1", match_params={"a": "1"})
- assert str(exception_info.value) == "Provided URL must not contain any query parameter when match_params is used."
+ assert (
+ str(exception_info.value)
+ == "Provided URL must not contain any query parameter when match_params is used."
+ )
def test_regex_url_and_match_params(httpx_mock: HTTPXMock) -> None:
with pytest.raises(ValueError) as exception_info:
httpx_mock.add_response(url=re.compile(".*test.*"), match_params={"a": "1"})
- assert str(exception_info.value) == "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only"
+ assert (
+ str(exception_info.value)
+ == "match_params cannot be used in addition to regex URL. Request this feature via https://github.com/Colin-b/pytest_httpx/issues/new?title=Regex%20URL%20should%20allow%20match_params&body=Hi,%20I%20need%20a%20regex%20to%20match%20the%20non%20query%20part%20of%20the%20URL%20only"
+ )
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
@@ -191,7 +199,9 @@ def test_url_query_params_not_matching(httpx_mock: HTTPXMock) -> None:
)
-def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMock) -> None:
+def test_url_matching_with_more_than_one_value_on_same_param(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
with httpx.Client() as client:
@@ -200,7 +210,9 @@ def test_url_matching_with_more_than_one_value_on_same_param(httpx_mock: HTTPXMo
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(httpx_mock: HTTPXMock) -> None:
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=2&a=3", is_optional=True)
with httpx.Client() as client:
@@ -214,7 +226,9 @@ def test_url_not_matching_with_more_than_one_value_on_same_param_and_diff_value(
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(httpx_mock: HTTPXMock) -> None:
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3", is_optional=True)
with httpx.Client() as client:
@@ -228,7 +242,9 @@ def test_url_not_matching_with_more_than_one_value_on_same_param_and_more_values
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
-def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(httpx_mock: HTTPXMock) -> None:
+def test_url_not_matching_with_more_than_one_value_on_same_param_and_less_values(
+ httpx_mock: HTTPXMock,
+) -> None:
httpx_mock.add_response(url="https://test_url?a=1&a=3&a=4", is_optional=True)
with httpx.Client() as client:
From 63b1ff1865126bd0258158bd18b2db879bfab4b0 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 17:22:15 +0100
Subject: [PATCH 28/29] Switch version to 0.36.0 and document breaking changes
---
CHANGELOG.md | 14 +++++++++++---
pytest_httpx/version.py | 2 +-
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02f9de8..ccad4f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-## [0.35.1] - 2025-12-02
+## [0.36.0] - 2025-12-02
+### Changed
+- `pytest` required version is now `9`.
+
### Added
+- Explicit support for python `3.14`.
- `match_params` parameter is now available on responses and callbacks registration, as well as request(s) retrieval. Allowing to provide query parameters as a dict instead of being part of the matched URL.
- This parameter allows to perform partial query params matching ([refer to documentation](README.md#matching-on-query-parameters) for more information).
@@ -15,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`).
+### Removed
+- `pytest` `8` is not supported anymore.
+- python `3.9` is not supported anymore.
+
## [0.35.0] - 2024-11-28
### Changed
- Requires [`httpx`](https://www.python-httpx.org)==0.28.\*
@@ -417,8 +425,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.35.1...HEAD
-[0.35.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.0...v0.35.1
+[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.36.0...HEAD
+[0.36.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.35.0...v0.36.0
[0.35.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.34.0...v0.35.0
[0.34.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0
[0.33.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.32.0...v0.33.0
diff --git a/pytest_httpx/version.py b/pytest_httpx/version.py
index f3ab3f5..11c1d94 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.35.1"
+__version__ = "0.36.0"
From e02259cdac7de8e63fff4e709a5c03e7e0326c26 Mon Sep 17 00:00:00 2001
From: Colin-b
Date: Tue, 2 Dec 2025 17:27:46 +0100
Subject: [PATCH 29/29] Use subprocess coverage
---
pyproject.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index e2fa967..0569403 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,3 +65,6 @@ version = {attr = "pytest_httpx.version.__version__"}
[tool.pytest]
# Silence deprecation warnings about option "asyncio_default_fixture_loop_scope"
asyncio_default_fixture_loop_scope = "function"
+
+[tool.coverage.run]
+patch = ["subprocess"]