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