From 4e84173d7af69018a3bf8f76f8efc092dbc42879 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 17 Jan 2022 13:57:21 +0100 Subject: [PATCH 1/4] Handle responses and callbacks the same way --- CHANGELOG.md | 5 ++++ README.md | 38 ++---------------------- pytest_httpx/_httpx_mock.py | 59 ++++++++----------------------------- setup.py | 1 + tests/test_httpx_async.py | 46 +++++++++++++++++++++++++++++ tests/test_httpx_sync.py | 44 +++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80690a6..3d4e116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Callback are now executed as expected when there is a matching already sent response. + +### Changed +- Registration order is now looking at responses and callbacks. Previous to this version, registration order was looking at responses before callbacks. ## [0.17.3] - 2021-12-27 ### Fixed diff --git a/README.md b/README.md index dfeaa2b..f4927a4 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

@@ -419,6 +419,8 @@ def assert_all_responses_were_requested() -> bool: return False ``` +Note that callbacks are considered as responses, and thus are [selected the same way](#how-response-is-selected). + ### Dynamic responses Callback should return a `httpx.Response`. @@ -478,40 +480,6 @@ def test_timeout(httpx_mock: HTTPXMock): ``` -### How callback is selected - -In case more than one callback match request, the first one not yet executed (according to the registration order) will be executed. - -In case all matching callbacks have been executed, the last one (according to the registration order) will be executed. - -You can add criteria so that callback will be sent only in case of a more specific matching. - -#### Matching on URL - -`url` parameter can either be a string, a python [`re.Pattern`](https://docs.python.org/3/library/re.html) instance or a [`httpx.URL`](https://www.python-httpx.org/api/#url) instance. - -Matching is performed on the full URL, query parameters included. - -#### Matching on HTTP method - -Use `method` parameter to specify the HTTP method (POST, PUT, DELETE, PATCH, HEAD) executing the callback. - -`method` parameter must be a string. It will be upper-cased, so it can be provided lower cased. - -Matching is performed on equality. - -#### Matching on HTTP headers - -Use `match_headers` parameter to specify the HTTP headers executing the callback. - -Matching is performed on equality for each provided header. - -#### Matching on HTTP body - -Use `match_content` parameter to specify the full HTTP body executing the callback. - -Matching is performed on equality. - ## Check sent requests The best way to ensure the content of your requests is still to use the `match_headers` and / or `match_content` parameters when adding a response. diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index dfba61e..3270a75 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -1,7 +1,6 @@ import re from typing import List, Union, Optional, Callable, Tuple, Pattern, Any, Dict from urllib.parse import parse_qs -import warnings import httpx @@ -87,7 +86,6 @@ def __str__(self) -> str: class HTTPXMock: def __init__(self) -> None: self._requests: List[httpx.Request] = [] - self._responses: List[Tuple[_RequestMatcher, httpx.Response]] = [] self._callbacks: List[ Tuple[ _RequestMatcher, @@ -144,7 +142,7 @@ def add_response( if files else stream, ) - self._responses.append((_RequestMatcher(**matchers), response)) + self.add_callback(lambda request: response, **matchers) def add_callback( self, callback: Callable[[httpx.Request], Optional[httpx.Response]], **matchers @@ -187,26 +185,24 @@ def _handle_request( ) -> httpx.Response: self._requests.append(request) - response = self._get_response(request) - if not response: - callback = self._get_callback(request) - if callback: - response = callback(request) + callback = self._get_callback(request) + if callback: + response = callback(request) - if response: - # Allow to read the response on client side - response.is_stream_consumed = False - response.is_closed = False - if hasattr(response, "_content"): - del response._content - return response + if response: + # Allow to read the response on client side + response.is_stream_consumed = False + response.is_closed = False + if hasattr(response, "_content"): + del response._content + return response raise httpx.TimeoutException( self._explain_that_no_response_was_found(request), request=request ) def _explain_that_no_response_was_found(self, request: httpx.Request) -> str: - matchers = [matcher for matcher, _ in self._responses + self._callbacks] + matchers = [matcher for matcher, _ in self._callbacks] expect_headers = set( [ header @@ -233,28 +229,6 @@ def _explain_that_no_response_was_found(self, request: httpx.Request) -> str: return message - def _get_response(self, request: httpx.Request) -> Optional[httpx.Response]: - responses = [ - (matcher, response) - for matcher, response in self._responses - if matcher.match(request) - ] - - # No response match this request - if not responses: - return None - - # Responses match this request - for matcher, response in responses: - # Return the first not yet called - if not matcher.nb_calls: - matcher.nb_calls += 1 - return response - - # Or the last registered - matcher.nb_calls += 1 - return response - def _get_callback( self, request: httpx.Request ) -> Optional[Callable[[httpx.Request], Optional[httpx.Response]]]: @@ -310,7 +284,7 @@ def get_request(self, **matchers) -> Optional[httpx.Request]: return requests[0] if requests else None def reset(self, assert_all_responses_were_requested: bool) -> None: - not_called = self._reset_responses() + self._reset_callbacks() + not_called = self._reset_callbacks() if assert_all_responses_were_requested: matchers_description = "\n".join([str(matcher) for matcher in not_called]) @@ -319,13 +293,6 @@ def reset(self, assert_all_responses_were_requested: bool) -> None: not not_called ), f"The following responses are mocked but not requested:\n{matchers_description}" - def _reset_responses(self) -> List[_RequestMatcher]: - responses_not_called = [ - matcher for matcher, _ in self._responses if not matcher.nb_calls - ] - self._responses.clear() - return responses_not_called - def _reset_callbacks(self) -> List[_RequestMatcher]: callbacks_not_executed = [ matcher for matcher, _ in self._callbacks if not matcher.nb_calls diff --git a/setup.py b/setup.py index 28afc12..e2fa16b 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Build Tools", "Topic :: Internet :: WWW/HTTP", "Framework :: Pytest", diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index 7f054bc..f4c9454 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -867,6 +867,52 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +@pytest.mark.asyncio +async def test_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: + def custom_response(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=200, json=["content2"]) + + httpx_mock.add_response(json=["content1"]) + httpx_mock.add_callback(custom_response) + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url") + assert response.json() == ["content1"] + assert response.headers["content-type"] == "application/json" + + response = await client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + # Assert that the last registered callback is sent again even if there is a response + response = await client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + +@pytest.mark.asyncio +async def test_response_registered_after_callback(httpx_mock: HTTPXMock) -> None: + def custom_response(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=200, json=["content1"]) + + httpx_mock.add_callback(custom_response) + httpx_mock.add_response(json=["content2"]) + + async with httpx.AsyncClient() as client: + response = await client.get("https://test_url") + assert response.json() == ["content1"] + assert response.headers["content-type"] == "application/json" + + response = await client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + # Assert that the last registered response is sent again even if there is a callback + response = await client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + @pytest.mark.asyncio async def test_callback_matching_method(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index cc47c5f..7207b39 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -796,6 +796,50 @@ def custom_response(request: httpx.Request) -> httpx.Response: assert response.headers["content-type"] == "application/json" +def test_callback_registered_after_response(httpx_mock: HTTPXMock) -> None: + def custom_response(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=200, json=["content2"]) + + httpx_mock.add_response(json=["content1"]) + httpx_mock.add_callback(custom_response) + + with httpx.Client() as client: + response = client.get("https://test_url") + assert response.json() == ["content1"] + assert response.headers["content-type"] == "application/json" + + response = client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + # Assert that the last registered callback is sent again even if there is a response + response = client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + +def test_response_registered_after_callback(httpx_mock: HTTPXMock) -> None: + def custom_response(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=200, json=["content1"]) + + httpx_mock.add_callback(custom_response) + httpx_mock.add_response(json=["content2"]) + + with httpx.Client() as client: + response = client.get("https://test_url") + assert response.json() == ["content1"] + assert response.headers["content-type"] == "application/json" + + response = client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + # Assert that the last registered response is sent again even if there is a callback + response = client.post("https://test_url") + assert response.json() == ["content2"] + assert response.headers["content-type"] == "application/json" + + def test_callback_matching_method(httpx_mock: HTTPXMock) -> None: def custom_response(request: httpx.Request) -> httpx.Response: return httpx.Response(status_code=200, json=["content"]) From 0c16dd882e85cbf296e50246ced19c247924a6fa Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 17 Jan 2022 13:57:36 +0100 Subject: [PATCH 2/4] Update license year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index b70e7e0..ab867f9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Colin Bounouar +Copyright (c) 2022 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 cc6ef017fe3972cfdd6d2f7d6c2e6ea9f08c82cc Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 17 Jan 2022 14:07:54 +0100 Subject: [PATCH 3/4] Remove deprecated data, files and boundary parameters --- CHANGELOG.md | 3 +++ README.md | 2 +- pytest_httpx/_httpx_internals.py | 16 ----------- pytest_httpx/_httpx_mock.py | 12 +-------- tests/test_httpx_async.py | 46 -------------------------------- tests/test_httpx_sync.py | 45 ------------------------------- 6 files changed, 5 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4e116..f24830f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Registration order is now looking at responses and callbacks. Previous to this version, registration order was looking at responses before callbacks. +### Removed +- `httpx_mock.add_response` `data`, `files` and `boundary` parameters have been removed. It was deprecated since `0.17.0`. Refer to this version changelog entry for more details on how to update your code. + ## [0.17.3] - 2021-12-27 ### Fixed - A callback can now raise an exception again (regression in mypy check since [0.16.0]). diff --git a/README.md b/README.md index f4927a4..c84afcd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

