sg-python-api 0.1.0__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.
- sg_python_api-0.1.0.dist-info/METADATA +177 -0
- sg_python_api-0.1.0.dist-info/RECORD +31 -0
- sg_python_api-0.1.0.dist-info/WHEEL +4 -0
- sg_python_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- shotgun_api3/__init__.py +68 -0
- shotgun_api3/_async_client.py +2906 -0
- shotgun_api3/_async_driver.py +945 -0
- shotgun_api3/_async_httpx2.py +662 -0
- shotgun_api3/_async_transport.py +162 -0
- shotgun_api3/_capabilities.py +165 -0
- shotgun_api3/_client.py +2682 -0
- shotgun_api3/_config.py +1463 -0
- shotgun_api3/_crud.py +916 -0
- shotgun_api3/_driver.py +879 -0
- shotgun_api3/_errors.py +49 -0
- shotgun_api3/_execution.py +273 -0
- shotgun_api3/_files.py +1094 -0
- shotgun_api3/_methods.py +1205 -0
- shotgun_api3/_protocol.py +1347 -0
- shotgun_api3/_sentinel.py +5 -0
- shotgun_api3/_sg_timezone.py +5 -0
- shotgun_api3/_signatures.py +53 -0
- shotgun_api3/_transport.py +901 -0
- shotgun_api3/_version.py +3 -0
- shotgun_api3/lib/__init__.py +1 -0
- shotgun_api3/lib/mockgun/__init__.py +16 -0
- shotgun_api3/lib/mockgun/_async.py +315 -0
- shotgun_api3/lib/mockgun/_mockgun.py +1666 -0
- shotgun_api3/lib/sgtimezone.py +209 -0
- shotgun_api3/py.typed +0 -0
- shotgun_api3/shotgun.py +58 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Async httpx2 transport adapter."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
5
|
+
import ssl
|
|
6
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
9
|
+
from typing import Self, cast
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
from ._async_transport import AsyncStreamingResponse
|
|
14
|
+
from ._transport import (
|
|
15
|
+
CaseInsensitiveHeaders,
|
|
16
|
+
ConnectError,
|
|
17
|
+
ProxyError,
|
|
18
|
+
ReadTimeoutError,
|
|
19
|
+
SendError,
|
|
20
|
+
TimeoutConfig,
|
|
21
|
+
TLSError,
|
|
22
|
+
TransportError,
|
|
23
|
+
TransportRequest,
|
|
24
|
+
TransportResponse,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Httpx2Transport:
|
|
29
|
+
"""Provide the shared async transport interface over httpx2.
|
|
30
|
+
|
|
31
|
+
Share an instance among tasks on one event loop. Logical RPC leases belong
|
|
32
|
+
to the task that checks them out and must be checked in by that task.
|
|
33
|
+
Streaming responses retain resources until they close.
|
|
34
|
+
|
|
35
|
+
Await `close()` after releasing the calling task's leases and streams.
|
|
36
|
+
Closing rejects unrelated new work, drains accepted work, and permanently
|
|
37
|
+
closes the transport.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
ca_certs: str | None = None,
|
|
43
|
+
proxy_url: str | None = None,
|
|
44
|
+
proxy_headers: dict[str, str] | None = None,
|
|
45
|
+
pool_maxsize: int = 10,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize one shared async HTTP transport.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ca_certs: Optional certificate-authority bundle path.
|
|
51
|
+
proxy_url: Optional proxy URL.
|
|
52
|
+
proxy_headers: Optional headers sent to the proxy.
|
|
53
|
+
pool_maxsize: Maximum concurrent and keep-alive connections.
|
|
54
|
+
"""
|
|
55
|
+
verify: ssl.SSLContext | bool = True
|
|
56
|
+
if ca_certs is not None:
|
|
57
|
+
verify = ssl.create_default_context(cafile=ca_certs)
|
|
58
|
+
proxy = None
|
|
59
|
+
if proxy_url is not None:
|
|
60
|
+
proxy = httpx2.Proxy(proxy_url, headers=proxy_headers)
|
|
61
|
+
self._client = httpx2.AsyncClient(
|
|
62
|
+
cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=())),
|
|
63
|
+
verify=verify,
|
|
64
|
+
proxy=proxy,
|
|
65
|
+
trust_env=False,
|
|
66
|
+
limits=httpx2.Limits(
|
|
67
|
+
max_connections=pool_maxsize,
|
|
68
|
+
max_keepalive_connections=pool_maxsize,
|
|
69
|
+
),
|
|
70
|
+
timeout=None,
|
|
71
|
+
)
|
|
72
|
+
self._active = 0
|
|
73
|
+
self._body_cleanup_tasks: set[asyncio.Task[None]] = set()
|
|
74
|
+
self._closed = False
|
|
75
|
+
self._closing = False
|
|
76
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
77
|
+
self._idle = asyncio.Event()
|
|
78
|
+
self._idle.set()
|
|
79
|
+
self._lease_holders: dict[asyncio.Task[object], int] = {}
|
|
80
|
+
self._reserved_tasks: set[asyncio.Task[object]] = set()
|
|
81
|
+
self._stream_owners: dict[asyncio.Task[object], int] = {}
|
|
82
|
+
|
|
83
|
+
def _acquire_lease(self, task: asyncio.Task[object]) -> None:
|
|
84
|
+
"""Acquire one active lease for an explicit task."""
|
|
85
|
+
self._active += 1
|
|
86
|
+
self._idle.clear()
|
|
87
|
+
self._lease_holders[task] = self._lease_holders.get(task, 0) + 1
|
|
88
|
+
|
|
89
|
+
def _end_active(self) -> None:
|
|
90
|
+
"""Release one active lease and wake a draining close when idle."""
|
|
91
|
+
if self._active <= 0:
|
|
92
|
+
raise TransportError('async transport lease accounting underflow')
|
|
93
|
+
self._active -= 1
|
|
94
|
+
if self._active == 0:
|
|
95
|
+
self._idle.set()
|
|
96
|
+
|
|
97
|
+
def _finish_body_cleanup(self, task: asyncio.Task[None]) -> None:
|
|
98
|
+
"""Consume and release one completed request-body cleanup task."""
|
|
99
|
+
with suppress(BaseException):
|
|
100
|
+
task.exception()
|
|
101
|
+
if task not in self._body_cleanup_tasks:
|
|
102
|
+
return
|
|
103
|
+
self._body_cleanup_tasks.remove(task)
|
|
104
|
+
self._end_active()
|
|
105
|
+
|
|
106
|
+
async def _finish_close(self) -> None:
|
|
107
|
+
"""Drain active work, close the pool, and seal the transport."""
|
|
108
|
+
try:
|
|
109
|
+
await self._idle.wait()
|
|
110
|
+
try:
|
|
111
|
+
await self._client.aclose()
|
|
112
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
113
|
+
raise _map_httpx2_error(error) from error
|
|
114
|
+
finally:
|
|
115
|
+
self._closed = True
|
|
116
|
+
|
|
117
|
+
def _has_lease(self, task: asyncio.Task[object]) -> bool:
|
|
118
|
+
"""Return whether a task owns a logical RPC lease."""
|
|
119
|
+
return self._lease_holders.get(task, 0) > 0
|
|
120
|
+
|
|
121
|
+
async def _open_stream(
|
|
122
|
+
self,
|
|
123
|
+
request: httpx2.Request,
|
|
124
|
+
deadline: float | None,
|
|
125
|
+
) -> httpx2.Response:
|
|
126
|
+
"""Send through the response-header phase under the total deadline."""
|
|
127
|
+
try:
|
|
128
|
+
if deadline is None:
|
|
129
|
+
return await self._client.send(request, stream=True)
|
|
130
|
+
async with asyncio.timeout_at(deadline):
|
|
131
|
+
return await self._client.send(request, stream=True)
|
|
132
|
+
except TimeoutError as error:
|
|
133
|
+
if deadline is None:
|
|
134
|
+
raise SendError(str(error)) from error
|
|
135
|
+
raise ReadTimeoutError('total timeout exceeded before response body') from error
|
|
136
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
137
|
+
raise _map_httpx2_error(error) from error
|
|
138
|
+
|
|
139
|
+
def _release_lease(self, task: asyncio.Task[object]) -> None:
|
|
140
|
+
"""Release one active lease owned by an explicit task."""
|
|
141
|
+
count = self._lease_holders.get(task, 0)
|
|
142
|
+
if count <= 0:
|
|
143
|
+
raise TransportError('async transport lease accounting underflow')
|
|
144
|
+
if count == 1:
|
|
145
|
+
del self._lease_holders[task]
|
|
146
|
+
else:
|
|
147
|
+
self._lease_holders[task] = count - 1
|
|
148
|
+
self._end_active()
|
|
149
|
+
|
|
150
|
+
def _release_reservation(self, task: asyncio.Task[object]) -> None:
|
|
151
|
+
"""Release one completed child-task reservation."""
|
|
152
|
+
if task not in self._reserved_tasks:
|
|
153
|
+
return
|
|
154
|
+
self._reserved_tasks.remove(task)
|
|
155
|
+
self._release_lease(task)
|
|
156
|
+
|
|
157
|
+
def _release_stream(self, owner: asyncio.Task[object]) -> None:
|
|
158
|
+
"""Release an open-stream lease owned by a task."""
|
|
159
|
+
count = self._stream_owners.get(owner, 0)
|
|
160
|
+
if count <= 0:
|
|
161
|
+
raise TransportError('async streaming lease accounting underflow')
|
|
162
|
+
if count == 1:
|
|
163
|
+
del self._stream_owners[owner]
|
|
164
|
+
else:
|
|
165
|
+
self._stream_owners[owner] = count - 1
|
|
166
|
+
self._end_active()
|
|
167
|
+
|
|
168
|
+
def _require_send_available(self, task: asyncio.Task[object]) -> None:
|
|
169
|
+
"""Reject new work after close while allowing a leased retry loop."""
|
|
170
|
+
if self._closed:
|
|
171
|
+
raise TransportError('async transport is closed')
|
|
172
|
+
if self._closing and not self._has_lease(task):
|
|
173
|
+
raise TransportError('async transport is closing')
|
|
174
|
+
|
|
175
|
+
def _begin_stream(self) -> asyncio.Task[object]:
|
|
176
|
+
"""Acquire an open-stream lease for the current task."""
|
|
177
|
+
owner = _current_task()
|
|
178
|
+
self._require_send_available(owner)
|
|
179
|
+
self._active += 1
|
|
180
|
+
self._idle.clear()
|
|
181
|
+
self._stream_owners[owner] = self._stream_owners.get(owner, 0) + 1
|
|
182
|
+
return owner
|
|
183
|
+
|
|
184
|
+
def _retain_body_cleanup(self, body: 'bytes | _ThreadedBody | None') -> None:
|
|
185
|
+
"""Retain background request-body cleanup as active transport work."""
|
|
186
|
+
task = _start_adapted_body_cleanup(body)
|
|
187
|
+
if task is None or task in self._body_cleanup_tasks:
|
|
188
|
+
return
|
|
189
|
+
self._body_cleanup_tasks.add(task)
|
|
190
|
+
self._active += 1
|
|
191
|
+
self._idle.clear()
|
|
192
|
+
task.add_done_callback(self._finish_body_cleanup)
|
|
193
|
+
|
|
194
|
+
def _retain_reserved_task(self, task: asyncio.Task[object]) -> None:
|
|
195
|
+
"""Retain one accepted child task until its done callback runs."""
|
|
196
|
+
if task in self._reserved_tasks:
|
|
197
|
+
raise TransportError('async transport task is already reserved')
|
|
198
|
+
self._reserved_tasks.add(task)
|
|
199
|
+
self._acquire_lease(task)
|
|
200
|
+
task.add_done_callback(self._release_reservation)
|
|
201
|
+
|
|
202
|
+
def _reserve_stream_task(
|
|
203
|
+
self,
|
|
204
|
+
task: asyncio.Task[object],
|
|
205
|
+
owner: asyncio.Task[object],
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Retain one child task on behalf of an open stream owner."""
|
|
208
|
+
if self._closed or self._stream_owners.get(owner, 0) <= 0:
|
|
209
|
+
raise TransportError('async transport stream is not active')
|
|
210
|
+
self._retain_reserved_task(task)
|
|
211
|
+
|
|
212
|
+
async def _send_once(self, request: TransportRequest) -> TransportResponse:
|
|
213
|
+
"""Send and convert one buffered response."""
|
|
214
|
+
body = _adapt_body(request.body)
|
|
215
|
+
try:
|
|
216
|
+
response = await self._client.request(
|
|
217
|
+
request.method,
|
|
218
|
+
request.url,
|
|
219
|
+
headers=request.headers,
|
|
220
|
+
content=body,
|
|
221
|
+
timeout=_to_httpx2_timeout(request.timeout),
|
|
222
|
+
)
|
|
223
|
+
except BaseException:
|
|
224
|
+
self._retain_body_cleanup(body)
|
|
225
|
+
raise
|
|
226
|
+
await _close_adapted_body(body)
|
|
227
|
+
return TransportResponse(
|
|
228
|
+
status=response.status_code,
|
|
229
|
+
reason=response.reason_phrase,
|
|
230
|
+
headers=CaseInsensitiveHeaders(response.headers),
|
|
231
|
+
content=response.content,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def checkin(self) -> None:
|
|
235
|
+
"""Release the current task's logical RPC lease.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
TransportError: If the current task owns no active lease.
|
|
239
|
+
"""
|
|
240
|
+
owner = _current_task()
|
|
241
|
+
if not self._has_lease(owner):
|
|
242
|
+
raise TransportError('current task has no active lease')
|
|
243
|
+
self._release_lease(owner)
|
|
244
|
+
|
|
245
|
+
def checkout(self) -> None:
|
|
246
|
+
"""Acquire a logical RPC lease for the current task.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
TransportError: If the transport is closed, or is closing and the
|
|
250
|
+
current task does not already hold a lease.
|
|
251
|
+
"""
|
|
252
|
+
owner = _current_task()
|
|
253
|
+
self._require_send_available(owner)
|
|
254
|
+
self._acquire_lease(owner)
|
|
255
|
+
|
|
256
|
+
async def close(self) -> None:
|
|
257
|
+
"""Drain active leases and close the pool exactly once.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
TransportError: If the current task owns a lease whose release
|
|
261
|
+
would depend on this call returning.
|
|
262
|
+
"""
|
|
263
|
+
owner = _current_task()
|
|
264
|
+
if self._has_lease(owner) or self._stream_owners.get(owner, 0) > 0:
|
|
265
|
+
raise TransportError('cannot close async transport while holding an active lease')
|
|
266
|
+
if self._close_task is None:
|
|
267
|
+
self._closing = True
|
|
268
|
+
self._close_task = asyncio.create_task(self._finish_close())
|
|
269
|
+
await asyncio.shield(self._close_task)
|
|
270
|
+
|
|
271
|
+
def reserve(self, task: asyncio.Task[object]) -> None:
|
|
272
|
+
"""Retain one child task until its done callback runs.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
task: Child task whose scheduled work must survive close.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
TransportError: If the transport rejects the reserving owner or
|
|
279
|
+
`task` is already reserved.
|
|
280
|
+
"""
|
|
281
|
+
owner = _current_task()
|
|
282
|
+
self._require_send_available(owner)
|
|
283
|
+
self._retain_reserved_task(task)
|
|
284
|
+
|
|
285
|
+
async def send(self, request: TransportRequest) -> TransportResponse:
|
|
286
|
+
"""Send one buffered request.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
request: Request expressed in shared transport types.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The complete response in shared transport types.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
TransportError: If httpx2 cannot complete the request.
|
|
296
|
+
"""
|
|
297
|
+
self._require_send_available(_current_task())
|
|
298
|
+
total = request.timeout.total
|
|
299
|
+
if total is None:
|
|
300
|
+
try:
|
|
301
|
+
return await self._send_once(request)
|
|
302
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
303
|
+
raise _map_httpx2_error(error) from error
|
|
304
|
+
try:
|
|
305
|
+
async with asyncio.timeout(request.timeout.total):
|
|
306
|
+
return await self._send_once(request)
|
|
307
|
+
except TimeoutError as error:
|
|
308
|
+
raise ReadTimeoutError('total timeout exceeded') from error
|
|
309
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
310
|
+
raise _map_httpx2_error(error) from error
|
|
311
|
+
|
|
312
|
+
async def send_streaming(self, request: TransportRequest) -> AsyncStreamingResponse:
|
|
313
|
+
"""Send one request and stream its body under one total deadline.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
request: Request expressed in shared transport types.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
A single-use streaming response in shared transport types.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
TransportError: If the request, response, or total deadline fails.
|
|
323
|
+
"""
|
|
324
|
+
current_task = _current_task()
|
|
325
|
+
self._require_send_available(current_task)
|
|
326
|
+
deadline = None
|
|
327
|
+
if request.timeout.total is not None:
|
|
328
|
+
deadline = asyncio.get_running_loop().time() + request.timeout.total
|
|
329
|
+
body = _adapt_body(request.body)
|
|
330
|
+
try:
|
|
331
|
+
outgoing = self._client.build_request(
|
|
332
|
+
request.method,
|
|
333
|
+
request.url,
|
|
334
|
+
headers=request.headers,
|
|
335
|
+
content=body,
|
|
336
|
+
timeout=_to_httpx2_timeout(request.timeout),
|
|
337
|
+
)
|
|
338
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
339
|
+
self._retain_body_cleanup(body)
|
|
340
|
+
raise _map_httpx2_error(error) from error
|
|
341
|
+
except BaseException:
|
|
342
|
+
self._retain_body_cleanup(body)
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
owner = self._begin_stream()
|
|
346
|
+
try:
|
|
347
|
+
response = await self._open_stream(outgoing, deadline)
|
|
348
|
+
except BaseException:
|
|
349
|
+
self._retain_body_cleanup(body)
|
|
350
|
+
self._release_stream(owner)
|
|
351
|
+
raise
|
|
352
|
+
try:
|
|
353
|
+
await _close_adapted_body(body)
|
|
354
|
+
except BaseException:
|
|
355
|
+
with suppress(BaseException):
|
|
356
|
+
await response.aclose()
|
|
357
|
+
self._release_stream(owner)
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
body_owner = _ResponseBody(
|
|
361
|
+
response,
|
|
362
|
+
deadline,
|
|
363
|
+
lambda: self._release_stream(owner),
|
|
364
|
+
lambda task: self._reserve_stream_task(task, owner),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return AsyncStreamingResponse(
|
|
368
|
+
status=response.status_code,
|
|
369
|
+
reason=response.reason_phrase,
|
|
370
|
+
headers=CaseInsensitiveHeaders(response.headers),
|
|
371
|
+
stream=body_owner.stream,
|
|
372
|
+
close_fn=body_owner.close,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclasses.dataclass(frozen=True)
|
|
377
|
+
class _PulledChunk:
|
|
378
|
+
"""Carry one worker-thread pull into its event-loop observation phase."""
|
|
379
|
+
|
|
380
|
+
chunk: bytes | None
|
|
381
|
+
observe: Callable[[bytes], None] | None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class _ResponseBody:
|
|
385
|
+
"""Own response pulls and cleanup until the stream lease is released."""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
response: httpx2.Response,
|
|
390
|
+
deadline: float | None,
|
|
391
|
+
release: Callable[[], None],
|
|
392
|
+
reserve: Callable[[asyncio.Task[object]], None],
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Initialize one deadline-aware async response body."""
|
|
395
|
+
self._deadline = deadline
|
|
396
|
+
self._pending: asyncio.Task[bytes] | None = None
|
|
397
|
+
self._release = release
|
|
398
|
+
self._reserve = reserve
|
|
399
|
+
self._response = response
|
|
400
|
+
self._task: asyncio.Task[None] | None = None
|
|
401
|
+
|
|
402
|
+
async def _finish(self) -> None:
|
|
403
|
+
"""Join the active pull, close the response, and release its lease."""
|
|
404
|
+
pending = self._pending
|
|
405
|
+
if pending is not None:
|
|
406
|
+
with suppress(BaseException):
|
|
407
|
+
await asyncio.shield(pending)
|
|
408
|
+
if self._pending is pending:
|
|
409
|
+
self._pending = None
|
|
410
|
+
try:
|
|
411
|
+
await self._response.aclose()
|
|
412
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
413
|
+
raise _map_httpx2_error(error) from error
|
|
414
|
+
finally:
|
|
415
|
+
self._release()
|
|
416
|
+
|
|
417
|
+
def _start(self) -> asyncio.Task[None]:
|
|
418
|
+
"""Start idempotent cleanup retained by the transport."""
|
|
419
|
+
if self._task is None:
|
|
420
|
+
pending = self._pending
|
|
421
|
+
if pending is not None:
|
|
422
|
+
pending.cancel()
|
|
423
|
+
self._task = asyncio.create_task(self._finish())
|
|
424
|
+
self._reserve(self._task)
|
|
425
|
+
self._task.add_done_callback(_consume_task_exception)
|
|
426
|
+
return self._task
|
|
427
|
+
|
|
428
|
+
async def _pull(self, iterator: AsyncIterator[bytes]) -> bytes:
|
|
429
|
+
"""Run one retained response pull behind a shielded consumer wait."""
|
|
430
|
+
if self._pending is not None:
|
|
431
|
+
raise TransportError('async response already has an active pull')
|
|
432
|
+
pull = asyncio.create_task(_next_response_chunk(iterator, self._deadline))
|
|
433
|
+
self._pending = pull
|
|
434
|
+
try:
|
|
435
|
+
self._reserve(pull)
|
|
436
|
+
except BaseException:
|
|
437
|
+
pull.cancel()
|
|
438
|
+
with suppress(BaseException):
|
|
439
|
+
await pull
|
|
440
|
+
if self._pending is pull:
|
|
441
|
+
self._pending = None
|
|
442
|
+
raise
|
|
443
|
+
try:
|
|
444
|
+
try:
|
|
445
|
+
return await asyncio.shield(pull)
|
|
446
|
+
except asyncio.CancelledError:
|
|
447
|
+
pull.cancel()
|
|
448
|
+
self._start()
|
|
449
|
+
raise
|
|
450
|
+
finally:
|
|
451
|
+
if pull.done() and self._pending is pull:
|
|
452
|
+
self._pending = None
|
|
453
|
+
|
|
454
|
+
async def close(self) -> None:
|
|
455
|
+
"""Wait for cleanup unless the calling task is cancelling."""
|
|
456
|
+
cleanup = self._start()
|
|
457
|
+
current = asyncio.current_task()
|
|
458
|
+
if current is not None and current.cancelling():
|
|
459
|
+
return
|
|
460
|
+
await asyncio.shield(cleanup)
|
|
461
|
+
|
|
462
|
+
async def stream(self, size: int) -> AsyncIterator[bytes]:
|
|
463
|
+
"""Yield mapped, deadline-aware response chunks.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
size: Maximum preferred chunk size in bytes.
|
|
467
|
+
|
|
468
|
+
Yields:
|
|
469
|
+
Response body chunks.
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
TransportError: If reading the response stream fails.
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
iterator = self._response.aiter_bytes(size)
|
|
476
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
477
|
+
raise _map_httpx2_error(error) from error
|
|
478
|
+
while True:
|
|
479
|
+
try:
|
|
480
|
+
yield await self._pull(iterator)
|
|
481
|
+
except StopAsyncIteration:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class _ThreadedBody(AsyncIterator[bytes]):
|
|
486
|
+
"""Serialize off-loop iterator pulls with idempotent cleanup."""
|
|
487
|
+
|
|
488
|
+
def __init__(self, chunks: Iterator[bytes]) -> None:
|
|
489
|
+
"""Initialize one threaded iterator-backed request body."""
|
|
490
|
+
self._chunks = chunks
|
|
491
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
492
|
+
self._pending: asyncio.Task[_PulledChunk] | None = None
|
|
493
|
+
|
|
494
|
+
def __aiter__(self) -> Self:
|
|
495
|
+
"""Return this asynchronous body iterator."""
|
|
496
|
+
return self
|
|
497
|
+
|
|
498
|
+
async def __anext__(self) -> bytes:
|
|
499
|
+
"""Return the next body chunk from the iterator thread."""
|
|
500
|
+
if self._close_task is not None:
|
|
501
|
+
raise StopAsyncIteration
|
|
502
|
+
pull = asyncio.create_task(asyncio.to_thread(_next_chunk, self._chunks))
|
|
503
|
+
self._pending = pull
|
|
504
|
+
try:
|
|
505
|
+
try:
|
|
506
|
+
pulled = await asyncio.shield(pull)
|
|
507
|
+
except asyncio.CancelledError:
|
|
508
|
+
self._start_close()
|
|
509
|
+
raise
|
|
510
|
+
except Exception as error:
|
|
511
|
+
raise SendError(str(error)) from error
|
|
512
|
+
finally:
|
|
513
|
+
if pull.done():
|
|
514
|
+
self._pending = None
|
|
515
|
+
chunk = pulled.chunk
|
|
516
|
+
if chunk is None:
|
|
517
|
+
await self.aclose()
|
|
518
|
+
raise StopAsyncIteration
|
|
519
|
+
if chunk and pulled.observe is not None:
|
|
520
|
+
pulled.observe(chunk)
|
|
521
|
+
return chunk
|
|
522
|
+
|
|
523
|
+
async def _finish_close(self) -> None:
|
|
524
|
+
"""Wait for an active pull before closing its iterator."""
|
|
525
|
+
pending = self._pending
|
|
526
|
+
if pending is not None:
|
|
527
|
+
with suppress(BaseException):
|
|
528
|
+
await asyncio.shield(pending)
|
|
529
|
+
await _close_body(self._chunks)
|
|
530
|
+
|
|
531
|
+
def _start_close(self) -> asyncio.Task[None]:
|
|
532
|
+
"""Start idempotent cleanup and retain its task."""
|
|
533
|
+
if self._close_task is None:
|
|
534
|
+
self._close_task = asyncio.create_task(self._finish_close())
|
|
535
|
+
self._close_task.add_done_callback(_consume_task_exception)
|
|
536
|
+
return self._close_task
|
|
537
|
+
|
|
538
|
+
async def aclose(self) -> None:
|
|
539
|
+
"""Close the underlying iterator exactly once and off-loop."""
|
|
540
|
+
close_task = self._start_close()
|
|
541
|
+
current = asyncio.current_task()
|
|
542
|
+
if current is not None and current.cancelling():
|
|
543
|
+
return
|
|
544
|
+
await asyncio.shield(close_task)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _adapt_body(
|
|
548
|
+
body: bytes | Iterator[bytes] | None,
|
|
549
|
+
) -> bytes | _ThreadedBody | None:
|
|
550
|
+
"""Adapt a shared request body for httpx2 without blocking the loop."""
|
|
551
|
+
if body is None or isinstance(body, bytes):
|
|
552
|
+
return body
|
|
553
|
+
return _ThreadedBody(body)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def _close_adapted_body(body: bytes | _ThreadedBody | None) -> None:
|
|
557
|
+
"""Close an adapted streaming request body when one is present."""
|
|
558
|
+
if isinstance(body, _ThreadedBody):
|
|
559
|
+
await body.aclose()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
async def _close_body(chunks: Iterator[bytes]) -> None:
|
|
563
|
+
"""Close a synchronous request body off the event loop when supported."""
|
|
564
|
+
close = getattr(chunks, 'close', None)
|
|
565
|
+
if callable(close):
|
|
566
|
+
try:
|
|
567
|
+
await asyncio.to_thread(cast('Callable[[], object]', close))
|
|
568
|
+
except asyncio.CancelledError:
|
|
569
|
+
raise
|
|
570
|
+
except Exception as error:
|
|
571
|
+
raise SendError(str(error)) from error
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _consume_task_exception(task: asyncio.Task[None]) -> None:
|
|
575
|
+
"""Retrieve a retained cleanup task's terminal exception."""
|
|
576
|
+
with suppress(BaseException):
|
|
577
|
+
task.exception()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _current_task() -> asyncio.Task[object]:
|
|
581
|
+
"""Return the current task or reject use outside an event-loop task."""
|
|
582
|
+
task = asyncio.current_task()
|
|
583
|
+
if task is None:
|
|
584
|
+
raise TransportError('async transport operation requires a running task')
|
|
585
|
+
return task
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _is_tls_failure(error: BaseException) -> bool:
|
|
589
|
+
"""Return whether an exception cause chain contains an SSL failure."""
|
|
590
|
+
cause = error.__cause__
|
|
591
|
+
while cause is not None:
|
|
592
|
+
if isinstance(cause, ssl.SSLError):
|
|
593
|
+
return True
|
|
594
|
+
cause = cause.__cause__
|
|
595
|
+
return False
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> TransportError:
|
|
599
|
+
"""Map an httpx2 failure into the shared transport vocabulary."""
|
|
600
|
+
if isinstance(error, httpx2.ProxyError):
|
|
601
|
+
return ProxyError(str(error))
|
|
602
|
+
if isinstance(error, (httpx2.ConnectError, httpx2.ConnectTimeout)):
|
|
603
|
+
if _is_tls_failure(error):
|
|
604
|
+
return TLSError(str(error))
|
|
605
|
+
return ConnectError(str(error))
|
|
606
|
+
if isinstance(error, httpx2.PoolTimeout):
|
|
607
|
+
return ConnectError(str(error))
|
|
608
|
+
if isinstance(error, httpx2.ReadTimeout):
|
|
609
|
+
return ReadTimeoutError(str(error))
|
|
610
|
+
return SendError(str(error))
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _next_chunk(chunks: Iterator[bytes]) -> _PulledChunk:
|
|
614
|
+
"""Pull one chunk and capture an exact-name loop observer when supported."""
|
|
615
|
+
pull_for_async = getattr(chunks, 'pull_for_async', None)
|
|
616
|
+
observe_async_chunk = getattr(chunks, 'observe_async_chunk', None)
|
|
617
|
+
if callable(pull_for_async) and callable(observe_async_chunk):
|
|
618
|
+
return _PulledChunk(
|
|
619
|
+
chunk=cast('Callable[[], bytes | None]', pull_for_async)(),
|
|
620
|
+
observe=cast('Callable[[bytes], None]', observe_async_chunk),
|
|
621
|
+
)
|
|
622
|
+
return _PulledChunk(chunk=next(chunks, None), observe=None)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
async def _next_response_chunk(
|
|
626
|
+
iterator: AsyncIterator[bytes],
|
|
627
|
+
deadline: float | None,
|
|
628
|
+
) -> bytes:
|
|
629
|
+
"""Read one response chunk against the remaining total budget."""
|
|
630
|
+
try:
|
|
631
|
+
if deadline is None:
|
|
632
|
+
return await anext(iterator)
|
|
633
|
+
async with asyncio.timeout_at(deadline):
|
|
634
|
+
return await anext(iterator)
|
|
635
|
+
except TimeoutError as error:
|
|
636
|
+
if deadline is None:
|
|
637
|
+
raise SendError(str(error)) from error
|
|
638
|
+
raise ReadTimeoutError('total timeout exceeded during response body') from error
|
|
639
|
+
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
640
|
+
current = asyncio.current_task()
|
|
641
|
+
if current is not None and current.cancelling():
|
|
642
|
+
raise asyncio.CancelledError from error
|
|
643
|
+
raise _map_httpx2_error(error) from error
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _start_adapted_body_cleanup(
|
|
647
|
+
body: bytes | _ThreadedBody | None,
|
|
648
|
+
) -> asyncio.Task[None] | None:
|
|
649
|
+
"""Start and return request-body cleanup without delaying an error path."""
|
|
650
|
+
if isinstance(body, _ThreadedBody):
|
|
651
|
+
return body._start_close() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _to_httpx2_timeout(timeout: TimeoutConfig) -> httpx2.Timeout:
|
|
656
|
+
"""Convert shared timeout fields to httpx2 phase budgets."""
|
|
657
|
+
return httpx2.Timeout(
|
|
658
|
+
connect=timeout.connect,
|
|
659
|
+
read=timeout.read,
|
|
660
|
+
write=timeout.read,
|
|
661
|
+
pool=timeout.connect,
|
|
662
|
+
)
|