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