cliV1 0.0.8__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 (87) hide show
  1. cliv1-0.0.8.dist-info/METADATA +168 -0
  2. cliv1-0.0.8.dist-info/RECORD +87 -0
  3. cliv1-0.0.8.dist-info/WHEEL +4 -0
  4. cliv1-0.0.8.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 +883 -0
  8. swagger_petstore_open_api_3_0/apis/store.py +413 -0
  9. swagger_petstore_open_api_3_0/apis/user_api.py +794 -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 +358 -0
  15. swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -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/sse_parser.py +124 -0
  20. swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
  21. swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
  22. swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
  23. swagger_petstore_open_api_3_0/core/adapters.py +177 -0
  24. swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
  25. swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
  26. swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
  27. swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
  28. swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
  29. swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
  30. swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
  31. swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
  32. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
  33. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
  34. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
  35. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
  36. swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
  37. swagger_petstore_open_api_3_0/core/bodies.py +550 -0
  38. swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
  39. swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
  40. swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
  41. swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
  42. swagger_petstore_open_api_3_0/core/decoding.py +498 -0
  43. swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
  44. swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
  45. swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
  46. swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
  47. swagger_petstore_open_api_3_0/core/files.py +173 -0
  48. swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
  49. swagger_petstore_open_api_3_0/core/models.py +143 -0
  50. swagger_petstore_open_api_3_0/core/optionality.py +130 -0
  51. swagger_petstore_open_api_3_0/core/params.py +157 -0
  52. swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
  53. swagger_petstore_open_api_3_0/core/request_options.py +94 -0
  54. swagger_petstore_open_api_3_0/core/results.py +123 -0
  55. swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
  56. swagger_petstore_open_api_3_0/core/servers.py +33 -0
  57. swagger_petstore_open_api_3_0/core/transport.py +191 -0
  58. swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
  59. swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
  60. swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
  61. swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
  62. swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
  63. swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
  64. swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
  65. swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
  66. swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
  67. swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
  68. swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
  69. swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
  70. swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
  71. swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
  72. swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
  73. swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
  74. swagger_petstore_open_api_3_0/models/__init__.py +23 -0
  75. swagger_petstore_open_api_3_0/models/api_response.py +18 -0
  76. swagger_petstore_open_api_3_0/models/category.py +15 -0
  77. swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
  78. swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
  79. swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
  80. swagger_petstore_open_api_3_0/models/order.py +27 -0
  81. swagger_petstore_open_api_3_0/models/pet.py +28 -0
  82. swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
  83. swagger_petstore_open_api_3_0/models/user.py +29 -0
  84. swagger_petstore_open_api_3_0/py.typed +0 -0
  85. swagger_petstore_open_api_3_0/server/__init__.py +21 -0
  86. swagger_petstore_open_api_3_0/server/server.py +17 -0
  87. swagger_petstore_open_api_3_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.sse_parser import SseParser
