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.
@@ -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
+ )