pyreqwest 0.5.0__cp313-cp313-macosx_11_0_arm64.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.
Potentially problematic release.
This version of pyreqwest might be problematic. Click here for more details.
- pyreqwest/__init__.py +3 -0
- pyreqwest/__init__.pyi +1 -0
- pyreqwest/_pyreqwest.cpython-313-darwin.so +0 -0
- pyreqwest/bytes/__init__.py +16 -0
- pyreqwest/bytes/__init__.pyi +106 -0
- pyreqwest/client/__init__.py +21 -0
- pyreqwest/client/__init__.pyi +341 -0
- pyreqwest/client/types.py +54 -0
- pyreqwest/cookie/__init__.py +5 -0
- pyreqwest/cookie/__init__.pyi +174 -0
- pyreqwest/exceptions/__init__.py +193 -0
- pyreqwest/http/__init__.py +19 -0
- pyreqwest/http/__init__.pyi +344 -0
- pyreqwest/middleware/__init__.py +5 -0
- pyreqwest/middleware/__init__.pyi +12 -0
- pyreqwest/middleware/asgi/__init__.py +5 -0
- pyreqwest/middleware/asgi/asgi.py +168 -0
- pyreqwest/middleware/types.py +26 -0
- pyreqwest/multipart/__init__.py +5 -0
- pyreqwest/multipart/__init__.pyi +75 -0
- pyreqwest/proxy/__init__.py +5 -0
- pyreqwest/proxy/__init__.pyi +47 -0
- pyreqwest/py.typed +0 -0
- pyreqwest/pytest_plugin/__init__.py +8 -0
- pyreqwest/pytest_plugin/internal/__init__.py +0 -0
- pyreqwest/pytest_plugin/internal/assert_eq.py +6 -0
- pyreqwest/pytest_plugin/internal/assert_message.py +123 -0
- pyreqwest/pytest_plugin/internal/matcher.py +34 -0
- pyreqwest/pytest_plugin/internal/plugin.py +15 -0
- pyreqwest/pytest_plugin/mock.py +493 -0
- pyreqwest/pytest_plugin/types.py +26 -0
- pyreqwest/request/__init__.py +25 -0
- pyreqwest/request/__init__.pyi +200 -0
- pyreqwest/response/__init__.py +19 -0
- pyreqwest/response/__init__.pyi +157 -0
- pyreqwest/types.py +12 -0
- pyreqwest-0.5.0.dist-info/METADATA +106 -0
- pyreqwest-0.5.0.dist-info/RECORD +41 -0
- pyreqwest-0.5.0.dist-info/WHEEL +4 -0
- pyreqwest-0.5.0.dist-info/entry_points.txt +2 -0
- pyreqwest-0.5.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Module providing HTTP request mocking capabilities for pyreqwest clients in tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from re import Pattern
|
|
7
|
+
from typing import Any, Literal, Self, TypeVar, assert_never
|
|
8
|
+
|
|
9
|
+
from pyreqwest.middleware import Next, SyncNext
|
|
10
|
+
from pyreqwest.middleware.types import Middleware, SyncMiddleware
|
|
11
|
+
from pyreqwest.pytest_plugin.internal.matcher import InternalMatcher
|
|
12
|
+
from pyreqwest.pytest_plugin.types import (
|
|
13
|
+
BodyContentMatcher,
|
|
14
|
+
CustomHandler,
|
|
15
|
+
CustomMatcher,
|
|
16
|
+
JsonMatcher,
|
|
17
|
+
Matcher,
|
|
18
|
+
MethodMatcher,
|
|
19
|
+
PathMatcher,
|
|
20
|
+
QueryMatcher,
|
|
21
|
+
UrlMatcher,
|
|
22
|
+
)
|
|
23
|
+
from pyreqwest.request import BaseRequestBuilder, Request, RequestBody, RequestBuilder, SyncRequestBuilder
|
|
24
|
+
from pyreqwest.response import BaseResponse, Response, ResponseBuilder, SyncResponse
|
|
25
|
+
from pyreqwest.types import HeadersType
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
pytest_fixture = pytest.fixture
|
|
31
|
+
MonkeyPatch = pytest.MonkeyPatch
|
|
32
|
+
except ImportError:
|
|
33
|
+
pytest_fixture = None # type: ignore[assignment]
|
|
34
|
+
MonkeyPatch = Any # type: ignore[assignment,misc]
|
|
35
|
+
|
|
36
|
+
_R = TypeVar("_R", bound=BaseResponse)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Mock:
|
|
40
|
+
"""Class representing a single mock rule."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self, method: MethodMatcher | None = None, *, path: PathMatcher | None = None, url: UrlMatcher | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Do not use directly. Instead, use ClientMocker.mock()."""
|
|
46
|
+
self._method_matcher = InternalMatcher(method) if method is not None else None
|
|
47
|
+
self._path_matcher = InternalMatcher(path) if path is not None else None
|
|
48
|
+
self._url_matcher = InternalMatcher(url) if url is not None else None
|
|
49
|
+
self._query_matcher: dict[str, InternalMatcher] | InternalMatcher | None = None
|
|
50
|
+
self._header_matchers: dict[str, InternalMatcher] = {}
|
|
51
|
+
self._body_matcher: tuple[InternalMatcher, Literal["content", "json"]] | None = None
|
|
52
|
+
self._custom_matcher: CustomMatcher | None = None
|
|
53
|
+
self._custom_handler: CustomHandler | None = None
|
|
54
|
+
|
|
55
|
+
self._matched_requests: list[Request] = []
|
|
56
|
+
self._unmatched_requests_repr_parts: list[dict[str, str | None]] = []
|
|
57
|
+
self._using_response_builder = False
|
|
58
|
+
|
|
59
|
+
def assert_called(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
count: int | None = None,
|
|
63
|
+
min_count: int | None = None,
|
|
64
|
+
max_count: int | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Assert that this mock was called the expected number of times. By default, exactly once."""
|
|
67
|
+
if count is None and min_count is None and max_count is None:
|
|
68
|
+
count = 1
|
|
69
|
+
|
|
70
|
+
if self._assertion_passes(count, min_count, max_count):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
from pyreqwest.pytest_plugin.internal.assert_message import assert_fail
|
|
74
|
+
|
|
75
|
+
assert_fail(self, count=count, min_count=min_count, max_count=max_count)
|
|
76
|
+
|
|
77
|
+
def _assertion_passes(
|
|
78
|
+
self,
|
|
79
|
+
count: int | None,
|
|
80
|
+
min_count: int | None,
|
|
81
|
+
max_count: int | None,
|
|
82
|
+
) -> bool:
|
|
83
|
+
actual_count = len(self._matched_requests)
|
|
84
|
+
if count is not None:
|
|
85
|
+
return actual_count == count
|
|
86
|
+
|
|
87
|
+
min_satisfied = min_count is None or actual_count >= min_count
|
|
88
|
+
max_satisfied = max_count is None or actual_count <= max_count
|
|
89
|
+
|
|
90
|
+
return min_satisfied and max_satisfied
|
|
91
|
+
|
|
92
|
+
def get_requests(self) -> list[Request]:
|
|
93
|
+
"""Get all captured requests by this mock."""
|
|
94
|
+
return [*self._matched_requests]
|
|
95
|
+
|
|
96
|
+
def get_call_count(self) -> int:
|
|
97
|
+
"""Get the total number of calls to this mock."""
|
|
98
|
+
return len(self._matched_requests)
|
|
99
|
+
|
|
100
|
+
def reset_requests(self) -> None:
|
|
101
|
+
"""Reset all captured requests for this mock."""
|
|
102
|
+
self._matched_requests.clear()
|
|
103
|
+
|
|
104
|
+
def match_query(self, query: QueryMatcher) -> Self:
|
|
105
|
+
"""Set a matcher to match the entire query string or query parameters."""
|
|
106
|
+
if isinstance(query, dict):
|
|
107
|
+
self._query_matcher = {k: InternalMatcher(v) for k, v in query.items()}
|
|
108
|
+
else:
|
|
109
|
+
self._query_matcher = InternalMatcher(query)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def match_query_param(self, name: str, value: Matcher) -> Self:
|
|
113
|
+
"""Set a matcher to match a specific query parameter."""
|
|
114
|
+
if not isinstance(self._query_matcher, dict):
|
|
115
|
+
self._query_matcher = {}
|
|
116
|
+
self._query_matcher[name] = InternalMatcher(value)
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def match_header(self, name: str, value: Matcher) -> Self:
|
|
120
|
+
"""Set a matcher to match a specific request header."""
|
|
121
|
+
self._header_matchers[name] = InternalMatcher(value)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def match_body(self, matcher: BodyContentMatcher) -> Self:
|
|
125
|
+
"""Set a matcher to match request bodies as raw content (text or bytes)."""
|
|
126
|
+
self._body_matcher = (InternalMatcher(matcher), "content")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def match_body_json(self, matcher: JsonMatcher) -> Self:
|
|
130
|
+
"""Set a matcher to match JSON request bodies."""
|
|
131
|
+
self._body_matcher = (InternalMatcher(matcher), "json")
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def match_request(self, matcher: CustomMatcher) -> Self:
|
|
135
|
+
"""Set a custom matcher to match requests."""
|
|
136
|
+
self._custom_matcher = matcher
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def match_request_with_response(self, handler: CustomHandler) -> Self:
|
|
140
|
+
"""Set a custom handler to generate the response for matched requests."""
|
|
141
|
+
assert not self._using_response_builder, "Cannot use response builder and custom handler together"
|
|
142
|
+
self._custom_handler = handler
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def with_status(self, status: int) -> Self:
|
|
146
|
+
"""Set the mocked response status code."""
|
|
147
|
+
self._response_builder.status(status)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def with_header(self, name: str, value: str) -> Self:
|
|
151
|
+
"""Add a header to the mocked response."""
|
|
152
|
+
self._response_builder.header(name, value)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def with_headers(self, headers: HeadersType) -> Self:
|
|
156
|
+
"""Add headers to the mocked response."""
|
|
157
|
+
self._response_builder.headers(headers)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def with_body_bytes(self, body: bytes | bytearray | memoryview) -> Self:
|
|
161
|
+
"""Set the mocked response body to the given bytes."""
|
|
162
|
+
self._response_builder.body_bytes(body)
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def with_body_text(self, body: str) -> Self:
|
|
166
|
+
"""Set the mocked response body to the given text."""
|
|
167
|
+
self._response_builder.body_text(body)
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def with_body_json(self, json_body: Any) -> Self:
|
|
171
|
+
"""Set the mocked response body to the given JSON-serializable object."""
|
|
172
|
+
self._response_builder.body_json(json_body)
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def with_version(self, version: str) -> Self:
|
|
176
|
+
"""Set the mocked response HTTP version."""
|
|
177
|
+
self._response_builder.version(version)
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def _handle_common_matchers(self, request: Request) -> dict[str, bool]:
|
|
181
|
+
return {
|
|
182
|
+
"method": self._matches_method(request),
|
|
183
|
+
"url": self._matches_url(request),
|
|
184
|
+
"path": self._matches_path(request),
|
|
185
|
+
"query": self._match_query(request),
|
|
186
|
+
"headers": self._match_headers(request),
|
|
187
|
+
"body": self._match_body(request),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async def _handle(self, request: Request) -> Response | None:
|
|
191
|
+
matches = self._handle_common_matchers(request)
|
|
192
|
+
response = await self._handle_callbacks(request, matches)
|
|
193
|
+
return self._check_matched(request, matches, response)
|
|
194
|
+
|
|
195
|
+
def _handle_sync(self, request: Request) -> SyncResponse | None:
|
|
196
|
+
matches = self._handle_common_matchers(request)
|
|
197
|
+
response = self._handle_callbacks_sync(request, matches)
|
|
198
|
+
return self._check_matched(request, matches, response)
|
|
199
|
+
|
|
200
|
+
async def _handle_callbacks(self, request: Request, matches: dict[str, bool]) -> Response | None:
|
|
201
|
+
matches["custom"] = await self._matches_custom(request)
|
|
202
|
+
|
|
203
|
+
if self._custom_handler:
|
|
204
|
+
response = await self._handle_custom_handler(request)
|
|
205
|
+
matches["handler"] = response is not None
|
|
206
|
+
else:
|
|
207
|
+
response = await self._response()
|
|
208
|
+
matches["handler"] = True
|
|
209
|
+
|
|
210
|
+
return response if all(matches.values()) else None
|
|
211
|
+
|
|
212
|
+
def _handle_callbacks_sync(self, request: Request, matches: dict[str, bool]) -> SyncResponse | None:
|
|
213
|
+
matches["custom"] = self._matches_custom_sync(request)
|
|
214
|
+
|
|
215
|
+
if self._custom_handler:
|
|
216
|
+
response = self._handle_custom_handler_sync(request)
|
|
217
|
+
matches["handler"] = response is not None
|
|
218
|
+
else:
|
|
219
|
+
response = self._response_sync()
|
|
220
|
+
matches["handler"] = True
|
|
221
|
+
|
|
222
|
+
return response if all(matches.values()) else None
|
|
223
|
+
|
|
224
|
+
def _check_matched(self, request: Request, matches: dict[str, bool], response: _R | None) -> _R | None:
|
|
225
|
+
if response is not None:
|
|
226
|
+
self._matched_requests.append(request)
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
from pyreqwest.pytest_plugin.internal.assert_message import format_unmatched_request_parts
|
|
230
|
+
|
|
231
|
+
# Memo the reprs as we may consume the request
|
|
232
|
+
self._unmatched_requests_repr_parts.append(
|
|
233
|
+
format_unmatched_request_parts(request, unmatched={k for k, matched in matches.items() if not matched}),
|
|
234
|
+
)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
@cached_property
|
|
238
|
+
def _response_builder(self) -> ResponseBuilder:
|
|
239
|
+
assert self._custom_handler is None, "Cannot use response builder and custom handler together"
|
|
240
|
+
self._using_response_builder = True
|
|
241
|
+
return ResponseBuilder()
|
|
242
|
+
|
|
243
|
+
async def _response(self) -> Response:
|
|
244
|
+
built_response = await self._response_builder.copy().build()
|
|
245
|
+
assert isinstance(built_response, Response)
|
|
246
|
+
return built_response
|
|
247
|
+
|
|
248
|
+
def _response_sync(self) -> SyncResponse:
|
|
249
|
+
built_response = self._response_builder.copy().build_sync()
|
|
250
|
+
assert isinstance(built_response, SyncResponse)
|
|
251
|
+
return built_response
|
|
252
|
+
|
|
253
|
+
def _matches_method(self, request: Request) -> bool:
|
|
254
|
+
return self._method_matcher is None or self._method_matcher.matches(request.method)
|
|
255
|
+
|
|
256
|
+
def _matches_url(self, request: Request) -> bool:
|
|
257
|
+
return self._url_matcher is None or self._url_matcher.matches(request.url)
|
|
258
|
+
|
|
259
|
+
def _matches_path(self, request: Request) -> bool:
|
|
260
|
+
return self._path_matcher is None or self._path_matcher.matches(request.url.path)
|
|
261
|
+
|
|
262
|
+
def _match_headers(self, request: Request) -> bool:
|
|
263
|
+
for header_name, expected_value in self._header_matchers.items():
|
|
264
|
+
actual_value = request.headers.get(header_name)
|
|
265
|
+
if actual_value is None or not expected_value.matches(actual_value):
|
|
266
|
+
return False
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def _match_body(self, request: Request) -> bool:
|
|
270
|
+
if self._body_matcher is None:
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
if request.body is None:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
assert request.body.get_stream() is None, "Stream should have been consumed into body bytes by mock middleware"
|
|
277
|
+
body_buf = request.body.copy_bytes()
|
|
278
|
+
assert body_buf is not None, "Unknown body type"
|
|
279
|
+
body_bytes = body_buf.to_bytes()
|
|
280
|
+
|
|
281
|
+
matcher, kind = self._body_matcher
|
|
282
|
+
if kind == "json":
|
|
283
|
+
try:
|
|
284
|
+
return matcher.matches(json.loads(body_bytes))
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
return False
|
|
287
|
+
elif kind == "content":
|
|
288
|
+
if isinstance(matcher.matcher, bytes):
|
|
289
|
+
return matcher.matches(body_bytes)
|
|
290
|
+
return matcher.matches(body_bytes.decode())
|
|
291
|
+
else:
|
|
292
|
+
assert_never(kind)
|
|
293
|
+
|
|
294
|
+
def _match_query(self, request: Request) -> bool:
|
|
295
|
+
if self._query_matcher is None:
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
query_str = request.url.query_string or ""
|
|
299
|
+
query_dict = request.url.query_dict_multi_value
|
|
300
|
+
|
|
301
|
+
if isinstance(self._query_matcher, dict):
|
|
302
|
+
for key, expected_value in self._query_matcher.items():
|
|
303
|
+
actual_value = query_dict.get(key)
|
|
304
|
+
if actual_value is None or not expected_value.matches(actual_value):
|
|
305
|
+
return False
|
|
306
|
+
return True
|
|
307
|
+
if isinstance(self._query_matcher.matcher, str | Pattern):
|
|
308
|
+
return self._query_matcher.matches(query_str)
|
|
309
|
+
return self._query_matcher.matches(query_dict)
|
|
310
|
+
|
|
311
|
+
async def _matches_custom(self, request: Request) -> bool:
|
|
312
|
+
if self._custom_matcher is None:
|
|
313
|
+
return True
|
|
314
|
+
res = self._custom_matcher(request)
|
|
315
|
+
assert isinstance(res, Awaitable)
|
|
316
|
+
return await res
|
|
317
|
+
|
|
318
|
+
def _matches_custom_sync(self, request: Request) -> bool:
|
|
319
|
+
if self._custom_matcher is None:
|
|
320
|
+
return True
|
|
321
|
+
res = self._custom_matcher(request)
|
|
322
|
+
assert isinstance(res, bool)
|
|
323
|
+
return res
|
|
324
|
+
|
|
325
|
+
async def _handle_custom_handler(self, request: Request) -> Response | None:
|
|
326
|
+
assert self._custom_handler
|
|
327
|
+
res = self._custom_handler(request)
|
|
328
|
+
assert isinstance(res, Awaitable)
|
|
329
|
+
return await res
|
|
330
|
+
|
|
331
|
+
def _handle_custom_handler_sync(self, request: Request) -> SyncResponse | None:
|
|
332
|
+
assert self._custom_handler
|
|
333
|
+
res = self._custom_handler(request)
|
|
334
|
+
assert res is None or isinstance(res, SyncResponse)
|
|
335
|
+
return res
|
|
336
|
+
|
|
337
|
+
def __repr__(self) -> str:
|
|
338
|
+
"""Return a string representation of the mock for debugging purposes."""
|
|
339
|
+
parts = []
|
|
340
|
+
if self._method_matcher is not None:
|
|
341
|
+
parts.append(f"method={self._method_matcher!r}")
|
|
342
|
+
if self._url_matcher is not None:
|
|
343
|
+
parts.append(f"url={self._url_matcher!r}")
|
|
344
|
+
if self._path_matcher is not None:
|
|
345
|
+
parts.append(f"path={self._path_matcher!r}")
|
|
346
|
+
if self._query_matcher is not None:
|
|
347
|
+
parts.append(f"query={self._query_matcher!r}")
|
|
348
|
+
if self._header_matchers:
|
|
349
|
+
parts.append(f"headers={self._header_matchers!r}")
|
|
350
|
+
if self._body_matcher is not None:
|
|
351
|
+
matcher, kind = self._body_matcher
|
|
352
|
+
parts.append(f"body.{kind}={matcher!r}")
|
|
353
|
+
if self._custom_matcher is not None:
|
|
354
|
+
parts.append(f"custom_matcher={self._custom_matcher!r}")
|
|
355
|
+
if self._custom_handler is not None:
|
|
356
|
+
parts.append(f"custom_handler={self._custom_handler!r}")
|
|
357
|
+
return "<Mock " + ", ".join(parts) + ">"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class ClientMocker:
|
|
361
|
+
"""Main class for mocking HTTP requests.
|
|
362
|
+
Use the `client_mocker` fixture or `ClientMocker.create_mocker` to create an instance.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def __init__(self) -> None:
|
|
366
|
+
"""@private"""
|
|
367
|
+
self._mocks: list[Mock] = []
|
|
368
|
+
self._strict = False
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def create_mocker(monkeypatch: MonkeyPatch) -> "ClientMocker":
|
|
372
|
+
"""Create a ClientMocker for mocking HTTP requests in tests."""
|
|
373
|
+
mocker = ClientMocker()
|
|
374
|
+
|
|
375
|
+
def setup(klass: type[BaseRequestBuilder], *, is_async: bool) -> None:
|
|
376
|
+
orig_build_consumed = klass.build # type: ignore[attr-defined]
|
|
377
|
+
orig_build_streamed = klass.build_streamed # type: ignore[attr-defined]
|
|
378
|
+
|
|
379
|
+
def build_patch(self: BaseRequestBuilder, orig: Callable[[BaseRequestBuilder], Request]) -> Request:
|
|
380
|
+
middleware = mocker._create_middleware() if is_async else mocker._create_sync_middleware()
|
|
381
|
+
return orig(self.with_middleware(middleware)) # type: ignore[attr-defined]
|
|
382
|
+
|
|
383
|
+
monkeypatch.setattr(klass, "build", lambda slf: build_patch(slf, orig_build_consumed))
|
|
384
|
+
monkeypatch.setattr(klass, "build_streamed", lambda slf: build_patch(slf, orig_build_streamed))
|
|
385
|
+
|
|
386
|
+
setup(RequestBuilder, is_async=True)
|
|
387
|
+
setup(SyncRequestBuilder, is_async=False)
|
|
388
|
+
|
|
389
|
+
return mocker
|
|
390
|
+
|
|
391
|
+
def mock(
|
|
392
|
+
self, method: MethodMatcher | None = None, *, path: PathMatcher | None = None, url: UrlMatcher | None = None
|
|
393
|
+
) -> Mock:
|
|
394
|
+
"""Add a mock rule for method and path or URL."""
|
|
395
|
+
mock = Mock(method, path=path, url=url)
|
|
396
|
+
self._mocks.append(mock)
|
|
397
|
+
return mock
|
|
398
|
+
|
|
399
|
+
def get(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
400
|
+
"""Mock GET requests to the given path or URL."""
|
|
401
|
+
return self.mock("GET", path=path, url=url)
|
|
402
|
+
|
|
403
|
+
def post(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
404
|
+
"""Mock POST requests to the given path or URL."""
|
|
405
|
+
return self.mock("POST", path=path, url=url)
|
|
406
|
+
|
|
407
|
+
def put(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
408
|
+
"""Mock PUT requests to the given path or URL."""
|
|
409
|
+
return self.mock("PUT", path=path, url=url)
|
|
410
|
+
|
|
411
|
+
def patch(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
412
|
+
"""Mock PATCH requests to the given path or URL."""
|
|
413
|
+
return self.mock("PATCH", path=path, url=url)
|
|
414
|
+
|
|
415
|
+
def delete(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
416
|
+
"""Mock DELETE requests to the given path or URL."""
|
|
417
|
+
return self.mock("DELETE", path=path, url=url)
|
|
418
|
+
|
|
419
|
+
def head(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
420
|
+
"""Mock HEAD requests to the given path or URL."""
|
|
421
|
+
return self.mock("HEAD", path=path, url=url)
|
|
422
|
+
|
|
423
|
+
def options(self, *, path: PathMatcher | None = None, url: UrlMatcher | None = None) -> Mock:
|
|
424
|
+
"""Mock OPTIONS requests to the given path or URL."""
|
|
425
|
+
return self.mock("OPTIONS", path=path, url=url)
|
|
426
|
+
|
|
427
|
+
def strict(self, enabled: bool = True) -> Self:
|
|
428
|
+
"""Enable strict mode - unmatched requests will raise an error."""
|
|
429
|
+
self._strict = enabled
|
|
430
|
+
return self
|
|
431
|
+
|
|
432
|
+
def get_requests(self) -> list[Request]:
|
|
433
|
+
"""Get all captured requests in all mocks."""
|
|
434
|
+
return [request for mock in self._mocks for request in mock.get_requests()]
|
|
435
|
+
|
|
436
|
+
def get_call_count(self) -> int:
|
|
437
|
+
"""Get the total number of calls in all mocks."""
|
|
438
|
+
return sum(mock.get_call_count() for mock in self._mocks)
|
|
439
|
+
|
|
440
|
+
def clear(self) -> None:
|
|
441
|
+
"""Remove all mocks."""
|
|
442
|
+
self._mocks.clear()
|
|
443
|
+
|
|
444
|
+
def reset_requests(self) -> None:
|
|
445
|
+
"""Reset all captured requests in all mocks."""
|
|
446
|
+
for mock in self._mocks:
|
|
447
|
+
mock.reset_requests()
|
|
448
|
+
|
|
449
|
+
def _create_middleware(self) -> Middleware:
|
|
450
|
+
async def mock_middleware(request: Request, next_handler: Next) -> Response:
|
|
451
|
+
if request.body is not None and (stream := request.body.get_stream()) is not None:
|
|
452
|
+
assert isinstance(stream, AsyncIterable)
|
|
453
|
+
body = [bytes(chunk) async for chunk in stream] # Read the body stream into bytes
|
|
454
|
+
request = request.from_request_and_body(request, RequestBody.from_bytes(b"".join(body)))
|
|
455
|
+
|
|
456
|
+
for mock in self._mocks:
|
|
457
|
+
if (response := await mock._handle(request)) is not None:
|
|
458
|
+
return response
|
|
459
|
+
|
|
460
|
+
# No rule matched
|
|
461
|
+
if self._strict:
|
|
462
|
+
msg = f"No mock rule matched request: {request.method} {request.url}"
|
|
463
|
+
raise AssertionError(msg)
|
|
464
|
+
return await next_handler.run(request) # Proceed normally
|
|
465
|
+
|
|
466
|
+
return mock_middleware
|
|
467
|
+
|
|
468
|
+
def _create_sync_middleware(self) -> SyncMiddleware:
|
|
469
|
+
def mock_middleware(request: Request, next_handler: SyncNext) -> SyncResponse:
|
|
470
|
+
if request.body is not None and (stream := request.body.get_stream()) is not None:
|
|
471
|
+
assert isinstance(stream, Iterable)
|
|
472
|
+
body = [bytes(chunk) for chunk in stream] # Read the body stream into bytes
|
|
473
|
+
request = request.from_request_and_body(request, RequestBody.from_bytes(b"".join(body)))
|
|
474
|
+
|
|
475
|
+
for mock in self._mocks:
|
|
476
|
+
if (response := mock._handle_sync(request)) is not None:
|
|
477
|
+
return response
|
|
478
|
+
|
|
479
|
+
# No rule matched
|
|
480
|
+
if self._strict:
|
|
481
|
+
msg = f"No mock rule matched request: {request.method} {request.url}"
|
|
482
|
+
raise AssertionError(msg)
|
|
483
|
+
return next_handler.run(request) # Proceed normally
|
|
484
|
+
|
|
485
|
+
return mock_middleware
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if pytest_fixture is not None:
|
|
489
|
+
|
|
490
|
+
@pytest_fixture
|
|
491
|
+
def client_mocker(monkeypatch: MonkeyPatch) -> ClientMocker:
|
|
492
|
+
"""Fixture that provides a ClientMocker for mocking HTTP requests in tests."""
|
|
493
|
+
return ClientMocker.create_mocker(monkeypatch)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Types used in the pytest plugin."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from re import Pattern
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyreqwest.http import Url
|
|
8
|
+
from pyreqwest.request import Request
|
|
9
|
+
from pyreqwest.response import Response, SyncResponse
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from dirty_equals import DirtyEquals
|
|
13
|
+
|
|
14
|
+
Matcher = str | Pattern[str] | DirtyEquals[Any]
|
|
15
|
+
JsonMatcher = DirtyEquals[Any] | Any
|
|
16
|
+
except ImportError:
|
|
17
|
+
Matcher = str | Pattern[str] # type: ignore[misc]
|
|
18
|
+
JsonMatcher = Any # type: ignore[assignment,misc]
|
|
19
|
+
|
|
20
|
+
MethodMatcher = Matcher
|
|
21
|
+
PathMatcher = Matcher
|
|
22
|
+
UrlMatcher = Matcher | Url
|
|
23
|
+
QueryMatcher = dict[str, Matcher | list[str]] | Matcher
|
|
24
|
+
BodyContentMatcher = bytes | Matcher
|
|
25
|
+
CustomMatcher = Callable[[Request], Awaitable[bool]] | Callable[[Request], bool]
|
|
26
|
+
CustomHandler = Callable[[Request], Awaitable[Response | None]] | Callable[[Request], SyncResponse | None]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Requests classes and builders."""
|
|
2
|
+
|
|
3
|
+
from pyreqwest._pyreqwest.request import (
|
|
4
|
+
BaseRequestBuilder,
|
|
5
|
+
ConsumedRequest,
|
|
6
|
+
Request,
|
|
7
|
+
RequestBody,
|
|
8
|
+
RequestBuilder,
|
|
9
|
+
StreamRequest,
|
|
10
|
+
SyncConsumedRequest,
|
|
11
|
+
SyncRequestBuilder,
|
|
12
|
+
SyncStreamRequest,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [ # noqa: RUF022
|
|
16
|
+
"ConsumedRequest",
|
|
17
|
+
"StreamRequest",
|
|
18
|
+
"SyncConsumedRequest",
|
|
19
|
+
"SyncStreamRequest",
|
|
20
|
+
"Request",
|
|
21
|
+
"RequestBuilder",
|
|
22
|
+
"SyncRequestBuilder",
|
|
23
|
+
"BaseRequestBuilder",
|
|
24
|
+
"RequestBody",
|
|
25
|
+
]
|