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.
- cliv1-0.0.8.dist-info/METADATA +168 -0
- cliv1-0.0.8.dist-info/RECORD +87 -0
- cliv1-0.0.8.dist-info/WHEEL +4 -0
- cliv1-0.0.8.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_0/__init__.py +15 -0
- swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_0/apis/pet_api.py +883 -0
- swagger_petstore_open_api_3_0/apis/store.py +413 -0
- swagger_petstore_open_api_3_0/apis/user_api.py +794 -0
- swagger_petstore_open_api_3_0/async_client.py +79 -0
- swagger_petstore_open_api_3_0/auth.py +17 -0
- swagger_petstore_open_api_3_0/base_client.py +18 -0
- swagger_petstore_open_api_3_0/client.py +77 -0
- swagger_petstore_open_api_3_0/core/__init__.py +358 -0
- swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
- swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
- swagger_petstore_open_api_3_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
- swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
- swagger_petstore_open_api_3_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_0/core/bodies.py +550 -0
- swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
- swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
- swagger_petstore_open_api_3_0/core/files.py +173 -0
- swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
- swagger_petstore_open_api_3_0/core/models.py +143 -0
- swagger_petstore_open_api_3_0/core/optionality.py +130 -0
- swagger_petstore_open_api_3_0/core/params.py +157 -0
- swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
- swagger_petstore_open_api_3_0/core/request_options.py +94 -0
- swagger_petstore_open_api_3_0/core/results.py +123 -0
- swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_0/core/transport.py +191 -0
- swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_0/models/category.py +15 -0
- swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_0/models/order.py +27 -0
- swagger_petstore_open_api_3_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_0/models/user.py +29 -0
- swagger_petstore_open_api_3_0/py.typed +0 -0
- swagger_petstore_open_api_3_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_0/server/server.py +17 -0
- swagger_petstore_open_api_3_0/server/server_config.py +52 -0
|
@@ -0,0 +1,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()
|