httpx2-pytest 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- httpx2_pytest-1.0.0.dist-info/METADATA +1051 -0
- httpx2_pytest-1.0.0.dist-info/RECORD +14 -0
- httpx2_pytest-1.0.0.dist-info/WHEEL +5 -0
- httpx2_pytest-1.0.0.dist-info/entry_points.txt +2 -0
- httpx2_pytest-1.0.0.dist-info/licenses/LICENSE.txt +21 -0
- httpx2_pytest-1.0.0.dist-info/top_level.txt +1 -0
- pytest_httpx2/__init__.py +96 -0
- pytest_httpx2/_httpx_internals.py +60 -0
- pytest_httpx2/_httpx_mock.py +372 -0
- pytest_httpx2/_options.py +18 -0
- pytest_httpx2/_pretty_print.py +72 -0
- pytest_httpx2/_request_matcher.py +293 -0
- pytest_httpx2/py.typed +0 -0
- pytest_httpx2/version.py +6 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
httpx2_pytest-1.0.0.dist-info/licenses/LICENSE.txt,sha256=ib2JF6cwoV7nyeX4c0sDLdhrZ7Ob_htsSjU2yntwlBs,1076
|
|
2
|
+
pytest_httpx2/__init__.py,sha256=YCQj6qz-vh1lMs05vBYRqyNCQp6rJEx9WGqIcwwh77s,2932
|
|
3
|
+
pytest_httpx2/_httpx_internals.py,sha256=Ilzvtg-JfXlJgmEzUtoBAXZCTfieI3FrnLjUbOfejxs,1885
|
|
4
|
+
pytest_httpx2/_httpx_mock.py,sha256=UtKcuv5WJhNBd9rjj5cPGQEt9OvATVyGLkIH9ibAoP0,20283
|
|
5
|
+
pytest_httpx2/_options.py,sha256=aNoquhLGycu3tSN0zt51JuzMaHbitRh2TNgf0HL-Xvw,681
|
|
6
|
+
pytest_httpx2/_pretty_print.py,sha256=racZ7du0l7uNhTfik2Kb0QLLZY1guXrkcsQ7yiu6gjc,2804
|
|
7
|
+
pytest_httpx2/_request_matcher.py,sha256=b5zsDqg8wVYrqsKmESs3v_gIcd3UQU2MoN4YGaMnbec,10828
|
|
8
|
+
pytest_httpx2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pytest_httpx2/version.py,sha256=OACSSeX9t5kwvTTY-7QCyGcmxapSWwM6xDUHbFQTiRk,399
|
|
10
|
+
httpx2_pytest-1.0.0.dist-info/METADATA,sha256=79Im7ZLJ5URiiQoatj2EwgGfozmAni9gJLe75M4TQZc,37713
|
|
11
|
+
httpx2_pytest-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
httpx2_pytest-1.0.0.dist-info/entry_points.txt,sha256=SlRnMRGPZ-RP71afgn-lVHWXs5z1HafRWmMdLlrYZqs,41
|
|
13
|
+
httpx2_pytest-1.0.0.dist-info/top_level.txt,sha256=o2aJBtQGp3dIFjbw5SIWSV6CuRwxaL-VCrnxrZbTcQw,14
|
|
14
|
+
httpx2_pytest-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020-2026 Colin Bounouar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_httpx2
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
from operator import methodcaller
|
|
3
|
+
|
|
4
|
+
import httpx2
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest import Config, FixtureRequest, MonkeyPatch
|
|
7
|
+
|
|
8
|
+
from pytest_httpx2._httpx_internals import IteratorStream
|
|
9
|
+
from pytest_httpx2._httpx_mock import HTTPXMock
|
|
10
|
+
from pytest_httpx2._options import _HTTPXMockOptions
|
|
11
|
+
from pytest_httpx2.version import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"HTTPXMock",
|
|
15
|
+
"IteratorStream",
|
|
16
|
+
"__version__",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _httpx_mock_options(request: FixtureRequest) -> _HTTPXMockOptions:
|
|
21
|
+
httpx_mock_markers: dict = {}
|
|
22
|
+
for marker_name in ("httpx_mock", "httpx2_mock"):
|
|
23
|
+
for marker in request.node.iter_markers(marker_name):
|
|
24
|
+
httpx_mock_markers = marker.kwargs | httpx_mock_markers
|
|
25
|
+
__tracebackhide__ = methodcaller("errisinstance", TypeError)
|
|
26
|
+
return _HTTPXMockOptions(**httpx_mock_markers)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def httpx_mock(
|
|
31
|
+
monkeypatch: MonkeyPatch,
|
|
32
|
+
request: FixtureRequest,
|
|
33
|
+
) -> Generator[HTTPXMock, None, None]:
|
|
34
|
+
options = _httpx_mock_options(request)
|
|
35
|
+
mock = HTTPXMock(options)
|
|
36
|
+
|
|
37
|
+
# Mock synchronous requests
|
|
38
|
+
real_handle_request = httpx2.HTTPTransport.handle_request
|
|
39
|
+
|
|
40
|
+
def mocked_handle_request(
|
|
41
|
+
transport: httpx2.HTTPTransport, request: httpx2.Request
|
|
42
|
+
) -> httpx2.Response:
|
|
43
|
+
if options.should_mock(request):
|
|
44
|
+
return mock._handle_request(transport, request)
|
|
45
|
+
return real_handle_request(transport, request)
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(
|
|
48
|
+
httpx2.HTTPTransport,
|
|
49
|
+
"handle_request",
|
|
50
|
+
mocked_handle_request,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Mock asynchronous requests
|
|
54
|
+
real_handle_async_request = httpx2.AsyncHTTPTransport.handle_async_request
|
|
55
|
+
|
|
56
|
+
async def mocked_handle_async_request(
|
|
57
|
+
transport: httpx2.AsyncHTTPTransport, request: httpx2.Request
|
|
58
|
+
) -> httpx2.Response:
|
|
59
|
+
if options.should_mock(request):
|
|
60
|
+
return await mock._handle_async_request(transport, request)
|
|
61
|
+
return await real_handle_async_request(transport, request)
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
httpx2.AsyncHTTPTransport,
|
|
65
|
+
"handle_async_request",
|
|
66
|
+
mocked_handle_async_request,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
yield mock
|
|
70
|
+
try:
|
|
71
|
+
mock._assert_options()
|
|
72
|
+
finally:
|
|
73
|
+
mock.reset()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def httpx2_mock(httpx_mock: HTTPXMock) -> HTTPXMock:
|
|
78
|
+
"""Alias of :func:`httpx_mock` for HTTPX2-oriented test suites."""
|
|
79
|
+
return httpx_mock
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pytest_configure(config: Config) -> None:
|
|
83
|
+
marker_signature = (
|
|
84
|
+
"*, assert_all_responses_were_requested=True, "
|
|
85
|
+
"assert_all_requests_were_expected=True, "
|
|
86
|
+
"can_send_already_matched_responses=False, "
|
|
87
|
+
"should_mock=lambda request: True"
|
|
88
|
+
)
|
|
89
|
+
config.addinivalue_line(
|
|
90
|
+
"markers",
|
|
91
|
+
f"httpx_mock({marker_signature}): Configure httpx_mock / httpx2_mock fixtures.",
|
|
92
|
+
)
|
|
93
|
+
config.addinivalue_line(
|
|
94
|
+
"markers",
|
|
95
|
+
f"httpx2_mock({marker_signature}): Configure httpx_mock / httpx2_mock fixtures.",
|
|
96
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from collections.abc import AsyncIterator, Iterable, Iterator, Sequence
|
|
3
|
+
|
|
4
|
+
import httpcore2 as httpcore
|
|
5
|
+
import httpx2
|
|
6
|
+
|
|
7
|
+
# TODO Get rid of this internal import
|
|
8
|
+
from httpx2._content import AsyncIteratorByteStream, IteratorByteStream
|
|
9
|
+
|
|
10
|
+
# Those types are internally defined within httpx2._types
|
|
11
|
+
HeaderTypes = (
|
|
12
|
+
httpx2.Headers
|
|
13
|
+
| dict[str, str]
|
|
14
|
+
| dict[bytes, bytes]
|
|
15
|
+
| Sequence[tuple[str, str]]
|
|
16
|
+
| Sequence[tuple[bytes, bytes]]
|
|
17
|
+
)
|
|
18
|
+
PrimitiveData = str | int | float | bool | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IteratorStream(AsyncIteratorByteStream, IteratorByteStream):
|
|
22
|
+
def __init__(self, stream: Iterable[bytes]):
|
|
23
|
+
class Stream:
|
|
24
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
25
|
+
yield from stream
|
|
26
|
+
|
|
27
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
28
|
+
for chunk in stream:
|
|
29
|
+
yield chunk
|
|
30
|
+
|
|
31
|
+
AsyncIteratorByteStream.__init__(self, stream=Stream())
|
|
32
|
+
IteratorByteStream.__init__(self, stream=Stream())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_httpx_url(url: httpcore.URL, headers: list[tuple[bytes, bytes]]) -> httpx2.URL:
|
|
36
|
+
for name, value in headers:
|
|
37
|
+
if b"Proxy-Authorization" == name:
|
|
38
|
+
return httpx2.URL(
|
|
39
|
+
scheme=url.scheme.decode(),
|
|
40
|
+
host=url.host.decode(),
|
|
41
|
+
port=url.port,
|
|
42
|
+
raw_path=url.target,
|
|
43
|
+
userinfo=base64.b64decode(value[6:]),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return httpx2.URL(
|
|
47
|
+
scheme=url.scheme.decode(),
|
|
48
|
+
host=url.host.decode(),
|
|
49
|
+
port=url.port,
|
|
50
|
+
raw_path=url.target,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _proxy_url(
|
|
55
|
+
real_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport,
|
|
56
|
+
) -> httpx2.URL | None:
|
|
57
|
+
real_pool = getattr(real_transport, "_pool", None)
|
|
58
|
+
if isinstance(real_pool, (httpcore.HTTPProxy, httpcore.AsyncHTTPProxy)):
|
|
59
|
+
return _to_httpx_url(real_pool._proxy_url, real_pool._proxy_headers)
|
|
60
|
+
return None
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx2
|
|
7
|
+
|
|
8
|
+
from pytest_httpx2 import _httpx_internals
|
|
9
|
+
from pytest_httpx2._options import _HTTPXMockOptions
|
|
10
|
+
from pytest_httpx2._pretty_print import RequestDescription
|
|
11
|
+
from pytest_httpx2._request_matcher import _RequestMatcher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPXMock:
|
|
15
|
+
"""
|
|
16
|
+
This class is only exposed for `httpx_mock` fixture type hinting purpose.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, options: _HTTPXMockOptions) -> None:
|
|
20
|
+
"""Private and subject to breaking changes without notice."""
|
|
21
|
+
self._options = options
|
|
22
|
+
self._requests: list[
|
|
23
|
+
tuple[httpx2.HTTPTransport | httpx2.AsyncHTTPTransport, httpx2.Request]
|
|
24
|
+
] = []
|
|
25
|
+
self._callbacks: list[
|
|
26
|
+
tuple[
|
|
27
|
+
_RequestMatcher,
|
|
28
|
+
Callable[
|
|
29
|
+
[httpx2.Request],
|
|
30
|
+
httpx2.Response | None | Awaitable[httpx2.Response | None],
|
|
31
|
+
],
|
|
32
|
+
]
|
|
33
|
+
] = []
|
|
34
|
+
self._requests_not_matched: list[httpx2.Request] = []
|
|
35
|
+
|
|
36
|
+
def add_response(
|
|
37
|
+
self,
|
|
38
|
+
status_code: int = 200,
|
|
39
|
+
http_version: str = "HTTP/1.1",
|
|
40
|
+
headers: _httpx_internals.HeaderTypes | None = None,
|
|
41
|
+
content: bytes | None = None,
|
|
42
|
+
text: str | None = None,
|
|
43
|
+
html: str | None = None,
|
|
44
|
+
stream: Any = None,
|
|
45
|
+
json: Any = None,
|
|
46
|
+
**matchers: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Mock the response that will be sent if a request match.
|
|
50
|
+
|
|
51
|
+
:param status_code: HTTP status code of the response to send. Default to 200 (OK).
|
|
52
|
+
:param http_version: HTTP protocol version of the response to send. Default to HTTP/1.1
|
|
53
|
+
:param headers: HTTP headers of the response to send. Default to no headers.
|
|
54
|
+
:param content: HTTP body of the response (as bytes).
|
|
55
|
+
:param text: HTTP body of the response (as string).
|
|
56
|
+
:param html: HTTP body of the response (as HTML string content).
|
|
57
|
+
:param stream: HTTP body of the response (as httpx2.SyncByteStream or httpx2.AsyncByteStream) as stream content.
|
|
58
|
+
:param json: HTTP body of the response (if JSON should be used as content type) if data is not provided.
|
|
59
|
+
: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.
|
|
60
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
61
|
+
:param method: HTTP method identifying the request(s) to match.
|
|
62
|
+
:param proxy_url: Full proxy URL identifying the request(s) to match.
|
|
63
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
64
|
+
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
|
|
65
|
+
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
|
|
66
|
+
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
|
|
67
|
+
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
|
|
68
|
+
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx2 documentation for more information on supported values: https://httpx2.pydantic.dev/advanced/clients/#multipart-file-encoding
|
|
69
|
+
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
|
|
70
|
+
: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, bool, int or float values (or a list of values if parameter is provided more than once).
|
|
71
|
+
: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).
|
|
72
|
+
: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).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
json = copy.deepcopy(json) if json is not None else None
|
|
76
|
+
|
|
77
|
+
def response_callback(request: httpx2.Request) -> httpx2.Response:
|
|
78
|
+
return httpx2.Response(
|
|
79
|
+
status_code=status_code,
|
|
80
|
+
extensions={"http_version": http_version.encode("ascii")},
|
|
81
|
+
headers=headers,
|
|
82
|
+
json=json,
|
|
83
|
+
content=content,
|
|
84
|
+
text=text,
|
|
85
|
+
html=html,
|
|
86
|
+
stream=stream,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self.add_callback(response_callback, **matchers)
|
|
90
|
+
|
|
91
|
+
def add_callback(
|
|
92
|
+
self,
|
|
93
|
+
callback: Callable[
|
|
94
|
+
[httpx2.Request],
|
|
95
|
+
httpx2.Response | None | Awaitable[httpx2.Response | None],
|
|
96
|
+
],
|
|
97
|
+
**matchers: Any,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Mock the action that will take place if a request match.
|
|
101
|
+
|
|
102
|
+
:param callback: The callable that will be called upon reception of the matched request.
|
|
103
|
+
It must expect one parameter, the received httpx2.Request and should return a httpx2.Response.
|
|
104
|
+
: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.
|
|
105
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
106
|
+
:param method: HTTP method identifying the request(s) to match.
|
|
107
|
+
:param proxy_url: Full proxy URL identifying the request(s) to match.
|
|
108
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
109
|
+
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
|
|
110
|
+
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
|
|
111
|
+
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
|
|
112
|
+
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
|
|
113
|
+
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx2 documentation for more information on supported values: https://httpx2.pydantic.dev/advanced/clients/#multipart-file-encoding
|
|
114
|
+
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
|
|
115
|
+
: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, bool, int or float values (or a list of values if parameter is provided more than once).
|
|
116
|
+
: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).
|
|
117
|
+
: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).
|
|
118
|
+
"""
|
|
119
|
+
self._callbacks.append((_RequestMatcher(self._options, **matchers), callback))
|
|
120
|
+
|
|
121
|
+
def add_exception(self, exception: BaseException, **matchers: Any) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Raise an exception if a request match.
|
|
124
|
+
|
|
125
|
+
:param exception: The exception that will be raised upon reception of the matched request.
|
|
126
|
+
: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.
|
|
127
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
128
|
+
:param method: HTTP method identifying the request(s) to match.
|
|
129
|
+
:param proxy_url: Full proxy URL identifying the request(s) to match.
|
|
130
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
131
|
+
:param match_headers: HTTP headers identifying the request(s) to match. Must be a dictionary.
|
|
132
|
+
:param match_content: Full HTTP body identifying the request(s) to match. Must be bytes.
|
|
133
|
+
:param match_json: JSON decoded HTTP body identifying the request(s) to match. Must be JSON encodable.
|
|
134
|
+
:param match_data: Multipart data (excluding files) identifying the request(s) to match. Must be a dictionary.
|
|
135
|
+
:param match_files: Multipart files identifying the request(s) to match. Refer to httpx2 documentation for more information on supported values: https://httpx2.pydantic.dev/advanced/clients/#multipart-file-encoding
|
|
136
|
+
:param match_extensions: Extensions identifying the request(s) to match. Must be a dictionary.
|
|
137
|
+
: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, bool, int or float values (or a list of values if parameter is provided more than once).
|
|
138
|
+
: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).
|
|
139
|
+
: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).
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def exception_callback(request: httpx2.Request) -> None:
|
|
143
|
+
if isinstance(exception, httpx2.RequestError):
|
|
144
|
+
exception.request = request
|
|
145
|
+
raise exception
|
|
146
|
+
|
|
147
|
+
self.add_callback(exception_callback, **matchers)
|
|
148
|
+
|
|
149
|
+
def _handle_request(
|
|
150
|
+
self,
|
|
151
|
+
real_transport: httpx2.HTTPTransport,
|
|
152
|
+
request: httpx2.Request,
|
|
153
|
+
) -> httpx2.Response:
|
|
154
|
+
# Store the content in request for future matching
|
|
155
|
+
request.read()
|
|
156
|
+
self._requests.append((real_transport, request))
|
|
157
|
+
|
|
158
|
+
callback = self._get_callback(real_transport, request)
|
|
159
|
+
if callback:
|
|
160
|
+
response = callback(request)
|
|
161
|
+
|
|
162
|
+
if isinstance(response, httpx2.Response):
|
|
163
|
+
return _unread(response)
|
|
164
|
+
|
|
165
|
+
raise self._request_not_matched(
|
|
166
|
+
request,
|
|
167
|
+
self._explain_that_callback_must_return_a_response(
|
|
168
|
+
real_transport, request
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
raise self._request_not_matched(
|
|
173
|
+
request, self._explain_that_no_response_was_found(real_transport, request)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def _handle_async_request(
|
|
177
|
+
self,
|
|
178
|
+
real_transport: httpx2.AsyncHTTPTransport,
|
|
179
|
+
request: httpx2.Request,
|
|
180
|
+
) -> httpx2.Response:
|
|
181
|
+
# Store the content in request for future matching
|
|
182
|
+
await request.aread()
|
|
183
|
+
self._requests.append((real_transport, request))
|
|
184
|
+
|
|
185
|
+
callback = self._get_callback(real_transport, request)
|
|
186
|
+
if callback:
|
|
187
|
+
response = callback(request)
|
|
188
|
+
|
|
189
|
+
if inspect.isawaitable(response):
|
|
190
|
+
response = await response
|
|
191
|
+
|
|
192
|
+
if isinstance(response, httpx2.Response):
|
|
193
|
+
return _unread(response)
|
|
194
|
+
|
|
195
|
+
raise self._request_not_matched(
|
|
196
|
+
request,
|
|
197
|
+
self._explain_that_callback_must_return_a_response(
|
|
198
|
+
real_transport, request
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
raise self._request_not_matched(
|
|
203
|
+
request, self._explain_that_no_response_was_found(real_transport, request)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _request_not_matched(
|
|
207
|
+
self, request: httpx2.Request, message: str
|
|
208
|
+
) -> httpx2.TimeoutException:
|
|
209
|
+
self._requests_not_matched.append(request)
|
|
210
|
+
return httpx2.TimeoutException(message, request=request)
|
|
211
|
+
|
|
212
|
+
def _explain_that_callback_must_return_a_response(
|
|
213
|
+
self,
|
|
214
|
+
real_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport,
|
|
215
|
+
request: httpx2.Request,
|
|
216
|
+
) -> str:
|
|
217
|
+
return f"Callback registered for {RequestDescription(real_transport, request, [])} MUST return httpx2.Response"
|
|
218
|
+
|
|
219
|
+
def _explain_that_no_response_was_found(
|
|
220
|
+
self,
|
|
221
|
+
real_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport,
|
|
222
|
+
request: httpx2.Request,
|
|
223
|
+
) -> str:
|
|
224
|
+
matchers = [matcher for matcher, _ in self._callbacks]
|
|
225
|
+
|
|
226
|
+
message = f"No response can be found for {RequestDescription(real_transport, request, matchers)}"
|
|
227
|
+
|
|
228
|
+
already_matched = []
|
|
229
|
+
unmatched = []
|
|
230
|
+
for matcher in matchers:
|
|
231
|
+
if matcher.nb_calls:
|
|
232
|
+
already_matched.append(matcher)
|
|
233
|
+
else:
|
|
234
|
+
unmatched.append(matcher)
|
|
235
|
+
|
|
236
|
+
matchers_description = "\n".join(
|
|
237
|
+
[f"- {matcher}" for matcher in unmatched + already_matched]
|
|
238
|
+
)
|
|
239
|
+
if matchers_description:
|
|
240
|
+
message += f" amongst:\n{matchers_description}"
|
|
241
|
+
# If we could not find a response, but we have already matched responses
|
|
242
|
+
# it might be that user is expecting one of those responses to be reused
|
|
243
|
+
if any(not matcher.is_reusable for matcher in already_matched):
|
|
244
|
+
message += "\n\nIf you wanted to reuse an already matched response instead of registering it again, refer to https://github.com/angryfoxx/pytest_httpx2/blob/master/README.md#allow-to-register-a-response-for-more-than-one-request"
|
|
245
|
+
|
|
246
|
+
return message
|
|
247
|
+
|
|
248
|
+
def _get_callback(
|
|
249
|
+
self,
|
|
250
|
+
real_transport: httpx2.HTTPTransport | httpx2.AsyncHTTPTransport,
|
|
251
|
+
request: httpx2.Request,
|
|
252
|
+
) -> (
|
|
253
|
+
Callable[
|
|
254
|
+
[httpx2.Request], httpx2.Response | None | Awaitable[httpx2.Response | None]
|
|
255
|
+
]
|
|
256
|
+
| None
|
|
257
|
+
):
|
|
258
|
+
callbacks = [
|
|
259
|
+
(matcher, callback)
|
|
260
|
+
for matcher, callback in self._callbacks
|
|
261
|
+
if matcher.match(real_transport, request)
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
# No callback match this request
|
|
265
|
+
if not callbacks:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
# Callbacks match this request
|
|
269
|
+
for matcher, callback in callbacks:
|
|
270
|
+
# Return the first not yet called
|
|
271
|
+
if not matcher.nb_calls:
|
|
272
|
+
matcher.nb_calls += 1
|
|
273
|
+
return callback
|
|
274
|
+
|
|
275
|
+
# Or the last registered (if it can be reused)
|
|
276
|
+
if matcher.is_reusable:
|
|
277
|
+
matcher.nb_calls += 1
|
|
278
|
+
return callback
|
|
279
|
+
|
|
280
|
+
# All callbacks have already been matched and last registered cannot be reused
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def get_requests(self, **matchers: Any) -> list[httpx2.Request]:
|
|
284
|
+
"""
|
|
285
|
+
Return all requests sent that match (empty list if no requests were matched).
|
|
286
|
+
|
|
287
|
+
: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.
|
|
288
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
289
|
+
:param method: HTTP method identifying the requests to retrieve. Must be an upper-cased string value.
|
|
290
|
+
:param proxy_url: Full proxy URL identifying the requests to retrieve.
|
|
291
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
292
|
+
:param match_headers: HTTP headers identifying the requests to retrieve. Must be a dictionary.
|
|
293
|
+
:param match_content: Full HTTP body identifying the requests to retrieve. Must be bytes.
|
|
294
|
+
:param match_json: JSON decoded HTTP body identifying the requests to retrieve. Must be JSON encodable.
|
|
295
|
+
:param match_data: Multipart data (excluding files) identifying the requests to retrieve. Must be a dictionary.
|
|
296
|
+
:param match_files: Multipart files identifying the requests to retrieve. Refer to httpx2 documentation for more information on supported values: https://httpx2.pydantic.dev/advanced/clients/#multipart-file-encoding
|
|
297
|
+
:param match_extensions: Extensions identifying the requests to retrieve. Must be a dictionary.
|
|
298
|
+
: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, bool, int or float values (or a list of values if parameter is provided more than once).
|
|
299
|
+
"""
|
|
300
|
+
matcher = _RequestMatcher(self._options, **matchers)
|
|
301
|
+
return [
|
|
302
|
+
request
|
|
303
|
+
for real_transport, request in self._requests
|
|
304
|
+
if matcher.match(real_transport, request)
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
def get_request(self, **matchers: Any) -> httpx2.Request | None:
|
|
308
|
+
"""
|
|
309
|
+
Return the single request that match (or None).
|
|
310
|
+
|
|
311
|
+
: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.
|
|
312
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
313
|
+
:param method: HTTP method identifying the request to retrieve. Must be an upper-cased string value.
|
|
314
|
+
:param proxy_url: Full proxy URL identifying the request to retrieve.
|
|
315
|
+
Can be a str, a re.Pattern instance or a httpx2.URL instance.
|
|
316
|
+
:param match_headers: HTTP headers identifying the request to retrieve. Must be a dictionary.
|
|
317
|
+
:param match_content: Full HTTP body identifying the request to retrieve. Must be bytes.
|
|
318
|
+
:param match_json: JSON decoded HTTP body identifying the request to retrieve. Must be JSON encodable.
|
|
319
|
+
:param match_data: Multipart data (excluding files) identifying the request to retrieve. Must be a dictionary.
|
|
320
|
+
:param match_files: Multipart files identifying the request to retrieve. Refer to httpx2 documentation for more information on supported values: https://httpx2.pydantic.dev/advanced/clients/#multipart-file-encoding
|
|
321
|
+
:param match_extensions: Extensions identifying the request to retrieve. Must be a dictionary.
|
|
322
|
+
: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, bool, int or float values (or a list of values if parameter is provided more than once).
|
|
323
|
+
:raises AssertionError: in case more than one request match.
|
|
324
|
+
"""
|
|
325
|
+
requests = self.get_requests(**matchers)
|
|
326
|
+
assert len(requests) <= 1, (
|
|
327
|
+
f"More than one request ({len(requests)}) matched, use get_requests instead or refine your filters."
|
|
328
|
+
)
|
|
329
|
+
return requests[0] if requests else None
|
|
330
|
+
|
|
331
|
+
def reset(self) -> None:
|
|
332
|
+
self._requests.clear()
|
|
333
|
+
self._callbacks.clear()
|
|
334
|
+
self._requests_not_matched.clear()
|
|
335
|
+
|
|
336
|
+
def _assert_options(self) -> None:
|
|
337
|
+
callbacks_not_executed = [
|
|
338
|
+
matcher for matcher, _ in self._callbacks if matcher.should_have_matched()
|
|
339
|
+
]
|
|
340
|
+
matchers_description = "\n".join(
|
|
341
|
+
[f"- {matcher}" for matcher in callbacks_not_executed]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
assert not callbacks_not_executed, (
|
|
345
|
+
"The following responses are mocked but not requested:\n"
|
|
346
|
+
f"{matchers_description}\n"
|
|
347
|
+
"\n"
|
|
348
|
+
"If this is on purpose, refer to https://github.com/angryfoxx/pytest_httpx2/blob/master/README.md#allow-to-register-more-responses-than-what-will-be-requested"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if self._options.assert_all_requests_were_expected:
|
|
352
|
+
requests_description = "\n".join(
|
|
353
|
+
[
|
|
354
|
+
f"- {request.method} request on {request.url}"
|
|
355
|
+
for request in self._requests_not_matched
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
assert not self._requests_not_matched, (
|
|
359
|
+
f"The following requests were not expected:\n"
|
|
360
|
+
f"{requests_description}\n"
|
|
361
|
+
"\n"
|
|
362
|
+
"If this is on purpose, refer to https://github.com/angryfoxx/pytest_httpx2/blob/master/README.md#allow-to-not-register-responses-for-every-request"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _unread(response: httpx2.Response) -> httpx2.Response:
|
|
367
|
+
# Allow to read the response on client side
|
|
368
|
+
response.is_stream_consumed = False
|
|
369
|
+
response.is_closed = False
|
|
370
|
+
if hasattr(response, "_content"):
|
|
371
|
+
del response._content
|
|
372
|
+
return response
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
import httpx2
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _HTTPXMockOptions:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
*,
|
|
10
|
+
assert_all_responses_were_requested: bool = True,
|
|
11
|
+
assert_all_requests_were_expected: bool = True,
|
|
12
|
+
can_send_already_matched_responses: bool = False,
|
|
13
|
+
should_mock: Callable[[httpx2.Request], bool] = lambda request: True,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.assert_all_responses_were_requested = assert_all_responses_were_requested
|
|
16
|
+
self.assert_all_requests_were_expected = assert_all_requests_were_expected
|
|
17
|
+
self.can_send_already_matched_responses = can_send_already_matched_responses
|
|
18
|
+
self.should_mock = should_mock
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import httpx2
|
|
2
|
+
|
|
3
|
+
from pytest_httpx2._httpx_internals import _proxy_url
|
|
4
|
+
from pytest_httpx2._request_matcher import _RequestMatcher
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestDescription:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
real_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport,
|
|
11
|
+
request: httpx2.Request,
|
|
12
|
+
matchers: list[_RequestMatcher],
|
|
13
|
+
):
|
|
14
|
+
self.real_transport = real_transport
|
|
15
|
+
self.request = request
|
|
16
|
+
|
|
17
|
+
headers_encoding = request.headers.encoding
|
|
18
|
+
self.expected_headers = {
|
|
19
|
+
# httpx2 uses lower cased header names as internal key
|
|
20
|
+
header.lower().encode(headers_encoding)
|
|
21
|
+
for matcher in matchers
|
|
22
|
+
if matcher.headers
|
|
23
|
+
for header in matcher.headers
|
|
24
|
+
}
|
|
25
|
+
self.expect_body = any([matcher.expect_body() for matcher in matchers])
|
|
26
|
+
self.expect_proxy = any([matcher.proxy_url is not None for matcher in matchers])
|
|
27
|
+
self.expected_extensions = {
|
|
28
|
+
extension
|
|
29
|
+
for matcher in matchers
|
|
30
|
+
if matcher.extensions
|
|
31
|
+
for extension in matcher.extensions
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
request_description = f"{self.request.method} request on {self.request.url}"
|
|
36
|
+
if extra_description := self.extra_request_description():
|
|
37
|
+
request_description += f" with {extra_description}"
|
|
38
|
+
return request_description
|
|
39
|
+
|
|
40
|
+
def extra_request_description(self) -> str:
|
|
41
|
+
extra_description = []
|
|
42
|
+
|
|
43
|
+
if self.expected_headers:
|
|
44
|
+
headers_encoding = self.request.headers.encoding
|
|
45
|
+
present_headers: dict = {}
|
|
46
|
+
# Can be cleaned based on the outcome of upstream header matching discussions
|
|
47
|
+
for name, lower_name, value in self.request.headers._list:
|
|
48
|
+
if lower_name in self.expected_headers:
|
|
49
|
+
name = name.decode(headers_encoding)
|
|
50
|
+
if name in present_headers:
|
|
51
|
+
present_headers[name] += f", {value.decode(headers_encoding)}"
|
|
52
|
+
else:
|
|
53
|
+
present_headers[name] = value.decode(headers_encoding)
|
|
54
|
+
|
|
55
|
+
extra_description.append(f"{present_headers} headers")
|
|
56
|
+
|
|
57
|
+
if self.expect_body:
|
|
58
|
+
extra_description.append(f"{self.request.read()} body")
|
|
59
|
+
|
|
60
|
+
if self.expect_proxy:
|
|
61
|
+
proxy_url = _proxy_url(self.real_transport)
|
|
62
|
+
extra_description.append(f"{proxy_url if proxy_url else 'no'} proxy URL")
|
|
63
|
+
|
|
64
|
+
if self.expected_extensions:
|
|
65
|
+
present_extensions = {
|
|
66
|
+
name: value
|
|
67
|
+
for name, value in self.request.extensions.items()
|
|
68
|
+
if name in self.expected_extensions
|
|
69
|
+
}
|
|
70
|
+
extra_description.append(f"{present_extensions} extensions")
|
|
71
|
+
|
|
72
|
+
return " and ".join(extra_description)
|