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,748 @@
1
+ """httpx-backed transports -- the default implementations of the two transport protocols.
2
+
3
+ The sync and async adapters live together because they are peers differing only in ``await``, the
4
+ same reason :class:`RawClient` and :class:`AsyncRawClient` share a module. Everything httpx-specific
5
+ is confined here: swapping HTTP libraries means writing one more module like this one, not touching
6
+ anything else.
7
+
8
+ File ownership is written down here and nowhere else: every handle this module opens for a body --
9
+ a ``Path`` in a multipart part or a raw body, and the temp file an async part is spilled to -- is
10
+ registered on the send's ``ExitStack``, so it is closed when the send returns or raises,
11
+ deterministically and never by a finalizer. A handle the caller opened is passed through untouched
12
+ and never closed."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import inspect
18
+ import io
19
+ import os
20
+ import ssl
21
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable, Iterator, Mapping
22
+ from contextlib import ExitStack, suppress
23
+ from dataclasses import dataclass, replace
24
+ from pathlib import Path
25
+ from tempfile import mkstemp
26
+ from typing import Any
27
+
28
+ import httpx
29
+ from typing_extensions import TypeIs
30
+
31
+ from ._internal.content_disposition import render_content_disposition
32
+ from .bodies import (
33
+ BinaryBody,
34
+ FormBody,
35
+ JsonBody,
36
+ MultipartBody,
37
+ MultipartFile,
38
+ MultipartPart,
39
+ MultipartText,
40
+ RequestBody,
41
+ TextBody,
42
+ )
43
+ from .files import AsyncBinaryContent, AsyncBinaryReader, BinaryReader
44
+ from .transport import (
45
+ AsyncHttpClient,
46
+ AsyncHttpResponse,
47
+ HttpClient,
48
+ HttpRequest,
49
+ HttpResponse,
50
+ )
51
+
52
+ _DEFAULT_TIMEOUT = 30.0
53
+
54
+ _CHUNK_SIZE = 65_536
55
+ """httpx's own multipart chunk size (``FileField.CHUNK_SIZE``), matched for the raw-body readers."""
56
+
57
+ _ASYNC_SOURCE_ON_SYNC_CLIENT = (
58
+ "an async byte source cannot be sent by the sync client -- pass bytes, a Path, "
59
+ "a binary reader or an Iterator[bytes], or use the async client"
60
+ )
61
+ """What both sync legs raise for an async arm no type checker caught -- a raw body and a multipart
62
+ part give the same answer to the same mistake.
63
+
64
+ Only the message is shared. Each ``if`` stays written out at its own site, because it is what
65
+ narrows the union away from the async arms; behind a helper, neither leg could prove its return
66
+ type."""
67
+
68
+
69
+ def _is_async_reader(content: object) -> TypeIs[AsyncBinaryReader]:
70
+ """Whether ``content`` is a reader whose ``read`` must be awaited.
71
+
72
+ ``isinstance`` alone cannot tell the two reader protocols apart -- ``runtime_checkable``
73
+ checks only that ``read`` exists -- so the async arm is decided by whether ``read`` is a
74
+ coroutine function. ``TypeIs`` rather than ``TypeGuard``: the negative branch must narrow
75
+ too, or the sync adapter could not prove its return type."""
76
+ return isinstance(content, AsyncBinaryReader) and inspect.iscoroutinefunction(getattr(type(content), "read", None))
77
+
78
+
79
+ def _require_binary_reader(reader: BinaryReader) -> None:
80
+ """Reject a text-mode handle before its first chunk reaches the wire.
81
+
82
+ ``read(0)`` returns ``b""`` for a binary handle and ``""`` for a text one, without consuming
83
+ anything -- a one-sided check: only ``str`` raises (``RawIOBase`` may legally return ``None``).
84
+
85
+ Raises:
86
+ TypeError: If ``reader`` was opened in text mode."""
87
+ if isinstance(reader.read(0), str):
88
+ raise TypeError("a text-mode file cannot carry binary content -- open it in binary mode ('rb')")
89
+
90
+
91
+ def _reports_its_own_length(reader: BinaryReader) -> bool:
92
+ """Whether the library's length peek would describe what ``read`` will yield.
93
+
94
+ It peeks with ``fstat(fileno())``, else a seek to the end -- both of which measure the stream's
95
+ own extent. That is the body only when the reader does not transform what it reads: a buffered
96
+ reader straight over a file, or an in-memory buffer. A decompressing reader answers with its
97
+ *compressed* size, and wrapping one changes nothing, because the raw layer is still the
98
+ transformer.
99
+
100
+ ``BufferedRandom`` belongs here beside ``BufferedReader``: it is what ``os.fdopen(fd, "w+b")``
101
+ returns, so :func:`_spilled`'s drained part stays measured.
102
+
103
+ A positive list, so a reader this does not recognise is chunked rather than mis-measured."""
104
+ if isinstance(reader, io.BytesIO):
105
+ return True
106
+ return isinstance(reader, (io.BufferedReader, io.BufferedRandom)) and isinstance(
107
+ getattr(reader, "raw", None), io.FileIO
108
+ )
109
+
110
+
111
+ def _is_at_start(reader: BinaryReader) -> bool:
112
+ """Whether nothing has been read yet, so the stream's extent is also what remains.
113
+
114
+ The peek reports the whole extent and never the remainder, so a handle already positioned
115
+ mid-file would be over-declared. ``tell`` is not part of the protocol and raises on a stream
116
+ that is fd-backed but not seekable; either is the same answer as *cannot prove it*."""
117
+ tell = getattr(reader, "tell", None)
118
+ if tell is None:
119
+ return False
120
+ try:
121
+ return bool(tell() == 0)
122
+ except OSError:
123
+ return False
124
+
125
+
126
+ def _sync_content(content: AsyncBinaryContent, stack: ExitStack) -> bytes | BinaryReader | Iterable[bytes]:
127
+ """Adapt a raw-body descriptor for the sync client, opening what needs opening onto ``stack``.
128
+
129
+ Takes the full async union because that is ``BinaryBody.content``'s type -- the *static* gate
130
+ is the emitted endpoint signature, which offers the sync client only the sync arms. The
131
+ rejection below is the runtime backstop for a caller no checker saw, replacing the opaque
132
+ stream-type error the underlying library would raise mid-send.
133
+
134
+ A reader is handed on **only when the library can size it correctly**. The peek behind that
135
+ ``Content-Length`` measures the descriptor's extent, not what ``read`` will yield from here, and
136
+ the two diverge for a transforming reader and for one starting mid-file -- so the body would go
137
+ out under a length that does not match it. :func:`_reports_its_own_length` and
138
+ :func:`_is_at_start` are the two halves of *provable*; everything else is chunked, which is also
139
+ what a reader carrying nothing but ``read`` needs, since the library's ``content=`` gate is
140
+ ``isinstance(..., Iterable)`` and would refuse it outright.
141
+
142
+ Raises:
143
+ TypeError: If ``content`` is an async arm, or a reader opened in text mode."""
144
+
145
+ def chunks(reader: BinaryReader) -> Iterator[bytes]:
146
+ while chunk := reader.read(_CHUNK_SIZE):
147
+ yield chunk
148
+
149
+ if isinstance(content, (bytes, bytearray)):
150
+ return bytes(content)
151
+ if isinstance(content, Path):
152
+ return stack.enter_context(content.open("rb"))
153
+ if _is_async_reader(content) or isinstance(content, AsyncIterable):
154
+ raise TypeError(_ASYNC_SOURCE_ON_SYNC_CLIENT)
155
+ if isinstance(content, BinaryReader):
156
+ _require_binary_reader(content)
157
+ if _reports_its_own_length(content) and _is_at_start(content):
158
+ return content
159
+ return chunks(content)
160
+ return content
161
+
162
+
163
+ async def _awaited_chunks(reader: AsyncBinaryReader) -> AsyncIterator[bytes]:
164
+ """Read an async reader to exhaustion in ``_CHUNK_SIZE`` windows.
165
+
166
+ Module-level rather than nested because it has two callers, and they want the same windows for
167
+ opposite reasons: a raw body streams them straight to the transport, and a multipart part writes
168
+ them to a spill file first."""
169
+ while chunk := await reader.read(_CHUNK_SIZE):
170
+ yield chunk
171
+
172
+
173
+ async def _aiter(chunks: AsyncIterable[bytes]) -> AsyncIterator[bytes]:
174
+ """Iterate an async iterable, so a caller's own chunk sizes reach :func:`_spilled` unchanged.
175
+
176
+ Only :func:`_drained` needs this: an ``AsyncIterable`` is not an ``AsyncIterator``, and
177
+ ``_spilled`` takes the narrower type so both of its callers hand it the same shape."""
178
+ async for chunk in chunks:
179
+ yield chunk
180
+
181
+
182
+ def _async_content(content: AsyncBinaryContent, stack: ExitStack) -> bytes | AsyncIterable[bytes]:
183
+ """Adapt a raw-body descriptor to the async byte stream the async client demands.
184
+
185
+ Sync arms read through a worker thread so a large upload from a slow disk does not park the
186
+ event loop; the two async arms are consumed natively. A ``Path`` is opened here, eagerly, onto
187
+ ``stack`` -- a momentary open on the loop, exactly as the multipart leg's ``_open`` does -- so
188
+ a mid-send failure closes it on the stack's unwind rather than at a finalizer.
189
+
190
+ The arm order is load-bearing: readers before iterables (a file handle iterates by *lines*),
191
+ the async-reader guard before ``AsyncIterable`` (an async file is async-iterable by lines),
192
+ and ``bytes`` before everything (it is an ``Iterable[int]``).
193
+
194
+ Raises:
195
+ TypeError: If ``content`` is a reader opened in text mode."""
196
+
197
+ async def threaded_chunks(reader: BinaryReader) -> AsyncIterator[bytes]:
198
+ # Every read in a worker thread: the non-blocking property of the async raw-body leg.
199
+ while chunk := await asyncio.to_thread(reader.read, _CHUNK_SIZE):
200
+ yield chunk
201
+
202
+ async def iterable_chunks(chunks: Iterable[bytes]) -> AsyncIterator[bytes]:
203
+ # Pulled on the loop: a caller's generator is the caller's own code; one that must not
204
+ # block the loop is passed as an AsyncIterable instead.
205
+ for chunk in chunks:
206
+ yield chunk
207
+
208
+ if isinstance(content, (bytes, bytearray)):
209
+ return bytes(content)
210
+ if isinstance(content, Path):
211
+ return threaded_chunks(stack.enter_context(content.open("rb")))
212
+ if _is_async_reader(content):
213
+ return _awaited_chunks(content)
214
+ if isinstance(content, BinaryReader):
215
+ _require_binary_reader(content)
216
+ return threaded_chunks(content)
217
+ if isinstance(content, AsyncIterable):
218
+ return content
219
+ return iterable_chunks(content)
220
+
221
+
222
+ class _ChunkReader:
223
+ """A read-only view over an iterable of chunks -- the shape the multipart encoder demands.
224
+
225
+ The encoder can pull a part from ``bytes`` or from anything with a synchronous ``read``, and
226
+ from nothing else, so an iterable reaches it through this and not at all otherwise.
227
+
228
+ One of the encoder's two adapters, and the cheap one. This arm needs only a *shape* -- the
229
+ chunks can be pulled synchronously, they simply do not arrive behind a ``read`` -- so a view
230
+ suffices and nothing is copied or stored. Its sibling :func:`_spilled` answers the same demand
231
+ for a source that must be **awaited**, which no view can bridge, and pays a temp file for it.
232
+ The full statement of the httpx constraint both exist for is on ``_spilled``.
233
+
234
+ It carries nothing but ``read``, deliberately. No ``seek``, so ``FileField.render_data`` skips
235
+ the rewind it performs on a real handle; no ``fileno`` and no ``tell``, so the part reports no
236
+ length and the whole body goes out chunked. An iterable is single-use, and a view that cannot
237
+ rewind is what makes that visible rather than silently re-sending nothing.
238
+
239
+ Owns nothing and closes nothing: the iterable is the caller's, exactly as a reader is."""
240
+
241
+ __slots__ = ("_buffer", "_chunks")
242
+
243
+ def __init__(self, chunks: Iterable[bytes]) -> None:
244
+ self._chunks = iter(chunks)
245
+ self._buffer = bytearray()
246
+
247
+ def read(self, size: int = -1, /) -> bytes:
248
+ """Return up to ``size`` bytes, or everything remaining when ``size`` is negative.
249
+
250
+ Args:
251
+ size: How many bytes to take; negative reads the iterable to exhaustion.
252
+
253
+ Returns:
254
+ The bytes taken, empty once the iterable is exhausted."""
255
+ while size < 0 or len(self._buffer) < size:
256
+ # ``next(..., None)`` rather than a falsy test: a generator may legally yield b"" in the
257
+ # middle of a stream, and reading that as EOF would truncate the upload.
258
+ chunk = next(self._chunks, None)
259
+ if chunk is None:
260
+ break
261
+ self._buffer.extend(chunk)
262
+ taken = bytes(self._buffer) if size < 0 else bytes(self._buffer[:size])
263
+ del self._buffer[: len(taken)]
264
+ return taken
265
+
266
+
267
+ class _UnsizedReader:
268
+ """A caller's reader with its length claim withheld -- everything else passes through.
269
+
270
+ Withholding ``fileno`` and ``tell`` makes the encoder's length peek answer ``None``, so the part
271
+ claims no length and the body frames itself chunked. That peek measures the descriptor rather
272
+ than the bytes ``read`` will produce, and the two diverge for a transforming reader; chunked is
273
+ correct where a wrong length is not.
274
+
275
+ ``seek`` is forwarded, unlike :class:`_ChunkReader`'s deliberate omission: ``render_data``
276
+ rewinds a part before every render, and that rewind is what makes a caller's seekable handle
277
+ re-sendable. A reader that cannot seek raises ``io.UnsupportedOperation``, which the encoder
278
+ already catches -- the same answer a pipe gives.
279
+
280
+ Owns nothing and closes nothing: the reader is the caller's."""
281
+
282
+ __slots__ = ("_reader",)
283
+
284
+ def __init__(self, reader: BinaryReader) -> None:
285
+ self._reader = reader
286
+
287
+ def read(self, size: int = -1, /) -> bytes:
288
+ """Read from the wrapped reader, unchanged.
289
+
290
+ Args:
291
+ size: How many bytes to take; negative reads to exhaustion.
292
+
293
+ Returns:
294
+ Whatever the wrapped reader returned."""
295
+ return self._reader.read(size)
296
+
297
+ def seek(self, offset: int, whence: int = 0, /) -> int:
298
+ """Reposition the wrapped reader, or refuse as a non-seekable stream does.
299
+
300
+ Args:
301
+ offset: Passed through unchanged.
302
+ whence: Passed through unchanged.
303
+
304
+ Returns:
305
+ The wrapped reader's new absolute position.
306
+
307
+ Raises:
308
+ io.UnsupportedOperation: If the wrapped reader cannot seek."""
309
+ seek = getattr(self._reader, "seek", None)
310
+ if seek is None:
311
+ raise io.UnsupportedOperation("underlying reader is not seekable")
312
+ return int(seek(offset, whence))
313
+
314
+
315
+ def _open(content: AsyncBinaryContent, stack: ExitStack) -> bytes | BinaryReader:
316
+ """Resolve a multipart part's content descriptor, opening what needs opening onto ``stack``.
317
+
318
+ The one place the ownership rule is written down in code: a ``Path``'s handle is the SDK's,
319
+ registered on the stack and closed when the send returns or raises; a caller's handle is
320
+ returned untouched and never closed, and an iterable is wrapped, never consumed here.
321
+
322
+ The arm order is ``_sync_content``'s, for the same reasons: ``bytes`` first (it is an
323
+ ``Iterable[int]``), the async guard before the reader check (``runtime_checkable`` cannot tell
324
+ the two reader protocols apart), readers before iterables (a file handle iterates by *lines*).
325
+ On the async leg the async guard is unreachable -- :func:`_drained_parts` has already spilled
326
+ those arms to a file, because the encoder cannot await.
327
+
328
+ Raises:
329
+ TypeError: If ``content`` is an async arm, or a reader opened in text mode."""
330
+ if isinstance(content, (bytes, bytearray)):
331
+ return bytes(content)
332
+ if isinstance(content, Path):
333
+ return stack.enter_context(content.open("rb"))
334
+ if _is_async_reader(content) or isinstance(content, AsyncIterable):
335
+ raise TypeError(_ASYNC_SOURCE_ON_SYNC_CLIENT)
336
+ if isinstance(content, BinaryReader):
337
+ _require_binary_reader(content)
338
+ return content if _reports_its_own_length(content) else _UnsizedReader(content)
339
+ return _ChunkReader(content)
340
+
341
+
342
+ async def _spilled(chunks: AsyncIterator[bytes], stack: ExitStack) -> BinaryReader:
343
+ """Write an async source to a temp file the multipart encoder can read synchronously.
344
+
345
+ **The limitation this exists for, at source (httpx 0.28.1).** ``MultipartStream.__aiter__`` is
346
+ ``for chunk in self.iter_chunks(): yield chunk`` -- an ``async`` generator delegating to a
347
+ *synchronous* one -- and ``FileField.render_data`` pulls each part with a plain
348
+ ``self.file.read(CHUNK_SIZE)``. There is no ``await`` anywhere in ``_multipart.py``. So the
349
+ encoder asks for bytes with a function call, and an awaitable source can only answer with one,
350
+ and the two cannot meet: **both** async arms are affected, ``AsyncBinaryReader`` and
351
+ ``AsyncIterable[bytes]`` alike, and neither can be driven from inside the encode at any cost.
352
+ Draining first is not an optimisation, it is the only way either arm reaches the wire.
353
+
354
+ A temp file is where it drains *to* because the encoder's demand is a synchronous ``read``, and
355
+ that is the cheapest thing that offers one without holding the payload: each chunk is written
356
+ straight out and dropped, so **nothing accumulates** -- the alternative that kept the bytes in
357
+ the process peaked at twice the payload, measured (ADR-0051).
358
+
359
+ **What bounds the residency is the chunk size, and only one arm's is the SDK's.**
360
+ :func:`_awaited_chunks` reads a reader in ``_CHUNK_SIZE`` windows, so that arm is bounded here
361
+ by construction. :func:`_aiter` forwards an ``AsyncIterable``'s own chunks unchanged, because
362
+ re-slicing a ``bytes`` the caller already built would add a copy rather than remove one -- so
363
+ there the peak is the caller's largest ``yield``, whatever that is, and a caller who yields
364
+ their whole payload at once has already made it resident before this is reached. Either way
365
+ this function's own overhead is one chunk, never the total.
366
+
367
+ **The sibling limitation, so the two are not confused.** A *sync* ``Iterator[bytes]`` in a part
368
+ hits the same ``read``-only demand from the other direction -- it can be pulled synchronously
369
+ but carries no ``read`` at all -- and is answered by :class:`_ChunkReader`, a view rather than a
370
+ file, because there is nothing to await and so nothing to drain. Same encoder constraint, two
371
+ adapters, and which one applies is decided by whether the source needs an ``await``.
372
+
373
+ Owning the encoder would retire both adapters and the loop-blocking with them. It was designed
374
+ and declined: ``core/`` is vendored per SDK, so a bug in it ships everywhere and is fixable only
375
+ by re-vendoring, and RFC 7578 escaping is the refusal that stops a CR/LF in a filename splicing
376
+ a header into the body. If httpx ever grows an ``__aiter__`` that awaits, consume it -- do not
377
+ write one (ADR-0051, ADR-0024).
378
+
379
+ Why not the two other things that offer a synchronous ``read``. Accumulating into ``bytes`` is
380
+ the defect this replaced -- it peaked at twice the source, once for the chunk list and once for
381
+ the join. A ``SpooledTemporaryFile``'s ``fileno()`` forces a rollover to disk (measured), so it
382
+ would reach disk for a ten-byte part *and* answer the encoder's length probe only by doing so;
383
+ a plain temp file is smaller and needs no threshold to tune. Being a real file is also what
384
+ keeps the part **sized**: the encoder reaches ``fstat`` through it, so this arm still contributes
385
+ a ``Content-Length`` exactly as the resident ``bytes`` did, and stays re-sendable because it is
386
+ seekable.
387
+
388
+ A caller whose async source is a local file should hand over the ``Path`` instead: this arm
389
+ then costs two reads and a write to move bytes that were already on disk in the right shape.
390
+ Said in ``files.py``'s module docstring, where a caller reads it.
391
+
392
+ Built from ``mkstemp`` rather than ``TemporaryFile`` so the object handed on is the same on
393
+ every platform. ``TemporaryFile`` is an alias for ``NamedTemporaryFile`` wherever an open file
394
+ cannot be unlinked -- Windows -- and that returns a wrapper proxying ``read`` through
395
+ ``__getattr__``. A ``runtime_checkable`` protocol tests the *class*, so the wrapper does not
396
+ satisfy :class:`BinaryReader`, and the part would fall through to the chunk-reader arm and go
397
+ out chunked. Normalised here rather than branched on later.
398
+
399
+ **Unlinked immediately where the platform allows it.** A POSIX file survives its own directory
400
+ entry as long as a descriptor is open, so removing the name now leaves the content reachable
401
+ through the handle and reclaimed by the kernel when the handle closes -- *including* if the
402
+ process is killed, which is the one exit an ``ExitStack`` cannot unwind. Windows refuses to
403
+ unlink an open file, so there the removal is deferred onto the stack instead. Probed rather than
404
+ branched on ``os.name``: the question is what this filesystem permits, and the answer decides
405
+ only *when* the name goes away.
406
+
407
+ Args:
408
+ chunks: The async source, normalised to an iterator of byte windows.
409
+ stack: The send's exit stack, which closes the file -- and deletes it, where it could not be
410
+ deleted up front -- when the send returns or raises.
411
+
412
+ Returns:
413
+ The file, positioned at 0 and ready for the encoder's own reads."""
414
+ descriptor, path = mkstemp()
415
+ # Registration order is the whole of the cleanup contract, and it reads backwards: an
416
+ # ``ExitStack`` unwinds last-registered-first, so the removal goes on *before* the handle in
417
+ # order to run *after* it. ``missing_ok`` is what lets the same callback stand whether or not
418
+ # the immediate unlink below already won.
419
+ stack.callback(Path(path).unlink, missing_ok=True)
420
+ spill = stack.enter_context(os.fdopen(descriptor, "w+b"))
421
+ with suppress(OSError):
422
+ os.unlink(path)
423
+ async for chunk in chunks:
424
+ spill.write(chunk)
425
+ spill.seek(0)
426
+ return spill
427
+
428
+
429
+ async def _drained(part: MultipartPart, stack: ExitStack) -> MultipartPart:
430
+ """Spill one part's content to a temp file if it is an async source, else hand it back unchanged.
431
+
432
+ Args:
433
+ part: The part to resolve.
434
+ stack: The send's exit stack, owning any file opened for this part.
435
+
436
+ Returns:
437
+ The part, or a copy of it reading from the file its source was drained into."""
438
+ if not isinstance(part, MultipartFile):
439
+ return part
440
+ if _is_async_reader(part.content):
441
+ return replace(part, content=await _spilled(_awaited_chunks(part.content), stack))
442
+ if isinstance(part.content, AsyncIterable):
443
+ return replace(part, content=await _spilled(_aiter(part.content), stack))
444
+ return part
445
+
446
+
447
+ async def _drained_parts(body: RequestBody | None, stack: ExitStack) -> RequestBody | None:
448
+ """Resolve any async part content before the multipart body is encoded.
449
+
450
+ The encoder pulls every part from a synchronous chunk generator in both flavours, so an
451
+ awaitable source cannot be driven from inside it. Draining it here is what makes the arm work;
452
+ :func:`_spilled` is what keeps it bounded. The loop stays free during the drain -- the source is
453
+ awaited natively -- and is then occupied by the encode itself, which is httpx's synchronous
454
+ generator and the one cost this leg still pays.
455
+
456
+ Runs **inside** the send's ``ExitStack``, necessarily: it opens a file per async part, and those
457
+ must unwind with everything else the send opened.
458
+
459
+ Returns the body **unchanged** when nothing was drained, which is every multipart request whose
460
+ parts are all sync arms.
461
+
462
+ Args:
463
+ body: The request's body, of any shape.
464
+ stack: The send's exit stack.
465
+
466
+ Returns:
467
+ The body, or a multipart body whose async parts now read from a file."""
468
+ if not isinstance(body, MultipartBody):
469
+ return body
470
+ parts = [await _drained(part, stack) for part in body.parts]
471
+ # Identity, not equality: ``_drained`` hands an untouched part straight back, so ``is`` asks
472
+ # exactly what this needs to know and cannot be answered by a part that merely compares equal.
473
+ return body if all(new is old for new, old in zip(parts, body.parts, strict=True)) else MultipartBody(parts)
474
+
475
+
476
+ def _httpx_part(part: MultipartPart, stack: ExitStack) -> tuple[str, Any]:
477
+ """Render one part in httpx's ``files=`` tuple spelling.
478
+
479
+ Text parts go through the *file* spelling -- ``(None, text)``, a part with no filename --
480
+ rather than through ``data=``, because httpx chooses the encoding on the **truthiness** of
481
+ ``files``: an operation declaring ``multipart/form-data`` with no binary part would otherwise
482
+ be sent as ``application/x-www-form-urlencoded``. The rendered bytes are identical either way,
483
+ repeated keys and name escaping included, so the invariant costs nothing."""
484
+ match part:
485
+ case MultipartText(name, text, None):
486
+ return (name, (None, text))
487
+ case MultipartText(name, text, media_type):
488
+ return (name, (None, text, media_type))
489
+ case MultipartFile(name, content, filename, media_type):
490
+ return (name, (filename, _open(content, stack), media_type))
491
+
492
+
493
+ def _body_kwargs(
494
+ body: RequestBody | None,
495
+ headers: Mapping[str, str],
496
+ stack: ExitStack,
497
+ *,
498
+ binary_content: Callable[[AsyncBinaryContent, ExitStack], object],
499
+ ) -> dict[str, Any]:
500
+ """Map the request's body -- and the headers that describe it -- onto httpx's keywords.
501
+
502
+ This ``match`` is the only place the body union is interpreted, and it is exhaustive by
503
+ construction: adding a body shape without handling it here is a type error.
504
+
505
+ Nothing here spells a media type, a charset or a filename. A body that has one carries it,
506
+ written at the endpoint from the operation's content key, and it is merged **underneath** the
507
+ request's own headers so a caller's ``extra_headers`` still wins -- the precedence httpx gives
508
+ its own derived headers. A text body is encoded with the charset it declares and labelled with
509
+ that same field, so the bytes and the label cannot disagree. A raw body that has a name -- given
510
+ or derived, the factory having already settled which -- takes a second derived header, its
511
+ ``Content-Disposition``, on that same precedence, and the nameless arm beneath it carries none
512
+ rather than an empty one. That pair is ordered name-first because
513
+ ``str()`` is what narrows the name for the renderer; the arm below is total over
514
+ :class:`BinaryBody`, which is what keeps this ``match`` exhaustive. The two arms that carry no
515
+ media type are the two whose media type belongs to the encoder: url-encoded is fixed by the
516
+ ``data=`` keyword, and multipart's boundary exists only once httpx has minted it. That is why
517
+ headers travel out of this function rather than beside it.
518
+
519
+ ``binary_content`` is the one flavour-differing arm, passed as data with no default so each
520
+ transport must name its own adapter -- ``_sync_content`` or ``_async_content``. One shared
521
+ match rather than two sibling copies: this module is hand-written, where five duplicated arms
522
+ to differ in one is drift risk rather than generator output.
523
+
524
+ Args:
525
+ body: The body being mapped -- the request's own, or the one :func:`_drained_parts`
526
+ resolved from it.
527
+ headers: The request's headers, which every arm carries through and two arms extend.
528
+ stack: The send's exit stack, onto which any handle opened for this body is registered.
529
+ binary_content: The flavour's raw-body adapter.
530
+
531
+ Returns:
532
+ The httpx keyword arguments carrying this body, ``headers`` among them."""
533
+ match body:
534
+ case None:
535
+ return {"headers": headers}
536
+ case JsonBody(None, media_type):
537
+ # ``json=None`` is indistinguishable to httpx from no ``json`` argument at all, so the
538
+ # body would vanish rather than going out as the four bytes the caller asked for.
539
+ # ``content=`` is the only keyword that will carry them.
540
+ return {"headers": {"content-type": media_type, **headers}, "content": b"null"}
541
+ case JsonBody(value, media_type):
542
+ return {"headers": {"content-type": media_type, **headers}, "json": value}
543
+ case FormBody(fields):
544
+ return {"headers": headers, "data": fields}
545
+ case MultipartBody(parts):
546
+ return {"headers": headers, "files": [_httpx_part(part, stack) for part in parts]}
547
+ case BinaryBody(content, str() as filename, media_type):
548
+ return {
549
+ "headers": {
550
+ "content-type": media_type,
551
+ "content-disposition": render_content_disposition(filename),
552
+ **headers,
553
+ },
554
+ "content": binary_content(content, stack),
555
+ }
556
+ case BinaryBody(content, _, media_type):
557
+ return {
558
+ "headers": {"content-type": media_type, **headers},
559
+ "content": binary_content(content, stack),
560
+ }
561
+ case TextBody(text, media_type, charset):
562
+ return {
563
+ "headers": {"content-type": f"{media_type}; charset={charset}", **headers},
564
+ "content": text.encode(charset),
565
+ }
566
+
567
+
568
+ def _timeout_kwargs(request: HttpRequest) -> dict[str, Any]:
569
+ """Map a per-request timeout onto httpx's keyword, or contribute nothing.
570
+
571
+ Omitting is deliberate rather than passing ``timeout=None``: to httpx that means *no timeout at
572
+ all*, not *use the client's own*, so an absent override must not reach the call.
573
+
574
+ Args:
575
+ request: The request whose ``timeout`` is being mapped.
576
+
577
+ Returns:
578
+ ``{"timeout": ...}`` when the request set one, otherwise an empty mapping."""
579
+ return {} if request.timeout is None else {"timeout": httpx.Timeout(request.timeout)}
580
+
581
+
582
+ @dataclass(frozen=True, slots=True)
583
+ class _HttpxResponse:
584
+ """A thin veneer meeting :class:`HttpResponse` over the library's own response.
585
+
586
+ httpx lowercases header names on iteration, so ``headers`` meets the protocols' casing
587
+ obligation by construction; ``read`` is the library's own, which both buffers the body and
588
+ releases the connection -- the exact obligation the protocol states."""
589
+
590
+ _response: httpx.Response
591
+
592
+ @property
593
+ def url(self) -> str:
594
+ # httpx answers this from the request it kept, and the SDK never sets follow_redirects, so
595
+ # it is the URL that was asked for. A property rather than a field: the generated dataclass
596
+ # repr would otherwise print a query string a credential can sit in (ADR-0017).
597
+ return str(self._response.url)
598
+
599
+ @property
600
+ def status_code(self) -> int:
601
+ return self._response.status_code
602
+
603
+ @property
604
+ def headers(self) -> Mapping[str, str]:
605
+ return dict(self._response.headers)
606
+
607
+ def iter_bytes(self, chunk_size: int | None) -> Iterator[bytes]:
608
+ return self._response.iter_bytes(chunk_size)
609
+
610
+ def read(self) -> bytes:
611
+ return self._response.read()
612
+
613
+ def close(self) -> None:
614
+ self._response.close()
615
+
616
+
617
+ @dataclass(frozen=True, slots=True)
618
+ class _AsyncHttpxResponse:
619
+ """The awaited twin of :class:`_HttpxResponse`, meeting :class:`AsyncHttpResponse`."""
620
+
621
+ _response: httpx.Response
622
+
623
+ @property
624
+ def url(self) -> str:
625
+ # httpx answers this from the request it kept, and the SDK never sets follow_redirects, so
626
+ # it is the URL that was asked for. A property rather than a field: the generated dataclass
627
+ # repr would otherwise print a query string a credential can sit in (ADR-0017).
628
+ return str(self._response.url)
629
+
630
+ @property
631
+ def status_code(self) -> int:
632
+ return self._response.status_code
633
+
634
+ @property
635
+ def headers(self) -> Mapping[str, str]:
636
+ return dict(self._response.headers)
637
+
638
+ def aiter_bytes(self, chunk_size: int | None) -> AsyncIterator[bytes]:
639
+ return self._response.aiter_bytes(chunk_size)
640
+
641
+ async def aread(self) -> bytes:
642
+ return await self._response.aread()
643
+
644
+ async def aclose(self) -> None:
645
+ await self._response.aclose()
646
+
647
+
648
+ class HttpxClient(HttpClient):
649
+ """Sync transport backed by httpx.
650
+
651
+ Uses connection pooling via a single ``httpx.Client``, and returns once the response head
652
+ has arrived; the body is read by whoever the raw client hands it to.
653
+
654
+ Requests honour the standard proxy and TLS environment variables -- ``HTTP_PROXY`` /
655
+ ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` (consulted only when ``proxy_url`` is unset) and
656
+ ``SSL_CERT_FILE`` / ``SSL_CERT_DIR`` (the trust store). That is the underlying library's default
657
+ rather than a decision made here, and it is stated because it is request behaviour the
658
+ environment can change; a caller needing it off supplies a transport of their own.
659
+
660
+ ``verify`` takes an ``ssl.SSLContext`` as well as a bool: a private CA bundle or a client
661
+ certificate is configured by building one (``ssl.create_default_context(cafile=...)``), which is
662
+ the one spelling the pinned library still supports for either."""
663
+
664
+ def __init__(
665
+ self,
666
+ *,
667
+ timeout: float = _DEFAULT_TIMEOUT,
668
+ proxy_url: str | None = None,
669
+ verify: ssl.SSLContext | bool = True,
670
+ ) -> None:
671
+ self._client = httpx.Client(
672
+ proxy=proxy_url,
673
+ timeout=httpx.Timeout(timeout),
674
+ verify=verify,
675
+ )
676
+ self._closed = False
677
+
678
+ def send(self, request: HttpRequest) -> HttpResponse:
679
+ # The stack owns every handle opened for this body, and closes once the head has arrived --
680
+ # which is after the request body has been sent in full, so closing there is correct, not
681
+ # early. An exception mid-send closes it on the unwind. The *response* body is pending; the
682
+ # returned wrapper owns that connection until it is closed.
683
+ with ExitStack() as stack:
684
+ httpx_request = self._client.build_request(
685
+ method=request.method,
686
+ url=request.url,
687
+ **_timeout_kwargs(request),
688
+ **_body_kwargs(request.body, request.headers, stack, binary_content=_sync_content),
689
+ )
690
+ response = self._client.send(httpx_request, stream=True)
691
+ return _HttpxResponse(response)
692
+
693
+ def close(self) -> None:
694
+ if self._closed:
695
+ return
696
+ self._closed = True
697
+ self._client.close()
698
+
699
+
700
+ class AsyncHttpxClient(AsyncHttpClient):
701
+ """Async transport backed by httpx.
702
+
703
+ Uses connection pooling via a single ``httpx.AsyncClient``, and returns once the response
704
+ head has arrived; the body is read by whoever the raw client hands it to.
705
+
706
+ Requests honour the standard proxy and TLS environment variables -- ``HTTP_PROXY`` /
707
+ ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` (consulted only when ``proxy_url`` is unset) and
708
+ ``SSL_CERT_FILE`` / ``SSL_CERT_DIR`` (the trust store). That is the underlying library's default
709
+ rather than a decision made here, and it is stated because it is request behaviour the
710
+ environment can change; a caller needing it off supplies a transport of their own.
711
+
712
+ ``verify`` takes an ``ssl.SSLContext`` as well as a bool: a private CA bundle or a client
713
+ certificate is configured by building one (``ssl.create_default_context(cafile=...)``), which is
714
+ the one spelling the pinned library still supports for either."""
715
+
716
+ def __init__(
717
+ self,
718
+ *,
719
+ timeout: float = _DEFAULT_TIMEOUT,
720
+ proxy_url: str | None = None,
721
+ verify: ssl.SSLContext | bool = True,
722
+ ) -> None:
723
+ self._client = httpx.AsyncClient(
724
+ proxy=proxy_url,
725
+ timeout=httpx.Timeout(timeout),
726
+ verify=verify,
727
+ )
728
+ self._closed = False
729
+
730
+ async def send(self, request: HttpRequest) -> AsyncHttpResponse:
731
+ with ExitStack() as stack:
732
+ # Inside the stack, necessarily: the pre-pass opens a spill file per async part, and
733
+ # those unwind with everything else this send opened.
734
+ body = await _drained_parts(request.body, stack)
735
+ httpx_request = self._client.build_request(
736
+ method=request.method,
737
+ url=request.url,
738
+ **_timeout_kwargs(request),
739
+ **_body_kwargs(body, request.headers, stack, binary_content=_async_content),
740
+ )
741
+ response = await self._client.send(httpx_request, stream=True)
742
+ return _AsyncHttpxResponse(response)
743
+
744
+ async def aclose(self) -> None:
745
+ if self._closed:
746
+ return
747
+ self._closed = True
748
+ await self._client.aclose()