diff --git a/pytest_httpx/_httpx_internals.py b/pytest_httpx/_httpx_internals.py index 3eb7962..a953bea 100644 --- a/pytest_httpx/_httpx_internals.py +++ b/pytest_httpx/_httpx_internals.py @@ -4,12 +4,9 @@ Sequence, Tuple, Iterable, - Optional, AsyncIterator, Iterator, - Any, ) -import warnings import httpx @@ -39,16 +36,3 @@ async def __aiter__(self) -> AsyncIterator[bytes]: AsyncIteratorByteStream.__init__(self, stream=Stream()) IteratorByteStream.__init__(self, stream=Stream()) - - -def multipart_stream( - data: dict, files: Any, boundary: Optional[bytes] -) -> Union[httpx.AsyncByteStream, httpx.SyncByteStream]: - warnings.warn( - "data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.", - DeprecationWarning, - ) - # import is performed at runtime when needed to reduce impact of internal changes in httpx - from httpx._multipart import MultipartStream - - return MultipartStream(data=data or {}, files=files, boundary=boundary) diff --git a/pytest_httpx/_httpx_mock.py b/pytest_httpx/_httpx_mock.py index 3270a75..66f026b 100644 --- a/pytest_httpx/_httpx_mock.py +++ b/pytest_httpx/_httpx_mock.py @@ -102,10 +102,7 @@ def add_response( text: Optional[str] = None, html: Optional[str] = None, stream: Any = None, - data: dict = None, - files: Any = None, json: Any = None, - boundary: bytes = None, **matchers, ) -> None: """ @@ -118,10 +115,7 @@ def add_response( :param text: HTTP body of the response (as string). :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 data: HTTP multipart body of the response (as a dictionary) if files is provided. - :param files: Multipart files. :param json: HTTP body of the response (if JSON should be used as content type) if data is not provided. - :param boundary: Multipart boundary if files is provided. :param url: Full URL identifying the request(s) to match. Can be a str, a re.Pattern instance or a httpx.URL instance. :param method: HTTP method identifying the request(s) to match. @@ -136,11 +130,7 @@ def add_response( content=content, text=text, html=html, - stream=_httpx_internals.multipart_stream( - data=data, files=files, boundary=boundary - ) - if files - else stream, + stream=stream, ) self.add_callback(lambda request: response, **matchers) diff --git a/tests/test_httpx_async.py b/tests/test_httpx_async.py index f4c9454..e6c7116 100644 --- a/tests/test_httpx_async.py +++ b/tests/test_httpx_async.py @@ -572,52 +572,6 @@ async def test_with_headers(httpx_mock: HTTPXMock) -> None: ) -@pytest.mark.asyncio -async def test_deprecated_multipart_response(httpx_mock: HTTPXMock) -> None: - with pytest.warns( - DeprecationWarning, - match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.", - ): - httpx_mock.add_response( - url="https://test_url", - files={"file1": b"content of file 1"}, - boundary=b"2256d3a36d2a61a1eba35a22bee5c74a", - ) - with pytest.warns( - DeprecationWarning, - match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.", - ): - httpx_mock.add_response( - url="https://test_url", - data={"key1": "value1"}, - files={"file1": b"content of file 1"}, - boundary=b"2256d3a36d2a61a1eba35a22bee5c74a", - ) - - async with httpx.AsyncClient() as client: - response = await client.get("https://test_url") - assert ( - response.text - == '--2256d3a36d2a61a1eba35a22bee5c74a\r\nContent-Disposition: form-data; name="file1"; filename="upload"\r\nContent-Type: application/octet-stream\r\n\r\ncontent of file 1\r\n--2256d3a36d2a61a1eba35a22bee5c74a--\r\n' - ) - - response = await client.get("https://test_url") - assert ( - response.text - == """--2256d3a36d2a61a1eba35a22bee5c74a\r -Content-Disposition: form-data; name="key1"\r -\r -value1\r ---2256d3a36d2a61a1eba35a22bee5c74a\r -Content-Disposition: form-data; name="file1"; filename="upload"\r -Content-Type: application/octet-stream\r -\r -content of file 1\r ---2256d3a36d2a61a1eba35a22bee5c74a--\r -""" - ) - - @pytest.mark.asyncio async def test_requests_retrieval(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( diff --git a/tests/test_httpx_sync.py b/tests/test_httpx_sync.py index 7207b39..8598500 100644 --- a/tests/test_httpx_sync.py +++ b/tests/test_httpx_sync.py @@ -516,51 +516,6 @@ def test_with_headers(httpx_mock: HTTPXMock) -> None: ) -def test_deprecated_multipart_response(httpx_mock: HTTPXMock) -> None: - with pytest.warns( - DeprecationWarning, - match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.", - ): - httpx_mock.add_response( - url="https://test_url", - files={"file1": b"content of file 1"}, - boundary=b"2256d3a36d2a61a1eba35a22bee5c74a", - ) - with pytest.warns( - DeprecationWarning, - match="data, files and boundary parameters will be removed in a future version. Use stream parameter with an instance of httpx._multipart.MultipartStream instead.", - ): - httpx_mock.add_response( - url="https://test_url", - data={"key1": "value1"}, - files={"file1": b"content of file 1"}, - boundary=b"2256d3a36d2a61a1eba35a22bee5c74a", - ) - - with httpx.Client() as client: - response = client.get("https://test_url") - assert ( - response.text - == '--2256d3a36d2a61a1eba35a22bee5c74a\r\nContent-Disposition: form-data; name="file1"; filename="upload"\r\nContent-Type: application/octet-stream\r\n\r\ncontent of file 1\r\n--2256d3a36d2a61a1eba35a22bee5c74a--\r\n' - ) - - response = client.get("https://test_url") - assert ( - response.text - == """--2256d3a36d2a61a1eba35a22bee5c74a\r -Content-Disposition: form-data; name="key1"\r -\r -value1\r ---2256d3a36d2a61a1eba35a22bee5c74a\r -Content-Disposition: form-data; name="file1"; filename="upload"\r -Content-Type: application/octet-stream\r -\r -content of file 1\r ---2256d3a36d2a61a1eba35a22bee5c74a--\r -""" - ) - - def test_requests_retrieval(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://test_url", method="GET", content=b"test content 1" From a0dd5669bb1389529e367ae67a01194f1bfc66ff Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 17 Jan 2022 14:11:45 +0100 Subject: [PATCH 4/4] Release version 0.18.0 today --- CHANGELOG.md | 7 +++++-- pytest_httpx/version.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24830f..c8a977d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,13 @@ 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.18.0] - 2022-01-17 ### Fixed - Callback are now executed as expected when there is a matching already sent response. ### Changed -- Registration order is now looking at responses and callbacks. Previous to this version, registration order was looking at responses before callbacks. +- Registration order is now looking at responses and callbacks. Prior to this version, registration order was looking at responses before callbacks. ### Removed - `httpx_mock.add_response` `data`, `files` and `boundary` parameters have been removed. It was deprecated since `0.17.0`. Refer to this version changelog entry for more details on how to update your code. @@ -203,7 +205,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.17.3...HEAD +[Unreleased]: https://github.com/Colin-b/pytest_httpx/compare/v0.18.0...HEAD +[0.18.0]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.3...v0.18.0 [0.17.3]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.2...v0.17.3 [0.17.2]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.1...v0.17.2 [0.17.1]: https://github.com/Colin-b/pytest_httpx/compare/v0.17.0...v0.17.1 diff --git a/pytest_httpx/version.py b/pytest_httpx/version.py index 2722665..7b66864 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.17.3" +__version__ = "0.18.0"