xtr-http-client 4.0.2__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.
- xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
- xtr_http_client/__init__.py +47 -0
- xtr_http_client/bundle/__init__.py +17 -0
- xtr_http_client/bundle/_clients.py +217 -0
- xtr_http_client/bundle/client_options_config.py +79 -0
- xtr_http_client/bundle/http_client_bundle.py +85 -0
- xtr_http_client/bundle/http_client_config.py +75 -0
- xtr_http_client/bundle/retry_failed_config.py +77 -0
- xtr_http_client/bundle/scoped_client_config.py +99 -0
- xtr_http_client/exception/__init__.py +40 -0
- xtr_http_client/exception/_http_error_message.py +129 -0
- xtr_http_client/http_client.py +57 -0
- xtr_http_client/http_client_mixin.py +176 -0
- xtr_http_client/httpx_http_client.py +327 -0
- xtr_http_client/internal/__init__.py +13 -0
- xtr_http_client/internal/debug_type.py +14 -0
- xtr_http_client/internal/httpx_body.py +88 -0
- xtr_http_client/internal/httpx_clients.py +203 -0
- xtr_http_client/internal/httpx_errors.py +63 -0
- xtr_http_client/internal/httpx_redirect.py +63 -0
- xtr_http_client/internal/httpx_request.py +272 -0
- xtr_http_client/internal/mock_head.py +144 -0
- xtr_http_client/internal/mock_info.py +86 -0
- xtr_http_client/internal/option_merge.py +108 -0
- xtr_http_client/internal/response_body.py +270 -0
- xtr_http_client/internal/retry_count_response.py +148 -0
- xtr_http_client/internal/retry_delay.py +45 -0
- xtr_http_client/internal/retry_options.py +98 -0
- xtr_http_client/mock_http_client.py +253 -0
- xtr_http_client/no_private_network_http_client.py +342 -0
- xtr_http_client/py.typed +0 -0
- xtr_http_client/recipe/__init__.py +15 -0
- xtr_http_client/recipe/manifest.toml +23 -0
- xtr_http_client/response/__init__.py +10 -0
- xtr_http_client/response/http_response.py +412 -0
- xtr_http_client/response/httpx_response.py +173 -0
- xtr_http_client/response/json_mock_response.py +119 -0
- xtr_http_client/response/mock_response.py +375 -0
- xtr_http_client/retry/__init__.py +25 -0
- xtr_http_client/retry/generic_retry_strategy.py +215 -0
- xtr_http_client/retry/retry_context.py +73 -0
- xtr_http_client/retry/retry_strategy_interface.py +74 -0
- xtr_http_client/retryable_http_client.py +346 -0
- xtr_http_client/scoping_http_client.py +303 -0
- xtr_http_client/throttling_http_client.py +143 -0
- xtr_http_client/traceable_http_client.py +406 -0
- xtr_http_client-4.0.2.dist-info/METADATA +505 -0
- xtr_http_client-4.0.2.dist-info/RECORD +51 -0
- xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_http_client-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""The response a client hands back once the head of it has arrived."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Self, cast, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_http_client_contracts import HttpResponseInterface
|
|
10
|
+
|
|
11
|
+
from xtr_http_client.exception import (
|
|
12
|
+
ClientError,
|
|
13
|
+
JsonError,
|
|
14
|
+
RedirectionError,
|
|
15
|
+
ServerError,
|
|
16
|
+
TransportError,
|
|
17
|
+
)
|
|
18
|
+
from xtr_http_client.exception._http_error_message import build_message
|
|
19
|
+
from xtr_http_client.internal.debug_type import debug_type
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
23
|
+
from types import TracebackType
|
|
24
|
+
|
|
25
|
+
from xtr_http_message import Response
|
|
26
|
+
from xtr_http_message_contracts import StreamInterface
|
|
27
|
+
|
|
28
|
+
from xtr_http_client.internal.response_body import ResponseBody
|
|
29
|
+
|
|
30
|
+
__all__ = ["HttpResponse"]
|
|
31
|
+
|
|
32
|
+
_REDIRECTION = 300
|
|
33
|
+
_CLIENT_ERROR = 400
|
|
34
|
+
_SERVER_ERROR = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@final
|
|
38
|
+
class HttpResponse(HttpResponseInterface):
|
|
39
|
+
"""A response whose head has arrived and whose body is read when it is asked for.
|
|
40
|
+
|
|
41
|
+
Three parts are held apart because they arrive apart: the message — the
|
|
42
|
+
status line and the headers, which came in whole — the body, which is
|
|
43
|
+
still coming off the transfer, and the info, which the transfer goes on
|
|
44
|
+
writing to as it does.
|
|
45
|
+
|
|
46
|
+
A ``with_*`` copy therefore copies the message alone. The body is the one
|
|
47
|
+
transfer's and cannot be had twice, and the info is that transfer's own
|
|
48
|
+
record, so every copy reads the one mapping and sees whatever has been
|
|
49
|
+
written to it since.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("_body", "_info", "_message", "_on_cancel")
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
message: Response,
|
|
57
|
+
body: ResponseBody,
|
|
58
|
+
info: dict[str, object],
|
|
59
|
+
on_cancel: Callable[[], Awaitable[None]] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Hold the parts of a response that arrived apart.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
message: The status line and the headers, as they came in.
|
|
65
|
+
body: The body, taken off the transfer as it is asked for.
|
|
66
|
+
info: What the transfer knows about itself. Kept rather than
|
|
67
|
+
copied, because the transfer is still writing to it.
|
|
68
|
+
on_cancel: What abandoning the transfer takes, when there is
|
|
69
|
+
anything: the client that opened it knows, a response does
|
|
70
|
+
not.
|
|
71
|
+
"""
|
|
72
|
+
self._message: Response = message
|
|
73
|
+
self._body: StreamInterface = body
|
|
74
|
+
self._info: dict[str, object] = info
|
|
75
|
+
self._on_cancel: Callable[[], Awaitable[None]] | None = on_cancel
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
def get_status_code(self) -> int:
|
|
79
|
+
"""Return the status code the server answered with.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The three-digit code off the status line.
|
|
83
|
+
"""
|
|
84
|
+
return self._message.get_status_code()
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
def get_reason_phrase(self) -> str:
|
|
88
|
+
"""Return the reason phrase that goes with the status code.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The phrase, or an empty string when the status line carried none.
|
|
92
|
+
"""
|
|
93
|
+
return self._message.get_reason_phrase()
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def with_status(self, code: int, reason_phrase: str = "") -> HttpResponse:
|
|
97
|
+
"""Return a copy carrying ``code`` and, optionally, ``reason_phrase``.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
code: The status code to set.
|
|
101
|
+
reason_phrase: The phrase to go with ``code``.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A new response; this one is left as it was.
|
|
105
|
+
"""
|
|
106
|
+
return self._around(self._message.with_status(code, reason_phrase))
|
|
107
|
+
|
|
108
|
+
@override
|
|
109
|
+
def get_protocol_version(self) -> str:
|
|
110
|
+
"""Return the HTTP version the answer came back on.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The version number alone, such as ``"1.1"``.
|
|
114
|
+
"""
|
|
115
|
+
return self._message.get_protocol_version()
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def with_protocol_version(self, version: str) -> HttpResponse:
|
|
119
|
+
"""Return a copy carrying ``version``.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
version: The HTTP version number alone.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A new response; this one is left as it was.
|
|
126
|
+
"""
|
|
127
|
+
return self._around(self._message.with_protocol_version(version))
|
|
128
|
+
|
|
129
|
+
@override
|
|
130
|
+
def get_headers(self) -> Mapping[str, Sequence[str]]:
|
|
131
|
+
"""Return every header, each name mapped to all of its values.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The names in the case they arrived in, mapped to their values.
|
|
135
|
+
"""
|
|
136
|
+
return self._message.get_headers()
|
|
137
|
+
|
|
138
|
+
@override
|
|
139
|
+
def has_header(self, name: str) -> bool:
|
|
140
|
+
"""Tell whether a header arrived, matching ``name`` without case.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: The header name, in any case.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
``True`` when some header name matches it.
|
|
147
|
+
"""
|
|
148
|
+
return self._message.has_header(name)
|
|
149
|
+
|
|
150
|
+
@override
|
|
151
|
+
def get_header(self, name: str) -> Sequence[str]:
|
|
152
|
+
"""Return every value recorded under a header, matched without case.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
name: The header name, in any case.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Its values in order, or an empty sequence when it did not arrive.
|
|
159
|
+
"""
|
|
160
|
+
return self._message.get_header(name)
|
|
161
|
+
|
|
162
|
+
@override
|
|
163
|
+
def get_header_line(self, name: str) -> str:
|
|
164
|
+
"""Return the values of a header joined with commas.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: The header name, in any case.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The values joined with commas, or an empty string.
|
|
171
|
+
"""
|
|
172
|
+
return self._message.get_header_line(name)
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def with_header(self, name: str, value: str | Sequence[str]) -> HttpResponse:
|
|
176
|
+
"""Return a copy whose ``name`` header holds ``value`` and nothing else.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name: The header name, in any case.
|
|
180
|
+
value: One value, or every value the header is to hold.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
A new response; this one is left as it was.
|
|
184
|
+
"""
|
|
185
|
+
return self._around(self._message.with_header(name, value))
|
|
186
|
+
|
|
187
|
+
@override
|
|
188
|
+
def with_added_header(self, name: str, value: str | Sequence[str]) -> HttpResponse:
|
|
189
|
+
"""Return a copy with ``value`` appended to the ``name`` header.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
name: The header name, in any case.
|
|
193
|
+
value: One value, or every value to append.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A new response; this one is left as it was.
|
|
197
|
+
"""
|
|
198
|
+
return self._around(self._message.with_added_header(name, value))
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
def without_header(self, name: str) -> HttpResponse:
|
|
202
|
+
"""Return a copy with the ``name`` header gone.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
name: The header name, in any case.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A new response; this one is left as it was.
|
|
209
|
+
"""
|
|
210
|
+
return self._around(self._message.without_header(name))
|
|
211
|
+
|
|
212
|
+
@override
|
|
213
|
+
def get_body(self) -> StreamInterface:
|
|
214
|
+
"""Return the body, which reads off the transfer as it is read.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The one body of this transfer, shared by every copy of the
|
|
218
|
+
response.
|
|
219
|
+
"""
|
|
220
|
+
return self._body
|
|
221
|
+
|
|
222
|
+
@override
|
|
223
|
+
def with_body(self, body: StreamInterface) -> HttpResponse:
|
|
224
|
+
"""Return a copy reading its body from ``body`` instead.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
body: The stream the copy is to read its body from, in place of
|
|
228
|
+
the one coming off the transfer.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
A new response; this one is left as it was, still over the
|
|
232
|
+
transfer's own body.
|
|
233
|
+
"""
|
|
234
|
+
return self._around(self._message, body)
|
|
235
|
+
|
|
236
|
+
@override
|
|
237
|
+
def get_info(self, key: str | None = None) -> object:
|
|
238
|
+
"""Return what the transfer knows about itself, as it stands.
|
|
239
|
+
|
|
240
|
+
The mapping handed back is a copy, so reading the info of a transfer
|
|
241
|
+
still running cannot be disturbed by the transfer writing to it, and
|
|
242
|
+
nothing a caller does to it reaches the transfer.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
key: One entry to look up. ``None`` asks for all of them.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Every entry when ``key`` is ``None``; otherwise the value under
|
|
249
|
+
``key``, or ``None`` when there is no such entry.
|
|
250
|
+
"""
|
|
251
|
+
if key is None:
|
|
252
|
+
return dict(self._info)
|
|
253
|
+
return self._info.get(key)
|
|
254
|
+
|
|
255
|
+
@override
|
|
256
|
+
async def cancel(self) -> None:
|
|
257
|
+
"""Abandon the transfer and let go of what it was holding.
|
|
258
|
+
|
|
259
|
+
The record is written before anything is let go of, so a reader that
|
|
260
|
+
finds the body closed can be told why it is closed rather than only
|
|
261
|
+
that it is.
|
|
262
|
+
"""
|
|
263
|
+
self._info["canceled"] = True
|
|
264
|
+
self._info["error"] = "Response has been canceled."
|
|
265
|
+
|
|
266
|
+
if self._on_cancel is not None:
|
|
267
|
+
await self._on_cancel()
|
|
268
|
+
|
|
269
|
+
await self._body.close()
|
|
270
|
+
|
|
271
|
+
@override
|
|
272
|
+
async def get_content(self, throw: bool = True) -> bytes:
|
|
273
|
+
"""Read the whole body, waiting for the rest of it to arrive.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
277
|
+
answered with the body that came with it.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Every byte of the body, empty when there was none.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
TransportError: When the body cannot be read — the transfer broke,
|
|
284
|
+
or it was cancelled.
|
|
285
|
+
RedirectionError: On a 3xx, when ``throw``.
|
|
286
|
+
ClientError: On a 4xx, when ``throw``.
|
|
287
|
+
ServerError: On a 5xx, when ``throw``.
|
|
288
|
+
"""
|
|
289
|
+
if throw:
|
|
290
|
+
await self._check_status()
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
return await self._body.get_contents()
|
|
294
|
+
except RuntimeError as error:
|
|
295
|
+
# A body that will not read only knows it is shut; the transfer
|
|
296
|
+
# knows what shut it, and that is the half worth reporting.
|
|
297
|
+
raise TransportError(self._recorded_error() or str(error)) from error
|
|
298
|
+
|
|
299
|
+
@override
|
|
300
|
+
async def to_array(self, throw: bool = True) -> dict[str, object]:
|
|
301
|
+
"""Read the whole body and decode it as a JSON object.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
305
|
+
answered with the body that came with it.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
What the body decoded to.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
JsonError: When the body is empty, is not JSON, or is JSON that is
|
|
312
|
+
not an object.
|
|
313
|
+
TransportError: When the body cannot be read.
|
|
314
|
+
RedirectionError: On a 3xx, when ``throw``.
|
|
315
|
+
ClientError: On a 4xx, when ``throw``.
|
|
316
|
+
ServerError: On a 5xx, when ``throw``.
|
|
317
|
+
"""
|
|
318
|
+
content = await self.get_content(throw)
|
|
319
|
+
url = self._info.get("url")
|
|
320
|
+
|
|
321
|
+
if not content:
|
|
322
|
+
raise JsonError("Response body is empty.")
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
decoded = cast("object", json.loads(content))
|
|
326
|
+
except ValueError as error:
|
|
327
|
+
raise JsonError(f'{error} for "{url}".') from error
|
|
328
|
+
|
|
329
|
+
if not isinstance(decoded, dict):
|
|
330
|
+
raise JsonError(
|
|
331
|
+
"JSON content was expected to decode to an array, "
|
|
332
|
+
f'"{debug_type(decoded)}" returned for "{url}".'
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# A decoded JSON object is keyed by strings; the cast says so once
|
|
336
|
+
# rather than at every lookup the caller makes.
|
|
337
|
+
return cast("dict[str, object]", decoded)
|
|
338
|
+
|
|
339
|
+
@override
|
|
340
|
+
async def __aenter__(self) -> Self:
|
|
341
|
+
"""Enter a block that owns the transfer.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
This response.
|
|
345
|
+
"""
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
@override
|
|
349
|
+
async def __aexit__(
|
|
350
|
+
self,
|
|
351
|
+
exc_type: type[BaseException] | None,
|
|
352
|
+
exc: BaseException | None,
|
|
353
|
+
traceback: TracebackType | None,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Close the body however the block ends, and nothing else.
|
|
356
|
+
|
|
357
|
+
Leaving the block is not cancelling: the body is let go of, which
|
|
358
|
+
frees the connection, but the transfer is not recorded as abandoned.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
exc_type: The class of the exception the block is leaving with.
|
|
362
|
+
exc: That exception.
|
|
363
|
+
traceback: Its traceback.
|
|
364
|
+
"""
|
|
365
|
+
del exc_type, exc, traceback
|
|
366
|
+
await self._body.close()
|
|
367
|
+
|
|
368
|
+
async def _check_status(self) -> None:
|
|
369
|
+
"""Raise the error the status code stands for, when it stands for one.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
RedirectionError: On a 3xx.
|
|
373
|
+
ClientError: On a 4xx.
|
|
374
|
+
ServerError: On a 5xx.
|
|
375
|
+
"""
|
|
376
|
+
code = self._message.get_status_code()
|
|
377
|
+
|
|
378
|
+
if code < _REDIRECTION:
|
|
379
|
+
return
|
|
380
|
+
if code < _CLIENT_ERROR:
|
|
381
|
+
raise RedirectionError(self, await build_message(self))
|
|
382
|
+
if code < _SERVER_ERROR:
|
|
383
|
+
raise ClientError(self, await build_message(self))
|
|
384
|
+
|
|
385
|
+
raise ServerError(self, await build_message(self))
|
|
386
|
+
|
|
387
|
+
def _recorded_error(self) -> str | None:
|
|
388
|
+
"""Return what the transfer wrote down as having gone wrong, if anything."""
|
|
389
|
+
error = self._info.get("error")
|
|
390
|
+
return error if isinstance(error, str) else None
|
|
391
|
+
|
|
392
|
+
def _around(self, message: Response, body: StreamInterface | None = None) -> HttpResponse:
|
|
393
|
+
"""Return a response of this kind around ``message``.
|
|
394
|
+
|
|
395
|
+
Built field by field rather than through the constructor, because a
|
|
396
|
+
copy may carry any stream as its body — :meth:`with_body` replaces it
|
|
397
|
+
— where a new response is always given the one the transfer produces.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
message: The message the copy answers from.
|
|
401
|
+
body: The body the copy reads. ``None`` keeps this response's.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
The copy, sharing this response's info and its way of cancelling.
|
|
405
|
+
"""
|
|
406
|
+
copy = HttpResponse.__new__(HttpResponse)
|
|
407
|
+
copy._message = message # noqa: SLF001 — a copy of this very class.
|
|
408
|
+
copy._body = self._body if body is None else body # noqa: SLF001 — likewise.
|
|
409
|
+
copy._info = self._info # noqa: SLF001 — likewise.
|
|
410
|
+
copy._on_cancel = self._on_cancel # noqa: SLF001 — likewise.
|
|
411
|
+
|
|
412
|
+
return copy
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""An answer from the backend, read as the response this package hands back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from xtr_http_message import Response
|
|
9
|
+
|
|
10
|
+
from xtr_http_client.internal.httpx_body import HttpxBody
|
|
11
|
+
from xtr_http_client.internal.httpx_redirect import next_hop
|
|
12
|
+
from xtr_http_client.internal.response_body import ResponseBody
|
|
13
|
+
|
|
14
|
+
from .http_response import HttpResponse
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
__all__ = ["to_http_response"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class _ConnectionInterface(Protocol):
|
|
26
|
+
"""The connection an answer came in on, as much of it as the record reads."""
|
|
27
|
+
|
|
28
|
+
def get_extra_info(self, info: str) -> object:
|
|
29
|
+
"""Return what the connection knows under ``info``, or ``None``."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def to_http_response( # noqa: PLR0913 — one call site, and every part arrives apart
|
|
34
|
+
response: httpx.Response,
|
|
35
|
+
history: Sequence[httpx.Response],
|
|
36
|
+
method: str,
|
|
37
|
+
start_time: float,
|
|
38
|
+
user_data: object,
|
|
39
|
+
*,
|
|
40
|
+
deadline: float | None = None,
|
|
41
|
+
decode: bool = True,
|
|
42
|
+
) -> HttpResponse:
|
|
43
|
+
"""Return the response for an answer whose head has arrived.
|
|
44
|
+
|
|
45
|
+
The three parts of a response are assembled from three different places,
|
|
46
|
+
which is why they are passed apart: the message is this answer's status
|
|
47
|
+
line and headers, the body is still coming off this answer's transfer,
|
|
48
|
+
and the info is the record of the whole chain that led here.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
response: The last answer of the chain, its body unread.
|
|
52
|
+
history: The answers before it, in the order they came, so the record
|
|
53
|
+
can count the hops and keep every status line.
|
|
54
|
+
method: The method of the last request made.
|
|
55
|
+
start_time: When the request was sent, as a wall clock reading.
|
|
56
|
+
user_data: What the caller asked to find again on the response.
|
|
57
|
+
deadline: When the whole request runs out of time, on anyio's clock;
|
|
58
|
+
the body is held to it as the head was. ``None`` is no limit.
|
|
59
|
+
decode: Whether the body is to be handed over decoded. It is, unless
|
|
60
|
+
the caller asked for an encoding outright — in which case what
|
|
61
|
+
they asked for is what they get, as it arrived.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The response, ready to be read.
|
|
65
|
+
"""
|
|
66
|
+
message = Response(
|
|
67
|
+
response.status_code,
|
|
68
|
+
_headers(response),
|
|
69
|
+
None,
|
|
70
|
+
response.http_version.rpartition("/")[2],
|
|
71
|
+
response.reason_phrase,
|
|
72
|
+
)
|
|
73
|
+
pieces = response.aiter_bytes() if decode else response.aiter_raw()
|
|
74
|
+
body = ResponseBody(HttpxBody(response, pieces, deadline))
|
|
75
|
+
|
|
76
|
+
return HttpResponse(
|
|
77
|
+
message,
|
|
78
|
+
body,
|
|
79
|
+
_info(response, history, method, start_time, user_data),
|
|
80
|
+
response.aclose,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _info(
|
|
85
|
+
response: httpx.Response,
|
|
86
|
+
history: Sequence[httpx.Response],
|
|
87
|
+
method: str,
|
|
88
|
+
start_time: float,
|
|
89
|
+
user_data: object,
|
|
90
|
+
) -> dict[str, object]:
|
|
91
|
+
"""What the transfer knows about itself by the time its head is in."""
|
|
92
|
+
info: dict[str, object] = {
|
|
93
|
+
"canceled": False,
|
|
94
|
+
"error": None,
|
|
95
|
+
"http_code": response.status_code,
|
|
96
|
+
"http_method": method,
|
|
97
|
+
"redirect_count": len(history),
|
|
98
|
+
"redirect_url": _redirect_url(response),
|
|
99
|
+
"response_headers": _lines([*history, response]),
|
|
100
|
+
"start_time": start_time,
|
|
101
|
+
"total_time": time.time() - start_time,
|
|
102
|
+
"url": _url(response),
|
|
103
|
+
"user_data": user_data,
|
|
104
|
+
}
|
|
105
|
+
address = _primary_ip(response)
|
|
106
|
+
if address is not None:
|
|
107
|
+
info["primary_ip"] = address
|
|
108
|
+
|
|
109
|
+
return info
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _headers(response: httpx.Response) -> dict[str, list[str]]:
|
|
113
|
+
"""Every header of an answer, in the case and the order it arrived in."""
|
|
114
|
+
headers: dict[str, list[str]] = {}
|
|
115
|
+
for raw_name, raw_value in response.headers.raw:
|
|
116
|
+
name = raw_name.decode("latin-1")
|
|
117
|
+
headers.setdefault(_first_cased(headers, name), []).append(raw_value.decode("latin-1"))
|
|
118
|
+
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _first_cased(headers: dict[str, list[str]], name: str) -> str:
|
|
123
|
+
"""The case a header name was first seen in, so a repeat does not split it."""
|
|
124
|
+
wanted = name.lower()
|
|
125
|
+
for seen in headers:
|
|
126
|
+
if seen.lower() == wanted:
|
|
127
|
+
return seen
|
|
128
|
+
|
|
129
|
+
return name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _lines(chain: Sequence[httpx.Response]) -> list[str]:
|
|
133
|
+
"""The raw status and header lines of every hop, in the order they arrived."""
|
|
134
|
+
lines: list[str] = []
|
|
135
|
+
for answer in chain:
|
|
136
|
+
lines.append(f"{answer.http_version} {answer.status_code} {answer.reason_phrase}")
|
|
137
|
+
lines += [
|
|
138
|
+
f"{name.decode('latin-1')}: {value.decode('latin-1')}"
|
|
139
|
+
for name, value in answer.headers.raw
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _redirect_url(response: httpx.Response) -> str | None:
|
|
146
|
+
"""Where the answer points, resolved, when nobody followed it."""
|
|
147
|
+
following = next_hop(response)
|
|
148
|
+
if following is None:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return str(following.url)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _url(response: httpx.Response) -> str:
|
|
155
|
+
"""The URL of the last hop, with an empty path written as the root it means."""
|
|
156
|
+
url = response.url
|
|
157
|
+
|
|
158
|
+
return str(url.copy_with(raw_path=url.raw_path))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _primary_ip(response: httpx.Response) -> str | None:
|
|
162
|
+
"""The address the last hop was answered from, when the transfer knows it."""
|
|
163
|
+
extensions = cast("Mapping[str, object]", response.extensions)
|
|
164
|
+
connection = extensions.get("network_stream")
|
|
165
|
+
if not isinstance(connection, _ConnectionInterface):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
address = connection.get_extra_info("server_addr")
|
|
169
|
+
if not isinstance(address, tuple) or not address:
|
|
170
|
+
return None
|
|
171
|
+
first = cast("object", address[0])
|
|
172
|
+
|
|
173
|
+
return first if isinstance(first, str) else None
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A scripted answer whose body is a JSON document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Final, Self, cast, final
|
|
8
|
+
|
|
9
|
+
from xtr_http_client.exception import InvalidArgumentError
|
|
10
|
+
from xtr_http_client.internal.mock_head import response_header_lines
|
|
11
|
+
|
|
12
|
+
from .mock_response import MockResponse
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from os import PathLike
|
|
17
|
+
|
|
18
|
+
__all__ = ["JsonMockResponse"]
|
|
19
|
+
|
|
20
|
+
_JSON: Final = "application/json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class JsonMockResponse(MockResponse):
|
|
25
|
+
"""A written answer carrying a value encoded as JSON, labelled as JSON.
|
|
26
|
+
|
|
27
|
+
The one thing a JSON answer adds to a scripted one: the body is a value
|
|
28
|
+
rather than bytes, and the content type says what it is — unless the
|
|
29
|
+
script named one of its own, which a test does to answer with a problem
|
|
30
|
+
document.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
client = MockHttpClient(JsonMockResponse({"id": 7}, {"http_code": 201}))
|
|
34
|
+
async with await client.request("GET", "/orders") as response:
|
|
35
|
+
assert await response.to_array() == {"id": 7}
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ()
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
body: object = None,
|
|
44
|
+
info: Mapping[str, object] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Write out an answer carrying ``body`` as JSON.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
body: Any value JSON can be made of, encoded as it stands. ``None``,
|
|
50
|
+
the default, is not encoded: it answers an empty list, so an
|
|
51
|
+
answer nobody wrote out is still a document. To send ``null``,
|
|
52
|
+
write the bytes with :class:`MockResponse` instead.
|
|
53
|
+
info: What the transfer is to claim about itself, as
|
|
54
|
+
:class:`MockResponse` takes it. A ``content-type`` among its
|
|
55
|
+
``response_headers`` is left alone; one is added when there is
|
|
56
|
+
none.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
InvalidArgumentError: When the value cannot be encoded as JSON.
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(_encoded([] if body is None else body), _labelled(info))
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_file(
|
|
65
|
+
cls,
|
|
66
|
+
path: str | PathLike[str],
|
|
67
|
+
info: Mapping[str, object] | None = None,
|
|
68
|
+
) -> Self:
|
|
69
|
+
"""Write out an answer carrying the JSON document a file holds.
|
|
70
|
+
|
|
71
|
+
The file is read now rather than when the response is issued, so a
|
|
72
|
+
test that names the wrong file is told so where it named it.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: The file to read.
|
|
76
|
+
info: What the transfer is to claim about itself.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The response, carrying the document the file holds.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
InvalidArgumentError: When there is no such file, or what it holds
|
|
83
|
+
is not JSON.
|
|
84
|
+
"""
|
|
85
|
+
file = Path(path)
|
|
86
|
+
if not file.is_file():
|
|
87
|
+
raise InvalidArgumentError(f'File not found: "{path}".')
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
document = cast("object", json.loads(file.read_text(encoding="utf-8")))
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
raise InvalidArgumentError(f'File "{path}" does not contain valid JSON.') from error
|
|
93
|
+
|
|
94
|
+
return cls(document, info)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _encoded(body: object) -> str:
|
|
98
|
+
"""``body`` as the JSON document it is to be sent as.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
InvalidArgumentError: When the value cannot be encoded as JSON. The
|
|
102
|
+
answer is refused where it is written, rather than failing later
|
|
103
|
+
as a transfer that produced nothing.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
return json.dumps(body)
|
|
107
|
+
except (TypeError, ValueError) as error:
|
|
108
|
+
raise InvalidArgumentError(f"JSON encoding failed: {error}") from error
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _labelled(info: Mapping[str, object] | None) -> dict[str, object]:
|
|
112
|
+
"""``info`` with a JSON content type, unless it already names one."""
|
|
113
|
+
script = dict(info or {})
|
|
114
|
+
lines = response_header_lines(script.get("response_headers"))
|
|
115
|
+
|
|
116
|
+
if not any(line.lower().startswith("content-type:") for line in lines):
|
|
117
|
+
script["response_headers"] = [*lines, f"Content-Type: {_JSON}"]
|
|
118
|
+
|
|
119
|
+
return script
|