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.
Files changed (83) hide show
  1. cliv1-0.1.1.dist-info/METADATA +156 -0
  2. cliv1-0.1.1.dist-info/RECORD +83 -0
  3. cliv1-0.1.1.dist-info/WHEEL +4 -0
  4. cliv1-0.1.1.dist-info/licenses/LICENSE +21 -0
  5. swagger_petstore_open_api_3_0/__init__.py +15 -0
  6. swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
  7. swagger_petstore_open_api_3_0/apis/pet_api.py +881 -0
  8. swagger_petstore_open_api_3_0/apis/store.py +411 -0
  9. swagger_petstore_open_api_3_0/apis/user_api.py +792 -0
  10. swagger_petstore_open_api_3_0/async_client.py +79 -0
  11. swagger_petstore_open_api_3_0/auth.py +17 -0
  12. swagger_petstore_open_api_3_0/base_client.py +18 -0
  13. swagger_petstore_open_api_3_0/client.py +77 -0
  14. swagger_petstore_open_api_3_0/core/__init__.py +342 -0
  15. swagger_petstore_open_api_3_0/core/_internal/__init__.py +7 -0
  16. swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
  17. swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
  18. swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
  19. swagger_petstore_open_api_3_0/core/_internal/urls.py +166 -0
  20. swagger_petstore_open_api_3_0/core/_internal/wire.py +173 -0
  21. swagger_petstore_open_api_3_0/core/adapters.py +177 -0
  22. swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
  23. swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
  24. swagger_petstore_open_api_3_0/core/auth/models.py +217 -0
  25. swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
  26. swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
  27. swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
  28. swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
  29. swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
  30. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
  31. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
  32. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
  33. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
  34. swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
  35. swagger_petstore_open_api_3_0/core/bodies.py +563 -0
  36. swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
  37. swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
  38. swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +271 -0
  39. swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
  40. swagger_petstore_open_api_3_0/core/decoding.py +263 -0
  41. swagger_petstore_open_api_3_0/core/exceptions.py +41 -0
  42. swagger_petstore_open_api_3_0/core/file_responses.py +324 -0
  43. swagger_petstore_open_api_3_0/core/files.py +173 -0
  44. swagger_petstore_open_api_3_0/core/httpx_transport.py +766 -0
  45. swagger_petstore_open_api_3_0/core/models.py +143 -0
  46. swagger_petstore_open_api_3_0/core/optionality.py +130 -0
  47. swagger_petstore_open_api_3_0/core/params.py +161 -0
  48. swagger_petstore_open_api_3_0/core/raw_client.py +388 -0
  49. swagger_petstore_open_api_3_0/core/request_options.py +94 -0
  50. swagger_petstore_open_api_3_0/core/results.py +110 -0
  51. swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
  52. swagger_petstore_open_api_3_0/core/servers.py +33 -0
  53. swagger_petstore_open_api_3_0/core/transport.py +248 -0
  54. swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
  55. swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
  56. swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
  57. swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
  58. swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
  59. swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
  60. swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
  61. swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
  62. swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
  63. swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
  64. swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
  65. swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
  66. swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
  67. swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
  68. swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
  69. swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
  70. swagger_petstore_open_api_3_0/models/__init__.py +23 -0
  71. swagger_petstore_open_api_3_0/models/api_response.py +18 -0
  72. swagger_petstore_open_api_3_0/models/category.py +15 -0
  73. swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
  74. swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
  75. swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
  76. swagger_petstore_open_api_3_0/models/order.py +27 -0
  77. swagger_petstore_open_api_3_0/models/pet.py +28 -0
  78. swagger_petstore_open_api_3_0/models/tag.py +15 -0
  79. swagger_petstore_open_api_3_0/models/user.py +29 -0
  80. swagger_petstore_open_api_3_0/py.typed +0 -0
  81. swagger_petstore_open_api_3_0/server/__init__.py +21 -0
  82. swagger_petstore_open_api_3_0/server/server.py +17 -0
  83. 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
+ )