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