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,406 @@
1
+ """The client that writes down every request made through it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Final, TypedDict, cast, final
7
+
8
+ from typing_extensions import Unpack, override
9
+ from xtr_http_client_contracts import HttpClientInterface, HttpResponseInterface
10
+ from xtr_service_contracts import ResetInterface
11
+
12
+ from .exception import TransportError
13
+ from .internal.option_merge import as_options
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Awaitable, Callable, Sequence
17
+ from types import TracebackType
18
+ from typing import Self
19
+
20
+ from xtr_http_client_contracts import RequestOptions
21
+ from xtr_http_message_contracts import RequestInterface, StreamInterface
22
+
23
+ __all__ = ["TraceableHttpClient", "TracedRequest"]
24
+
25
+ # The options that carry what was sent, which a request may ask to be left
26
+ # out of its record.
27
+ _BODIES: Final = frozenset({"body", "json"})
28
+
29
+
30
+ class TracedRequest(TypedDict):
31
+ """The record of one request made through a tracing client.
32
+
33
+ Attributes:
34
+ method: The HTTP method the request was made with.
35
+ url: The URL it was made to, as it was asked for.
36
+ options: The options it was made with, as they were given — without
37
+ ``body`` and ``json`` when ``extra.trace_content`` turned the
38
+ bodies off.
39
+ info: What the transfer knew about itself when the response arrived.
40
+ Empty until it arrives, and carrying ``error`` alone when nothing
41
+ does.
42
+ content: Every byte of the body, once the caller has read it;
43
+ ``None`` while nobody has, and ``False`` when
44
+ ``extra.trace_content`` turned the bodies off.
45
+ """
46
+
47
+ method: str
48
+ url: str
49
+ options: RequestOptions
50
+ info: Mapping[str, object]
51
+ content: bytes | bool | None
52
+
53
+
54
+ @final
55
+ class TraceableHttpClient(HttpClientInterface, ResetInterface):
56
+ """Wraps a client and writes down every request made through it.
57
+
58
+ "Which calls did this go on to make, with what, and what came back" is
59
+ asked of a running application constantly — by a profiler, by a test
60
+ asserting on a call it has no other sight of, by whoever is reading a log
61
+ that stops short of the call. Answered at the call site that is logging
62
+ around every request; here it is the client, so the code calling out is
63
+ unchanged and the record is one list.
64
+
65
+ ```python
66
+ client = TraceableHttpClient(inner)
67
+ async with await client.request("GET", "https://example.com/orders") as response:
68
+ orders = await response.to_array()
69
+
70
+ traced = client.get_traced_requests()
71
+ assert traced[0]["method"] == "GET"
72
+ assert traced[0]["content"] == b'{"orders": []}'
73
+ ```
74
+
75
+ A record is added the moment a request is made and filled in as that
76
+ request goes on: ``info`` when the response arrives — or the error alone,
77
+ when nothing arrives and the error is raised on — and ``content`` when the
78
+ caller reads the body. So a trace read while a request is still being
79
+ answered says how far it has got, rather than nothing until it is over.
80
+
81
+ The bodies are the costly half of a record and the half most likely to
82
+ carry a credential, so ``extra={"trace_content": False}`` leaves them out:
83
+ the ``body`` and ``json`` that were sent are dropped from ``options``, and
84
+ ``content`` is ``False`` rather than the bytes. Nothing is recorded at all
85
+ for a request made while ``disabled`` says so, which is how a profiler
86
+ that collects per request stops collecting between them.
87
+
88
+ The record is the point, so a copy made by :meth:`with_options` shares it:
89
+ narrowing a client for one job hands out another way of making requests,
90
+ all of them written into the one list.
91
+ """
92
+
93
+ __slots__ = ("_client", "_disabled", "_traces")
94
+
95
+ def __init__(
96
+ self,
97
+ client: HttpClientInterface,
98
+ disabled: Callable[[], bool] | None = None,
99
+ ) -> None:
100
+ """Write down what ``client`` is asked to send, unless ``disabled`` says not to.
101
+
102
+ Args:
103
+ client: The client every request is handed to.
104
+ disabled: Asked before each request whether to record it. ``None``
105
+ records every one.
106
+ """
107
+ self._client = client
108
+ self._disabled = disabled
109
+ self._traces: list[TracedRequest] = []
110
+
111
+ @override
112
+ async def request(
113
+ self,
114
+ method: str,
115
+ url: str,
116
+ **options: Unpack[RequestOptions],
117
+ ) -> HttpResponseInterface:
118
+ """Make a request for ``method`` and ``url``, and write it down.
119
+
120
+ Args:
121
+ method: The HTTP method, in upper case.
122
+ url: The URL to request, absolute or relative to a ``base_uri``.
123
+ **options: How the request is to be made.
124
+
125
+ Returns:
126
+ What the wrapped client answers, reading its body into the record
127
+ as the caller reads it.
128
+
129
+ Raises:
130
+ InvalidArgumentError: For an invalid method, URL or option.
131
+ TransportError: When no answer came, which is written down before
132
+ it is raised on.
133
+ """
134
+ if self._skipped():
135
+ return await self._client.request(method, url, **options)
136
+
137
+ trace = self._written(method, url, options)
138
+
139
+ return await self._answered(trace, self._client.request(method, url, **options))
140
+
141
+ @override
142
+ async def send_request(
143
+ self,
144
+ request: RequestInterface,
145
+ **options: Unpack[RequestOptions],
146
+ ) -> HttpResponseInterface:
147
+ """Send ``request``, and write it down.
148
+
149
+ Args:
150
+ request: The request to send, whose method and URI are what the
151
+ record names it by.
152
+ **options: What the request leaves out.
153
+
154
+ Returns:
155
+ What the wrapped client answers, reading its body into the record
156
+ as the caller reads it.
157
+
158
+ Raises:
159
+ InvalidArgumentError: For an invalid method, URL or option.
160
+ TransportError: When no answer came, which is written down before
161
+ it is raised on.
162
+ """
163
+ if self._skipped():
164
+ return await self._client.send_request(request, **options)
165
+
166
+ trace = self._written(request.get_method(), str(request.get_uri()), options)
167
+
168
+ return await self._answered(trace, self._client.send_request(request, **options))
169
+
170
+ def get_traced_requests(self) -> list[TracedRequest]:
171
+ """Return the record of every request made through this client so far.
172
+
173
+ Returns:
174
+ A list of its own, so taking the record does not hand over the
175
+ one the client goes on appending to. The records in it are the
176
+ live ones: a request still being answered fills in the record
177
+ this list holds.
178
+ """
179
+ return list(self._traces)
180
+
181
+ @override
182
+ def with_options(self, **options: Unpack[RequestOptions]) -> Self:
183
+ """Return a copy around a wrapped client whose defaults have ``options`` over them.
184
+
185
+ Args:
186
+ **options: The defaults of the wrapped client of the copy.
187
+
188
+ Returns:
189
+ A new client writing into this one's record, so a request made
190
+ through either is traced in one place; this one is left as it was.
191
+
192
+ Raises:
193
+ InvalidArgumentError: When an option is invalid.
194
+ """
195
+ narrowed = type(self)(self._client.with_options(**options), self._disabled)
196
+ narrowed._traces = self._traces # noqa: SLF001 — same class, sharing the one record
197
+
198
+ return narrowed
199
+
200
+ @override
201
+ async def reset(self) -> None:
202
+ """Forget every request written down, and reset the wrapped client.
203
+
204
+ The record goes for every client sharing it, copies included: there is
205
+ one record, and this ends the unit of work it was kept for.
206
+ """
207
+ self._traces.clear()
208
+
209
+ if isinstance(self._client, ResetInterface):
210
+ await self._client.reset()
211
+
212
+ def _skipped(self) -> bool:
213
+ """Whether the request about to be made is one nobody is recording."""
214
+ return self._disabled is not None and self._disabled()
215
+
216
+ def _written(self, method: str, url: str, options: RequestOptions) -> TracedRequest:
217
+ """Add the record of a request about to be made, for the rest to be filled into."""
218
+ kept = _keeps_content(options)
219
+ trace: TracedRequest = {
220
+ "method": method,
221
+ "url": url,
222
+ "options": options if kept else _without_bodies(options),
223
+ "info": {},
224
+ "content": None if kept else False,
225
+ }
226
+ self._traces.append(trace)
227
+
228
+ return trace
229
+
230
+ async def _answered(
231
+ self,
232
+ trace: TracedRequest,
233
+ made: Awaitable[HttpResponseInterface],
234
+ ) -> HttpResponseInterface:
235
+ """Wait for a request, write down what came back, and keep reading recorded.
236
+
237
+ Args:
238
+ trace: The record of the request being made.
239
+ made: That request, as the wrapped client is making it.
240
+
241
+ Returns:
242
+ The response wrapped so that reading its body writes the bytes
243
+ down, or the response as it came when the bodies are not kept.
244
+
245
+ Raises:
246
+ TransportError: When no answer came, with what went wrong written
247
+ down first — the record of a request that failed is the half
248
+ worth having.
249
+ """
250
+ try:
251
+ response = await made
252
+ except TransportError as error:
253
+ trace["info"] = {"error": str(error)}
254
+
255
+ raise
256
+
257
+ trace["info"] = _recorded_info(response)
258
+
259
+ if trace["content"] is False:
260
+ return response
261
+
262
+ return _TracedResponse(response, trace)
263
+
264
+
265
+ def _keeps_content(options: RequestOptions) -> bool:
266
+ """Whether the bodies of a request made with ``options`` are written down.
267
+
268
+ They are, unless ``extra.trace_content`` is exactly ``False``: an entry
269
+ meaning "do not keep this" is the one thing it says, and anything else
270
+ there is a value for some other client to read.
271
+ """
272
+ extra = options.get("extra")
273
+
274
+ return extra is None or extra.get("trace_content", True) is not False
275
+
276
+
277
+ def _without_bodies(options: RequestOptions) -> RequestOptions:
278
+ """``options`` with what was sent dropped, for a request whose bodies are not kept."""
279
+ return as_options({name: value for name, value in options.items() if name not in _BODIES})
280
+
281
+
282
+ def _recorded_info(response: HttpResponseInterface) -> Mapping[str, object]:
283
+ """What a response's transfer knows about itself, as a mapping of its own."""
284
+ whole = response.get_info()
285
+ if isinstance(whole, Mapping):
286
+ return dict(cast("Mapping[str, object]", whole))
287
+
288
+ return {}
289
+
290
+
291
+ @final
292
+ class _TracedResponse(HttpResponseInterface):
293
+ """A response that writes its body into a record once the caller reads it.
294
+
295
+ A record is written as the request goes on, and the body is the part that
296
+ arrives last — when the caller asks for it, which may be long after the
297
+ response was handed over. Nothing but the caller's own read can say what
298
+ the body was, so the read is what writes it down: everything else here is
299
+ the wrapped response's, which keeps the one body and the one transfer
300
+ rather than copying either.
301
+ """
302
+
303
+ __slots__ = ("_inner", "_trace")
304
+
305
+ def __init__(self, inner: HttpResponseInterface, trace: TracedRequest) -> None:
306
+ self._inner = inner
307
+ self._trace = trace
308
+
309
+ @override
310
+ def get_status_code(self) -> int:
311
+ return self._inner.get_status_code()
312
+
313
+ @override
314
+ def get_reason_phrase(self) -> str:
315
+ return self._inner.get_reason_phrase()
316
+
317
+ @override
318
+ def with_status(self, code: int, reason_phrase: str = "") -> Self:
319
+ return self._around(self._inner.with_status(code, reason_phrase))
320
+
321
+ @override
322
+ def get_protocol_version(self) -> str:
323
+ return self._inner.get_protocol_version()
324
+
325
+ @override
326
+ def with_protocol_version(self, version: str) -> Self:
327
+ return self._around(self._inner.with_protocol_version(version))
328
+
329
+ @override
330
+ def get_headers(self) -> Mapping[str, Sequence[str]]:
331
+ return self._inner.get_headers()
332
+
333
+ @override
334
+ def has_header(self, name: str) -> bool:
335
+ return self._inner.has_header(name)
336
+
337
+ @override
338
+ def get_header(self, name: str) -> Sequence[str]:
339
+ return self._inner.get_header(name)
340
+
341
+ @override
342
+ def get_header_line(self, name: str) -> str:
343
+ return self._inner.get_header_line(name)
344
+
345
+ @override
346
+ def with_header(self, name: str, value: str | Sequence[str]) -> Self:
347
+ return self._around(self._inner.with_header(name, value))
348
+
349
+ @override
350
+ def with_added_header(self, name: str, value: str | Sequence[str]) -> Self:
351
+ return self._around(self._inner.with_added_header(name, value))
352
+
353
+ @override
354
+ def without_header(self, name: str) -> Self:
355
+ return self._around(self._inner.without_header(name))
356
+
357
+ @override
358
+ def get_body(self) -> StreamInterface:
359
+ return self._inner.get_body()
360
+
361
+ @override
362
+ def with_body(self, body: StreamInterface) -> Self:
363
+ return self._around(self._inner.with_body(body))
364
+
365
+ @override
366
+ def get_info(self, key: str | None = None) -> object:
367
+ return self._inner.get_info(key)
368
+
369
+ @override
370
+ async def cancel(self) -> None:
371
+ await self._inner.cancel()
372
+
373
+ @override
374
+ async def get_content(self, throw: bool = True) -> bytes:
375
+ content = await self._inner.get_content(throw)
376
+ self._trace["content"] = content
377
+
378
+ return content
379
+
380
+ @override
381
+ async def to_array(self, throw: bool = True) -> dict[str, object]:
382
+ # The bytes are written down before they are decoded, because they are
383
+ # what was read whether or not they turn out to be the JSON the caller
384
+ # read them as — and a body that would not decode is the one a trace is
385
+ # being read to explain.
386
+ self._trace["content"] = await self._inner.get_content(throw)
387
+
388
+ return await self._inner.to_array(throw)
389
+
390
+ @override
391
+ async def __aenter__(self) -> Self:
392
+ _ = await self._inner.__aenter__()
393
+
394
+ return self
395
+
396
+ @override
397
+ async def __aexit__(
398
+ self,
399
+ exc_type: type[BaseException] | None,
400
+ exc: BaseException | None,
401
+ traceback: TracebackType | None,
402
+ ) -> None:
403
+ await self._inner.__aexit__(exc_type, exc, traceback)
404
+
405
+ def _around(self, inner: HttpResponseInterface) -> Self:
406
+ return type(self)(inner, self._trace)