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,144 @@
|
|
|
1
|
+
"""The head of a scripted answer, read out of the info a script was written with.
|
|
2
|
+
|
|
3
|
+
A scripted response is written as a body and a mapping of what the transfer is
|
|
4
|
+
to claim about itself. The status line, the protocol version and the headers
|
|
5
|
+
are all in that mapping, under one entry and in any of three shapes, so reading
|
|
6
|
+
them out is one decision made here rather than at each place that needs one of
|
|
7
|
+
them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from typing import TYPE_CHECKING, Final, cast
|
|
15
|
+
|
|
16
|
+
from xtr_http_message import Response
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
20
|
+
|
|
21
|
+
__all__ = ["header_lines", "response_header_lines", "scripted_message"]
|
|
22
|
+
|
|
23
|
+
# "HTTP/1.1 404 Not Found": what a hop begins with, and the one line in the
|
|
24
|
+
# headers that is not a header.
|
|
25
|
+
_STATUS_LINE: Final = re.compile(r"^HTTP/(\d+(?:\.\d+)?) (\d{3})(?: (.*))?$")
|
|
26
|
+
|
|
27
|
+
_DEFAULT_STATUS: Final = 200
|
|
28
|
+
_DEFAULT_VERSION: Final = "1.1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scripted_message(info: Mapping[str, object], length: int | None) -> Response:
|
|
32
|
+
"""The status line and headers a scripted answer arrives with.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
info: What the script says the transfer claims about itself.
|
|
36
|
+
``http_code`` is the status, and ``response_headers`` the headers —
|
|
37
|
+
which a status line among them overrides, since a line the script
|
|
38
|
+
wrote out says the version and the reason phrase as well.
|
|
39
|
+
length: The real length of the body, when the body is one that has a
|
|
40
|
+
length. ``Content-Length`` is then set to it, so a script that
|
|
41
|
+
claims a length its body does not have cannot leave a reader
|
|
42
|
+
waiting for bytes that are never coming. ``None`` — a body
|
|
43
|
+
produced piece by piece — leaves whatever was claimed.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The message the answer is read from.
|
|
47
|
+
"""
|
|
48
|
+
status = _status(info.get("http_code"))
|
|
49
|
+
version = _DEFAULT_VERSION
|
|
50
|
+
reason: str | None = None
|
|
51
|
+
headers: dict[str, list[str]] = {}
|
|
52
|
+
|
|
53
|
+
for line in response_header_lines(info.get("response_headers")):
|
|
54
|
+
hop = _STATUS_LINE.match(line)
|
|
55
|
+
if hop is not None:
|
|
56
|
+
# No phrase at all (`None`) asked for the recommended one; an
|
|
57
|
+
# empty phrase after the space asked for none, and gets none.
|
|
58
|
+
version, status, reason = hop[1], int(hop[2]), hop[3]
|
|
59
|
+
# A new hop answers for itself: the headers before it were the
|
|
60
|
+
# previous one's.
|
|
61
|
+
headers.clear()
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
name, separator, value = line.partition(":")
|
|
65
|
+
if separator:
|
|
66
|
+
headers.setdefault(_first_cased(headers, name), []).append(value.lstrip())
|
|
67
|
+
|
|
68
|
+
if length is not None:
|
|
69
|
+
headers[_first_cased(headers, "Content-Length")] = [str(length)]
|
|
70
|
+
|
|
71
|
+
return Response(status, headers, None, version, reason)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def response_header_lines(value: object) -> list[str]:
|
|
75
|
+
"""Headers written in any of the three shapes a script may write them, as lines.
|
|
76
|
+
|
|
77
|
+
A mapping is the shape a header is named in; a sequence of lines is the
|
|
78
|
+
shape a transfer reports, and the only one that can carry a status line;
|
|
79
|
+
a single string is one of those lines on its own.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
value: What the script gave for ``response_headers``.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
One line per value, each ``"Name: value"`` or a status line.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
return [value]
|
|
89
|
+
if isinstance(value, Mapping):
|
|
90
|
+
mapping = cast("Mapping[object, object]", value)
|
|
91
|
+
|
|
92
|
+
return [f"{name}: {one}" for name, values in mapping.items() for one in _each(values)]
|
|
93
|
+
if isinstance(value, Iterable):
|
|
94
|
+
return [str(line) for line in value]
|
|
95
|
+
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def header_lines(message: ResponseInterface) -> list[str]:
|
|
100
|
+
"""The raw status and header lines a message would have arrived as.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
message: The message the answer is read from.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The status line, then one line per header value.
|
|
107
|
+
"""
|
|
108
|
+
status_line = (
|
|
109
|
+
f"HTTP/{message.get_protocol_version()} "
|
|
110
|
+
f"{message.get_status_code()} {message.get_reason_phrase()}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
status_line,
|
|
115
|
+
*(f"{name}: {value}" for name, values in message.get_headers().items() for value in values),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _each(values: object) -> list[str]:
|
|
120
|
+
"""Every value given for one header name."""
|
|
121
|
+
if isinstance(values, str):
|
|
122
|
+
return [values]
|
|
123
|
+
if isinstance(values, Sequence):
|
|
124
|
+
return [str(one) for one in values]
|
|
125
|
+
|
|
126
|
+
return [str(values)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _status(value: object) -> int:
|
|
130
|
+
"""The status the script claims, or the one an answer has when nobody said."""
|
|
131
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
132
|
+
return _DEFAULT_STATUS
|
|
133
|
+
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _first_cased(headers: Mapping[str, list[str]], name: str) -> str:
|
|
138
|
+
"""The case a header name was first seen in, so a repeat does not split it."""
|
|
139
|
+
wanted = name.lower()
|
|
140
|
+
for seen in headers:
|
|
141
|
+
if seen.lower() == wanted:
|
|
142
|
+
return seen
|
|
143
|
+
|
|
144
|
+
return name
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""What a scripted transfer knows about itself, once a client has issued it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from .mock_head import header_lines
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
14
|
+
|
|
15
|
+
__all__ = ["issued_info"]
|
|
16
|
+
|
|
17
|
+
# The entries the client fills in, whatever the script says: a script cannot
|
|
18
|
+
# know the method, the URL or the options it is going to answer, and the rest
|
|
19
|
+
# are what sending it made true. Everything else a script wrote is carried
|
|
20
|
+
# through untouched, which is how a test puts an entry of its own on a
|
|
21
|
+
# response.
|
|
22
|
+
_FILLED_IN: Final = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"canceled",
|
|
25
|
+
"error",
|
|
26
|
+
"http_code",
|
|
27
|
+
"http_method",
|
|
28
|
+
"redirect_count",
|
|
29
|
+
"redirect_url",
|
|
30
|
+
"response_headers",
|
|
31
|
+
"start_time",
|
|
32
|
+
"total_time",
|
|
33
|
+
"url",
|
|
34
|
+
"user_data",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def issued_info(
|
|
40
|
+
script: Mapping[str, object],
|
|
41
|
+
message: ResponseInterface,
|
|
42
|
+
method: str,
|
|
43
|
+
url: str,
|
|
44
|
+
options: Mapping[str, object],
|
|
45
|
+
) -> dict[str, object]:
|
|
46
|
+
"""The record of a scripted transfer, as it stands the moment it is handed back.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
script: What the response was written with, whose entries outside the
|
|
50
|
+
ones filled in here are kept as they were given.
|
|
51
|
+
message: The status line and headers the answer arrives with.
|
|
52
|
+
method: The method the request was made with.
|
|
53
|
+
url: The URL it was made to, resolved.
|
|
54
|
+
options: The options it was made with, merged.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Every entry a caller can read off the response.
|
|
58
|
+
"""
|
|
59
|
+
given_start = _reading(script.get("start_time"))
|
|
60
|
+
start_time = time.time() if given_start is None else given_start
|
|
61
|
+
lines = header_lines(message)
|
|
62
|
+
total_time = _reading(script.get("total_time"))
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
**{name: value for name, value in script.items() if name not in _FILLED_IN},
|
|
66
|
+
"canceled": False,
|
|
67
|
+
"error": None,
|
|
68
|
+
"http_code": message.get_status_code(),
|
|
69
|
+
"http_method": method,
|
|
70
|
+
# Nothing is followed: a script answers the request that was made.
|
|
71
|
+
"redirect_count": 0,
|
|
72
|
+
"redirect_url": None,
|
|
73
|
+
"response_headers": lines,
|
|
74
|
+
"start_time": start_time,
|
|
75
|
+
"total_time": total_time if total_time is not None else time.time() - start_time,
|
|
76
|
+
"url": url,
|
|
77
|
+
"user_data": options.get("user_data"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _reading(value: object) -> float | None:
|
|
82
|
+
"""A clock reading the script gave, or ``None`` when it gave none."""
|
|
83
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return float(value)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""What a request asked for, over what its client always asks for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from xtr_http_client.exception import InvalidArgumentError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from xtr_http_client_contracts import RequestOptions
|
|
14
|
+
|
|
15
|
+
__all__ = ["as_options", "merge_default_options", "validate_options"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def as_options(options: Mapping[str, object]) -> RequestOptions:
|
|
19
|
+
"""``options`` read as the typed options, for handing on as keyword arguments.
|
|
20
|
+
|
|
21
|
+
A client that builds options name by name — adding one, dropping another —
|
|
22
|
+
holds them as a plain mapping, which is the same thing the contract types
|
|
23
|
+
key by key. This says so once, rather than at every call that passes them
|
|
24
|
+
on.
|
|
25
|
+
"""
|
|
26
|
+
return cast("RequestOptions", options)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def merge_default_options(
|
|
30
|
+
client_name: str,
|
|
31
|
+
options: Mapping[str, object],
|
|
32
|
+
default_options: Mapping[str, object],
|
|
33
|
+
) -> dict[str, object]:
|
|
34
|
+
"""Merge what a request asked for over what its client always asks for.
|
|
35
|
+
|
|
36
|
+
An option the request named wins whole — there is no merging *inside* an
|
|
37
|
+
option, because half a timeout or half a body means nothing. ``extra`` and
|
|
38
|
+
``resolve`` are the exceptions, and merge by key: an entry the defaults
|
|
39
|
+
carry that the request did not mention is kept, which is what makes a
|
|
40
|
+
client-wide setting work, while one it did mention is the request's.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
client_name: The client the options are for, named in the message when
|
|
44
|
+
one of them is not supported.
|
|
45
|
+
options: What the request asked for.
|
|
46
|
+
default_options: What the client always asks for, and the list of what
|
|
47
|
+
it understands at all.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Every option, the request's over the client's.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
InvalidArgumentError: When an option is not one this client knows.
|
|
54
|
+
"""
|
|
55
|
+
merged: dict[str, object] = dict(options)
|
|
56
|
+
for name, value in default_options.items():
|
|
57
|
+
_ = merged.setdefault(name, value)
|
|
58
|
+
|
|
59
|
+
if "extra" in default_options:
|
|
60
|
+
merged["extra"] = _filled(merged.get("extra"), default_options["extra"])
|
|
61
|
+
if default_options.get("resolve"):
|
|
62
|
+
merged["resolve"] = _filled(merged.get("resolve"), default_options["resolve"])
|
|
63
|
+
|
|
64
|
+
if default_options:
|
|
65
|
+
validate_options(client_name, merged, default_options)
|
|
66
|
+
|
|
67
|
+
return merged
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_options(
|
|
71
|
+
client_name: str,
|
|
72
|
+
options: Mapping[str, object],
|
|
73
|
+
default_options: Mapping[str, object],
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Refuse an option this client does not know, naming the closest it does.
|
|
76
|
+
|
|
77
|
+
Refused rather than ignored, since a misspelled option that is quietly
|
|
78
|
+
dropped is a setting that silently never applied.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
client_name: The client the options are for, named in the message.
|
|
82
|
+
options: The names to check.
|
|
83
|
+
default_options: The list of what the client understands at all.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
InvalidArgumentError: When an option is not one this client knows.
|
|
87
|
+
"""
|
|
88
|
+
for name in options:
|
|
89
|
+
if name in default_options:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
close = difflib.get_close_matches(
|
|
93
|
+
name, list(default_options), n=len(default_options), cutoff=0.6
|
|
94
|
+
)
|
|
95
|
+
containing = [known for known in default_options if name in known]
|
|
96
|
+
alternatives = list(dict.fromkeys([*close, *containing]))
|
|
97
|
+
did_you_mean = '", "'.join(alternatives or list(default_options))
|
|
98
|
+
raise InvalidArgumentError(
|
|
99
|
+
f'Unsupported option "{name}" passed to "{client_name}", did you mean "{did_you_mean}"?'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _filled(given: object, defaults: object) -> dict[str, object]:
|
|
104
|
+
filled: dict[str, object] = dict(cast("Mapping[str, object]", given or {}))
|
|
105
|
+
for name, value in cast("Mapping[str, object]", defaults).items():
|
|
106
|
+
_ = filled.setdefault(name, value)
|
|
107
|
+
|
|
108
|
+
return filled
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""A message body read as the transfer produces it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from tempfile import SpooledTemporaryFile
|
|
7
|
+
from typing import TYPE_CHECKING, Final, Protocol, cast, final, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from xtr_http_message import Stream
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from typing import BinaryIO
|
|
14
|
+
|
|
15
|
+
from xtr_http_message_contracts import StreamInterface
|
|
16
|
+
|
|
17
|
+
__all__ = ["ResponseBody"]
|
|
18
|
+
|
|
19
|
+
_READ_SIZE: Final = 65536
|
|
20
|
+
|
|
21
|
+
# Where a body stops being held in memory and starts being held in a file:
|
|
22
|
+
# enough that an ordinary response never reaches the disk, little enough that
|
|
23
|
+
# a download nobody sized cannot exhaust what is left of memory.
|
|
24
|
+
_SPILL_AT: Final = 2 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class _ClosableInterface(Protocol):
|
|
29
|
+
"""A source with something of its own to let go of."""
|
|
30
|
+
|
|
31
|
+
async def aclose(self) -> None:
|
|
32
|
+
"""Let go of whatever the source was holding open."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@final
|
|
37
|
+
class ResponseBody:
|
|
38
|
+
"""The body of a response, taken off the transfer as it is asked for.
|
|
39
|
+
|
|
40
|
+
A message body is a cursor over bytes; a body still arriving has no bytes
|
|
41
|
+
to be a cursor over yet. So a read that finds nothing waiting takes the
|
|
42
|
+
next piece from the transfer, one piece at a time, until bytes turn up or
|
|
43
|
+
the transfer is over — which the source says by ending. Nothing is fetched
|
|
44
|
+
that nobody asked for, which is what makes a body the caller never reads
|
|
45
|
+
cost nothing.
|
|
46
|
+
|
|
47
|
+
Everything that arrives is written into a stream of its own, so the body
|
|
48
|
+
can be read again — and is then seekable, once complete, because only then
|
|
49
|
+
is there a whole body to seek in. That stream holds the first two
|
|
50
|
+
mebibytes in memory and spills the rest to a file, so a large download is
|
|
51
|
+
re-readable without being resident.
|
|
52
|
+
|
|
53
|
+
Writing is not part of it: the bytes come from the far end.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("_closed", "_complete", "_kept", "_received", "_source")
|
|
57
|
+
|
|
58
|
+
def __init__(self, source: AsyncIterator[bytes]) -> None:
|
|
59
|
+
"""Hold onto the transfer this body comes out of.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
source: The pieces of the body, in the order they arrive. Ending
|
|
63
|
+
is how it says the transfer is complete. Whatever else it
|
|
64
|
+
raises is left alone: what a broken transfer means for a
|
|
65
|
+
response is the response's to decide, not the body's.
|
|
66
|
+
"""
|
|
67
|
+
self._source = source
|
|
68
|
+
self._kept: StreamInterface | None = None
|
|
69
|
+
self._received = 0
|
|
70
|
+
self._complete = False
|
|
71
|
+
self._closed = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def complete(self) -> bool:
|
|
75
|
+
"""Whether the whole body has arrived."""
|
|
76
|
+
return self._complete
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def received(self) -> int:
|
|
80
|
+
"""How many bytes have arrived so far."""
|
|
81
|
+
return self._received
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Let go of the body, the transfer behind it, and what it was kept in.
|
|
85
|
+
|
|
86
|
+
Nothing can be read from it afterwards.
|
|
87
|
+
"""
|
|
88
|
+
self._closed = True
|
|
89
|
+
|
|
90
|
+
if isinstance(self._source, _ClosableInterface):
|
|
91
|
+
await self._source.aclose()
|
|
92
|
+
|
|
93
|
+
if self._kept is not None:
|
|
94
|
+
await self._kept.close()
|
|
95
|
+
|
|
96
|
+
def detach(self) -> BinaryIO | None:
|
|
97
|
+
"""Return nothing: what a body is kept in is its own, not a caller's to take over."""
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def get_size(self) -> int | None:
|
|
101
|
+
"""Return the size of the body, which is known once it is complete."""
|
|
102
|
+
if not self._complete:
|
|
103
|
+
return None
|
|
104
|
+
if self._kept is None:
|
|
105
|
+
return self._received
|
|
106
|
+
return self._kept.get_size()
|
|
107
|
+
|
|
108
|
+
def tell(self) -> int:
|
|
109
|
+
"""Return how far into the body reading has got."""
|
|
110
|
+
if self._kept is None:
|
|
111
|
+
return 0
|
|
112
|
+
return self._kept.tell()
|
|
113
|
+
|
|
114
|
+
def eof(self) -> bool:
|
|
115
|
+
"""Tell whether reading has reached the end of a body that has all arrived."""
|
|
116
|
+
return self._complete and self.tell() >= self._received
|
|
117
|
+
|
|
118
|
+
def is_seekable(self) -> bool:
|
|
119
|
+
"""Tell whether the cursor can be moved.
|
|
120
|
+
|
|
121
|
+
Only once the whole body has arrived: before that there is no whole
|
|
122
|
+
body to move about in.
|
|
123
|
+
"""
|
|
124
|
+
return not self._closed and self._complete
|
|
125
|
+
|
|
126
|
+
async def seek(self, offset: int, whence: int = os.SEEK_SET) -> None:
|
|
127
|
+
"""Move the cursor to a position in the body.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: When ``whence`` is not one of the three the standard
|
|
131
|
+
library defines.
|
|
132
|
+
RuntimeError: When this body cannot be seeked in, or the position
|
|
133
|
+
asked for is before its first byte.
|
|
134
|
+
"""
|
|
135
|
+
if not self.is_seekable():
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"The response body cannot be rewound: it is read as the transfer produces it."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
origins = {os.SEEK_SET: 0, os.SEEK_CUR: self.tell(), os.SEEK_END: self._received}
|
|
141
|
+
if whence not in origins:
|
|
142
|
+
raise ValueError(f"Invalid whence: {whence}.")
|
|
143
|
+
|
|
144
|
+
target = origins[whence] + offset
|
|
145
|
+
if target < 0:
|
|
146
|
+
raise RuntimeError(f"Cannot seek to {target}: that is before the body starts.")
|
|
147
|
+
|
|
148
|
+
if self._kept is not None:
|
|
149
|
+
await self._kept.seek(target)
|
|
150
|
+
|
|
151
|
+
async def rewind(self) -> None:
|
|
152
|
+
"""Move the cursor back to the first byte of the body.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
RuntimeError: When this body cannot be seeked in.
|
|
156
|
+
"""
|
|
157
|
+
await self.seek(0)
|
|
158
|
+
|
|
159
|
+
def is_writable(self) -> bool:
|
|
160
|
+
"""Say no: the bytes of a response come from the far end."""
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
async def write(self, data: bytes) -> int:
|
|
164
|
+
"""Refuse to write.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: Always.
|
|
168
|
+
"""
|
|
169
|
+
del data
|
|
170
|
+
raise RuntimeError("A response body cannot be written to.")
|
|
171
|
+
|
|
172
|
+
def is_readable(self) -> bool:
|
|
173
|
+
"""Tell whether the body can still be read."""
|
|
174
|
+
return not self._closed
|
|
175
|
+
|
|
176
|
+
async def read(self, length: int) -> bytes:
|
|
177
|
+
"""Read up to ``length`` bytes from the cursor.
|
|
178
|
+
|
|
179
|
+
Takes pieces off the transfer until bytes turn up past the cursor or
|
|
180
|
+
the transfer is over, then hands over what is there — which may be
|
|
181
|
+
less than was asked for, because a transfer arrives in pieces and
|
|
182
|
+
waiting for the rest would hold up a reader that has something to do
|
|
183
|
+
with this piece.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
What was read, or empty bytes once the body is exhausted.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
RuntimeError: When the body has been closed.
|
|
190
|
+
"""
|
|
191
|
+
if self._closed:
|
|
192
|
+
raise RuntimeError("The response body is closed.")
|
|
193
|
+
if length <= 0:
|
|
194
|
+
return b""
|
|
195
|
+
|
|
196
|
+
while self._waiting() == 0 and not self._complete:
|
|
197
|
+
await self._receive()
|
|
198
|
+
|
|
199
|
+
if self._kept is None:
|
|
200
|
+
return b""
|
|
201
|
+
return await self._kept.read(length)
|
|
202
|
+
|
|
203
|
+
async def get_contents(self) -> bytes:
|
|
204
|
+
"""Read the whole of the body, rewinding first when that is possible.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Every byte left in the body.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
RuntimeError: When the body has been closed.
|
|
211
|
+
"""
|
|
212
|
+
if self.is_seekable():
|
|
213
|
+
await self.rewind()
|
|
214
|
+
|
|
215
|
+
pieces: list[bytes] = []
|
|
216
|
+
while True:
|
|
217
|
+
piece = await self.read(_READ_SIZE)
|
|
218
|
+
if not piece:
|
|
219
|
+
return b"".join(pieces)
|
|
220
|
+
pieces.append(piece)
|
|
221
|
+
|
|
222
|
+
def get_metadata(self, key: str | None = None) -> object:
|
|
223
|
+
"""Return what this body knows about itself, or one entry of it."""
|
|
224
|
+
metadata: dict[str, object] = {
|
|
225
|
+
"mode": "rb",
|
|
226
|
+
"seekable": self.is_seekable(),
|
|
227
|
+
"eof": self.eof(),
|
|
228
|
+
}
|
|
229
|
+
if key is None:
|
|
230
|
+
return metadata
|
|
231
|
+
return metadata.get(key)
|
|
232
|
+
|
|
233
|
+
async def _receive(self) -> None:
|
|
234
|
+
"""Take the next piece off the transfer, or learn that there is none."""
|
|
235
|
+
try:
|
|
236
|
+
piece = await anext(self._source)
|
|
237
|
+
except StopAsyncIteration:
|
|
238
|
+
self._complete = True
|
|
239
|
+
return
|
|
240
|
+
await self._arrived(piece)
|
|
241
|
+
|
|
242
|
+
async def _arrived(self, piece: bytes) -> None:
|
|
243
|
+
"""Count a piece in, and keep it where a reader can still reach it."""
|
|
244
|
+
self._received += len(piece)
|
|
245
|
+
|
|
246
|
+
# The kept stream's cursor *is* the read cursor, so an arriving piece
|
|
247
|
+
# is appended at the end and the cursor put back where the reader left
|
|
248
|
+
# it.
|
|
249
|
+
kept = self._keep()
|
|
250
|
+
reading_at = kept.tell()
|
|
251
|
+
await kept.seek(0, os.SEEK_END)
|
|
252
|
+
_ = await kept.write(piece)
|
|
253
|
+
await kept.seek(reading_at)
|
|
254
|
+
|
|
255
|
+
def _keep(self) -> StreamInterface:
|
|
256
|
+
"""Return the stream the body is held in, opening it on first use."""
|
|
257
|
+
if self._kept is None:
|
|
258
|
+
# Held open past this call on purpose: `close` is what lets go of
|
|
259
|
+
# it. And it is a binary file object in every way a stream uses
|
|
260
|
+
# one — only typed as the wider `IO[bytes]`, whose `write` takes
|
|
261
|
+
# the narrower `bytes` a body ever passes.
|
|
262
|
+
spooled = SpooledTemporaryFile(max_size=_SPILL_AT) # noqa: SIM115
|
|
263
|
+
self._kept = Stream.create(cast("BinaryIO", cast("object", spooled)))
|
|
264
|
+
return self._kept
|
|
265
|
+
|
|
266
|
+
def _waiting(self) -> int:
|
|
267
|
+
"""How many bytes have arrived that the cursor has not reached."""
|
|
268
|
+
if self._kept is None:
|
|
269
|
+
return 0
|
|
270
|
+
return max(0, self._received - self._kept.tell())
|