29
+ from ._internal.urls import strip_query
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; query-stripped, so an api key placed there cannot reach a warning.
62
+ self._url = strip_query(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 stripped, as in the sync twin.
154
+ self._response = response
155
+ self._closed = False
156
+ self._url = strip_query(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,38 @@
1
+ """The SDK's single exception type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, Generic
7
+
8
+ from typing_extensions import TypeVar
9
+
10
+ E = TypeVar("E", default=object)
11
+
12
+
13
+ class ApiError(Exception, Generic[E]):
14
+ """A failed API call, raised by the parsed response mode.
15
+
16
+ ``error`` is the decoded response body -- a typed union of the operation's documented
17
+ error schemas, or :class:`RawError` for a status the operation does not document. That
18
+ payload *is* the information, so neither string form embeds it: ``str`` and ``repr``
19
+ identify the failure by status and error type only, which keeps response bodies out of logs
20
+ and tracebacks. Read ``.error`` to inspect it."""
21
+
22
+ def __init__(self, error: E, status_code: int, headers: Mapping[str, str]) -> None:
23
+ # Composed once: ``Exception.__str__`` returns a single argument verbatim, so
24
+ # ``args[0]`` and ``str(self)`` are the same string by construction.
25
+ super().__init__(f"HTTP {status_code}: {type(error).__name__}")
26
+ self.error: E = error
27
+ self.status_code = status_code
28
+ self.headers: Mapping[str, str] = headers
29
+ """Header names lowercased, per the transports' obligation -- look keys up in lowercase."""
30
+
31
+ def __repr__(self) -> str:
32
+ return f"{type(self).__name__}(status_code={self.status_code}, error={type(self.error).__name__})"
33
+
34
+ def __reduce__(self) -> tuple[type[ApiError[E]], tuple[E, int, Mapping[str, str]], dict[str, Any]]:
35
+ # ``BaseException``'s default reduce replays ``args`` through ``__init__``, which takes
36
+ # three arguments -- so pickling and copying both fail without this. The state mapping is
37
+ # what carries ``__notes__`` and any subclass attribute across the round trip.
38
+ return (type(self), (self.error, self.status_code, self.headers), self.__dict__)
@@ -0,0 +1,335 @@
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.urls import strip_query
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. Stripped of its query string because this
67
+ # string's only reader is the abandonment warning, and a query-placed api key would
68
+ # otherwise reach stderr and the logs with it.
69
+ self._url = strip_query(response.url)
70
+ self.headers: Mapping[str, str] = response.headers
71
+ self.media_type = self.headers.get("content-type")
72
+ length = self.headers.get("content-length")
73
+ self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
74
+ self.filename = content_disposition_filename(self.headers.get("content-disposition"))
75
+
76
+ def iter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> Iterator[bytes]:
77
+ """Iterate the body in chunks, closing when it is exhausted.
78
+
79
+ The default is the underlying library's own 64 KiB: a 2 GiB download is 32 768 iterations
80
+ at this size. An exception mid-iteration closes before propagating; only abandoning the
81
+ iteration part-way leaves the connection held, which ``__del__`` reports.
82
+
83
+ Args:
84
+ chunk_size: How many bytes each chunk carries at most.
85
+
86
+ Returns:
87
+ The body's chunks, in order.
88
+
89
+ Raises:
90
+ ValueError: If ``chunk_size`` is not positive."""
91
+ # Checked here, not in the generator: a closed response must raise at the call, the way a
92
+ # closed file does -- not at the first next().
93
+ self._ensure_open()
94
+ if chunk_size <= 0:
95
+ raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
96
+ return self._consume(chunk_size)
97
+
98
+ def _consume(self, chunk_size: int) -> Iterator[bytes]:
99
+ try:
100
+ yield from self._response.iter_bytes(chunk_size)
101
+ except GeneratorExit:
102
+ # Abandoned mid-iteration: deliberately left open. Closing here would run at GC time,
103
+ # and a finalizer may report, never act (ADR-0007) -- __del__ warns, client.close()
104
+ # reaps. The warning is the remedy's pointer, not the remedy.
105
+ raise
106
+ except BaseException:
107
+ self.close()
108
+ raise
109
+ self.close()
110
+
111
+ def read(self) -> bytes:
112
+ """Buffer the whole body and close. For a payload that fits in memory.
113
+
114
+ Returns:
115
+ The complete body."""
116
+ self._ensure_open()
117
+ try:
118
+ return self._response.read()
119
+ finally:
120
+ self.close()
121
+
122
+ def save_to(self, destination: str | Path) -> Path:
123
+ """Stream the body to ``destination``, closing when done.
124
+
125
+ A directory is a legal destination: the file is named by ``filename`` when the server sent
126
+ one -- reduced to a safe basename first, since a ``Content-Disposition`` is
127
+ attacker-controlled -- and ``ValueError`` is raised when it did not. Written to
128
+ ``<name>.part`` and moved into place with ``os.replace``, which is atomic on both
129
+ platforms, so an interrupted download never appears under the final name; the partial is
130
+ unlinked on failure. A failure to open the partial -- a missing parent directory, or
131
+ permissions -- propagates with the response still consumable, exactly as the no-filename
132
+ precondition does.
133
+
134
+ Args:
135
+ destination: The file path to write, or a directory to write into under the
136
+ server-suggested name.
137
+
138
+ Returns:
139
+ The path written.
140
+
141
+ Raises:
142
+ ValueError: If ``destination`` is a directory and the server sent no usable filename."""
143
+ # First: a second save_to must raise before it can touch the filesystem -- without this, a
144
+ # doomed call would create, truncate and unlink the .part before iter_bytes refuses.
145
+ self._ensure_open()
146
+ target = Path(destination)
147
+ if target.is_dir():
148
+ if self.filename is None:
149
+ raise ValueError(
150
+ "the server sent no filename to save under -- pass a full file path instead of a directory"
151
+ )
152
+ target = target / safe_filename(self.filename)
153
+ partial = target.with_name(target.name + ".part")
154
+ out = partial.open("wb") # an open failure, like the precondition above, leaves the response consumable
155
+ try:
156
+ with out:
157
+ for chunk in self.iter_bytes():
158
+ out.write(chunk)
159
+ os.replace(partial, target) # inside the try: a failed replace must also clean up
160
+ except BaseException:
161
+ self.close() # idempotent -- iter_bytes' own error path may have closed already
162
+ partial.unlink(missing_ok=True)
163
+ raise
164
+ return target
165
+
166
+ def close(self) -> None:
167
+ """Release the connection. Idempotent, and every consuming path above calls it."""
168
+ if self._closed:
169
+ return
170
+ self._closed = True
171
+ self._response.close()
172
+
173
+ def __enter__(self) -> Self:
174
+ return self
175
+
176
+ def __exit__(self, *exc: object) -> None:
177
+ self.close()
178
+
179
+ def __del__(self) -> None:
180
+ # getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
181
+ if not getattr(self, "_closed", True):
182
+ warnings.warn(
183
+ f"unclosed {type(self).__name__} for {self._url} -- use `with`, or call read() / save_to() / close()",
184
+ ResourceWarning,
185
+ stacklevel=2,
186
+ source=self,
187
+ )
188
+
189
+ def __repr__(self) -> str:
190
+ # Identify by media type and length only -- never the bytes, which may be enormous and
191
+ # are not read yet anyway. Same rule as RawError and ApiError.
192
+ return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
193
+
194
+ def _ensure_open(self) -> None:
195
+ if self._closed:
196
+ raise ValueError(
197
+ f"this {type(self).__name__} is closed -- it is single-use: read(), save_to() and "
198
+ "a drained iter_bytes() each consume and close it"
199
+ )
200
+
201
+
202
+ class AsyncFileResponse:
203
+ """The awaited twin of :class:`FileResponse` -- same lifecycle, ``a``-prefixed verbs.
204
+
205
+ ``async with`` is the spelling to reach for: an abandoned instance defers finalization to
206
+ ``loop.shutdown_asyncgens()``, where the ``ResourceWarning`` still fires but later and less
207
+ usefully, and ``aclose`` must run on the loop that created the response."""
208
+
209
+ __slots__ = ("_closed", "_response", "_url", "content_length", "filename", "headers", "media_type")
210
+
211
+ def __init__(self, response: AsyncHttpResponse) -> None:
212
+ # Total by design, exactly as the sync twin's: a raise here would leak the connection.
213
+ self._response = response
214
+ self._closed = False
215
+ # Captured here rather than read off the response in __del__, which can run at interpreter
216
+ # shutdown where a property call into the transport may find its objects already torn down --
217
+ # and a finalizer that raises reports nothing. Stripped of its query string because this
218
+ # string's only reader is the abandonment warning, and a query-placed api key would
219
+ # otherwise reach stderr and the logs with it.
220
+ self._url = strip_query(response.url)
221
+ self.headers: Mapping[str, str] = response.headers
222
+ self.media_type = self.headers.get("content-type")
223
+ length = self.headers.get("content-length")
224
+ self.content_length = int(length) if length is not None and length.isascii() and length.isdigit() else None
225
+ self.filename = content_disposition_filename(self.headers.get("content-disposition"))
226
+
227
+ def aiter_bytes(self, chunk_size: int = _CHUNK_SIZE) -> AsyncIterator[bytes]:
228
+ """Iterate the body in chunks, closing when it is exhausted; see the sync twin.
229
+
230
+ Args:
231
+ chunk_size: How many bytes each chunk carries at most.
232
+
233
+ Returns:
234
+ The body's chunks, in order.
235
+
236
+ Raises:
237
+ ValueError: If ``chunk_size`` is not positive."""
238
+ self._ensure_open()
239
+ if chunk_size <= 0:
240
+ raise ValueError(f"chunk_size must be a positive number of bytes -- got {chunk_size}")
241
+ return self._consume(chunk_size)
242
+
243
+ async def _consume(self, chunk_size: int) -> AsyncIterator[bytes]:
244
+ try:
245
+ async for chunk in self._response.aiter_bytes(chunk_size):
246
+ yield chunk
247
+ except GeneratorExit:
248
+ # Abandoned mid-iteration: deliberately left open, as in the sync twin.
249
+ raise
250
+ except BaseException:
251
+ await self.aclose()
252
+ raise
253
+ await self.aclose()
254
+
255
+ async def aread(self) -> bytes:
256
+ """Buffer the whole body and close. For a payload that fits in memory.
257
+
258
+ Returns:
259
+ The complete body."""
260
+ self._ensure_open()
261
+ try:
262
+ return await self._response.aread()
263
+ finally:
264
+ await self.aclose()
265
+
266
+ async def save_to(self, destination: str | Path) -> Path:
267
+ """Stream the body to ``destination``, closing when done; see the sync twin.
268
+
269
+ Every filesystem touch runs in a worker thread, so a slow disk does not park the loop.
270
+
271
+ Args:
272
+ destination: The file path to write, or a directory to write into under the
273
+ server-suggested name.
274
+
275
+ Returns:
276
+ The path written.
277
+
278
+ Raises:
279
+ ValueError: If ``destination`` is a directory and the server sent no usable filename."""
280
+ self._ensure_open() # first, as in the sync twin: raise before touching the filesystem
281
+ target = Path(destination)
282
+ if await asyncio.to_thread(target.is_dir):
283
+ if self.filename is None:
284
+ raise ValueError(
285
+ "the server sent no filename to save under -- pass a full file path instead of a directory"
286
+ )
287
+ target = target / safe_filename(self.filename)
288
+ partial = target.with_name(target.name + ".part")
289
+ out = await asyncio.to_thread(partial.open, "wb") # an open failure leaves the response consumable
290
+ try:
291
+ try:
292
+ async for chunk in self.aiter_bytes():
293
+ await asyncio.to_thread(out.write, chunk)
294
+ finally:
295
+ await asyncio.to_thread(out.close)
296
+ await asyncio.to_thread(os.replace, partial, target)
297
+ except BaseException:
298
+ await self.aclose() # idempotent -- aiter_bytes' own error path may have closed already
299
+ await asyncio.to_thread(partial.unlink, True)
300
+ raise
301
+ return target
302
+
303
+ async def aclose(self) -> None:
304
+ """Release the connection. Idempotent, and every consuming path above awaits it."""
305
+ if self._closed:
306
+ return
307
+ self._closed = True
308
+ await self._response.aclose()
309
+
310
+ async def __aenter__(self) -> Self:
311
+ return self
312
+
313
+ async def __aexit__(self, *exc: object) -> None:
314
+ await self.aclose()
315
+
316
+ def __del__(self) -> None:
317
+ # getattr guard: __init__ may not have completed. Report only -- never act (ADR-0007).
318
+ if not getattr(self, "_closed", True):
319
+ warnings.warn(
320
+ f"unclosed {type(self).__name__} for {self._url} -- use `async with`, or call "
321
+ "aread() / save_to() / aclose()",
322
+ ResourceWarning,
323
+ stacklevel=2,
324
+ source=self,
325
+ )
326
+
327
+ def __repr__(self) -> str:
328
+ return f"{type(self).__name__}(media_type={self.media_type!r}, content_length={self.content_length})"
329
+
330
+ def _ensure_open(self) -> None:
331
+ if self._closed:
332
+ raise ValueError(
333
+ f"this {type(self).__name__} is closed -- it is single-use: aread(), save_to() and "
334
+ "a drained aiter_bytes() each consume and close it"
335
+ )