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,945 @@
|
|
|
1
|
+
"""Async attempt, consumption, retry, and observer orchestration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import copy
|
|
5
|
+
import dataclasses
|
|
6
|
+
import inspect
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from typing import Protocol, TypeVar, cast
|
|
14
|
+
|
|
15
|
+
from . import _protocol, _transport
|
|
16
|
+
from ._async_transport import AsyncTransport
|
|
17
|
+
from ._config import (
|
|
18
|
+
CompatibilityWarningCode,
|
|
19
|
+
EventRoute,
|
|
20
|
+
ObserverCallback,
|
|
21
|
+
Outcome,
|
|
22
|
+
ProgressEvent,
|
|
23
|
+
RequestContext,
|
|
24
|
+
RequestEvent,
|
|
25
|
+
RetryEvent,
|
|
26
|
+
compatibility_warning_events,
|
|
27
|
+
event_route,
|
|
28
|
+
notify_observer,
|
|
29
|
+
response_correlation_id,
|
|
30
|
+
)
|
|
31
|
+
from ._execution import (
|
|
32
|
+
AttemptOutcomes,
|
|
33
|
+
AttemptRetry,
|
|
34
|
+
AttemptState,
|
|
35
|
+
AttemptSuccess,
|
|
36
|
+
ExceptionCapture,
|
|
37
|
+
SuppressedExceptionCapture,
|
|
38
|
+
build_rpc_request,
|
|
39
|
+
)
|
|
40
|
+
from ._transport import (
|
|
41
|
+
ReadTimeoutError,
|
|
42
|
+
SendError,
|
|
43
|
+
TransportError,
|
|
44
|
+
TransportRequest,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_T = TypeVar('_T')
|
|
48
|
+
|
|
49
|
+
_DETACHED_CLEANUP_TASKS: set[asyncio.Task[None]] = set()
|
|
50
|
+
_HTTP_REDIRECT = 302
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass(frozen=True)
|
|
54
|
+
class AsyncAttemptControl:
|
|
55
|
+
"""Consume async attempt bytes under deadline and progress control."""
|
|
56
|
+
|
|
57
|
+
_attempt: int
|
|
58
|
+
_clock: Callable[[], float]
|
|
59
|
+
_deadline: float | None
|
|
60
|
+
_observer: ObserverCallback | None
|
|
61
|
+
_operation: _protocol.OperationCategory
|
|
62
|
+
_route: EventRoute
|
|
63
|
+
|
|
64
|
+
def _check_deadline(self) -> None:
|
|
65
|
+
"""Raise when cumulative monotonic attempt time exceeds its limit."""
|
|
66
|
+
if self._deadline is not None and self._clock() > self._deadline:
|
|
67
|
+
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
68
|
+
|
|
69
|
+
async def _consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
|
|
70
|
+
"""Observe a received buffered body without rebuilding it."""
|
|
71
|
+
self._check_deadline()
|
|
72
|
+
if body:
|
|
73
|
+
notify_observer(
|
|
74
|
+
self._observer,
|
|
75
|
+
ProgressEvent(
|
|
76
|
+
operation=self._operation,
|
|
77
|
+
route=self._route,
|
|
78
|
+
attempt=self._attempt,
|
|
79
|
+
bytes_transferred=len(body),
|
|
80
|
+
total_bytes=total_bytes,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
def _remaining_timeout(self) -> float | None:
|
|
86
|
+
"""Return the remaining cumulative attempt timeout."""
|
|
87
|
+
if self._deadline is None:
|
|
88
|
+
return None
|
|
89
|
+
remaining = self._deadline - self._clock()
|
|
90
|
+
if remaining < 0:
|
|
91
|
+
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
92
|
+
return remaining
|
|
93
|
+
|
|
94
|
+
async def consume_into(
|
|
95
|
+
self,
|
|
96
|
+
chunks: AsyncIterator[bytes],
|
|
97
|
+
sink: Callable[[bytes], object | Awaitable[object]],
|
|
98
|
+
total_bytes: int | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Write async chunks under deadline and progress control.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
chunks: Fresh response-body chunks for this attempt.
|
|
104
|
+
sink: Synchronous or awaitable attempt-local byte sink.
|
|
105
|
+
total_bytes: Known complete body size, when available.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ReadTimeoutError: If cumulative attempt time exceeds the total
|
|
109
|
+
limit.
|
|
110
|
+
"""
|
|
111
|
+
transferred = 0
|
|
112
|
+
async for chunk in chunks:
|
|
113
|
+
sink_result = sink(chunk)
|
|
114
|
+
if inspect.isawaitable(sink_result):
|
|
115
|
+
await sink_result
|
|
116
|
+
transferred += len(chunk)
|
|
117
|
+
self._check_deadline()
|
|
118
|
+
if chunk:
|
|
119
|
+
notify_observer(
|
|
120
|
+
self._observer,
|
|
121
|
+
ProgressEvent(
|
|
122
|
+
operation=self._operation,
|
|
123
|
+
route=self._route,
|
|
124
|
+
attempt=self._attempt,
|
|
125
|
+
bytes_transferred=transferred,
|
|
126
|
+
total_bytes=total_bytes,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def consume(
|
|
131
|
+
self,
|
|
132
|
+
chunks: AsyncIterator[bytes],
|
|
133
|
+
total_bytes: int | None = None,
|
|
134
|
+
) -> bytes:
|
|
135
|
+
"""Collect async chunks while enforcing the attempt deadline.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
chunks: Fresh response-body chunks for this attempt.
|
|
139
|
+
total_bytes: Known complete body size, when available.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Concatenated response bytes.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ReadTimeoutError: If cumulative attempt time exceeds the total
|
|
146
|
+
limit.
|
|
147
|
+
"""
|
|
148
|
+
body = BytesIO()
|
|
149
|
+
await self.consume_into(chunks, body.write, total_bytes=total_bytes)
|
|
150
|
+
return body.getvalue()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclasses.dataclass(frozen=True)
|
|
154
|
+
class _AsyncDriverTiming:
|
|
155
|
+
"""Provide explicit deterministic async attempt timing sources."""
|
|
156
|
+
|
|
157
|
+
clock: Callable[[], float] = time.monotonic
|
|
158
|
+
jitter_seed: Callable[[], float] = time.perf_counter
|
|
159
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
|
|
160
|
+
wall_clock: Callable[[], float] = time.time
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AsyncLiveResponse(Protocol):
|
|
164
|
+
"""Minimal response lifecycle required by the async executor.
|
|
165
|
+
|
|
166
|
+
Attributes:
|
|
167
|
+
headers: Read-only normalized response headers.
|
|
168
|
+
status: HTTP response status code.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def close(self) -> Awaitable[None] | None:
|
|
172
|
+
"""Release this attempt response exactly once.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Awaitable cleanup for async responses, otherwise `None`.
|
|
176
|
+
"""
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def headers(self) -> Mapping[str, str]:
|
|
181
|
+
"""Return normalized response headers.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Read-only response headers.
|
|
185
|
+
"""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def status(self) -> int:
|
|
190
|
+
"""Return the HTTP response status.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
HTTP status code.
|
|
194
|
+
"""
|
|
195
|
+
...
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclasses.dataclass(frozen=True)
|
|
199
|
+
class AsyncExecutionOptions:
|
|
200
|
+
"""Control async executor transport choreography.
|
|
201
|
+
|
|
202
|
+
Attributes:
|
|
203
|
+
compatibility_warning_codes: Compatibility warnings emitted before
|
|
204
|
+
attempts.
|
|
205
|
+
logical_method_name: Optional observer-facing operation name.
|
|
206
|
+
redirect_factory: Optional request factory for redirect responses.
|
|
207
|
+
streaming: Whether the response body is consumed as a stream.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = ()
|
|
211
|
+
logical_method_name: str | None = None
|
|
212
|
+
redirect_factory: Callable[['AsyncLiveResponse'], TransportRequest] | None = None
|
|
213
|
+
streaming: bool = False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AsyncDriver:
|
|
217
|
+
"""Own the only async transport attempt and retry loop."""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
transport: AsyncTransport,
|
|
222
|
+
timing: _AsyncDriverTiming | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Initialize an async driver around one transport and timing source.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
transport: Async transport used for every request attempt.
|
|
228
|
+
timing: Optional deterministic timing sources.
|
|
229
|
+
"""
|
|
230
|
+
sources = timing or _AsyncDriverTiming()
|
|
231
|
+
self._clock = sources.clock
|
|
232
|
+
self._outcomes = AttemptOutcomes(clock=sources.clock, jitter_seed=sources.jitter_seed)
|
|
233
|
+
self._sleep = sources.sleep
|
|
234
|
+
self._transport = transport
|
|
235
|
+
self._wall_clock = sources.wall_clock
|
|
236
|
+
|
|
237
|
+
def _attempt_state(
|
|
238
|
+
self,
|
|
239
|
+
operation: _protocol.OperationDescriptor,
|
|
240
|
+
context: RequestContext,
|
|
241
|
+
route: EventRoute,
|
|
242
|
+
attempt: int,
|
|
243
|
+
) -> AttemptState[AsyncAttemptControl]:
|
|
244
|
+
"""Build immutable timing and observer state for one fresh attempt."""
|
|
245
|
+
started = self._clock()
|
|
246
|
+
deadline = None if context.timeout.total is None else started + context.timeout.total
|
|
247
|
+
control = AsyncAttemptControl(
|
|
248
|
+
_attempt=attempt,
|
|
249
|
+
_clock=self._clock,
|
|
250
|
+
_deadline=deadline,
|
|
251
|
+
_observer=context.on_progress,
|
|
252
|
+
_operation=operation.category,
|
|
253
|
+
_route=route,
|
|
254
|
+
)
|
|
255
|
+
return AttemptState(
|
|
256
|
+
context=context,
|
|
257
|
+
control=control,
|
|
258
|
+
number=attempt,
|
|
259
|
+
operation=operation,
|
|
260
|
+
route=route,
|
|
261
|
+
started=started,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _close_received_response_after_send_failure(
|
|
265
|
+
self,
|
|
266
|
+
response: AsyncLiveResponse,
|
|
267
|
+
state: AttemptState[AsyncAttemptControl],
|
|
268
|
+
primary_exception: BaseException,
|
|
269
|
+
response_status: int | None,
|
|
270
|
+
correlation_id: str | None,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Close a received response while preserving the active exception."""
|
|
273
|
+
_close_error, cancellation = await _close_response_capturing(
|
|
274
|
+
response,
|
|
275
|
+
primary_exception,
|
|
276
|
+
)
|
|
277
|
+
self._outcomes.emit_response(
|
|
278
|
+
state,
|
|
279
|
+
status=response_status,
|
|
280
|
+
outcome='error',
|
|
281
|
+
correlation_id=correlation_id,
|
|
282
|
+
)
|
|
283
|
+
if cancellation is not None:
|
|
284
|
+
raise cancellation
|
|
285
|
+
|
|
286
|
+
async def _consume_response(
|
|
287
|
+
self,
|
|
288
|
+
response: AsyncLiveResponse,
|
|
289
|
+
state: AttemptState[AsyncAttemptControl],
|
|
290
|
+
consumer: Callable[[AsyncLiveResponse, AsyncAttemptControl], Awaitable[_T]],
|
|
291
|
+
) -> AttemptRetry | AttemptSuccess[_T]:
|
|
292
|
+
"""Classify and consume a response with exact-once cleanup."""
|
|
293
|
+
attempt_result: AttemptRetry | AttemptSuccess[_T] | None = None
|
|
294
|
+
correlation_id: str | None = None
|
|
295
|
+
exception_capture = ExceptionCapture()
|
|
296
|
+
planned_outcome: Outcome = 'error'
|
|
297
|
+
response_status: int | None = None
|
|
298
|
+
try:
|
|
299
|
+
with exception_capture:
|
|
300
|
+
response_status = response.status
|
|
301
|
+
response_headers = response.headers
|
|
302
|
+
correlation_id = response_correlation_id(response_headers)
|
|
303
|
+
state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
304
|
+
failure = _protocol.classify_http_failure(
|
|
305
|
+
response_status,
|
|
306
|
+
response_headers,
|
|
307
|
+
self._wall_clock(),
|
|
308
|
+
)
|
|
309
|
+
retry = (
|
|
310
|
+
None
|
|
311
|
+
if failure is None
|
|
312
|
+
else self._outcomes.retry(state, failure, response_status)
|
|
313
|
+
)
|
|
314
|
+
if retry is None:
|
|
315
|
+
attempt_result = AttemptSuccess(await consumer(response, state.control))
|
|
316
|
+
planned_outcome = 'success'
|
|
317
|
+
else:
|
|
318
|
+
attempt_result = retry
|
|
319
|
+
planned_outcome = 'retry'
|
|
320
|
+
except TransportError as exc:
|
|
321
|
+
with exception_capture:
|
|
322
|
+
retry = self._outcomes.retry(
|
|
323
|
+
state,
|
|
324
|
+
_protocol.classify_transport_failure(exc, response_received=True),
|
|
325
|
+
response_status,
|
|
326
|
+
)
|
|
327
|
+
if retry is None:
|
|
328
|
+
raise
|
|
329
|
+
attempt_result = retry
|
|
330
|
+
planned_outcome = 'retry'
|
|
331
|
+
finally:
|
|
332
|
+
close_error, cancellation = await _close_response_capturing(
|
|
333
|
+
response,
|
|
334
|
+
exception_capture.exception,
|
|
335
|
+
)
|
|
336
|
+
self._outcomes.emit_response(
|
|
337
|
+
state,
|
|
338
|
+
status=response_status,
|
|
339
|
+
outcome=(
|
|
340
|
+
'error'
|
|
341
|
+
if exception_capture.exception is not None or cancellation is not None
|
|
342
|
+
else planned_outcome
|
|
343
|
+
),
|
|
344
|
+
correlation_id=correlation_id,
|
|
345
|
+
)
|
|
346
|
+
if cancellation is not None:
|
|
347
|
+
raise cancellation
|
|
348
|
+
if (
|
|
349
|
+
close_error is not None
|
|
350
|
+
and attempt_result is None
|
|
351
|
+
and exception_capture.exception is None
|
|
352
|
+
):
|
|
353
|
+
raise SendError('response cleanup failed') from None
|
|
354
|
+
if attempt_result is None:
|
|
355
|
+
raise AssertionError('response consumption ended without an outcome')
|
|
356
|
+
return attempt_result
|
|
357
|
+
|
|
358
|
+
def _prepare_request(
|
|
359
|
+
self,
|
|
360
|
+
request: TransportRequest,
|
|
361
|
+
state: AttemptState[AsyncAttemptControl],
|
|
362
|
+
) -> TransportRequest:
|
|
363
|
+
"""Attach progress control and publish one redacted request event."""
|
|
364
|
+
prepared = _request_with_progress(request, state)
|
|
365
|
+
notify_observer(
|
|
366
|
+
state.context.on_request,
|
|
367
|
+
RequestEvent(
|
|
368
|
+
operation=state.operation.category,
|
|
369
|
+
route=state.route,
|
|
370
|
+
http_method=prepared.method,
|
|
371
|
+
attempt=state.number,
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
return prepared
|
|
375
|
+
|
|
376
|
+
async def _redirect_request(
|
|
377
|
+
self,
|
|
378
|
+
response: AsyncLiveResponse,
|
|
379
|
+
state: AttemptState[AsyncAttemptControl],
|
|
380
|
+
redirect_factory: Callable[[AsyncLiveResponse], TransportRequest],
|
|
381
|
+
) -> TransportRequest:
|
|
382
|
+
"""Close one redirect response and return its next request."""
|
|
383
|
+
correlation_id: str | None = None
|
|
384
|
+
exception_capture = ExceptionCapture()
|
|
385
|
+
request: TransportRequest | None = None
|
|
386
|
+
response_status: int | None = None
|
|
387
|
+
try:
|
|
388
|
+
with exception_capture:
|
|
389
|
+
response_status = response.status
|
|
390
|
+
correlation_id = response_correlation_id(response.headers)
|
|
391
|
+
request = redirect_factory(response)
|
|
392
|
+
finally:
|
|
393
|
+
close_error, cancellation = await _close_response_capturing(
|
|
394
|
+
response,
|
|
395
|
+
exception_capture.exception,
|
|
396
|
+
)
|
|
397
|
+
self._outcomes.emit_response(
|
|
398
|
+
state,
|
|
399
|
+
status=response_status,
|
|
400
|
+
outcome=(
|
|
401
|
+
'error'
|
|
402
|
+
if exception_capture.exception is not None or cancellation is not None
|
|
403
|
+
else 'success'
|
|
404
|
+
),
|
|
405
|
+
correlation_id=correlation_id,
|
|
406
|
+
)
|
|
407
|
+
if cancellation is not None:
|
|
408
|
+
raise cancellation
|
|
409
|
+
if close_error is not None and request is None and exception_capture.exception is None:
|
|
410
|
+
raise SendError('response cleanup failed') from None
|
|
411
|
+
return cast('TransportRequest', request)
|
|
412
|
+
|
|
413
|
+
async def _retry(self, observer: ObserverCallback | None, event: RetryEvent) -> None:
|
|
414
|
+
"""Publish a retry event and sleep after attempt cleanup."""
|
|
415
|
+
notify_observer(observer, event)
|
|
416
|
+
await self._sleep(event.delay)
|
|
417
|
+
|
|
418
|
+
async def _send_request(
|
|
419
|
+
self,
|
|
420
|
+
request: TransportRequest,
|
|
421
|
+
state: AttemptState[AsyncAttemptControl],
|
|
422
|
+
*,
|
|
423
|
+
redirected: bool,
|
|
424
|
+
streaming: bool,
|
|
425
|
+
) -> AsyncLiveResponse | AttemptRetry:
|
|
426
|
+
"""Send once and close the request body under response ownership."""
|
|
427
|
+
correlation_id: str | None = None
|
|
428
|
+
response: AsyncLiveResponse | None = None
|
|
429
|
+
response_status: int | None = None
|
|
430
|
+
try:
|
|
431
|
+
try:
|
|
432
|
+
if redirected:
|
|
433
|
+
request = _request_with_remaining_timeout(request, state.control)
|
|
434
|
+
if streaming:
|
|
435
|
+
response = await self._transport.send_streaming(request)
|
|
436
|
+
else:
|
|
437
|
+
response = await self._transport.send(request)
|
|
438
|
+
response_status = response.status
|
|
439
|
+
correlation_id = response_correlation_id(response.headers)
|
|
440
|
+
finally:
|
|
441
|
+
await _close_request_body_after_send(request)
|
|
442
|
+
except BaseException as exc:
|
|
443
|
+
if response is not None:
|
|
444
|
+
await self._close_received_response_after_send_failure(
|
|
445
|
+
response,
|
|
446
|
+
state,
|
|
447
|
+
exc,
|
|
448
|
+
response_status,
|
|
449
|
+
correlation_id,
|
|
450
|
+
)
|
|
451
|
+
raise
|
|
452
|
+
if not isinstance(exc, TransportError):
|
|
453
|
+
raise
|
|
454
|
+
retry = self._outcomes.send_failure(exc, state)
|
|
455
|
+
if retry is None:
|
|
456
|
+
raise
|
|
457
|
+
return retry
|
|
458
|
+
if response is None:
|
|
459
|
+
raise AssertionError('transport returned without a response')
|
|
460
|
+
return response
|
|
461
|
+
|
|
462
|
+
async def execute(
|
|
463
|
+
self,
|
|
464
|
+
operation: _protocol.OperationDescriptor,
|
|
465
|
+
context: RequestContext,
|
|
466
|
+
request_factory: Callable[[int], TransportRequest],
|
|
467
|
+
consumer: Callable[[AsyncLiveResponse, AsyncAttemptControl], Awaitable[_T]],
|
|
468
|
+
options: AsyncExecutionOptions | None = None,
|
|
469
|
+
) -> _T:
|
|
470
|
+
"""Execute one logical operation through fresh transport attempts.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
operation: Immutable replay and route classification.
|
|
474
|
+
context: Immutable operation configuration.
|
|
475
|
+
request_factory: Synchronous fresh-attempt builder.
|
|
476
|
+
consumer: Async buffered or streaming response consumer.
|
|
477
|
+
options: Redirect, streaming, and observer controls.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
The successful consumer result.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
TransportError: If a terminal or exhausted transport failure
|
|
484
|
+
occurs.
|
|
485
|
+
Exception: If request construction, status validation, response
|
|
486
|
+
consumption, or cleanup fails.
|
|
487
|
+
"""
|
|
488
|
+
execution_options = options or AsyncExecutionOptions()
|
|
489
|
+
for event in compatibility_warning_events(
|
|
490
|
+
execution_options.compatibility_warning_codes,
|
|
491
|
+
context.timeout,
|
|
492
|
+
):
|
|
493
|
+
notify_observer(context.on_compatibility_warning, event)
|
|
494
|
+
route = event_route(operation)
|
|
495
|
+
self._transport.checkout()
|
|
496
|
+
try:
|
|
497
|
+
for attempt in range(1, context.max_rpc_attempts + 1):
|
|
498
|
+
state = self._attempt_state(operation, context, route, attempt)
|
|
499
|
+
request = request_factory(attempt)
|
|
500
|
+
redirected = False
|
|
501
|
+
while True:
|
|
502
|
+
request = self._prepare_request(request, state)
|
|
503
|
+
sent = await self._send_request(
|
|
504
|
+
request,
|
|
505
|
+
state,
|
|
506
|
+
redirected=redirected,
|
|
507
|
+
streaming=execution_options.streaming,
|
|
508
|
+
)
|
|
509
|
+
if isinstance(sent, AttemptRetry):
|
|
510
|
+
await self._retry(context.on_retry, sent.event)
|
|
511
|
+
break
|
|
512
|
+
response = sent
|
|
513
|
+
|
|
514
|
+
redirect_factory = execution_options.redirect_factory
|
|
515
|
+
if (
|
|
516
|
+
redirect_factory is not None
|
|
517
|
+
and not redirected
|
|
518
|
+
and response.status == _HTTP_REDIRECT
|
|
519
|
+
):
|
|
520
|
+
request = await self._redirect_request(
|
|
521
|
+
response,
|
|
522
|
+
state,
|
|
523
|
+
redirect_factory,
|
|
524
|
+
)
|
|
525
|
+
redirected = True
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
attempt_result = await self._consume_response(response, state, consumer)
|
|
529
|
+
if isinstance(attempt_result, AttemptRetry):
|
|
530
|
+
await self._retry(context.on_retry, attempt_result.event)
|
|
531
|
+
break
|
|
532
|
+
return attempt_result.result
|
|
533
|
+
finally:
|
|
534
|
+
self._transport.checkin()
|
|
535
|
+
raise AssertionError('attempt loop exhausted without a terminal outcome')
|
|
536
|
+
|
|
537
|
+
async def _execute_rpc(
|
|
538
|
+
self,
|
|
539
|
+
method_name: str,
|
|
540
|
+
params: object,
|
|
541
|
+
context: RequestContext,
|
|
542
|
+
include_auth: bool,
|
|
543
|
+
options: AsyncExecutionOptions,
|
|
544
|
+
) -> object:
|
|
545
|
+
"""Supply fresh RPC builders and a decoder to the common executor."""
|
|
546
|
+
api_url = context.api_url
|
|
547
|
+
if api_url is None:
|
|
548
|
+
raise ValueError('server parameters must be configured before RPC I/O')
|
|
549
|
+
operation = _protocol.classify_rpc_operation(
|
|
550
|
+
method_name,
|
|
551
|
+
options.logical_method_name,
|
|
552
|
+
params,
|
|
553
|
+
context.has_one_use_auth_token,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def request_factory(_attempt: int) -> TransportRequest:
|
|
557
|
+
return build_rpc_request(method_name, params, context, include_auth=include_auth)
|
|
558
|
+
|
|
559
|
+
async def consume(
|
|
560
|
+
response: AsyncLiveResponse,
|
|
561
|
+
control: AsyncAttemptControl,
|
|
562
|
+
) -> object:
|
|
563
|
+
buffered = cast('_AsyncBufferedResponse', response)
|
|
564
|
+
_protocol.check_http_status(
|
|
565
|
+
buffered.status,
|
|
566
|
+
buffered.reason,
|
|
567
|
+
context.canonical_origin or '',
|
|
568
|
+
buffered.headers,
|
|
569
|
+
buffered.content,
|
|
570
|
+
)
|
|
571
|
+
body = await control._consume_buffered( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
572
|
+
buffered.content,
|
|
573
|
+
total_bytes=len(buffered.content),
|
|
574
|
+
)
|
|
575
|
+
decoded = _protocol.decode_response(buffered.headers, body)
|
|
576
|
+
_protocol.raise_fault(decoded)
|
|
577
|
+
return _protocol.extract_results(decoded)
|
|
578
|
+
|
|
579
|
+
return await self.execute(
|
|
580
|
+
operation,
|
|
581
|
+
context,
|
|
582
|
+
request_factory,
|
|
583
|
+
consume,
|
|
584
|
+
options=options,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def call(
|
|
588
|
+
self,
|
|
589
|
+
method_name: str,
|
|
590
|
+
params: object,
|
|
591
|
+
context: RequestContext,
|
|
592
|
+
include_auth: bool = True,
|
|
593
|
+
compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = (),
|
|
594
|
+
*,
|
|
595
|
+
_logical_method_name: str | None = None,
|
|
596
|
+
) -> object:
|
|
597
|
+
"""Adapt one RPC to the common async attempt executor.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
method_name: Normalized RPC wire method name.
|
|
601
|
+
params: Normalized method parameters.
|
|
602
|
+
context: Immutable operation configuration.
|
|
603
|
+
include_auth: Whether to include captured authentication.
|
|
604
|
+
compatibility_warning_codes: Sanitized compatibility notices.
|
|
605
|
+
_logical_method_name: Optional logical operation name when it
|
|
606
|
+
differs from the wire method.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Decoded RPC results.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
TransportError: If a terminal or exhausted transport failure
|
|
613
|
+
occurs.
|
|
614
|
+
Exception: If request construction, status validation, response
|
|
615
|
+
consumption, or cleanup fails.
|
|
616
|
+
"""
|
|
617
|
+
normalized_params = copy.deepcopy(params)
|
|
618
|
+
options = AsyncExecutionOptions(
|
|
619
|
+
compatibility_warning_codes=compatibility_warning_codes,
|
|
620
|
+
logical_method_name=_logical_method_name,
|
|
621
|
+
)
|
|
622
|
+
return await self._execute_rpc(
|
|
623
|
+
method_name,
|
|
624
|
+
normalized_params,
|
|
625
|
+
context,
|
|
626
|
+
include_auth=False if method_name == 'info' else include_auth,
|
|
627
|
+
options=options,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
async def call_pages(
|
|
631
|
+
self,
|
|
632
|
+
build_page_params: Callable[[int], object],
|
|
633
|
+
first_page: int,
|
|
634
|
+
method_name: str,
|
|
635
|
+
context: RequestContext,
|
|
636
|
+
) -> AsyncGenerator[object, None]:
|
|
637
|
+
"""Yield page results in order from one immutable context.
|
|
638
|
+
|
|
639
|
+
The sequence is unbounded, so the consumer decides when to stop and
|
|
640
|
+
must call `aclose()` on early exit. When prefetch is active, cleanup
|
|
641
|
+
cancels and drains pending prefetch work.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
build_page_params: Build independent parameters for a page number.
|
|
645
|
+
first_page: First page number to request.
|
|
646
|
+
method_name: Normalized RPC method name.
|
|
647
|
+
context: Immutable operation configuration.
|
|
648
|
+
|
|
649
|
+
Yields:
|
|
650
|
+
Decoded RPC results in page order.
|
|
651
|
+
"""
|
|
652
|
+
if context.page_prefetch <= 0:
|
|
653
|
+
page = first_page
|
|
654
|
+
while True:
|
|
655
|
+
yield await self.call(method_name, build_page_params(page), context)
|
|
656
|
+
page += 1
|
|
657
|
+
|
|
658
|
+
pending: dict[int, asyncio.Task[object]] = {}
|
|
659
|
+
|
|
660
|
+
def submit(page_number: int) -> None:
|
|
661
|
+
if page_number not in pending:
|
|
662
|
+
params = build_page_params(page_number)
|
|
663
|
+
task = asyncio.create_task(self.call(method_name, params, context))
|
|
664
|
+
pending[page_number] = task
|
|
665
|
+
self._transport.reserve(task)
|
|
666
|
+
|
|
667
|
+
page = first_page
|
|
668
|
+
try:
|
|
669
|
+
submit(page)
|
|
670
|
+
while True:
|
|
671
|
+
yield await pending.pop(page)
|
|
672
|
+
page += 1
|
|
673
|
+
for page_number in range(page, page + context.page_prefetch):
|
|
674
|
+
submit(page_number)
|
|
675
|
+
finally:
|
|
676
|
+
tasks = tuple(pending.values())
|
|
677
|
+
for task in tasks:
|
|
678
|
+
task.cancel()
|
|
679
|
+
if tasks:
|
|
680
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
681
|
+
|
|
682
|
+
async def poll(
|
|
683
|
+
self,
|
|
684
|
+
predicate: Callable[[], Awaitable[bool]],
|
|
685
|
+
attempts: int,
|
|
686
|
+
interval: float,
|
|
687
|
+
) -> bool:
|
|
688
|
+
"""Poll an async predicate with driver-owned sleeps between reads.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
predicate: Awaitable read operation returning readiness.
|
|
692
|
+
attempts: Maximum number of predicate calls.
|
|
693
|
+
interval: Seconds to sleep between calls.
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
Whether the predicate became true within the attempt bound.
|
|
697
|
+
|
|
698
|
+
Raises:
|
|
699
|
+
ValueError: If `attempts` is not positive or `interval` is
|
|
700
|
+
negative.
|
|
701
|
+
"""
|
|
702
|
+
if attempts <= 0:
|
|
703
|
+
raise ValueError('poll attempts must be positive')
|
|
704
|
+
if interval < 0:
|
|
705
|
+
raise ValueError('poll interval must be nonnegative')
|
|
706
|
+
for attempt in range(attempts):
|
|
707
|
+
if await predicate():
|
|
708
|
+
return True
|
|
709
|
+
if attempt + 1 < attempts:
|
|
710
|
+
await self._sleep(interval)
|
|
711
|
+
return False
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class _AsyncBufferedResponse(AsyncLiveResponse, Protocol):
|
|
715
|
+
"""Buffered response fields consumed only by the RPC adapter."""
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def content(self) -> bytes:
|
|
719
|
+
"""Return the buffered response body.
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
Complete response body bytes.
|
|
723
|
+
"""
|
|
724
|
+
...
|
|
725
|
+
|
|
726
|
+
@property
|
|
727
|
+
def reason(self) -> str:
|
|
728
|
+
"""Return the HTTP reason phrase.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
HTTP reason phrase.
|
|
732
|
+
"""
|
|
733
|
+
...
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class _ObservedRequestBody(Iterator[bytes]):
|
|
737
|
+
"""Publish progress while owning exact-once request-body cleanup."""
|
|
738
|
+
|
|
739
|
+
def __init__(
|
|
740
|
+
self,
|
|
741
|
+
chunks: Iterator[bytes],
|
|
742
|
+
total_bytes: int | None,
|
|
743
|
+
state: AttemptState[AsyncAttemptControl],
|
|
744
|
+
) -> None:
|
|
745
|
+
"""Initialize request-body observation for one attempt."""
|
|
746
|
+
self._condition = threading.Condition()
|
|
747
|
+
self._chunks: Iterator[bytes] | None = chunks
|
|
748
|
+
self._active_pulls = 0
|
|
749
|
+
self._closed = False
|
|
750
|
+
self._closing = False
|
|
751
|
+
self._state = state
|
|
752
|
+
self._total_bytes = total_bytes
|
|
753
|
+
self._transferred = 0
|
|
754
|
+
|
|
755
|
+
def __iter__(self) -> '_ObservedRequestBody':
|
|
756
|
+
"""Return this request-body iterator."""
|
|
757
|
+
return self
|
|
758
|
+
|
|
759
|
+
def __next__(self) -> bytes:
|
|
760
|
+
"""Return and observe the next request-body chunk."""
|
|
761
|
+
chunk = self.pull_for_async()
|
|
762
|
+
if chunk is None:
|
|
763
|
+
raise StopIteration
|
|
764
|
+
self.observe_async_chunk(chunk)
|
|
765
|
+
return chunk
|
|
766
|
+
|
|
767
|
+
def close(self) -> None:
|
|
768
|
+
"""Close the source iterator exactly once."""
|
|
769
|
+
with self._condition:
|
|
770
|
+
if self._closed:
|
|
771
|
+
return
|
|
772
|
+
if self._closing:
|
|
773
|
+
while not self._closed:
|
|
774
|
+
self._condition.wait()
|
|
775
|
+
return
|
|
776
|
+
self._closing = True
|
|
777
|
+
while self._active_pulls:
|
|
778
|
+
self._condition.wait()
|
|
779
|
+
chunks = self._chunks
|
|
780
|
+
self._chunks = None
|
|
781
|
+
try:
|
|
782
|
+
if chunks is not None:
|
|
783
|
+
close = _transport.body_close(chunks)
|
|
784
|
+
if close is not None:
|
|
785
|
+
close()
|
|
786
|
+
finally:
|
|
787
|
+
with self._condition:
|
|
788
|
+
self._closed = True
|
|
789
|
+
self._condition.notify_all()
|
|
790
|
+
|
|
791
|
+
def observe_async_chunk(self, chunk: bytes) -> None:
|
|
792
|
+
"""Account for and report one asynchronously pulled chunk.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
chunk: Chunk pulled from the source iterator.
|
|
796
|
+
|
|
797
|
+
Raises:
|
|
798
|
+
ReadTimeoutError: If the attempt deadline passed before the chunk
|
|
799
|
+
could be returned.
|
|
800
|
+
"""
|
|
801
|
+
self._transferred += len(chunk)
|
|
802
|
+
if not chunk:
|
|
803
|
+
return
|
|
804
|
+
self._state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
805
|
+
notify_observer(
|
|
806
|
+
self._state.context.on_progress,
|
|
807
|
+
ProgressEvent(
|
|
808
|
+
operation=self._state.operation.category,
|
|
809
|
+
route=self._state.route,
|
|
810
|
+
attempt=self._state.number,
|
|
811
|
+
bytes_transferred=self._transferred,
|
|
812
|
+
total_bytes=self._total_bytes,
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def pull_for_async(self) -> bytes | None:
|
|
817
|
+
"""Pull one source chunk without observing progress.
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
The next chunk, or `None` when the source is exhausted or the body
|
|
821
|
+
is closing or closed.
|
|
822
|
+
"""
|
|
823
|
+
with self._condition:
|
|
824
|
+
chunks = self._chunks
|
|
825
|
+
if chunks is None or self._closing:
|
|
826
|
+
return None
|
|
827
|
+
self._active_pulls += 1
|
|
828
|
+
try:
|
|
829
|
+
return next(chunks, None)
|
|
830
|
+
finally:
|
|
831
|
+
with self._condition:
|
|
832
|
+
self._active_pulls -= 1
|
|
833
|
+
self._condition.notify_all()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
async def _attempt_response_close(response: AsyncLiveResponse) -> None:
|
|
837
|
+
"""Run response cleanup to completion."""
|
|
838
|
+
result = response.close()
|
|
839
|
+
if inspect.isawaitable(result):
|
|
840
|
+
await result
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
async def _close_response_capturing(
|
|
844
|
+
response: AsyncLiveResponse,
|
|
845
|
+
operation_exception: BaseException | None,
|
|
846
|
+
) -> tuple[BaseException | None, asyncio.CancelledError | None]:
|
|
847
|
+
"""Join response cleanup while retaining the first caller cancellation."""
|
|
848
|
+
cancellation: asyncio.CancelledError | None = None
|
|
849
|
+
current = asyncio.current_task()
|
|
850
|
+
if (
|
|
851
|
+
isinstance(operation_exception, asyncio.CancelledError)
|
|
852
|
+
and current is not None
|
|
853
|
+
and current.cancelling() > 0
|
|
854
|
+
):
|
|
855
|
+
cancellation = operation_exception
|
|
856
|
+
current.uncancel()
|
|
857
|
+
cleanup = asyncio.create_task(_attempt_response_close(response))
|
|
858
|
+
cleanup_capture = SuppressedExceptionCapture()
|
|
859
|
+
while not cleanup.done():
|
|
860
|
+
with cleanup_capture:
|
|
861
|
+
await asyncio.shield(cleanup)
|
|
862
|
+
error = cleanup_capture.exception
|
|
863
|
+
if (
|
|
864
|
+
isinstance(error, asyncio.CancelledError)
|
|
865
|
+
and current is not None
|
|
866
|
+
and current.cancelling() > 0
|
|
867
|
+
):
|
|
868
|
+
if cancellation is None:
|
|
869
|
+
cancellation = error
|
|
870
|
+
current.uncancel()
|
|
871
|
+
continue
|
|
872
|
+
if error is not None:
|
|
873
|
+
break
|
|
874
|
+
result_capture = SuppressedExceptionCapture()
|
|
875
|
+
with result_capture:
|
|
876
|
+
cleanup.result()
|
|
877
|
+
return result_capture.exception, cancellation
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _finish_detached_cleanup(task: asyncio.Task[None]) -> None:
|
|
881
|
+
"""Consume and release one completed detached cleanup task."""
|
|
882
|
+
with suppress(BaseException):
|
|
883
|
+
task.exception()
|
|
884
|
+
_DETACHED_CLEANUP_TASKS.discard(task)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _request_with_progress(
|
|
888
|
+
request: TransportRequest,
|
|
889
|
+
state: AttemptState[AsyncAttemptControl],
|
|
890
|
+
) -> TransportRequest:
|
|
891
|
+
"""Wrap only streamed request bodies in attempt progress control."""
|
|
892
|
+
body = request.body
|
|
893
|
+
if body is None or isinstance(body, bytes):
|
|
894
|
+
return request
|
|
895
|
+
return dataclasses.replace(
|
|
896
|
+
request,
|
|
897
|
+
body=_ObservedRequestBody(
|
|
898
|
+
body,
|
|
899
|
+
_transport.content_length(request.headers),
|
|
900
|
+
state,
|
|
901
|
+
),
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _request_with_remaining_timeout(
|
|
906
|
+
request: TransportRequest,
|
|
907
|
+
control: AsyncAttemptControl,
|
|
908
|
+
) -> TransportRequest:
|
|
909
|
+
"""Restrict a redirect hop to its remaining cumulative attempt timeout."""
|
|
910
|
+
return _transport.narrow_total_timeout(
|
|
911
|
+
request,
|
|
912
|
+
control._remaining_timeout(), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _retain_detached_cleanup(task: asyncio.Task[None]) -> None:
|
|
917
|
+
"""Retain a detached cleanup task until its completion is observed."""
|
|
918
|
+
_DETACHED_CLEANUP_TASKS.add(task)
|
|
919
|
+
task.add_done_callback(_finish_detached_cleanup)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
async def _close_request_body_after_send(request: TransportRequest) -> None:
|
|
923
|
+
"""Clean up a request body without replacing a primary exception."""
|
|
924
|
+
body = request.body
|
|
925
|
+
if body is None or isinstance(body, bytes):
|
|
926
|
+
return
|
|
927
|
+
primary_exception = sys.exception()
|
|
928
|
+
|
|
929
|
+
def run_close() -> None:
|
|
930
|
+
close = _transport.body_close(body)
|
|
931
|
+
if close is not None:
|
|
932
|
+
close()
|
|
933
|
+
|
|
934
|
+
cleanup = asyncio.create_task(asyncio.to_thread(run_close))
|
|
935
|
+
if isinstance(primary_exception, asyncio.CancelledError):
|
|
936
|
+
_retain_detached_cleanup(cleanup)
|
|
937
|
+
return
|
|
938
|
+
try:
|
|
939
|
+
await asyncio.wait({cleanup})
|
|
940
|
+
except asyncio.CancelledError:
|
|
941
|
+
_retain_detached_cleanup(cleanup)
|
|
942
|
+
raise
|
|
943
|
+
cleanup_error = asyncio.CancelledError() if cleanup.cancelled() else cleanup.exception()
|
|
944
|
+
if primary_exception is None and cleanup_error is not None:
|
|
945
|
+
raise cleanup_error
|