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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest_httpx2 = pytest_httpx2
@@ -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)