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,303 @@
1
+ """The client that picks a request's defaults from the URL it is for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING, cast, final
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ from typing_extensions import Unpack, override
11
+ from xtr_http_client_contracts import HttpClientInterface
12
+ from xtr_http_message import Uri
13
+ from xtr_service_contracts import ResetInterface
14
+
15
+ from .exception import InvalidArgumentError
16
+ from .internal.option_merge import as_options
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+ from typing import Self
21
+
22
+ from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
23
+ from xtr_http_message_contracts import RequestInterface
24
+
25
+ __all__ = ["ScopingHttpClient"]
26
+
27
+
28
+ @final
29
+ class ScopingHttpClient(HttpClientInterface, ResetInterface):
30
+ r"""Wraps a client and gives each request the options its URL falls under.
31
+
32
+ One application talks to several services, and each wants its own
33
+ credentials, headers and deadlines. Written at the call sites, that is the
34
+ same four options repeated wherever a service is reached, and one place
35
+ that forgot them. Here it is said once: a scope is a regular expression
36
+ the start of a URL is matched against, and the options every request
37
+ landing in it starts from.
38
+
39
+ ```python
40
+ client = ScopingHttpClient(
41
+ inner,
42
+ {r"https://api\.example\.com/": {"auth_bearer": "t0ken"}},
43
+ )
44
+ response = await client.request("GET", "https://api.example.com/orders")
45
+ ```
46
+
47
+ What the request itself asked for always wins — a scope fills in what was
48
+ left out, it never overrides. ``headers`` is the one option merged rather
49
+ than replaced, name by name, so a scope-wide ``Accept`` survives a request
50
+ setting a ``Content-Type`` of its own.
51
+
52
+ With a ``default_regexp``, that scope's ``base_uri`` also resolves a
53
+ relative URL, which is what makes :meth:`for_base_uri` read as "this
54
+ client is that service".
55
+ """
56
+
57
+ __slots__ = ("_by_regexp", "_client", "_default_regexp")
58
+
59
+ def __init__(
60
+ self,
61
+ client: HttpClientInterface,
62
+ default_options_by_regexp: Mapping[str, RequestOptions],
63
+ default_regexp: str | None = None,
64
+ ) -> None:
65
+ """Scope ``client``, matching a URL against each regexp in turn.
66
+
67
+ Args:
68
+ client: The client every vetted request is handed to.
69
+ default_options_by_regexp: Each scope's pattern mapped to the
70
+ options a request falling in it starts from. The first
71
+ pattern matching the start of a URL is the one that applies,
72
+ so order them from the most specific to the least.
73
+ default_regexp: Which of those scopes a relative URL belongs to,
74
+ and whose ``base_uri`` resolves it. ``None`` leaves a
75
+ relative URL to the wrapped client.
76
+
77
+ Raises:
78
+ InvalidArgumentError: When ``default_regexp`` names a scope that
79
+ is not in ``default_options_by_regexp`` — a default scope
80
+ with no options is a client whose relative URLs quietly
81
+ resolve against nothing.
82
+ """
83
+ if default_regexp is not None and default_regexp not in default_options_by_regexp:
84
+ raise InvalidArgumentError(
85
+ f'No options are mapped to the provided "{default_regexp}" default regexp.'
86
+ )
87
+
88
+ self._client = client
89
+ self._by_regexp = dict(default_options_by_regexp)
90
+ self._default_regexp = default_regexp
91
+
92
+ @classmethod
93
+ def for_base_uri(
94
+ cls,
95
+ client: HttpClientInterface,
96
+ base_uri: str,
97
+ default_options: RequestOptions | None = None,
98
+ regexp: str | None = None,
99
+ ) -> Self:
100
+ """Scope ``client`` to one service, reached at ``base_uri``.
101
+
102
+ Args:
103
+ client: The client every request is handed to.
104
+ base_uri: What a relative URL resolves against, and — unless
105
+ ``regexp`` says otherwise — what a URL must start with to be
106
+ in scope.
107
+ default_options: What a request in scope starts from.
108
+ regexp: The scope's pattern, for a service whose URLs are not all
109
+ under one base.
110
+
111
+ Returns:
112
+ A client scoped to that one base URI.
113
+ """
114
+ return cls._scoped(client, [base_uri], default_options, regexp)
115
+
116
+ @classmethod
117
+ def for_base_uris(
118
+ cls,
119
+ client: HttpClientInterface,
120
+ base_uris: Sequence[str],
121
+ default_options: RequestOptions | None = None,
122
+ regexp: str | None = None,
123
+ ) -> Self:
124
+ """Scope ``client`` to one service reachable at several base URIs.
125
+
126
+ The scope matches all of them, so the options keep applying whichever
127
+ one a request is resolved against; the first is what a relative URL
128
+ resolves against.
129
+
130
+ Args:
131
+ client: The client every request is handed to.
132
+ base_uris: Where the service answers, the first being the default.
133
+ default_options: What a request in scope starts from.
134
+ regexp: The scope's pattern, replacing the one built from them.
135
+
136
+ Returns:
137
+ A client scoped to all of those base URIs.
138
+
139
+ Raises:
140
+ InvalidArgumentError: When no base URI is given, since a scope
141
+ over nothing would match nothing and resolve nothing.
142
+ """
143
+ uris = list(base_uris)
144
+ if not uris:
145
+ raise InvalidArgumentError("At least one base URI must be provided.")
146
+
147
+ return cls._scoped(client, uris, default_options, regexp)
148
+
149
+ @override
150
+ async def request(
151
+ self,
152
+ method: str,
153
+ url: str,
154
+ **options: Unpack[RequestOptions],
155
+ ) -> HttpResponseInterface:
156
+ """Make a request for ``method`` and ``url``, under the scope it falls in.
157
+
158
+ Args:
159
+ method: The HTTP method, in upper case.
160
+ url: The URL to request, absolute or relative to a ``base_uri``.
161
+ **options: How the request is to be made; the scope fills in what
162
+ is left out.
163
+
164
+ Returns:
165
+ Whatever the wrapped client answers.
166
+
167
+ Raises:
168
+ InvalidArgumentError: For an invalid method, URL or option.
169
+ TransportError: When no answer came.
170
+ """
171
+ target, scoped = self._scope(url, options)
172
+
173
+ return await self._client.request(method, target, **scoped)
174
+
175
+ @override
176
+ async def send_request(
177
+ self,
178
+ request: RequestInterface,
179
+ **options: Unpack[RequestOptions],
180
+ ) -> HttpResponseInterface:
181
+ """Send ``request`` under the scope the URI it carries falls in.
182
+
183
+ Args:
184
+ request: The request to send; it goes on carrying the URL this
185
+ client resolved for it.
186
+ **options: What the request leaves out; the scope fills in what
187
+ both leave out.
188
+
189
+ Returns:
190
+ Whatever the wrapped client answers.
191
+
192
+ Raises:
193
+ InvalidArgumentError: For an invalid method, URL or option.
194
+ TransportError: When no answer came.
195
+ """
196
+ target, scoped = self._scope(str(request.get_uri()), options)
197
+
198
+ return await self._client.send_request(request.with_uri(Uri(target)), **scoped)
199
+
200
+ @override
201
+ def with_options(self, **options: Unpack[RequestOptions]) -> Self:
202
+ """Return a copy around a wrapped client whose defaults have ``options`` over them.
203
+
204
+ The scopes are kept as they are: they say what belongs to which
205
+ service, which is not something a caller narrowing a client for one
206
+ job is changing.
207
+
208
+ Args:
209
+ **options: The defaults of the wrapped client of the copy.
210
+
211
+ Returns:
212
+ A new client; this one is left as it was.
213
+
214
+ Raises:
215
+ InvalidArgumentError: When an option is invalid.
216
+ """
217
+ return type(self)(
218
+ self._client.with_options(**options),
219
+ self._by_regexp,
220
+ self._default_regexp,
221
+ )
222
+
223
+ @override
224
+ async def reset(self) -> None:
225
+ """Reset the wrapped client, if it holds state between units of work.
226
+
227
+ The scopes are configuration, not state, so they are kept.
228
+ """
229
+ if isinstance(self._client, ResetInterface):
230
+ await self._client.reset()
231
+
232
+ @classmethod
233
+ def _scoped(
234
+ cls,
235
+ client: HttpClientInterface,
236
+ base_uris: list[str],
237
+ default_options: RequestOptions | None,
238
+ regexp: str | None,
239
+ ) -> Self:
240
+ """One scope over ``base_uris``, which is also the default one."""
241
+ pattern = regexp if regexp is not None else "|".join(_in_scope(uri) for uri in base_uris)
242
+ options: Mapping[str, object] = {**(default_options or {}), "base_uri": base_uris[0]}
243
+
244
+ return cls(client, {pattern: as_options(options)}, pattern)
245
+
246
+ def _scope(
247
+ self,
248
+ url: str,
249
+ options: Mapping[str, object],
250
+ ) -> tuple[str, RequestOptions]:
251
+ """The URL to request and the options to make it with, scope included."""
252
+ given = dict(options)
253
+ base = given.get("base_uri")
254
+
255
+ if isinstance(base, str):
256
+ url = urljoin(base, url)
257
+ del given["base_uri"]
258
+ elif not urlsplit(url).scheme and self._default_regexp is not None:
259
+ default = self._by_regexp[self._default_regexp].get("base_uri")
260
+ if isinstance(default, str):
261
+ url = urljoin(default, url)
262
+
263
+ for pattern, defaults in self._by_regexp.items():
264
+ if re.match(pattern, url):
265
+ given = _under(given, defaults)
266
+ break
267
+
268
+ return url, as_options(given)
269
+
270
+
271
+ def _in_scope(base_uri: str) -> str:
272
+ """The pattern matching every URL under ``base_uri``, taken literally."""
273
+ return re.escape(urljoin(base_uri, "."))
274
+
275
+
276
+ def _under(given: dict[str, object], defaults: Mapping[str, object]) -> dict[str, object]:
277
+ """``defaults`` under ``given``: an option the request named is the one used."""
278
+ merged = dict(given)
279
+ for name, value in defaults.items():
280
+ _ = merged.setdefault(name, value)
281
+
282
+ headers = _headers(given.get("headers"))
283
+ scoped = _headers(defaults.get("headers"))
284
+ if headers is not None and scoped is not None:
285
+ # Name by name rather than whole, so a request setting one header
286
+ # still sends the scope's others; the request's come first, as they
287
+ # are the ones that were asked for here. Names are case-insensitive
288
+ # on the wire, so ``accept`` from the request hides a scope's ``Accept``.
289
+ taken = {name.lower() for name in headers}
290
+ merged["headers"] = {
291
+ **headers,
292
+ **{n: v for n, v in scoped.items() if n.lower() not in taken},
293
+ }
294
+
295
+ return merged
296
+
297
+
298
+ def _headers(value: object) -> Mapping[str, object] | None:
299
+ """``value`` as the ``headers`` option, or ``None`` when it is not one."""
300
+ if isinstance(value, Mapping):
301
+ return cast("Mapping[str, object]", value)
302
+
303
+ return None
@@ -0,0 +1,143 @@
1
+ """The client that holds a request back until the rate it is under allows it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import Unpack, override
8
+ from xtr_http_client_contracts import HttpClientInterface
9
+ from xtr_service_contracts import ResetInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from typing import Self
13
+
14
+ from xtr_http_client_contracts import HttpResponseInterface, RequestOptions
15
+ from xtr_http_message_contracts import RequestInterface
16
+ from xtr_rate_limiter import LimiterInterface
17
+
18
+ __all__ = ["ThrottlingHttpClient"]
19
+
20
+
21
+ @final
22
+ class ThrottlingHttpClient(HttpClientInterface, ResetInterface):
23
+ """Wraps a client and lets each request go only once the rate allows it.
24
+
25
+ An API that answers a hundred calls a minute answers the hundred and
26
+ first with a refusal, and a caller that keeps asking is throttled by the
27
+ service it is calling rather than by itself. Written at the call site
28
+ that is a counter and a sleep around every request; here it is the client,
29
+ so the code calling out is unchanged and the pace is kept in one place —
30
+ including between several callers, when the limiter they share counts
31
+ where they can all see it.
32
+
33
+ ```python
34
+ client = ThrottlingHttpClient(inner, limiter)
35
+ async with await client.request("GET", "https://api.example.com/orders") as response:
36
+ orders = await response.to_array()
37
+ ```
38
+
39
+ A token is booked before each request, and the request waits for the
40
+ moment that booking is due. Booking first and waiting after is what makes
41
+ the pace hold under concurrency: the limiter counts the token as spent as
42
+ soon as it is reserved, so requests leave in the order they were asked
43
+ for, each at its own turn, rather than all at once as a limiter merely
44
+ asked whether it may go ahead would allow.
45
+
46
+ The limiter is the pace, so a copy made by :meth:`with_options` shares it:
47
+ narrowing a client for one job hands out another way of making requests,
48
+ not another allowance to make them.
49
+ """
50
+
51
+ __slots__ = ("_client", "_limiter")
52
+
53
+ def __init__(self, client: HttpClientInterface, limiter: LimiterInterface) -> None:
54
+ """Pace what ``client`` sends by ``limiter``.
55
+
56
+ Args:
57
+ client: The client every request is handed to, once it is due.
58
+ limiter: What a token is booked from before each request. Which
59
+ requests share a pace is decided by which of them are given
60
+ the same limiter — one per host, one per account, one for
61
+ the whole application.
62
+ """
63
+ self._client = client
64
+ self._limiter = limiter
65
+
66
+ @override
67
+ async def request(
68
+ self,
69
+ method: str,
70
+ url: str,
71
+ **options: Unpack[RequestOptions],
72
+ ) -> HttpResponseInterface:
73
+ """Make a request for ``method`` and ``url``, once a token is due.
74
+
75
+ Args:
76
+ method: The HTTP method, in upper case.
77
+ url: The URL to request, absolute or relative to a ``base_uri``.
78
+ **options: How the request is to be made.
79
+
80
+ Returns:
81
+ Whatever the wrapped client answers.
82
+
83
+ Raises:
84
+ InvalidArgumentError: For an invalid method, URL or option.
85
+ TransportError: When no answer came.
86
+ """
87
+ await self._due()
88
+
89
+ return await self._client.request(method, url, **options)
90
+
91
+ @override
92
+ async def send_request(
93
+ self,
94
+ request: RequestInterface,
95
+ **options: Unpack[RequestOptions],
96
+ ) -> HttpResponseInterface:
97
+ """Send ``request``, once a token is due.
98
+
99
+ Args:
100
+ request: The request to send.
101
+ **options: What the request leaves out.
102
+
103
+ Returns:
104
+ Whatever the wrapped client answers.
105
+
106
+ Raises:
107
+ InvalidArgumentError: For an invalid method, URL or option.
108
+ TransportError: When no answer came.
109
+ """
110
+ await self._due()
111
+
112
+ return await self._client.send_request(request, **options)
113
+
114
+ @override
115
+ def with_options(self, **options: Unpack[RequestOptions]) -> Self:
116
+ """Return a copy around a wrapped client whose defaults have ``options`` over them.
117
+
118
+ Args:
119
+ **options: The defaults of the wrapped client of the copy.
120
+
121
+ Returns:
122
+ A new client, paced by this one's limiter, so requests made
123
+ through either count against the same rate; this one is left as
124
+ it was.
125
+
126
+ Raises:
127
+ InvalidArgumentError: When an option is invalid.
128
+ """
129
+ return type(self)(self._client.with_options(**options), self._limiter)
130
+
131
+ @override
132
+ async def reset(self) -> None:
133
+ """Forget every request counted against the rate, and reset the wrapped client."""
134
+ await self._limiter.reset()
135
+
136
+ if isinstance(self._client, ResetInterface):
137
+ await self._client.reset()
138
+
139
+ async def _due(self) -> None:
140
+ """Book a token and wait for the moment it may be spent."""
141
+ reservation = await self._limiter.reserve()
142
+
143
+ await reservation.wait()