cliV1 0.1.1__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.1.1.dist-info/METADATA +156 -0
- cliv1-0.1.1.dist-info/RECORD +83 -0
- cliv1-0.1.1.dist-info/WHEEL +4 -0
- cliv1-0.1.1.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_0/__init__.py +15 -0
- swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_0/apis/pet_api.py +881 -0
- swagger_petstore_open_api_3_0/apis/store.py +411 -0
- swagger_petstore_open_api_3_0/apis/user_api.py +792 -0
- swagger_petstore_open_api_3_0/async_client.py +79 -0
- swagger_petstore_open_api_3_0/auth.py +17 -0
- swagger_petstore_open_api_3_0/base_client.py +18 -0
- swagger_petstore_open_api_3_0/client.py +77 -0
- swagger_petstore_open_api_3_0/core/__init__.py +342 -0
- swagger_petstore_open_api_3_0/core/_internal/__init__.py +7 -0
- swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
- swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
- swagger_petstore_open_api_3_0/core/_internal/urls.py +166 -0
- swagger_petstore_open_api_3_0/core/_internal/wire.py +173 -0
- swagger_petstore_open_api_3_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_0/core/auth/models.py +217 -0
- swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_0/core/bodies.py +563 -0
- swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +271 -0
- swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_0/core/decoding.py +263 -0
- swagger_petstore_open_api_3_0/core/exceptions.py +41 -0
- swagger_petstore_open_api_3_0/core/file_responses.py +324 -0
- swagger_petstore_open_api_3_0/core/files.py +173 -0
- swagger_petstore_open_api_3_0/core/httpx_transport.py +766 -0
- swagger_petstore_open_api_3_0/core/models.py +143 -0
- swagger_petstore_open_api_3_0/core/optionality.py +130 -0
- swagger_petstore_open_api_3_0/core/params.py +161 -0
- swagger_petstore_open_api_3_0/core/raw_client.py +388 -0
- swagger_petstore_open_api_3_0/core/request_options.py +94 -0
- swagger_petstore_open_api_3_0/core/results.py +110 -0
- swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_0/core/transport.py +248 -0
- swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_0/models/category.py +15 -0
- swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_0/models/order.py +27 -0
- swagger_petstore_open_api_3_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_0/models/tag.py +15 -0
- swagger_petstore_open_api_3_0/models/user.py +29 -0
- swagger_petstore_open_api_3_0/py.typed +0 -0
- swagger_petstore_open_api_3_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_0/server/server.py +17 -0
- swagger_petstore_open_api_3_0/server/server_config.py +52 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Turning wire bytes into typed values: response decoding and error-body mapping.
|
|
2
|
+
|
|
3
|
+
One verb per act, and they are not interchangeable. ``decode`` turns a 2xx body into its typed
|
|
4
|
+
payload: it is the method a :class:`ResponseDecoder` carries, built by the ``*_decoder``
|
|
5
|
+
factories for an endpoint that has not sent its request yet, and run in one step by the
|
|
6
|
+
``decode_*`` helpers for a response already in hand -- ``decode_json[t](response)`` is
|
|
7
|
+
``json_decoder[t].decode(response)``.
|
|
8
|
+
|
|
9
|
+
``map`` is the error path's act, because there the work is **selection**: an :class:`ErrorMapper`
|
|
10
|
+
picks the documented schema for a response's status, and each generated error module's ``match``
|
|
11
|
+
on ``response.status_code`` is that mapping written out.
|
|
12
|
+
|
|
13
|
+
The decoders below resolve their adapter through :func:`adapter_for`, the single cache the request
|
|
14
|
+
path (``json_body``, ``param``) draws from too.
|
|
15
|
+
|
|
16
|
+
Every factory here takes its target type in its subscript, as a ``TypeForm[T]`` (PEP 747) rather
|
|
17
|
+
than a ``type[T]``. That is what keeps ``T`` precise for the type expressions no class can stand in
|
|
18
|
+
for -- a union alias, an ``Annotated`` wire-format alias, a ``list[...]``/``dict[...]`` wrapper --
|
|
19
|
+
and so what lets a type checker compare an endpoint's *declared* payload type against the type it
|
|
20
|
+
actually parses. A ``type[T]``-plus-``object`` overload pair cannot: given a declared type the
|
|
21
|
+
precise overload does not fit, inference falls through to the ``object`` one, yields ``Any``, and
|
|
22
|
+
the disagreement has nowhere left to surface.
|
|
23
|
+
|
|
24
|
+
Not every decoder is subscripted: :data:`empty_response` is a shared singleton for an operation
|
|
25
|
+
with no 2xx body, mirroring :data:`raw_error_response` on the error side, and :data:`file_decoder`
|
|
26
|
+
is one for an operation whose body is a file. None of the three has a type to name. Between them,
|
|
27
|
+
every argument ``execute`` and ``stream`` need is always written out, so a payload type is never
|
|
28
|
+
left to be inferred from the surrounding declaration.
|
|
29
|
+
|
|
30
|
+
There are two decoder families, told apart by what they receive. A :class:`ResponseDecoder` takes a
|
|
31
|
+
buffered :class:`HttpResponse` whose ``content`` is already in memory; a :class:`StreamDecoder`
|
|
32
|
+
takes the still-open :class:`StreamedResponse` and hands the connection to the value it builds. So a
|
|
33
|
+
streamed payload cannot be produced by the buffered protocol at all, which is why ``stream`` names
|
|
34
|
+
its decoder from the second family."""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from typing import TYPE_CHECKING, Final, Generic, Protocol, TypeVar
|
|
41
|
+
|
|
42
|
+
from pydantic import TypeAdapter
|
|
43
|
+
from typing_extensions import TypeForm
|
|
44
|
+
|
|
45
|
+
from .adapters import adapter_for
|
|
46
|
+
from .file_responses import AsyncFileResponse, FileResponse
|
|
47
|
+
from .results import RawError
|
|
48
|
+
from .transport import AsyncStreamedResponse, HttpResponse, StreamedResponse
|
|
49
|
+
|
|
50
|
+
T = TypeVar("T")
|
|
51
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
52
|
+
E_co = TypeVar("E_co", covariant=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ResponseDecoder(Protocol[T_co]):
|
|
56
|
+
"""Decodes a 2xx response into the operation's typed success payload."""
|
|
57
|
+
|
|
58
|
+
def decode(self, response: HttpResponse) -> T_co: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ErrorMapper(Protocol[E_co]):
|
|
62
|
+
"""Maps a non-2xx response to the operation's typed error body -- a union of the
|
|
63
|
+
documented error schemas, or ``RawError`` for an unmapped status."""
|
|
64
|
+
|
|
65
|
+
def map(self, response: HttpResponse) -> E_co: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StreamDecoder(Protocol[T_co]):
|
|
69
|
+
"""Builds a 2xx payload from a body that has not been read.
|
|
70
|
+
|
|
71
|
+
The lazy counterpart of :class:`ResponseDecoder`, and a sibling rather than a subtype: that one
|
|
72
|
+
receives a buffered :class:`HttpResponse` whose ``content`` is already in memory, this one
|
|
73
|
+
receives the open :class:`StreamedResponse` and hands the connection to the value it returns.
|
|
74
|
+
|
|
75
|
+
``decode`` is synchronous in both flavours -- building the payload never touches the wire, and
|
|
76
|
+
the awaiting happens when a caller consumes it."""
|
|
77
|
+
|
|
78
|
+
def decode(self, streamed: StreamedResponse, url: str) -> T_co: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AsyncStreamDecoder(Protocol[T_co]):
|
|
82
|
+
"""Builds a 2xx payload from an unread async body; the twin of :class:`StreamDecoder`.
|
|
83
|
+
|
|
84
|
+
A twin rather than one protocol over both streamed types, because the two are what make the
|
|
85
|
+
flavours non-interchangeable: an argument position is contravariant, so a decoder over an
|
|
86
|
+
:class:`AsyncStreamedResponse` cannot satisfy :class:`StreamDecoder`, and the sync seam can
|
|
87
|
+
never be handed the async decoder."""
|
|
88
|
+
|
|
89
|
+
def decode(self, streamed: AsyncStreamedResponse, url: str) -> T_co: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class JsonDecoder(Generic[T]):
|
|
94
|
+
"""Decodes a JSON body (object, array or scalar) through ``adapter``."""
|
|
95
|
+
|
|
96
|
+
adapter: TypeAdapter[T]
|
|
97
|
+
|
|
98
|
+
def decode(self, response: HttpResponse) -> T:
|
|
99
|
+
return self.adapter.validate_python(response.json())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class TextDecoder(Generic[T]):
|
|
104
|
+
"""Decodes a plain-text body through ``adapter``."""
|
|
105
|
+
|
|
106
|
+
adapter: TypeAdapter[T]
|
|
107
|
+
|
|
108
|
+
def decode(self, response: HttpResponse) -> T:
|
|
109
|
+
return self.adapter.validate_python(response.text(errors="strict"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _JsonDecoderFactory:
|
|
113
|
+
"""``json_decoder[T]`` -- build a decoder that reads a JSON body as the subscripted type.
|
|
114
|
+
|
|
115
|
+
The subscript is a ``TypeForm``, so a runtime union alias (``Person``, which is
|
|
116
|
+
``Employee | Boss``) is as acceptable as a concrete class and ``T`` is precise for either.
|
|
117
|
+
That precision is what makes an endpoint's declared payload type checkable against what it
|
|
118
|
+
decodes. And because the factory declares no ``__call__``, the type cannot be omitted
|
|
119
|
+
(``[operator]``); its runtime ``__call__`` is checker-invisible and only guides."""
|
|
120
|
+
|
|
121
|
+
def __getitem__(self, declared: TypeForm[T]) -> ResponseDecoder[T]:
|
|
122
|
+
return JsonDecoder(adapter_for(declared))
|
|
123
|
+
|
|
124
|
+
if not TYPE_CHECKING:
|
|
125
|
+
|
|
126
|
+
def __call__(self, *args, **kwargs):
|
|
127
|
+
raise TypeError(
|
|
128
|
+
"json_decoder is not called -- subscript it with the payload type: "
|
|
129
|
+
"decoder=json_decoder[T], e.g. json_decoder[Person]"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _TextDecoderFactory:
|
|
134
|
+
"""``text_decoder[T]`` -- build a decoder reading a plain-text body; see ``json_decoder``."""
|
|
135
|
+
|
|
136
|
+
def __getitem__(self, declared: TypeForm[T]) -> ResponseDecoder[T]:
|
|
137
|
+
return TextDecoder(adapter_for(declared))
|
|
138
|
+
|
|
139
|
+
if not TYPE_CHECKING:
|
|
140
|
+
|
|
141
|
+
def __call__(self, *args, **kwargs):
|
|
142
|
+
raise TypeError(
|
|
143
|
+
"text_decoder is not called -- subscript it with the payload type: "
|
|
144
|
+
"decoder=text_decoder[T], e.g. text_decoder[int]"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _DecodeJsonFactory:
|
|
149
|
+
"""``decode_json[T](response)`` -- decode a JSON body as the subscripted type, now.
|
|
150
|
+
|
|
151
|
+
The result is typed as the subscripted type whatever shape it takes -- a concrete class, a
|
|
152
|
+
union such as ``Accountant | Manager``, an ``Annotated`` alias -- so a generated error mapper
|
|
153
|
+
returns its documented body union directly, with no ``cast`` standing in for what the type
|
|
154
|
+
system now carries itself."""
|
|
155
|
+
|
|
156
|
+
def __getitem__(self, declared: TypeForm[T]) -> Callable[[HttpResponse], T]:
|
|
157
|
+
return JsonDecoder(adapter_for(declared)).decode
|
|
158
|
+
|
|
159
|
+
if not TYPE_CHECKING:
|
|
160
|
+
|
|
161
|
+
def __call__(self, *args, **kwargs):
|
|
162
|
+
raise TypeError(
|
|
163
|
+
"decode_json is not called directly -- name the body type in its subscript: decode_json[T](response)"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _DecodeTextFactory:
|
|
168
|
+
"""``decode_text[T](response)`` -- decode a plain-text body, now; see ``decode_json``.
|
|
169
|
+
|
|
170
|
+
Also callable bare -- ``decode_text(response)`` -- which is the ``str`` default the retired
|
|
171
|
+
two-member overload existed to carry: the default lives on ``__call__`` and a named type can
|
|
172
|
+
only take the subscript path, so a mismatch has nowhere to fall through to."""
|
|
173
|
+
|
|
174
|
+
def __getitem__(self, declared: TypeForm[T]) -> Callable[[HttpResponse], T]:
|
|
175
|
+
return TextDecoder(adapter_for(declared)).decode
|
|
176
|
+
|
|
177
|
+
def __call__(self, response: HttpResponse) -> str:
|
|
178
|
+
return self[str](response)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
json_decoder: Final = _JsonDecoderFactory()
|
|
182
|
+
text_decoder: Final = _TextDecoderFactory()
|
|
183
|
+
decode_json: Final = _DecodeJsonFactory()
|
|
184
|
+
decode_text: Final = _DecodeTextFactory()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class EmptyResponse:
|
|
189
|
+
"""Response decoder for an operation whose 2xx body is empty.
|
|
190
|
+
|
|
191
|
+
The success-side counterpart of :class:`RawErrorResponse`, and the reason ``decoder`` is a
|
|
192
|
+
required argument rather than an optional one: an operation that returns no content says so by
|
|
193
|
+
naming this, instead of by leaving the argument out. Presence is auditable where absence is
|
|
194
|
+
not -- a missing ``decoder=`` could be a no-content operation or a generator that dropped the
|
|
195
|
+
line, and only one of those should compile.
|
|
196
|
+
|
|
197
|
+
Naming it is also what keeps the payload type honest. ``T`` is solved from this argument, so the
|
|
198
|
+
operation can declare ``ApiResult[None, E]`` and nothing else. Were the argument omitted
|
|
199
|
+
instead, ``T`` would be solved from the declared type, and an operation that parses nothing
|
|
200
|
+
could claim any payload it liked and hand back ``None`` at runtime -- the last place a declared
|
|
201
|
+
type could go unstated, now that each factory makes its own subscript mandatory.
|
|
202
|
+
|
|
203
|
+
Not subscripted, and not a factory: there is no type to name. ``json_decoder[None]`` would still
|
|
204
|
+
parse the body and raise on an empty one, which is the opposite of what this does.
|
|
205
|
+
|
|
206
|
+
Carries nothing SDK-specific, so it lives here rather than in the generated error layer, and is
|
|
207
|
+
shared by every such operation."""
|
|
208
|
+
|
|
209
|
+
def decode(self, response: HttpResponse) -> None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
empty_response: Final[ResponseDecoder[None]] = EmptyResponse()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class RawErrorResponse:
|
|
218
|
+
"""Error mapper for an operation that declares no error schemas.
|
|
219
|
+
|
|
220
|
+
Deliberately has **no** ``match`` on the status code: the absence is the
|
|
221
|
+
signal. Where an operation's spec documents error responses, its own mapper
|
|
222
|
+
in the generated error layer selects a schema per status; where it documents
|
|
223
|
+
none there is nothing to select, so every non-2xx response maps to the same
|
|
224
|
+
:class:`RawError` -- status plus the undecoded body, decoded on demand.
|
|
225
|
+
|
|
226
|
+
Carries nothing SDK-specific, so it lives here rather than in the generated
|
|
227
|
+
error layer, and is shared by every such operation."""
|
|
228
|
+
|
|
229
|
+
def map(self, response: HttpResponse) -> RawError:
|
|
230
|
+
return RawError(response)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
raw_error_response: Final[ErrorMapper[RawError]] = RawErrorResponse()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True, slots=True)
|
|
237
|
+
class FileDecoder:
|
|
238
|
+
"""Stream decoder for an operation whose 2xx body is a file.
|
|
239
|
+
|
|
240
|
+
Not subscripted, and not a factory: a file bypasses the model layer, so there is no adapter to
|
|
241
|
+
name -- the same absence that leaves ``file_part`` unsubscripted on the request side.
|
|
242
|
+
|
|
243
|
+
Naming it is what keeps the payload solved from an *argument* rather than from the seam's own
|
|
244
|
+
return type, which is what lets a second streamed payload shape join ``stream`` instead of
|
|
245
|
+
forking it. ``url`` is carried in because a :class:`StreamedResponse` does not know it and the
|
|
246
|
+
payload reports it when abandoned."""
|
|
247
|
+
|
|
248
|
+
def decode(self, streamed: StreamedResponse, url: str) -> FileResponse:
|
|
249
|
+
return FileResponse(streamed, url)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
file_decoder: Final[StreamDecoder[FileResponse]] = FileDecoder()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, slots=True)
|
|
256
|
+
class AsyncFileDecoder:
|
|
257
|
+
"""Stream decoder for an operation whose 2xx body is a file; the twin of :class:`FileDecoder`."""
|
|
258
|
+
|
|
259
|
+
def decode(self, streamed: AsyncStreamedResponse, url: str) -> AsyncFileResponse:
|
|
260
|
+
return AsyncFileResponse(streamed, url)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async_file_decoder: Final[AsyncStreamDecoder[AsyncFileResponse]] = AsyncFileDecoder()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The SDK's single exception type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic
|
|
6
|
+
|
|
7
|
+
from typing_extensions import TypeVar
|
|
8
|
+
|
|
9
|
+
from .transport import HttpResponse
|
|
10
|
+
|
|
11
|
+
E = TypeVar("E", default=object)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(Exception, Generic[E]):
|
|
15
|
+
"""A failed API call, raised by the parsed response mode.
|
|
16
|
+
|
|
17
|
+
``error`` is the decoded response body -- a typed union of the operation's documented
|
|
18
|
+
error schemas, or :class:`RawError` for a status the operation does not document. That
|
|
19
|
+
payload *is* the information, so neither string form embeds it: ``str`` and ``repr``
|
|
20
|
+
identify the failure by status and error type only, which keeps response bodies out of logs
|
|
21
|
+
and tracebacks. Read ``.error`` to inspect it."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, error: E, response: HttpResponse) -> None:
|
|
24
|
+
# Composed once: ``Exception.__str__`` returns a single argument verbatim, so
|
|
25
|
+
# ``args[0]`` and ``str(self)`` are the same string by construction.
|
|
26
|
+
super().__init__(f"HTTP {response.status_code}: {type(error).__name__}")
|
|
27
|
+
self.error: E = error
|
|
28
|
+
self.response: HttpResponse = response
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def status_code(self) -> int:
|
|
32
|
+
return self.response.status_code
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"{type(self).__name__}(status_code={self.status_code}, error={type(self.error).__name__})"
|
|
36
|
+
|
|
37
|
+
def __reduce__(self) -> tuple[type[ApiError[E]], tuple[E, HttpResponse], dict[str, Any]]:
|
|
38
|
+
# ``BaseException``'s default reduce replays ``args`` through ``__init__``, which takes
|
|
39
|
+
# two arguments -- so pickling and copying both fail without this. The state mapping is
|
|
40
|
+
# what carries ``__notes__`` and any subclass attribute across the round trip.
|
|
41
|
+
return (type(self), (self.error, self.response), self.__dict__)
|
|
@@ -0,0 +1,324 @@
|
|
|
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 .transport import AsyncStreamedResponse, StreamedResponse
|
|
25
|
+
|
|
26
|
+
_CHUNK_SIZE = 65_536
|
|
27
|
+
"""The library's own multipart chunk size, matched so a download iterates as an upload reads."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileResponse:
|
|
31
|
+
"""A response body that has not been read yet.
|
|
32
|
+
|
|
33
|
+
Three ways to consume it, and all three close the connection::
|
|
34
|
+
|
|
35
|
+
client.files.download(id).save_to("out.bin") # streams to disk
|
|
36
|
+
data = client.files.download(id).read() # buffers, for a small file
|
|
37
|
+
with client.files.download(id) as f: # anything else
|
|
38
|
+
for chunk in f.iter_bytes():
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
Reading is what closes it, so only a *partially* consumed manual iteration outside a ``with``
|
|
42
|
+
can leak. That case emits a ``ResourceWarning`` at finalization, the same way an unclosed file
|
|
43
|
+
object does -- the mistake is made loud rather than quietly cleaned up (ADR-0007, amended).
|
|
44
|
+
The client's own ``close()`` is the backstop: it disposes the pool, and any connection still
|
|
45
|
+
held with it. Single-use: once closed, reading again raises rather than yielding nothing. One
|
|
46
|
+
consumer per response: ``read()`` after a partially-driven ``iter_bytes()`` raises the
|
|
47
|
+
underlying single-pass error. The per-call ``timeout`` applies to each read of a streamed
|
|
48
|
+
body, so a stalled download raises rather than hanging.
|
|
49
|
+
|
|
50
|
+
``headers`` is the full response head -- ``ETag``, ``Last-Modified`` and friends cost nothing
|
|
51
|
+
to expose since the head has already arrived; ``media_type``, ``content_length`` and
|
|
52
|
+
``filename`` are the three curated conveniences over it. ``filename`` is the server's own
|
|
53
|
+
suggestion from ``Content-Disposition`` and is sanitised before it can name a path -- see
|
|
54
|
+
:meth:`save_to`."""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("_closed", "_stream", "_url", "content_length", "filename", "headers", "media_type")
|
|
57
|
+
|
|
58
|
+
def __init__(self, stream: StreamedResponse, url: str) -> None:
|
|
59
|
+
# Total by design: this runs between the head arriving and the raw client returning, so a
|
|
60
|
+
# raise here would leak the connection. Malformed headers degrade to None, never raise.
|
|
61
|
+
self._stream = stream
|
|
62
|
+
self._closed = False
|
|
63
|
+
self._url = url
|
|
64
|
+
self.headers: Mapping[str, str] = stream.headers
|
|
65
|
+
self.media_type = self.headers.get("content-type")
|
|
66
|
+
length = self.headers.get("content-length")
|
|
67
|
+
self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
|
|
68
|
+
self.filename = content_disposition_filename(self.headers.get("content-disposition"))
|
|
69
|
+
|
|
70
|
+
def iter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> Iterator[bytes]:
|
|
71
|
+
"""Iterate the body in chunks, closing when it is exhausted.
|
|
72
|
+
|
|
73
|
+
The default is the underlying library's own 64 KiB: a 2 GiB download is 32 768 iterations
|
|
74
|
+
at this size. An exception mid-iteration closes before propagating; only abandoning the
|
|
75
|
+
iteration part-way leaves the connection held, which ``__del__`` reports.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
chunk_size: How many bytes each chunk carries at most.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The body's chunks, in order.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If ``chunk_size`` is not positive."""
|
|
85
|
+
# Checked here, not in the generator: a closed response must raise at the call, the way a
|
|
86
|
+
# closed file does -- not at the first next().
|
|
87
|
+
self._ensure_open()
|
|
88
|
+
if chunk_size <= 0:
|
|
89
|
+
raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
|
|
90
|
+
return self._consume(chunk_size)
|
|
91
|
+
|
|
92
|
+
def _consume(self, chunk_size: int) -> Iterator[bytes]:
|
|
93
|
+
try:
|
|
94
|
+
yield from self._stream.iter_bytes(chunk_size)
|
|
95
|
+
except GeneratorExit:
|
|
96
|
+
# Abandoned mid-iteration: deliberately left open. Closing here would run at GC time,
|
|
97
|
+
# and a finalizer may report, never act (ADR-0007) -- __del__ warns, client.close()
|
|
98
|
+
# reaps. The warning is the remedy's pointer, not the remedy.
|
|
99
|
+
raise
|
|
100
|
+
except BaseException:
|
|
101
|
+
self.close()
|
|
102
|
+
raise
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
def read(self) -> bytes:
|
|
106
|
+
"""Buffer the whole body and close. For a payload that fits in memory.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The complete body."""
|
|
110
|
+
self._ensure_open()
|
|
111
|
+
try:
|
|
112
|
+
return self._stream.read()
|
|
113
|
+
finally:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
def save_to(self, destination: str | Path) -> Path:
|
|
117
|
+
"""Stream the body to ``destination``, closing when done.
|
|
118
|
+
|
|
119
|
+
A directory is a legal destination: the file is named by ``filename`` when the server sent
|
|
120
|
+
one -- reduced to a safe basename first, since a ``Content-Disposition`` is
|
|
121
|
+
attacker-controlled -- and ``ValueError`` is raised when it did not. Written to
|
|
122
|
+
``<name>.part`` and moved into place with ``os.replace``, which is atomic on both
|
|
123
|
+
platforms, so an interrupted download never appears under the final name; the partial is
|
|
124
|
+
unlinked on failure. A failure to open the partial -- a missing parent directory, or
|
|
125
|
+
permissions -- propagates with the response still consumable, exactly as the no-filename
|
|
126
|
+
precondition does.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
destination: The file path to write, or a directory to write into under the
|
|
130
|
+
server-suggested name.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The path written.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If ``destination`` is a directory and the server sent no usable filename."""
|
|
137
|
+
# First: a second save_to must raise before it can touch the filesystem -- without this, a
|
|
138
|
+
# doomed call would create, truncate and unlink the .part before iter_bytes refuses.
|
|
139
|
+
self._ensure_open()
|
|
140
|
+
target = Path(destination)
|
|
141
|
+
if target.is_dir():
|
|
142
|
+
if self.filename is None:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"the server sent no filename to save under -- pass a full file path instead of a directory"
|
|
145
|
+
)
|
|
146
|
+
target = target / safe_filename(self.filename)
|
|
147
|
+
partial = target.with_name(target.name + ".part")
|
|
148
|
+
out = partial.open("wb") # an open failure, like the precondition above, leaves the response consumable
|
|
149
|
+
try:
|
|
150
|
+
with out:
|
|
151
|
+
for chunk in self.iter_bytes():
|
|
152
|
+
out.write(chunk)
|
|
153
|
+
os.replace(partial, target) # inside the try: a failed replace must also clean up
|
|
154
|
+
except BaseException:
|
|
155
|
+
self.close() # idempotent -- iter_bytes' own error path may have closed already
|
|
156
|
+
partial.unlink(missing_ok=True)
|
|
157
|
+
raise
|
|
158
|
+
return target
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""Release the connection. Idempotent, and every consuming path above calls it."""
|
|
162
|
+
if self._closed:
|
|
163
|
+
return
|
|
164
|
+
self._closed = True
|
|
165
|
+
self._stream.close()
|
|
166
|
+
|
|
167
|
+
def __enter__(self) -> Self:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def __exit__(self, *exc: object) -> None:
|
|
171
|
+
self.close()
|
|
172
|
+
|
|
173
|
+
def __del__(self) -> None:
|
|
174
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
175
|
+
if not getattr(self, "_closed", True):
|
|
176
|
+
warnings.warn(
|
|
177
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `with`, or call read() / save_to() / close()",
|
|
178
|
+
ResourceWarning,
|
|
179
|
+
stacklevel=2,
|
|
180
|
+
source=self,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
# Identify by media type and length only -- never the bytes, which may be enormous and
|
|
185
|
+
# are not read yet anyway. Same rule as RawError and ApiError.
|
|
186
|
+
return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
|
|
187
|
+
|
|
188
|
+
def _ensure_open(self) -> None:
|
|
189
|
+
if self._closed:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"this {type(self).__name__} is closed -- it is single-use: read(), save_to() and "
|
|
192
|
+
"a drained iter_bytes() each consume and close it"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AsyncFileResponse:
|
|
197
|
+
"""The awaited twin of :class:`FileResponse` -- same lifecycle, ``a``-prefixed verbs.
|
|
198
|
+
|
|
199
|
+
``async with`` is the spelling to reach for: an abandoned instance defers finalization to
|
|
200
|
+
``loop.shutdown_asyncgens()``, where the ``ResourceWarning`` still fires but later and less
|
|
201
|
+
usefully, and ``aclose`` must run on the loop that created the response."""
|
|
202
|
+
|
|
203
|
+
__slots__ = ("_closed", "_stream", "_url", "content_length", "filename", "headers", "media_type")
|
|
204
|
+
|
|
205
|
+
def __init__(self, stream: AsyncStreamedResponse, url: str) -> None:
|
|
206
|
+
# Total by design, exactly as the sync twin's: a raise here would leak the connection.
|
|
207
|
+
self._stream = stream
|
|
208
|
+
self._closed = False
|
|
209
|
+
self._url = url
|
|
210
|
+
self.headers: Mapping[str, str] = stream.headers
|
|
211
|
+
self.media_type = self.headers.get("content-type")
|
|
212
|
+
length = self.headers.get("content-length")
|
|
213
|
+
self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
|
|
214
|
+
self.filename = content_disposition_filename(self.headers.get("content-disposition"))
|
|
215
|
+
|
|
216
|
+
def aiter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> AsyncIterator[bytes]:
|
|
217
|
+
"""Iterate the body in chunks, closing when it is exhausted; see the sync twin.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
chunk_size: How many bytes each chunk carries at most.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The body's chunks, in order.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If ``chunk_size`` is not positive."""
|
|
227
|
+
self._ensure_open()
|
|
228
|
+
if chunk_size <= 0:
|
|
229
|
+
raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
|
|
230
|
+
return self._consume(chunk_size)
|
|
231
|
+
|
|
232
|
+
async def _consume(self, chunk_size: int) -> AsyncIterator[bytes]:
|
|
233
|
+
try:
|
|
234
|
+
async for chunk in self._stream.aiter_bytes(chunk_size):
|
|
235
|
+
yield chunk
|
|
236
|
+
except GeneratorExit:
|
|
237
|
+
# Abandoned mid-iteration: deliberately left open, as in the sync twin.
|
|
238
|
+
raise
|
|
239
|
+
except BaseException:
|
|
240
|
+
await self.aclose()
|
|
241
|
+
raise
|
|
242
|
+
await self.aclose()
|
|
243
|
+
|
|
244
|
+
async def aread(self) -> bytes:
|
|
245
|
+
"""Buffer the whole body and close. For a payload that fits in memory.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The complete body."""
|
|
249
|
+
self._ensure_open()
|
|
250
|
+
try:
|
|
251
|
+
return await self._stream.aread()
|
|
252
|
+
finally:
|
|
253
|
+
await self.aclose()
|
|
254
|
+
|
|
255
|
+
async def save_to(self, destination: str | Path) -> Path:
|
|
256
|
+
"""Stream the body to ``destination``, closing when done; see the sync twin.
|
|
257
|
+
|
|
258
|
+
Every filesystem touch runs in a worker thread, so a slow disk does not park the loop.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
destination: The file path to write, or a directory to write into under the
|
|
262
|
+
server-suggested name.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The path written.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: If ``destination`` is a directory and the server sent no usable filename."""
|
|
269
|
+
self._ensure_open() # first, as in the sync twin: raise before touching the filesystem
|
|
270
|
+
target = Path(destination)
|
|
271
|
+
if await asyncio.to_thread(target.is_dir):
|
|
272
|
+
if self.filename is None:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"the server sent no filename to save under -- pass a full file path instead of a directory"
|
|
275
|
+
)
|
|
276
|
+
target = target / safe_filename(self.filename)
|
|
277
|
+
partial = target.with_name(target.name + ".part")
|
|
278
|
+
out = await asyncio.to_thread(partial.open, "wb") # an open failure leaves the response consumable
|
|
279
|
+
try:
|
|
280
|
+
try:
|
|
281
|
+
async for chunk in self.aiter_bytes():
|
|
282
|
+
await asyncio.to_thread(out.write, chunk)
|
|
283
|
+
finally:
|
|
284
|
+
await asyncio.to_thread(out.close)
|
|
285
|
+
await asyncio.to_thread(os.replace, partial, target)
|
|
286
|
+
except BaseException:
|
|
287
|
+
await self.aclose() # idempotent -- aiter_bytes' own error path may have closed already
|
|
288
|
+
await asyncio.to_thread(partial.unlink, True)
|
|
289
|
+
raise
|
|
290
|
+
return target
|
|
291
|
+
|
|
292
|
+
async def aclose(self) -> None:
|
|
293
|
+
"""Release the connection. Idempotent, and every consuming path above awaits it."""
|
|
294
|
+
if self._closed:
|
|
295
|
+
return
|
|
296
|
+
self._closed = True
|
|
297
|
+
await self._stream.aclose()
|
|
298
|
+
|
|
299
|
+
async def __aenter__(self) -> Self:
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
303
|
+
await self.aclose()
|
|
304
|
+
|
|
305
|
+
def __del__(self) -> None:
|
|
306
|
+
# getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
|
|
307
|
+
if not getattr(self, "_closed", True):
|
|
308
|
+
warnings.warn(
|
|
309
|
+
f"unclosed {type(self).__name__} for {self._url} -- use `async with`, or call "
|
|
310
|
+
"aread() / save_to() / aclose()",
|
|
311
|
+
ResourceWarning,
|
|
312
|
+
stacklevel=2,
|
|
313
|
+
source=self,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def __repr__(self) -> str:
|
|
317
|
+
return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
|
|
318
|
+
|
|
319
|
+
def _ensure_open(self) -> None:
|
|
320
|
+
if self._closed:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"this {type(self).__name__} is closed -- it is single-use: aread(), save_to() and "
|
|
323
|
+
"a drained aiter_bytes() each consume and close it"
|
|
324
|
+
)
|