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 @@
-
+
@@ -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 @@
-
+
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"