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.
Files changed (51) hide show
  1. xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
  2. xtr_http_client/__init__.py +47 -0
  3. xtr_http_client/bundle/__init__.py +17 -0
  4. xtr_http_client/bundle/_clients.py +217 -0
  5. xtr_http_client/bundle/client_options_config.py +79 -0
  6. xtr_http_client/bundle/http_client_bundle.py +85 -0
  7. xtr_http_client/bundle/http_client_config.py +75 -0
  8. xtr_http_client/bundle/retry_failed_config.py +77 -0
  9. xtr_http_client/bundle/scoped_client_config.py +99 -0
  10. xtr_http_client/exception/__init__.py +40 -0
  11. xtr_http_client/exception/_http_error_message.py +129 -0
  12. xtr_http_client/http_client.py +57 -0
  13. xtr_http_client/http_client_mixin.py +176 -0
  14. xtr_http_client/httpx_http_client.py +327 -0
  15. xtr_http_client/internal/__init__.py +13 -0
  16. xtr_http_client/internal/debug_type.py +14 -0
  17. xtr_http_client/internal/httpx_body.py +88 -0
  18. xtr_http_client/internal/httpx_clients.py +203 -0
  19. xtr_http_client/internal/httpx_errors.py +63 -0
  20. xtr_http_client/internal/httpx_redirect.py +63 -0
  21. xtr_http_client/internal/httpx_request.py +272 -0
  22. xtr_http_client/internal/mock_head.py +144 -0
  23. xtr_http_client/internal/mock_info.py +86 -0
  24. xtr_http_client/internal/option_merge.py +108 -0
  25. xtr_http_client/internal/response_body.py +270 -0
  26. xtr_http_client/internal/retry_count_response.py +148 -0
  27. xtr_http_client/internal/retry_delay.py +45 -0
  28. xtr_http_client/internal/retry_options.py +98 -0
  29. xtr_http_client/mock_http_client.py +253 -0
  30. xtr_http_client/no_private_network_http_client.py +342 -0
  31. xtr_http_client/py.typed +0 -0
  32. xtr_http_client/recipe/__init__.py +15 -0
  33. xtr_http_client/recipe/manifest.toml +23 -0
  34. xtr_http_client/response/__init__.py +10 -0
  35. xtr_http_client/response/http_response.py +412 -0
  36. xtr_http_client/response/httpx_response.py +173 -0
  37. xtr_http_client/response/json_mock_response.py +119 -0
  38. xtr_http_client/response/mock_response.py +375 -0
  39. xtr_http_client/retry/__init__.py +25 -0
  40. xtr_http_client/retry/generic_retry_strategy.py +215 -0
  41. xtr_http_client/retry/retry_context.py +73 -0
  42. xtr_http_client/retry/retry_strategy_interface.py +74 -0
  43. xtr_http_client/retryable_http_client.py +346 -0
  44. xtr_http_client/scoping_http_client.py +303 -0
  45. xtr_http_client/throttling_http_client.py +143 -0
  46. xtr_http_client/traceable_http_client.py +406 -0
  47. xtr_http_client-4.0.2.dist-info/METADATA +505 -0
  48. xtr_http_client-4.0.2.dist-info/RECORD +51 -0
  49. xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
  50. xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
  51. 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