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,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)
|