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,148 @@
1
+ """A response that also reports how many attempts it took to get it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
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
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+ from types import TracebackType
14
+
15
+ from xtr_http_message_contracts import StreamInterface
16
+
17
+ __all__ = ["with_retry_count"]
18
+
19
+
20
+ def with_retry_count(response: HttpResponseInterface, retry_count: int) -> HttpResponseInterface:
21
+ """``response`` with ``retry_count`` among what its transfer reports.
22
+
23
+ A response is handed over by whoever opened the transfer, and the record
24
+ it reads from is that transfer's own; a client that wrapped another has
25
+ nothing to write into it. So the count is added by wrapping the response
26
+ instead — everything but ``get_info`` is the wrapped response's, which
27
+ keeps the one body and the one record rather than copying either.
28
+
29
+ Args:
30
+ response: The answer the last attempt came back with.
31
+ retry_count: How many attempts were made again before this one
32
+ answered.
33
+
34
+ Returns:
35
+ A response reading exactly as ``response`` does, whose
36
+ ``get_info("retry_count")`` is ``retry_count``.
37
+ """
38
+ return _RetryCountResponse(response, retry_count)
39
+
40
+
41
+ @final
42
+ class _RetryCountResponse(HttpResponseInterface):
43
+ __slots__ = ("_inner", "_retry_count")
44
+
45
+ def __init__(self, inner: HttpResponseInterface, retry_count: int) -> None:
46
+ self._inner = inner
47
+ self._retry_count = retry_count
48
+
49
+ @override
50
+ def get_status_code(self) -> int:
51
+ return self._inner.get_status_code()
52
+
53
+ @override
54
+ def get_reason_phrase(self) -> str:
55
+ return self._inner.get_reason_phrase()
56
+
57
+ @override
58
+ def with_status(self, code: int, reason_phrase: str = "") -> Self:
59
+ return self._around(self._inner.with_status(code, reason_phrase))
60
+
61
+ @override
62
+ def get_protocol_version(self) -> str:
63
+ return self._inner.get_protocol_version()
64
+
65
+ @override
66
+ def with_protocol_version(self, version: str) -> Self:
67
+ return self._around(self._inner.with_protocol_version(version))
68
+
69
+ @override
70
+ def get_headers(self) -> Mapping[str, Sequence[str]]:
71
+ return self._inner.get_headers()
72
+
73
+ @override
74
+ def has_header(self, name: str) -> bool:
75
+ return self._inner.has_header(name)
76
+
77
+ @override
78
+ def get_header(self, name: str) -> Sequence[str]:
79
+ return self._inner.get_header(name)
80
+
81
+ @override
82
+ def get_header_line(self, name: str) -> str:
83
+ return self._inner.get_header_line(name)
84
+
85
+ @override
86
+ def with_header(self, name: str, value: str | Sequence[str]) -> Self:
87
+ return self._around(self._inner.with_header(name, value))
88
+
89
+ @override
90
+ def with_added_header(self, name: str, value: str | Sequence[str]) -> Self:
91
+ return self._around(self._inner.with_added_header(name, value))
92
+
93
+ @override
94
+ def without_header(self, name: str) -> Self:
95
+ return self._around(self._inner.without_header(name))
96
+
97
+ @override
98
+ def get_body(self) -> StreamInterface:
99
+ return self._inner.get_body()
100
+
101
+ @override
102
+ def with_body(self, body: StreamInterface) -> Self:
103
+ return self._around(self._inner.with_body(body))
104
+
105
+ @override
106
+ def get_info(self, key: str | None = None) -> object:
107
+ if key is None:
108
+ whole = self._inner.get_info()
109
+ entries = (
110
+ dict(cast("Mapping[str, object]", whole)) if isinstance(whole, Mapping) else {}
111
+ )
112
+ entries["retry_count"] = self._retry_count
113
+
114
+ return entries
115
+ if key == "retry_count":
116
+ return self._retry_count
117
+
118
+ return self._inner.get_info(key)
119
+
120
+ @override
121
+ async def cancel(self) -> None:
122
+ await self._inner.cancel()
123
+
124
+ @override
125
+ async def get_content(self, throw: bool = True) -> bytes:
126
+ return await self._inner.get_content(throw)
127
+
128
+ @override
129
+ async def to_array(self, throw: bool = True) -> dict[str, object]:
130
+ return await self._inner.to_array(throw)
131
+
132
+ @override
133
+ async def __aenter__(self) -> Self:
134
+ _ = await self._inner.__aenter__()
135
+
136
+ return self
137
+
138
+ @override
139
+ async def __aexit__(
140
+ self,
141
+ exc_type: type[BaseException] | None,
142
+ exc: BaseException | None,
143
+ traceback: TracebackType | None,
144
+ ) -> None:
145
+ await self._inner.__aexit__(exc_type, exc, traceback)
146
+
147
+ def _around(self, inner: HttpResponseInterface) -> Self:
148
+ return type(self)(inner, self._retry_count)
@@ -0,0 +1,45 @@
1
+ """How long the server itself said to wait before asking again."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from xtr_http_message_contracts import ResponseInterface
11
+
12
+ __all__ = ["retry_after_ms"]
13
+
14
+
15
+ def retry_after_ms(response: ResponseInterface) -> int | None:
16
+ """The wait a ``Retry-After`` header asks for, in milliseconds.
17
+
18
+ The header comes in two shapes — a number of seconds, or the date the
19
+ answer will be worth asking for again — and both are read, because which
20
+ one a server sends is the server's business. A date already past asks for
21
+ no wait at all rather than a negative one.
22
+
23
+ Args:
24
+ response: The answer that may be asking to be asked again later.
25
+
26
+ Returns:
27
+ The wait in milliseconds, or ``None`` when the header is absent or is
28
+ neither a number nor a date, which leaves the wait to the strategy.
29
+ """
30
+ after = response.get_header("retry-after")
31
+ if not after:
32
+ return None
33
+
34
+ value = after[0].strip()
35
+ try:
36
+ return int(float(value) * 1000)
37
+ except (OverflowError, ValueError):
38
+ pass
39
+
40
+ try:
41
+ when = parsedate_to_datetime(value)
42
+ except (TypeError, ValueError):
43
+ return None
44
+
45
+ return max(0, int(when.timestamp() - time.time())) * 1000
@@ -0,0 +1,98 @@
1
+ """The options a retrying client reads, rewrites and keeps to itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterable, Mapping, Sequence
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from xtr_http_message_contracts import RequestInterface
10
+
11
+ __all__ = ["base_uri_rotation", "replays_body", "replays_request", "retries", "with_base_uri"]
12
+
13
+
14
+ def base_uri_rotation(value: object) -> tuple[str, ...] | None:
15
+ """The base URIs an attempt moves along, or ``None`` when there is one URI.
16
+
17
+ Args:
18
+ value: What the ``base_uri`` option was given as.
19
+
20
+ Returns:
21
+ Every URI in order when several were named, or ``None`` when the
22
+ option is one URI, or none, which is for the wrapped client to resolve
23
+ against as it always does.
24
+ """
25
+ if isinstance(value, str) or not isinstance(value, Sequence):
26
+ return None
27
+
28
+ return tuple(str(uri) for uri in value)
29
+
30
+
31
+ def with_base_uri(
32
+ options: Mapping[str, object],
33
+ base_uris: Sequence[str],
34
+ attempt: int,
35
+ ) -> dict[str, object]:
36
+ """``options`` carrying the one base URI this attempt is to be made against.
37
+
38
+ Moving along the list is what makes several base URIs worth naming: the
39
+ first attempt goes to the first, the second to the second, and once the
40
+ list runs out every further attempt stays on the last — the alternative
41
+ being to come back to a URI that has already refused.
42
+
43
+ Args:
44
+ options: The options the request is being made with.
45
+ base_uris: The URIs to move along, empty when none were named.
46
+ attempt: How many attempts came before this one.
47
+
48
+ Returns:
49
+ The options to hand the wrapped client, whose ``base_uri`` is one URI
50
+ or is gone — a list of them is this client's to walk, and means
51
+ nothing to a client that sends one request.
52
+ """
53
+ sent = dict(options)
54
+ if base_uris:
55
+ sent["base_uri"] = base_uris[min(attempt, len(base_uris) - 1)]
56
+ elif base_uri_rotation(sent.get("base_uri")) is not None:
57
+ del sent["base_uri"]
58
+
59
+ return sent
60
+
61
+
62
+ def retries(value: object, fallback: int) -> int:
63
+ """How many times a request may be made again, as the option says or not.
64
+
65
+ Args:
66
+ value: What the ``max_retries`` option was given as.
67
+ fallback: What the client was built with, used when the option says
68
+ nothing.
69
+
70
+ Returns:
71
+ The number of retries allowed.
72
+ """
73
+ if isinstance(value, bool) or not isinstance(value, int):
74
+ return fallback
75
+
76
+ return value
77
+
78
+
79
+ def replays_body(options: Mapping[str, object]) -> bool:
80
+ """Whether the body in ``options`` can be sent a second time.
81
+
82
+ A body produced as it goes is gone once it has gone out: whatever
83
+ produced it has been drained, and there is nothing left to send again. A
84
+ request carrying one is made once, because a retry would send a request
85
+ with no body at all rather than the request that failed.
86
+ """
87
+ return not isinstance(options.get("body"), AsyncIterable)
88
+
89
+
90
+ def replays_request(request: RequestInterface) -> bool:
91
+ """Whether the body of ``request`` can be sent a second time.
92
+
93
+ A stream that cannot be rewound has the same problem as a body produced
94
+ as it goes: it has been read to its end by the attempt that failed.
95
+ """
96
+ body = request.get_body()
97
+
98
+ return body.get_size() == 0 or body.is_seekable()
@@ -0,0 +1,253 @@
1
+ """The client that answers from a script, so a test never leaves the process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping
6
+ from typing import TYPE_CHECKING, Final, cast, final
7
+
8
+ from typing_extensions import Unpack, override
9
+ from xtr_http_client_contracts import HttpClientInterface, RequestOptions
10
+ from xtr_http_message import Request
11
+ from xtr_service_contracts import ResetInterface
12
+
13
+ from .exception import InvalidArgumentError, TransportError
14
+ from .http_client_mixin import HttpClientMixin
15
+ from .internal.debug_type import debug_type
16
+ from .internal.httpx_request import target_url
17
+ from .response.mock_response import MockResponse
18
+
19
+ if TYPE_CHECKING:
20
+ from xtr_http_message_contracts import RequestInterface
21
+
22
+ __all__ = ["MockHttpClient", "ResponseFactory"]
23
+
24
+ ScriptedFactory = Callable[[str, str, RequestOptions], "MockResponse | Awaitable[MockResponse]"]
25
+ """Something called with a request, that says what is to come back for it."""
26
+
27
+ ResponseFactory = MockResponse | ScriptedFactory | Iterable[MockResponse | ScriptedFactory]
28
+ """Where a scripted client's answers come from: one, many, or one per request."""
29
+
30
+ _EMPTY: Final = "The response factory iterator passed to MockHttpClient is empty."
31
+ _EXHAUSTED: Final = (
32
+ "No more response left in the response factory iterator passed to MockHttpClient: "
33
+ "the number of requests exceeds the number of responses."
34
+ )
35
+
36
+ # Nothing a factory could legitimately be, so it can stand for "the iterator
37
+ # had nothing left" without `None` — a perfectly good thing to yield — meaning
38
+ # that too.
39
+ _NOTHING: Final = object()
40
+
41
+
42
+ @final
43
+ class MockHttpClient(HttpClientMixin, HttpClientInterface, ResetInterface):
44
+ """Answers every request from what a test wrote out, and opens no connection.
45
+
46
+ It is the same client as any other here — the options it refuses, the
47
+ defaults it starts from and the way a request and its options are merged
48
+ are all the ones a real transfer uses — with the transfer replaced by a
49
+ script. So a test exercises the code that calls out, down to the options
50
+ it passed, without a server to point it at.
51
+
52
+ ```python
53
+ client = MockHttpClient([MockResponse(b"first"), MockResponse(b"second")])
54
+ async with await client.request("GET", "/orders") as response:
55
+ assert await response.get_content() == b"first"
56
+ assert client.get_requests_count() == 1
57
+ ```
58
+
59
+ What the answers come from is whatever a test finds easiest to write:
60
+
61
+ - nothing, and every request is answered with an empty 200;
62
+ - one :class:`MockResponse <xtr_http_client.response.mock_response.MockResponse>`,
63
+ which answers one request, and a second request is a mistake, reported
64
+ as the script running out;
65
+ - an iterable of them, one per request, in order — a request past the end
66
+ of it is a mistake and is reported as one, since a test that makes more
67
+ requests than it wrote answers for is no longer testing what it says;
68
+ - a callable, which is handed the method, the URL and the merged options
69
+ and says what to answer with — the way to assert on the request that was
70
+ made, and to answer differently depending on it. It may be async.
71
+
72
+ An iterable may hold callables too, so one request in a sequence can be
73
+ answered that way while the rest are written out.
74
+
75
+ Nothing is followed and nothing is resolved: a 3xx is the answer, and a
76
+ relative URL is resolved against ``base_uri`` — which starts at
77
+ ``https://example.com`` so a test can say ``/orders`` and mean something.
78
+ """
79
+
80
+ __slots__ = ("_factory", "_requests_count")
81
+
82
+ _factory: ScriptedFactory | Iterator[MockResponse | ScriptedFactory] | None
83
+
84
+ def __init__(
85
+ self,
86
+ response_factory: ResponseFactory | None = None,
87
+ base_uri: str | None = "https://example.com",
88
+ ) -> None:
89
+ """Start from ``response_factory``, which nothing is taken from until asked.
90
+
91
+ Args:
92
+ response_factory: Where the answers come from.
93
+ base_uri: What a relative URL is resolved against. ``None`` leaves
94
+ a relative URL unresolvable, which is then refused.
95
+ """
96
+ super().__init__({"base_uri": base_uri})
97
+ self._requests_count = 0
98
+ self.set_response_factory(response_factory)
99
+
100
+ def set_response_factory(self, response_factory: ResponseFactory | None = None) -> None:
101
+ """Answer from ``response_factory`` from now on.
102
+
103
+ What was left of the previous one is dropped, and the count of
104
+ requests already made is kept: a test that changes its mind about the
105
+ answers has not unmade the requests.
106
+
107
+ Args:
108
+ response_factory: Where the answers come from.
109
+ """
110
+ if isinstance(response_factory, MockResponse):
111
+ # A single answer is one item of a script, so it answers one request.
112
+ self._factory = iter([response_factory])
113
+ elif isinstance(response_factory, Iterable):
114
+ # Taken one at a time from here on, so a generator is not drawn
115
+ # on until the request its answer is for.
116
+ self._factory = iter(response_factory)
117
+ else:
118
+ self._factory = response_factory
119
+
120
+ def get_requests_count(self) -> int:
121
+ """Return how many requests this client has been asked to make."""
122
+ return self._requests_count
123
+
124
+ @override
125
+ async def reset(self) -> None:
126
+ """Forget how many requests were made, leaving the answers as they are."""
127
+ self._requests_count = 0
128
+
129
+ @override
130
+ async def request(
131
+ self,
132
+ method: str,
133
+ url: str,
134
+ **options: Unpack[RequestOptions],
135
+ ) -> MockResponse:
136
+ """Answer a request for ``method`` and ``url`` from the script.
137
+
138
+ Args:
139
+ method: The HTTP method, in upper case.
140
+ url: The URL to request, absolute or relative to ``base_uri``.
141
+ **options: How the request would have been made.
142
+
143
+ Returns:
144
+ The scripted answer, issued for this request.
145
+
146
+ Raises:
147
+ InvalidArgumentError: For an invalid method, URL or option.
148
+ TransportError: When the script says the transfer fails, or has
149
+ no answer left to give.
150
+ """
151
+ self.validate_method(method)
152
+
153
+ return await self.send_request(Request(method, url), **options)
154
+
155
+ @override
156
+ async def send_request(
157
+ self,
158
+ request: RequestInterface,
159
+ **options: Unpack[RequestOptions],
160
+ ) -> MockResponse:
161
+ """Answer ``request`` from the script, the options filling what it leaves out.
162
+
163
+ Args:
164
+ request: The request that would have been sent.
165
+ **options: What the request leaves out.
166
+
167
+ Returns:
168
+ The scripted answer, issued for this request.
169
+
170
+ Raises:
171
+ InvalidArgumentError: For an invalid method, URL or option, and
172
+ for a body given both with the request and as an option.
173
+ TransportError: When the script says the transfer fails, has no
174
+ answer left to give, or gives something that is not an answer.
175
+ """
176
+ self.validate_options(options)
177
+ self.validate_method(request.get_method())
178
+ merged = self.merge_default_options(options, self._default_options)
179
+ self.validate_auth(merged)
180
+ self.validate_body(merged)
181
+
182
+ method = request.get_method()
183
+ url = _resolved(request, merged)
184
+ answer = await self._scripted(method, url, merged)
185
+ self._requests_count += 1
186
+
187
+ if not isinstance(answer, MockResponse):
188
+ raise TransportError(
189
+ "The response factory passed to MockHttpClient must return/yield an "
190
+ f'instance of MockResponse, "{debug_type(answer)}" given.'
191
+ )
192
+
193
+ return answer.issue(method, url, merged)
194
+
195
+ async def _scripted(self, method: str, url: str, options: Mapping[str, object]) -> object:
196
+ """Whatever the script offers for this request, answer or not."""
197
+ factory = self._factory
198
+
199
+ if factory is None:
200
+ return MockResponse()
201
+ if callable(factory):
202
+ return await _called(factory, method, url, options)
203
+
204
+ return await _called(_taken(factory, self._requests_count), method, url, options)
205
+
206
+
207
+ def _taken(factory: Iterator[object], made: int) -> object:
208
+ """The next thing the script offers.
209
+
210
+ Raises:
211
+ TransportError: When there is nothing left — which is a test that
212
+ wrote no answers at all, or fewer than it makes requests, and
213
+ those are two different mistakes.
214
+ """
215
+ offered = next(factory, _NOTHING)
216
+ if offered is _NOTHING:
217
+ raise TransportError(_EXHAUSTED if made else _EMPTY)
218
+
219
+ return offered
220
+
221
+
222
+ async def _called(
223
+ offered: object,
224
+ method: str,
225
+ url: str,
226
+ options: Mapping[str, object],
227
+ ) -> object:
228
+ """``offered`` itself, or what it answers with when it is something to call."""
229
+ if not callable(offered):
230
+ return offered
231
+
232
+ said = offered(method, url, cast("RequestOptions", options))
233
+ if isinstance(said, Awaitable):
234
+ return await said
235
+
236
+ return said
237
+
238
+
239
+ def _resolved(request: RequestInterface, options: Mapping[str, object]) -> str:
240
+ """The URL the request is for, resolved and with ``query`` merged in.
241
+
242
+ Raises:
243
+ InvalidArgumentError: When the URL is relative and there is no base to
244
+ resolve it against, since what was asked for then names nothing.
245
+ """
246
+ url = target_url(request, options)
247
+ if url.is_relative_url:
248
+ raise InvalidArgumentError(
249
+ f'Invalid URL "{url}": it is relative, and no "base_uri" option '
250
+ "says what to resolve it against."
251
+ )
252
+
253
+ return str(url)