cliV1 0.0.7__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.
- cliv1-0.0.7.dist-info/METADATA +212 -0
- cliv1-0.0.7.dist-info/RECORD +92 -0
- cliv1-0.0.7.dist-info/WHEEL +4 -0
- cliv1-0.0.7.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_1_0/__init__.py +15 -0
- swagger_petstore_open_api_3_1_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_1_0/apis/pet_api.py +922 -0
- swagger_petstore_open_api_3_1_0/apis/store.py +432 -0
- swagger_petstore_open_api_3_1_0/apis/user_api.py +829 -0
- swagger_petstore_open_api_3_1_0/async_client.py +87 -0
- swagger_petstore_open_api_3_1_0/auth.py +17 -0
- swagger_petstore_open_api_3_1_0/base_client.py +27 -0
- swagger_petstore_open_api_3_1_0/client.py +85 -0
- swagger_petstore_open_api_3_1_0/core/__init__.py +395 -0
- swagger_petstore_open_api_3_1_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_1_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_1_0/core/_internal/flattening.py +104 -0
- swagger_petstore_open_api_3_1_0/core/_internal/headers.py +243 -0
- swagger_petstore_open_api_3_1_0/core/_internal/redaction.py +208 -0
- swagger_petstore_open_api_3_1_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_1_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_1_0/core/_internal/urls.py +172 -0
- swagger_petstore_open_api_3_1_0/core/_internal/wire.py +201 -0
- swagger_petstore_open_api_3_1_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_1_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_1_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_1_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_1_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_authorization_code.py +395 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_client_credentials.py +130 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_password.py +145 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_1_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_1_0/core/bodies.py +590 -0
- swagger_petstore_open_api_3_1_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_1_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_1_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_1_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_1_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_1_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_1_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_1_0/core/exceptions.py +78 -0
- swagger_petstore_open_api_3_1_0/core/file_responses.py +337 -0
- swagger_petstore_open_api_3_1_0/core/files.py +174 -0
- swagger_petstore_open_api_3_1_0/core/httpx2_transport.py +748 -0
- swagger_petstore_open_api_3_1_0/core/logging.py +472 -0
- swagger_petstore_open_api_3_1_0/core/models.py +143 -0
- swagger_petstore_open_api_3_1_0/core/optionality.py +144 -0
- swagger_petstore_open_api_3_1_0/core/pagination.py +452 -0
- swagger_petstore_open_api_3_1_0/core/paginator.py +55 -0
- swagger_petstore_open_api_3_1_0/core/params.py +157 -0
- swagger_petstore_open_api_3_1_0/core/raw_client.py +480 -0
- swagger_petstore_open_api_3_1_0/core/request_options.py +172 -0
- swagger_petstore_open_api_3_1_0/core/results.py +178 -0
- swagger_petstore_open_api_3_1_0/core/retries.py +237 -0
- swagger_petstore_open_api_3_1_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_1_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_1_0/core/transport.py +218 -0
- swagger_petstore_open_api_3_1_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_1_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_1_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_1_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_1_0/models/category.py +15 -0
- swagger_petstore_open_api_3_1_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_1_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/order.py +27 -0
- swagger_petstore_open_api_3_1_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_1_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_1_0/models/user.py +29 -0
- swagger_petstore_open_api_3_1_0/py.typed +0 -0
- swagger_petstore_open_api_3_1_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_1_0/server/server.py +17 -0
- swagger_petstore_open_api_3_1_0/server/server_config.py +52 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The event stream's payload: a ``text/event-stream`` body not yet read, owning its connection.
|
|
2
|
+
|
|
3
|
+
``EventStream`` and ``AsyncEventStream`` join ``FileResponse`` and its twin as the runtime's non-frozen
|
|
4
|
+
classes, for the same reason: they own a resource with a lifecycle. Iterating one yields the typed payload
|
|
5
|
+
of each event as the server dispatches it. Every path that ends the iteration releases the connection --
|
|
6
|
+
exhaustion, the operation's sentinel, an exception, a ``with`` block, ``close`` -- and abandoning a partial
|
|
7
|
+
iteration is the one leak, reported by ``__del__`` (a ``ResourceWarning`` naming the URL) and reaped by the
|
|
8
|
+
client's own close, exactly as a file's is. A stream that is never iterated at all is reported the same way.
|
|
9
|
+
|
|
10
|
+
The parser is ``_internal/sse_parser.py``; this module decides what happens to what it yields. An event
|
|
11
|
+
whose ``data`` equals the sentinel ends the stream and is never surfaced. An event the declared type
|
|
12
|
+
rejects raises pydantic's ``ValidationError`` -- a ``ValueError``, exactly what a buffered body's decoder
|
|
13
|
+
raises -- after the close; raised, not skipped, because a silently dropped event in a token stream is data
|
|
14
|
+
loss the caller cannot see. The bytes are taken as the transport delivers them, ``iter_bytes(None)``, never
|
|
15
|
+
re-chunked: a fixed chunk size would hold a twenty-byte event back until enough followed it.
|
|
16
|
+
|
|
17
|
+
A finalizer here only ever *reports*, never acts (ADR-0007): nothing is closed at GC time, and the
|
|
18
|
+
consuming generator re-raises ``GeneratorExit`` without closing for the same reason."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import warnings
|
|
23
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
24
|
+
from typing import Generic, TypeVar
|
|
25
|
+
|
|
26
|
+
from typing_extensions import Self
|
|
27
|
+
|
|
28
|
+
from ._internal.redaction import redact_url
|
|
29
|
+
from ._internal.sse_parser import SseParser
|
|
30
|
+
from .transport import AsyncHttpResponse, HttpResponse
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventStream(Generic[T]):
|
|
36
|
+
"""An event stream whose body has not been read yet; iterate it to receive the events.
|
|
37
|
+
|
|
38
|
+
``for chunk in stream`` yields each event's decoded ``data`` and closes the connection when the server
|
|
39
|
+
ends the stream or the operation's sentinel arrives, so a loop run to the end leaks nothing::
|
|
40
|
+
|
|
41
|
+
with client.streaming.create_chat_completion(request) as chunks:
|
|
42
|
+
for chunk in chunks:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
Only a *partially* consumed iteration outside a ``with`` can leak; that case emits a ``ResourceWarning``
|
|
46
|
+
at finalization, the same way an unclosed file does, and the client's own ``close()`` is the backstop.
|
|
47
|
+
A ``break`` inside a ``with`` is the idiomatic early stop. Single-use: once closed, iterating again
|
|
48
|
+
raises rather than yielding nothing. The per-call ``timeout`` applies to each read of the body, so it
|
|
49
|
+
bounds the wait *between* events and a stalled server raises rather than hangs. Each open stream holds
|
|
50
|
+
one connection from the transport's pool until it is closed, so many left open will make later
|
|
51
|
+
requests wait on the pool."""
|
|
52
|
+
|
|
53
|
+
__slots__ = ("_closed", "_decode_data", "_parser", "_response", "_sentinel", "_url")
|
|
54
|
+
|
|
55
|
+
def __init__(self, response: HttpResponse, decode_data: Callable[[str], T], sentinel: str | None) -> None:
|
|
56
|
+
# Total by design: this runs between the head arriving and the raw client returning, so a
|
|
57
|
+
# raise here would leak the connection.
|
|
58
|
+
self._response = response
|
|
59
|
+
self._closed = False
|
|
60
|
+
# Captured here rather than read off the response inside the finalizer, which can run at
|
|
61
|
+
# interpreter shutdown; its query values masked, so an api key placed there cannot reach a warning.
|
|
62
|
+
self._url = redact_url(response.url)
|
|
63
|
+
self._decode_data = decode_data
|
|
64
|
+
self._sentinel = sentinel
|
|
65
|
+
self._parser = SseParser()
|
|
66
|
+
|
|
67
|
+
def __iter__(self) -> Iterator[T]:
|
|
68
|
+
"""Iterate the events, closing when the stream ends.
|
|
69
|
+
|
|
70
|
+
Ends -- and closes -- when the server closes the connection, or when an event's ``data`` equals
|
|
71
|
+
the operation's sentinel, which is consumed and never yielded. An exception mid-iteration closes
|
|
72
|
+
before propagating; only abandoning the iteration part-way leaves the connection held, which
|
|
73
|
+
``__del__`` reports.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Each event's decoded ``data``, in order.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If the stream is already closed; if an event's ``data`` is rejected by the
|
|
80
|
+
declared type (pydantic's ``ValidationError``); or if the wire breaks the event-stream
|
|
81
|
+
grammar."""
|
|
82
|
+
# Checked here, not in the generator: a closed stream must raise at the call, the way a closed
|
|
83
|
+
# file does -- not at the first next().
|
|
84
|
+
self._ensure_open()
|
|
85
|
+
return self._consume()
|
|
86
|
+
|
|
87
|
+
def _consume(self) -> Iterator[T]:
|
|
88
|
+
try:
|
|
89
|
+
for data in self._events():
|
|
90
|
+
if data == self._sentinel:
|
|
91
|
+
break
|
|
92
|
+
yield self._decode_data(data)
|
|
93
|
+
except GeneratorExit:
|
|
94
|
+
# Abandoned mid-iteration: deliberately left open. Closing here would run at GC time, and a
|
|
95
|
+
# finalizer may report, never act (ADR-0007) -- __del__ warns, client.close() reaps.
|
|
96
|
+
raise
|
|
97
|
+
except BaseException:
|
|
98
|
+
self.close()
|
|
99
|
+
raise
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def _events(self) -> Iterator[str]:
|
|
103
|
+
# None, not a size: the transport's own delivery units, so an event is parsed the moment its
|
|
104
|
+
# bytes arrive.
|
|
105
|
+
for chunk in self._response.iter_bytes(None):
|
|
106
|
+
yield from self._parser.parse_chunk(chunk)
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
"""Release the connection. Idempotent, and every path that ends an iteration calls it."""
|
|
110
|
+
if self._closed:
|
|
111
|
+
return
|
|
112
|
+
self._closed = True
|
|
113
|
+
self._response.close()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> Self:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc: object) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def __del__(self) -> None:
|
|
122
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
123
|
+
if not getattr(self, "_closed", True):
|
|
124
|
+
warnings.warn(
|
|
125
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `with`, iterate to the end, or call close()",
|
|
126
|
+
ResourceWarning,
|
|
127
|
+
stacklevel=2,
|
|
128
|
+
source=self,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def __repr__(self) -> str:
|
|
132
|
+
# Identify by state only -- never an event, which is the caller's data and not read yet anyway.
|
|
133
|
+
return f"{type(self).__name__}(closed={self._closed})"
|
|
134
|
+
|
|
135
|
+
def _ensure_open(self) -> None:
|
|
136
|
+
if self._closed:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"this {type(self).__name__} is closed -- it is single-use: the end of the stream, its "
|
|
139
|
+
"sentinel, an error and close() each close it"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AsyncEventStream(Generic[T]):
|
|
144
|
+
"""The awaited twin of :class:`EventStream` -- same lifecycle, ``a``-prefixed verbs.
|
|
145
|
+
|
|
146
|
+
``async with`` is the spelling to reach for: an abandoned instance defers finalization to
|
|
147
|
+
``loop.shutdown_asyncgens()``, where the ``ResourceWarning`` still fires but later and less usefully,
|
|
148
|
+
and ``aclose`` must run on the loop that created the stream."""
|
|
149
|
+
|
|
150
|
+
__slots__ = ("_closed", "_decode_data", "_parser", "_response", "_sentinel", "_url")
|
|
151
|
+
|
|
152
|
+
def __init__(self, response: AsyncHttpResponse, decode_data: Callable[[str], T], sentinel: str | None) -> None:
|
|
153
|
+
# Total by design, and the URL captured and masked, as in the sync twin.
|
|
154
|
+
self._response = response
|
|
155
|
+
self._closed = False
|
|
156
|
+
self._url = redact_url(response.url)
|
|
157
|
+
self._decode_data = decode_data
|
|
158
|
+
self._sentinel = sentinel
|
|
159
|
+
self._parser = SseParser()
|
|
160
|
+
|
|
161
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
162
|
+
"""Iterate the events, closing when the stream ends; the twin of :meth:`EventStream.__iter__`.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Each event's decoded ``data``, in order.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If the stream is already closed; if an event's ``data`` is rejected by the
|
|
169
|
+
declared type (pydantic's ``ValidationError``); or if the wire breaks the event-stream
|
|
170
|
+
grammar."""
|
|
171
|
+
self._ensure_open()
|
|
172
|
+
return self._consume()
|
|
173
|
+
|
|
174
|
+
async def _consume(self) -> AsyncIterator[T]:
|
|
175
|
+
try:
|
|
176
|
+
async for data in self._events():
|
|
177
|
+
if data == self._sentinel:
|
|
178
|
+
break
|
|
179
|
+
yield self._decode_data(data)
|
|
180
|
+
except GeneratorExit:
|
|
181
|
+
# Abandoned mid-iteration: deliberately left open, as in the sync twin.
|
|
182
|
+
raise
|
|
183
|
+
except BaseException:
|
|
184
|
+
await self.aclose()
|
|
185
|
+
raise
|
|
186
|
+
await self.aclose()
|
|
187
|
+
|
|
188
|
+
async def _events(self) -> AsyncIterator[str]:
|
|
189
|
+
async for chunk in self._response.aiter_bytes(None):
|
|
190
|
+
for data in self._parser.parse_chunk(chunk):
|
|
191
|
+
yield data
|
|
192
|
+
|
|
193
|
+
async def aclose(self) -> None:
|
|
194
|
+
"""Release the connection. Idempotent, and every path that ends an iteration awaits it."""
|
|
195
|
+
if self._closed:
|
|
196
|
+
return
|
|
197
|
+
self._closed = True
|
|
198
|
+
await self._response.aclose()
|
|
199
|
+
|
|
200
|
+
async def __aenter__(self) -> Self:
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
204
|
+
await self.aclose()
|
|
205
|
+
|
|
206
|
+
def __del__(self) -> None:
|
|
207
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
208
|
+
if not getattr(self, "_closed", True):
|
|
209
|
+
warnings.warn(
|
|
210
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `async with`, iterate to the end, or "
|
|
211
|
+
"call aclose()",
|
|
212
|
+
ResourceWarning,
|
|
213
|
+
stacklevel=2,
|
|
214
|
+
source=self,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def __repr__(self) -> str:
|
|
218
|
+
return f"{type(self).__name__}(closed={self._closed})"
|
|
219
|
+
|
|
220
|
+
def _ensure_open(self) -> None:
|
|
221
|
+
if self._closed:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"this {type(self).__name__} is closed -- it is single-use: the end of the stream, its "
|
|
224
|
+
"sentinel, an error and aclose() each close it"
|
|
225
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The SDK's own exception types: a failed call, and a call that never happened.
|
|
2
|
+
|
|
3
|
+
The two are disjoint by construction. :class:`ApiError` means the server answered and the answer was
|
|
4
|
+
an error, so it carries a decoded body. :class:`TransportError` means no answer arrived at all, so
|
|
5
|
+
there is nothing to decode -- only what went wrong on the way."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, Generic
|
|
11
|
+
|
|
12
|
+
from typing_extensions import TypeVar
|
|
13
|
+
|
|
14
|
+
E = TypeVar("E", default=object)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(Exception, Generic[E]):
|
|
18
|
+
"""A failed API call, raised by the parsed response mode.
|
|
19
|
+
|
|
20
|
+
``error`` is the decoded response body -- a typed union of the operation's documented
|
|
21
|
+
error schemas, or :class:`RawError` for a status the operation does not document. That
|
|
22
|
+
payload *is* the information, so neither string form embeds it: ``str`` and ``repr``
|
|
23
|
+
identify the failure by status and error type only, which keeps response bodies out of logs
|
|
24
|
+
and tracebacks. Read ``.error`` to inspect it."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, error: E, status_code: int, headers: Mapping[str, str]) -> None:
|
|
27
|
+
# Composed once: ``Exception.__str__`` returns a single argument verbatim, so
|
|
28
|
+
# ``args[0]`` and ``str(self)`` are the same string by construction.
|
|
29
|
+
super().__init__(f"HTTP {status_code}: {type(error).__name__}")
|
|
30
|
+
self.error: E = error
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.headers: Mapping[str, str] = headers
|
|
33
|
+
"""Header names lowercased, per the transports' obligation -- look keys up in lowercase."""
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"{type(self).__name__}(status_code={self.status_code}, error={type(self.error).__name__})"
|
|
37
|
+
|
|
38
|
+
def __reduce__(self) -> tuple[type[ApiError[E]], tuple[E, int, Mapping[str, str]], dict[str, Any]]:
|
|
39
|
+
# ``BaseException``'s default reduce replays ``args`` through ``__init__``, which takes
|
|
40
|
+
# three arguments -- so pickling and copying both fail without this. The state mapping is
|
|
41
|
+
# what carries ``__notes__`` and any subclass attribute across the round trip.
|
|
42
|
+
return (type(self), (self.error, self.status_code, self.headers), self.__dict__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TransportError(Exception):
|
|
46
|
+
"""A request never produced a response -- the connection failed, timed out, or was dropped.
|
|
47
|
+
|
|
48
|
+
Raised by a transport, never by the pipeline. It is the one failure shape the retry loop can act
|
|
49
|
+
on without knowing which HTTP library moved the bytes, which is what keeps that library named in
|
|
50
|
+
a single module: a loop that caught the library's own connection error would have to import that
|
|
51
|
+
library to say so.
|
|
52
|
+
|
|
53
|
+
A transport author's vocabulary, not a caller's. It is exported from ``core`` beside
|
|
54
|
+
:class:`~.transport.HttpClient`, because custom transport has to raise it for the retry loop to
|
|
55
|
+
act on a failed send -- whatever else that transport raises reaches the caller on the first
|
|
56
|
+
attempt. A caller does not catch it: when the last attempt fails, ``execute`` raises
|
|
57
|
+
:attr:`inner_exception` in this wrapper's place, ``from`` the cause that exception already
|
|
58
|
+
carried -- which restates what it held rather than clearing it, and suppresses this wrapper from
|
|
59
|
+
the chain it is printed with. So
|
|
60
|
+
it adds nothing to what it wraps. Transport passes the original exception itself rather
|
|
61
|
+
than a string of it, both as ``__cause__`` and as the single argument: ``Exception.__str__``
|
|
62
|
+
renders a lone argument through ``str``, so the message reads identically, and nothing is
|
|
63
|
+
flattened on the way through.
|
|
64
|
+
A distinction worth narrowing on, a timeout against a refused connection among them, belongs to
|
|
65
|
+
the exception the caller actually sees; the loop itself draws none, waiting the same curve for
|
|
66
|
+
both.
|
|
67
|
+
|
|
68
|
+
A response that arrived and said 4xx or 5xx is not this -- that is an :class:`ApiError`, or a
|
|
69
|
+
:class:`~.results.Failure` in the raw response mode. This type means there was no response."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, inner_exception: Exception) -> None:
|
|
72
|
+
super().__init__(inner_exception)
|
|
73
|
+
self.inner_exception = inner_exception
|
|
74
|
+
"""What the transport itself raised, and the only thing this type carries.
|
|
75
|
+
|
|
76
|
+
Required, so a wrapper that cannot say what failed is a build failure rather than a runtime
|
|
77
|
+
discovery. ``args`` is the same one value, which is what lets ``BaseException``'s default
|
|
78
|
+
``__reduce__`` replay it unaided."""
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The streamed download's payload: a response body not yet read, owning its connection.
|
|
2
|
+
|
|
3
|
+
``FileResponse`` and ``AsyncFileResponse`` are the only non-frozen classes in the runtime, and
|
|
4
|
+
deliberately so: everything else is an inert value, these own a resource with a lifecycle. Every
|
|
5
|
+
consuming path -- ``read``, ``save_to``, a drained ``iter_bytes``, a ``with`` block, ``close`` --
|
|
6
|
+
releases the connection; abandoning a partial manual iteration is the one leak, reported by
|
|
7
|
+
``__del__`` (a ``ResourceWarning``, the io/socket idiom) and reaped by the client's own close.
|
|
8
|
+
|
|
9
|
+
A finalizer here only ever *reports*, never acts (ADR-0007): nothing is closed at GC time, which
|
|
10
|
+
is also why the consuming generator re-raises ``GeneratorExit`` without closing -- closing there
|
|
11
|
+
would run at collection time and silently suppress the warning that names the mistake."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import warnings
|
|
18
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from typing_extensions import Self
|
|
22
|
+
|
|
23
|
+
from ._internal.content_disposition import content_disposition_filename, safe_filename
|
|
24
|
+
from ._internal.redaction import redact_url
|
|
25
|
+
from .transport import AsyncHttpResponse, HttpResponse
|
|
26
|
+
|
|
27
|
+
_CHUNK_SIZE = 65_536
|
|
28
|
+
"""The library's own multipart chunk size, matched so a download iterates as an upload reads."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FileResponse:
|
|
32
|
+
"""A response body that has not been read yet.
|
|
33
|
+
|
|
34
|
+
Three ways to consume it, and all three close the connection::
|
|
35
|
+
|
|
36
|
+
client.files.download(id).save_to("out.bin") # streams to disk
|
|
37
|
+
data = client.files.download(id).read() # buffers, for a small file
|
|
38
|
+
with client.files.download(id) as f: # anything else
|
|
39
|
+
for chunk in f.iter_bytes():
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
Reading is what closes it, so only a *partially* consumed manual iteration outside a ``with``
|
|
43
|
+
can leak. That case emits a ``ResourceWarning`` at finalization, the same way an unclosed file
|
|
44
|
+
object does -- the mistake is made loud rather than quietly cleaned up (ADR-0007, amended).
|
|
45
|
+
The client's own ``close()`` is the backstop: it disposes the pool, and any connection still
|
|
46
|
+
held with it. Single-use: once closed, reading again raises rather than yielding nothing. One
|
|
47
|
+
consumer per response: ``read()`` after a partially-driven ``iter_bytes()`` raises the
|
|
48
|
+
underlying single-pass error. The per-call ``timeout`` applies to each read of a streamed
|
|
49
|
+
body, so a stalled download raises rather than hanging.
|
|
50
|
+
|
|
51
|
+
``headers`` is the full response head -- ``ETag``, ``Last-Modified`` and friends cost nothing
|
|
52
|
+
to expose since the head has already arrived; ``media_type``, ``content_length`` and
|
|
53
|
+
``filename`` are the three curated conveniences over it. ``filename`` is the server's own
|
|
54
|
+
suggestion from ``Content-Disposition`` and is sanitised before it can name a path -- see
|
|
55
|
+
:meth:`save_to`."""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("_closed", "_response", "_url", "content_length", "filename", "headers", "media_type")
|
|
58
|
+
|
|
59
|
+
def __init__(self, response: HttpResponse) -> None:
|
|
60
|
+
# Total by design: this runs between the head arriving and the raw client returning, so a
|
|
61
|
+
# raise here would leak the connection. Malformed headers degrade to None, never raise.
|
|
62
|
+
self._response = response
|
|
63
|
+
self._closed = False
|
|
64
|
+
# Captured here rather than read off the response in __del__, which can run at interpreter
|
|
65
|
+
# shutdown where a property call into the transport may find its objects already torn down --
|
|
66
|
+
# and a finalizer that raises reports nothing. Rendered through ``redact_url`` with its
|
|
67
|
+
# defaults, unknown query values ``REDACTED``, because this string's only reader is the
|
|
68
|
+
# abandonment warning, and a query-placed api key would otherwise reach stderr and the logs
|
|
69
|
+
# with it.
|
|
70
|
+
self._url = redact_url(response.url)
|
|
71
|
+
self.headers: Mapping[str, str] = response.headers
|
|
72
|
+
self.media_type = self.headers.get("content-type")
|
|
73
|
+
length = self.headers.get("content-length")
|
|
74
|
+
self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
|
|
75
|
+
self.filename = content_disposition_filename(self.headers.get("content-disposition"))
|
|
76
|
+
|
|
77
|
+
def iter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> Iterator[bytes]:
|
|
78
|
+
"""Iterate the body in chunks, closing when it is exhausted.
|
|
79
|
+
|
|
80
|
+
The default is the underlying library's own 64 KiB: a 2 GiB download is 32 768 iterations
|
|
81
|
+
at this size. An exception mid-iteration closes before propagating; only abandoning the
|
|
82
|
+
iteration part-way leaves the connection held, which ``__del__`` reports.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
chunk_size: How many bytes each chunk carries at most.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The body's chunks, in order.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If ``chunk_size`` is not positive."""
|
|
92
|
+
# Checked here, not in the generator: a closed response must raise at the call, the way a
|
|
93
|
+
# closed file does -- not at the first next().
|
|
94
|
+
self._ensure_open()
|
|
95
|
+
if chunk_size <= 0:
|
|
96
|
+
raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
|
|
97
|
+
return self._consume(chunk_size)
|
|
98
|
+
|
|
99
|
+
def _consume(self, chunk_size: int) -> Iterator[bytes]:
|
|
100
|
+
try:
|
|
101
|
+
yield from self._response.iter_bytes(chunk_size)
|
|
102
|
+
except GeneratorExit:
|
|
103
|
+
# Abandoned mid-iteration: deliberately left open. Closing here would run at GC time,
|
|
104
|
+
# and a finalizer may report, never act (ADR-0007) -- __del__ warns, client.close()
|
|
105
|
+
# reaps. The warning is the remedy's pointer, not the remedy.
|
|
106
|
+
raise
|
|
107
|
+
except BaseException:
|
|
108
|
+
self.close()
|
|
109
|
+
raise
|
|
110
|
+
self.close()
|
|
111
|
+
|
|
112
|
+
def read(self) -> bytes:
|
|
113
|
+
"""Buffer the whole body and close. For a payload that fits in memory.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The complete body."""
|
|
117
|
+
self._ensure_open()
|
|
118
|
+
try:
|
|
119
|
+
return self._response.read()
|
|
120
|
+
finally:
|
|
121
|
+
self.close()
|
|
122
|
+
|
|
123
|
+
def save_to(self, destination: str | Path) -> Path:
|
|
124
|
+
"""Stream the body to ``destination``, closing when done.
|
|
125
|
+
|
|
126
|
+
A directory is a legal destination: the file is named by ``filename`` when the server sent
|
|
127
|
+
one -- reduced to a safe basename first, since a ``Content-Disposition`` is
|
|
128
|
+
attacker-controlled -- and ``ValueError`` is raised when it did not. Written to
|
|
129
|
+
``<name>.part`` and moved into place with ``os.replace``, which is atomic on both
|
|
130
|
+
platforms, so an interrupted download never appears under the final name; the partial is
|
|
131
|
+
unlinked on failure. A failure to open the partial -- a missing parent directory, or
|
|
132
|
+
permissions -- propagates with the response still consumable, exactly as the no-filename
|
|
133
|
+
precondition does.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
destination: The file path to write, or a directory to write into under the
|
|
137
|
+
server-suggested name.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The path written.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If ``destination`` is a directory and the server sent no usable filename."""
|
|
144
|
+
# First: a second save_to must raise before it can touch the filesystem -- without this, a
|
|
145
|
+
# doomed call would create, truncate and unlink the .part before iter_bytes refuses.
|
|
146
|
+
self._ensure_open()
|
|
147
|
+
target = Path(destination)
|
|
148
|
+
if target.is_dir():
|
|
149
|
+
if self.filename is None:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"the server sent no filename to save under -- pass a full file path instead of a directory"
|
|
152
|
+
)
|
|
153
|
+
target = target / safe_filename(self.filename)
|
|
154
|
+
partial = target.with_name(target.name + ".part")
|
|
155
|
+
out = partial.open("wb") # an open failure, like the precondition above, leaves the response consumable
|
|
156
|
+
try:
|
|
157
|
+
with out:
|
|
158
|
+
for chunk in self.iter_bytes():
|
|
159
|
+
out.write(chunk)
|
|
160
|
+
os.replace(partial, target) # inside the try: a failed replace must also clean up
|
|
161
|
+
except BaseException:
|
|
162
|
+
self.close() # idempotent -- iter_bytes' own error path may have closed already
|
|
163
|
+
partial.unlink(missing_ok=True)
|
|
164
|
+
raise
|
|
165
|
+
return target
|
|
166
|
+
|
|
167
|
+
def close(self) -> None:
|
|
168
|
+
"""Release the connection. Idempotent, and every consuming path above calls it."""
|
|
169
|
+
if self._closed:
|
|
170
|
+
return
|
|
171
|
+
self._closed = True
|
|
172
|
+
self._response.close()
|
|
173
|
+
|
|
174
|
+
def __enter__(self) -> Self:
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def __exit__(self, *exc: object) -> None:
|
|
178
|
+
self.close()
|
|
179
|
+
|
|
180
|
+
def __del__(self) -> None:
|
|
181
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
182
|
+
if not getattr(self, "_closed", True):
|
|
183
|
+
warnings.warn(
|
|
184
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `with`, or call read() / save_to() / close()",
|
|
185
|
+
ResourceWarning,
|
|
186
|
+
stacklevel=2,
|
|
187
|
+
source=self,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def __repr__(self) -> str:
|
|
191
|
+
# Identify by media type and length only -- never the bytes, which may be enormous and
|
|
192
|
+
# are not read yet anyway. Same rule as RawError and ApiError.
|
|
193
|
+
return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
|
|
194
|
+
|
|
195
|
+
def _ensure_open(self) -> None:
|
|
196
|
+
if self._closed:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"this {type(self).__name__} is closed -- it is single-use: read(), save_to() and "
|
|
199
|
+
"a drained iter_bytes() each consume and close it"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncFileResponse:
|
|
204
|
+
"""The awaited twin of :class:`FileResponse` -- same lifecycle, ``a``-prefixed verbs.
|
|
205
|
+
|
|
206
|
+
``async with`` is the spelling to reach for: an abandoned instance defers finalization to
|
|
207
|
+
``loop.shutdown_asyncgens()``, where the ``ResourceWarning`` still fires but later and less
|
|
208
|
+
usefully, and ``aclose`` must run on the loop that created the response."""
|
|
209
|
+
|
|
210
|
+
__slots__ = ("_closed", "_response", "_url", "content_length", "filename", "headers", "media_type")
|
|
211
|
+
|
|
212
|
+
def __init__(self, response: AsyncHttpResponse) -> None:
|
|
213
|
+
# Total by design, exactly as the sync twin's: a raise here would leak the connection.
|
|
214
|
+
self._response = response
|
|
215
|
+
self._closed = False
|
|
216
|
+
# Captured here rather than read off the response in __del__, which can run at interpreter
|
|
217
|
+
# shutdown where a property call into the transport may find its objects already torn down --
|
|
218
|
+
# and a finalizer that raises reports nothing. Rendered through ``redact_url`` with its
|
|
219
|
+
# defaults, unknown query values ``REDACTED``, because this string's only reader is the
|
|
220
|
+
# abandonment warning, and a query-placed api key would otherwise reach stderr and the logs
|
|
221
|
+
# with it.
|
|
222
|
+
self._url = redact_url(response.url)
|
|
223
|
+
self.headers: Mapping[str, str] = response.headers
|
|
224
|
+
self.media_type = self.headers.get("content-type")
|
|
225
|
+
length = self.headers.get("content-length")
|
|
226
|
+
self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
|
|
227
|
+
self.filename = content_disposition_filename(self.headers.get("content-disposition"))
|
|
228
|
+
|
|
229
|
+
def aiter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> AsyncIterator[bytes]:
|
|
230
|
+
"""Iterate the body in chunks, closing when it is exhausted; see the sync twin.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
chunk_size: How many bytes each chunk carries at most.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The body's chunks, in order.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ValueError: If ``chunk_size`` is not positive."""
|
|
240
|
+
self._ensure_open()
|
|
241
|
+
if chunk_size <= 0:
|
|
242
|
+
raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
|
|
243
|
+
return self._consume(chunk_size)
|
|
244
|
+
|
|
245
|
+
async def _consume(self, chunk_size: int) -> AsyncIterator[bytes]:
|
|
246
|
+
try:
|
|
247
|
+
async for chunk in self._response.aiter_bytes(chunk_size):
|
|
248
|
+
yield chunk
|
|
249
|
+
except GeneratorExit:
|
|
250
|
+
# Abandoned mid-iteration: deliberately left open, as in the sync twin.
|
|
251
|
+
raise
|
|
252
|
+
except BaseException:
|
|
253
|
+
await self.aclose()
|
|
254
|
+
raise
|
|
255
|
+
await self.aclose()
|
|
256
|
+
|
|
257
|
+
async def aread(self) -> bytes:
|
|
258
|
+
"""Buffer the whole body and close. For a payload that fits in memory.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The complete body."""
|
|
262
|
+
self._ensure_open()
|
|
263
|
+
try:
|
|
264
|
+
return await self._response.aread()
|
|
265
|
+
finally:
|
|
266
|
+
await self.aclose()
|
|
267
|
+
|
|
268
|
+
async def save_to(self, destination: str | Path) -> Path:
|
|
269
|
+
"""Stream the body to ``destination``, closing when done; see the sync twin.
|
|
270
|
+
|
|
271
|
+
Every filesystem touch runs in a worker thread, so a slow disk does not park the loop.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
destination: The file path to write, or a directory to write into under the
|
|
275
|
+
server-suggested name.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The path written.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
ValueError: If ``destination`` is a directory and the server sent no usable filename."""
|
|
282
|
+
self._ensure_open() # first, as in the sync twin: raise before touching the filesystem
|
|
283
|
+
target = Path(destination)
|
|
284
|
+
if await asyncio.to_thread(target.is_dir):
|
|
285
|
+
if self.filename is None:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
"the server sent no filename to save under -- pass a full file path instead of a directory"
|
|
288
|
+
)
|
|
289
|
+
target = target / safe_filename(self.filename)
|
|
290
|
+
partial = target.with_name(target.name + ".part")
|
|
291
|
+
out = await asyncio.to_thread(partial.open, "wb") # an open failure leaves the response consumable
|
|
292
|
+
try:
|
|
293
|
+
try:
|
|
294
|
+
async for chunk in self.aiter_bytes():
|
|
295
|
+
await asyncio.to_thread(out.write, chunk)
|
|
296
|
+
finally:
|
|
297
|
+
await asyncio.to_thread(out.close)
|
|
298
|
+
await asyncio.to_thread(os.replace, partial, target)
|
|
299
|
+
except BaseException:
|
|
300
|
+
await self.aclose() # idempotent -- aiter_bytes' own error path may have closed already
|
|
301
|
+
await asyncio.to_thread(partial.unlink, True)
|
|
302
|
+
raise
|
|
303
|
+
return target
|
|
304
|
+
|
|
305
|
+
async def aclose(self) -> None:
|
|
306
|
+
"""Release the connection. Idempotent, and every consuming path above awaits it."""
|
|
307
|
+
if self._closed:
|
|
308
|
+
return
|
|
309
|
+
self._closed = True
|
|
310
|
+
await self._response.aclose()
|
|
311
|
+
|
|
312
|
+
async def __aenter__(self) -> Self:
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
316
|
+
await self.aclose()
|
|
317
|
+
|
|
318
|
+
def __del__(self) -> None:
|
|
319
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
320
|
+
if not getattr(self, "_closed", True):
|
|
321
|
+
warnings.warn(
|
|
322
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `async with`, or call "
|
|
323
|
+
"aread() / save_to() / aclose()",
|
|
324
|
+
ResourceWarning,
|
|
325
|
+
stacklevel=2,
|
|
326
|
+
source=self,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def __repr__(self) -> str:
|
|
330
|
+
return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
|
|
331
|
+
|
|
332
|
+
def _ensure_open(self) -> None:
|
|
333
|
+
if self._closed:
|
|
334
|
+
raise ValueError(
|
|
335
|
+
f"this {type(self).__name__} is closed -- it is single-use: aread(), save_to() and "
|
|
336
|
+
"a drained aiter_bytes() each consume and close it"
|
|
337
|
+
)
|