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 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

@@ -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 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

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"]