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,901 @@
1
+ """Transport vocabulary and HTTP backend implementations."""
2
+
3
+ # Same-file collaboration follows module-boundary privacy.
4
+ # pyright: reportPrivateUsage=false
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextvars
9
+ import socket
10
+ import ssl
11
+ import sys
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable, Iterator, Mapping
15
+ from contextlib import suppress
16
+ from dataclasses import dataclass, field, replace
17
+ from types import MappingProxyType
18
+ from typing import TYPE_CHECKING, Any, Protocol, cast
19
+
20
+ import urllib3
21
+ import urllib3.connectionpool
22
+ import urllib3.exceptions
23
+ from urllib3.util.ssl_match_hostname import CertificateError
24
+
25
+ from ._errors import ShotgunError
26
+
27
+ if TYPE_CHECKING:
28
+ from urllib3._base_connection import BaseHTTPConnection
29
+
30
+ _CERTIFICATE_ERRORS = (ssl.SSLCertVerificationError, CertificateError)
31
+ _CURRENT_ABORT: contextvars.ContextVar[tuple[_AbortRegistry, _AbortHandle] | None] = (
32
+ contextvars.ContextVar('_CURRENT_ABORT', default=None)
33
+ )
34
+
35
+
36
+ class CaseInsensitiveHeaders(Mapping[str, str]):
37
+ """Immutable response headers with case-insensitive lookup."""
38
+
39
+ _items: tuple[tuple[str, str], ...]
40
+ _lookup: Mapping[str, str]
41
+
42
+ def __init__(self, headers: Mapping[str, str]) -> None:
43
+ """Index response headers for case-insensitive lookup.
44
+
45
+ Args:
46
+ headers: Response headers in their received iteration order.
47
+ """
48
+ names: dict[str, str] = {}
49
+ order: list[str] = []
50
+ values: dict[str, str] = {}
51
+ for name, value in headers.items():
52
+ normalized = name.casefold()
53
+ if normalized not in values:
54
+ names[normalized] = name
55
+ order.append(normalized)
56
+ values[normalized] = value
57
+
58
+ self._items = tuple((names[key], values[key]) for key in order)
59
+ self._lookup = MappingProxyType(values)
60
+
61
+ def __getitem__(self, key: str) -> str:
62
+ """Return one header value by case-insensitive name."""
63
+ return self._lookup[key.casefold()]
64
+
65
+ def __iter__(self) -> Iterator[str]:
66
+ """Iterate over headers in received order."""
67
+ return (name for name, _value in self._items)
68
+
69
+ def __len__(self) -> int:
70
+ """Return the number of response headers."""
71
+ return len(self._items)
72
+
73
+
74
+ @dataclass(init=False)
75
+ class StreamingResponse:
76
+ """A response whose body is consumed incrementally exactly once.
77
+
78
+ Fully consuming the body releases response resources. Call `close()` when
79
+ leaving the body unconsumed or stopping early. Explicit closure is
80
+ idempotent.
81
+
82
+ Attributes:
83
+ status: HTTP status code.
84
+ reason: HTTP reason phrase.
85
+ headers: Response headers.
86
+ """
87
+
88
+ status: int
89
+ reason: str
90
+ headers: CaseInsensitiveHeaders
91
+ _stream: Callable[[int], Iterator[bytes]]
92
+ _release: Callable[[], None] | None
93
+ _claimed: bool
94
+ _close_lock: threading.Lock
95
+
96
+ def __init__(
97
+ self,
98
+ status: int,
99
+ reason: str,
100
+ headers: Mapping[str, str],
101
+ stream: Callable[[int], Iterator[bytes]],
102
+ release: Callable[[], None],
103
+ ) -> None:
104
+ """Initialize a one-use streaming response.
105
+
106
+ Args:
107
+ status: HTTP status code.
108
+ reason: HTTP reason phrase.
109
+ headers: Response headers.
110
+ stream: Factory returning body chunks for a requested size.
111
+ release: Callback that releases response resources.
112
+ """
113
+ self.status = status
114
+ self.reason = reason
115
+ self.headers = (
116
+ headers
117
+ if isinstance(headers, CaseInsensitiveHeaders)
118
+ else CaseInsensitiveHeaders(headers)
119
+ )
120
+ self._stream = stream
121
+ self._release = release
122
+ self._claimed = False
123
+ self._close_lock = threading.Lock()
124
+
125
+ def close(self) -> None:
126
+ """Release resources associated with the streaming response."""
127
+ with self._close_lock:
128
+ release = self._release
129
+ self._release = None
130
+ if release is not None:
131
+ release()
132
+
133
+ def _iterate(self, size: int) -> Iterator[bytes]:
134
+ """Yield chunks while closing on every terminal iterator path."""
135
+ try:
136
+ with self._close_lock:
137
+ if self._release is None:
138
+ raise TransportError('streaming response is closed')
139
+ for chunk in self._stream(size):
140
+ with self._close_lock:
141
+ if self._release is None:
142
+ raise TransportError('streaming response is closed')
143
+ yield chunk
144
+ finally:
145
+ self.close()
146
+
147
+ def iter_chunks(self, size: int = 65536) -> Iterator[bytes]:
148
+ """Claim and return the single-use response-body iterator.
149
+
150
+ Args:
151
+ size: Maximum bytes per yielded chunk.
152
+
153
+ Returns:
154
+ An iterator over the response body.
155
+
156
+ Raises:
157
+ TransportError: If `size` is not positive, the response is closed,
158
+ or its stream has already been claimed.
159
+ """
160
+ if size <= 0:
161
+ raise TransportError('streaming chunk size must be positive')
162
+ with self._close_lock:
163
+ if self._release is None:
164
+ raise TransportError('streaming response is closed')
165
+ if self._claimed:
166
+ raise TransportError('streaming response was already consumed')
167
+ self._claimed = True
168
+ return self._iterate(size)
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class TimeoutConfig:
173
+ """Connect/read limits and total monotonic ceiling through body consumption.
174
+
175
+ The total limit applies per attempt. A `None` field has no timeout limit.
176
+
177
+ Attributes:
178
+ connect: Connection-establishment timeout in seconds.
179
+ read: Socket-read timeout in seconds.
180
+ total: Total attempt timeout through body consumption.
181
+ """
182
+
183
+ connect: float | None
184
+ read: float | None
185
+ total: float | None = None
186
+
187
+
188
+ class TransportError(ShotgunError):
189
+ """Base exception for transport-level failures."""
190
+
191
+
192
+ class AbortedError(TransportError):
193
+ """Raised when a request is deliberately aborted via its token."""
194
+
195
+
196
+ class ConnectError(TransportError):
197
+ """Raised when establishing a connection fails before transmission."""
198
+
199
+
200
+ class ProxyError(TransportError):
201
+ """Raised when proxy connection or negotiation fails before transmission."""
202
+
203
+
204
+ class ReadTimeoutError(TransportError):
205
+ """Raised when waiting for response data times out."""
206
+
207
+
208
+ class SendError(TransportError):
209
+ """Raised after a request may have started transmitting."""
210
+
211
+
212
+ class TLSError(TransportError):
213
+ """Raised when TLS setup or verification fails before transmission."""
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class TransportRequest:
218
+ """One HTTP request in transport vocabulary.
219
+
220
+ Attributes:
221
+ method: HTTP method.
222
+ url: Complete request URL.
223
+ headers: Request headers.
224
+ body: Buffered or streaming request body.
225
+ timeout: Timeout limits for this request.
226
+ token: Unique token used for cancellation.
227
+ """
228
+
229
+ method: str
230
+ url: str
231
+ headers: dict[str, str]
232
+ body: bytes | Iterator[bytes] | None
233
+ timeout: TimeoutConfig
234
+ token: str
235
+
236
+
237
+ @dataclass(frozen=True, init=False)
238
+ class TransportResponse:
239
+ """One HTTP response in transport vocabulary.
240
+
241
+ Attributes:
242
+ status: HTTP status code.
243
+ reason: HTTP reason phrase.
244
+ headers: Response headers.
245
+ content: Complete buffered response body.
246
+ """
247
+
248
+ status: int
249
+ reason: str
250
+ headers: CaseInsensitiveHeaders
251
+ content: bytes
252
+ _close_lock: threading.Lock = field(init=False, repr=False, compare=False)
253
+ _on_close: Callable[[], None] | None = field(init=False, repr=False, compare=False)
254
+
255
+ def __init__(
256
+ self,
257
+ status: int,
258
+ reason: str,
259
+ headers: Mapping[str, str],
260
+ content: bytes,
261
+ on_close: Callable[[], None] | None = None,
262
+ ) -> None:
263
+ """Initialize one buffered transport response.
264
+
265
+ Args:
266
+ status: HTTP status code.
267
+ reason: HTTP reason phrase.
268
+ headers: Response headers.
269
+ content: Complete buffered response body.
270
+ on_close: Optional callback invoked once when closed.
271
+ """
272
+ normalized_headers = (
273
+ headers
274
+ if isinstance(headers, CaseInsensitiveHeaders)
275
+ else CaseInsensitiveHeaders(headers)
276
+ )
277
+ object.__setattr__(self, 'status', status)
278
+ object.__setattr__(self, 'reason', reason)
279
+ object.__setattr__(self, 'headers', normalized_headers)
280
+ object.__setattr__(self, 'content', content)
281
+ object.__setattr__(self, '_close_lock', threading.Lock())
282
+ object.__setattr__(self, '_on_close', on_close)
283
+
284
+ def close(self) -> None:
285
+ """Release resources associated with the buffered response."""
286
+ with self._close_lock:
287
+ callback = self._on_close
288
+ object.__setattr__(self, '_on_close', None)
289
+ if callback is not None:
290
+ callback()
291
+
292
+
293
+ class Transport(Protocol):
294
+ """HTTP backend operations required by the driver."""
295
+
296
+ def abort(self, token: str) -> None:
297
+ """Abort the request identified by a token.
298
+
299
+ Args:
300
+ token: Unique request token.
301
+ """
302
+ ...
303
+
304
+ def close(self) -> None:
305
+ """Release resources owned by the transport."""
306
+ ...
307
+
308
+ def send(self, request: TransportRequest) -> TransportResponse:
309
+ """Send one request and return its complete response.
310
+
311
+ Args:
312
+ request: Transport request to send.
313
+
314
+ Returns:
315
+ The complete transport response.
316
+
317
+ Raises:
318
+ TransportError: If the transport operation fails.
319
+ """
320
+ ...
321
+
322
+ def send_streaming(self, request: TransportRequest) -> StreamingResponse:
323
+ """Send one request and return its single-use streaming response.
324
+
325
+ Args:
326
+ request: Transport request to send.
327
+
328
+ Returns:
329
+ The streaming transport response.
330
+
331
+ Raises:
332
+ TransportError: If the transport operation fails.
333
+ """
334
+ ...
335
+
336
+
337
+ class _AbortableConnection(Protocol):
338
+ """Socket operations required to abort an urllib3 connection."""
339
+
340
+ sock: socket.socket | ssl.SSLSocket | None
341
+
342
+ def close(self) -> None:
343
+ """Close the connection and prevent its socket from being reused."""
344
+
345
+
346
+ @dataclass(eq=False)
347
+ class _AbortHandle:
348
+ """Track one request registration independently of token reuse."""
349
+
350
+ token: str
351
+ generation: int
352
+ aborted: bool = False
353
+ connection: _AbortableConnection | None = None
354
+
355
+
356
+ class _AbortRegistry:
357
+ """Track live connections and aborted requests under a lock."""
358
+
359
+ def __init__(self) -> None:
360
+ """Initialize an empty transport-abort registry."""
361
+ self._active: dict[str, dict[int, _AbortHandle]] = {}
362
+ self._generation = 0
363
+ self._lock = threading.Lock()
364
+
365
+ def _abort(self, token: str) -> None:
366
+ """Abort every active request registered for `token`."""
367
+ with self._lock:
368
+ connections: list[_AbortableConnection] = []
369
+ for handle in self._active.get(token, {}).values():
370
+ handle.aborted = True
371
+ if handle.connection is not None:
372
+ connections.append(handle.connection)
373
+
374
+ for connection in connections:
375
+ live_socket = connection.sock
376
+ if live_socket is not None:
377
+ with suppress(OSError):
378
+ live_socket.shutdown(socket.SHUT_RDWR)
379
+ if sys.platform == 'win32':
380
+ # Buffered readers defer close on Windows, leaving recv blocked.
381
+ # Transfer ownership so later reader closes cannot close a reused fd.
382
+ with suppress(OSError):
383
+ descriptor = live_socket.detach()
384
+ if descriptor != -1:
385
+ socket.close(descriptor)
386
+ connection.close()
387
+
388
+ def _attach(
389
+ self,
390
+ handle: _AbortHandle,
391
+ connection: _AbortableConnection,
392
+ ) -> bool:
393
+ """Attach a checked-out connection and report an earlier abort."""
394
+ with self._lock:
395
+ active = self._active.get(handle.token, {}).get(handle.generation)
396
+ if active is handle:
397
+ handle.connection = connection
398
+ return handle.aborted
399
+
400
+ def _begin(self, token: str) -> _AbortHandle:
401
+ """Begin tracking a request before it checks out a connection."""
402
+ with self._lock:
403
+ self._generation += 1
404
+ handle = _AbortHandle(token=token, generation=self._generation)
405
+ self._active.setdefault(token, {})[handle.generation] = handle
406
+ return handle
407
+
408
+ def _detach(self, handle: _AbortHandle) -> bool:
409
+ """Detach a connection before urllib3 can return it to the pool."""
410
+ with self._lock:
411
+ handle.connection = None
412
+ return handle.aborted
413
+
414
+ def _release(self, handle: _AbortHandle) -> None:
415
+ """Release exactly one request generation."""
416
+ with self._lock:
417
+ active = self._active.get(handle.token)
418
+ if active is not None and active.get(handle.generation) is handle:
419
+ active.pop(handle.generation)
420
+ if not active:
421
+ self._active.pop(handle.token)
422
+ handle.connection = None
423
+
424
+ def _was_aborted(self, handle: _AbortHandle) -> bool:
425
+ """Return whether a particular request was aborted."""
426
+ with self._lock:
427
+ return handle.aborted
428
+
429
+
430
+ @dataclass(frozen=True)
431
+ class _StartedUrllib3Request:
432
+ """Hold request-start state whose lifetime remains caller-owned."""
433
+
434
+ response: urllib3.response.HTTPResponse
435
+ handle: _AbortHandle
436
+ started: float
437
+
438
+
439
+ class _HTTPPool(urllib3.connectionpool.HTTPConnectionPool):
440
+ """HTTP pool that aligns abort registration with connection ownership."""
441
+
442
+ def _get_conn(self, timeout: float | None = None) -> BaseHTTPConnection:
443
+ """Attach a request handle when a connection is checked out."""
444
+ return _attach_checked_out_connection(super()._get_conn(timeout))
445
+
446
+ def _put_conn(self, conn: BaseHTTPConnection | None) -> None:
447
+ """Detach a request handle before returning a connection."""
448
+ super()._put_conn(_detach_returned_connection(conn))
449
+
450
+ def _validate_conn(self, conn: BaseHTTPConnection) -> None:
451
+ """Connect while checking abort state before and after socket creation."""
452
+ _raise_if_current_aborted(conn)
453
+ super()._validate_conn(conn)
454
+ if conn.is_closed:
455
+ conn.connect()
456
+ _raise_if_current_aborted(conn)
457
+
458
+
459
+ class _HTTPSPool(urllib3.connectionpool.HTTPSConnectionPool):
460
+ """HTTPS pool that aligns abort registration with connection ownership."""
461
+
462
+ def _get_conn(self, timeout: float | None = None) -> BaseHTTPConnection:
463
+ """Attach a request handle when a connection is checked out."""
464
+ return _attach_checked_out_connection(super()._get_conn(timeout))
465
+
466
+ def _put_conn(self, conn: BaseHTTPConnection | None) -> None:
467
+ """Detach a request handle before returning a connection."""
468
+ super()._put_conn(_detach_returned_connection(conn))
469
+
470
+ def _validate_conn(self, conn: BaseHTTPConnection) -> None:
471
+ """Validate TLS while checking abort state around socket creation."""
472
+ _raise_if_current_aborted(conn)
473
+ super()._validate_conn(conn)
474
+ _raise_if_current_aborted(conn)
475
+
476
+
477
+ class Urllib3Transport:
478
+ """Transport implementation on a urllib3 connection pool.
479
+
480
+ Attributes:
481
+ proxy_info: Compatibility proxy metadata for a direct connection.
482
+ """
483
+
484
+ def __init__(
485
+ self,
486
+ ca_certs: str | None = None,
487
+ proxy_url: str | None = None,
488
+ proxy_headers: dict[str, str] | None = None,
489
+ pool_maxsize: int = 10,
490
+ ) -> None:
491
+ """Initialize a urllib3 connection pool transport.
492
+
493
+ Args:
494
+ ca_certs: Optional certificate-authority bundle path.
495
+ proxy_url: Optional proxy URL.
496
+ proxy_headers: Optional headers sent to the proxy.
497
+ pool_maxsize: Maximum connections retained by the pool.
498
+ """
499
+ self._abort_registry = _AbortRegistry()
500
+ common: dict[str, Any] = {'maxsize': pool_maxsize}
501
+ if ca_certs is not None:
502
+ common['ca_certs'] = ca_certs
503
+ if proxy_url is not None:
504
+ self._pool: urllib3.PoolManager = urllib3.ProxyManager(
505
+ proxy_url,
506
+ proxy_headers=proxy_headers,
507
+ **common,
508
+ )
509
+ else:
510
+ self._pool = urllib3.PoolManager(**common)
511
+ pool_classes: dict[
512
+ str,
513
+ type[
514
+ urllib3.connectionpool.HTTPConnectionPool
515
+ | urllib3.connectionpool.HTTPSConnectionPool
516
+ ],
517
+ ] = {
518
+ 'http': _HTTPPool,
519
+ 'https': _HTTPSPool,
520
+ }
521
+ self._pool.pool_classes_by_scheme = pool_classes
522
+
523
+ def _start_request(
524
+ self,
525
+ request: TransportRequest,
526
+ *,
527
+ preload_content: bool,
528
+ ) -> _StartedUrllib3Request:
529
+ """Start one urllib3 request and leave its live handle caller-owned."""
530
+ timeout = urllib3.Timeout(
531
+ connect=request.timeout.connect,
532
+ read=request.timeout.read,
533
+ total=request.timeout.total,
534
+ )
535
+ handle = self._abort_registry._begin(request.token)
536
+ context_token = _CURRENT_ABORT.set((self._abort_registry, handle))
537
+ try:
538
+ try:
539
+ started = time.monotonic()
540
+ response = cast(
541
+ 'urllib3.response.HTTPResponse',
542
+ self._pool.request(
543
+ request.method,
544
+ request.url,
545
+ body=request.body,
546
+ headers=request.headers,
547
+ timeout=timeout,
548
+ retries=False,
549
+ preload_content=preload_content,
550
+ ),
551
+ )
552
+ except urllib3.exceptions.HTTPError as exc:
553
+ if self._abort_registry._was_aborted(handle):
554
+ raise AbortedError(request.token) from exc
555
+ raise _map_urllib3_error(exc) from exc
556
+ except urllib3.exceptions.ProxySchemeUnsupported as exc:
557
+ raise ProxyError(str(exc)) from exc
558
+ except Exception as exc:
559
+ raise _map_unexpected_request_error(
560
+ exc, self._abort_registry, handle, request.token
561
+ ) from exc
562
+ except BaseException:
563
+ self._abort_registry._release(handle)
564
+ raise
565
+ finally:
566
+ _CURRENT_ABORT.reset(context_token)
567
+ return _StartedUrllib3Request(response=response, handle=handle, started=started)
568
+
569
+ def abort(self, token: str) -> None:
570
+ """Abort the in-flight request identified by a token.
571
+
572
+ Args:
573
+ token: Unique request token.
574
+ """
575
+ self._abort_registry._abort(token)
576
+
577
+ def close(self) -> None:
578
+ """Release pooled connections owned by the transport."""
579
+ self._pool.clear()
580
+
581
+ @property
582
+ def proxy_info(self) -> None:
583
+ """Return compatibility proxy metadata for a direct connection.
584
+
585
+ Returns:
586
+ `None` when no proxy is configured.
587
+
588
+ Raises:
589
+ AttributeError: If the transport is configured to use a proxy.
590
+ """
591
+ if isinstance(self._pool, urllib3.ProxyManager):
592
+ raise AttributeError('proxy_info') # noqa: TRY004
593
+
594
+ def send(self, request: TransportRequest) -> TransportResponse:
595
+ """Send one request without retrying it.
596
+
597
+ Args:
598
+ request: Transport request to send.
599
+
600
+ Returns:
601
+ The complete transport response.
602
+
603
+ Raises:
604
+ TransportError: If urllib3 reports a transport failure.
605
+ """
606
+ started = self._start_request(request, preload_content=True)
607
+ try:
608
+ return TransportResponse(
609
+ status=started.response.status,
610
+ reason=started.response.reason or '',
611
+ headers=dict(started.response.headers),
612
+ content=started.response.data,
613
+ )
614
+ finally:
615
+ self._abort_registry._release(started.handle)
616
+
617
+ def send_streaming(self, request: TransportRequest) -> StreamingResponse:
618
+ """Send one request and return its single-use streaming response.
619
+
620
+ Args:
621
+ request: Transport request to send.
622
+
623
+ Returns:
624
+ The streaming transport response.
625
+
626
+ Raises:
627
+ TransportError: If urllib3 reports a transport failure while
628
+ receiving response headers.
629
+ """
630
+ started = self._start_request(request, preload_content=False)
631
+ response = started.response
632
+
633
+ deadline = (
634
+ None if request.timeout.total is None else started.started + request.timeout.total
635
+ )
636
+
637
+ def release() -> None:
638
+ _release_urllib3_stream(response, self._abort_registry, started.handle)
639
+
640
+ def stream(size: int) -> Iterator[bytes]:
641
+ attempt = _Urllib3StreamingAttempt(
642
+ deadline=deadline,
643
+ handle=started.handle,
644
+ read_timeout=request.timeout.read,
645
+ registry=self._abort_registry,
646
+ response=response,
647
+ token=request.token,
648
+ )
649
+ return _stream_urllib3_response(attempt, size)
650
+
651
+ try:
652
+ return StreamingResponse(
653
+ status=response.status,
654
+ reason=response.reason or '',
655
+ headers=dict(response.headers),
656
+ stream=stream,
657
+ release=release,
658
+ )
659
+ except BaseException:
660
+ release()
661
+ raise
662
+
663
+
664
+ @dataclass(frozen=True)
665
+ class _Urllib3StreamingAttempt:
666
+ """Bundle state that remains live through streaming body consumption."""
667
+
668
+ deadline: float | None
669
+ handle: _AbortHandle
670
+ read_timeout: float | None
671
+ registry: _AbortRegistry
672
+ response: urllib3.response.HTTPResponse
673
+ token: str
674
+
675
+
676
+ def _attach_checked_out_connection(
677
+ connection: BaseHTTPConnection,
678
+ ) -> BaseHTTPConnection:
679
+ """Attach the active request handle to a checked-out connection."""
680
+ current = _CURRENT_ABORT.get()
681
+ if current is None:
682
+ return connection
683
+ registry, handle = current
684
+ abortable = cast('_AbortableConnection', connection)
685
+ if registry._attach(handle, abortable):
686
+ connection.close()
687
+ raise AbortedError(handle.token)
688
+ return connection
689
+
690
+
691
+ def _detach_returned_connection(
692
+ connection: BaseHTTPConnection | None,
693
+ ) -> BaseHTTPConnection | None:
694
+ """Detach the active handle and discard a connection lost to abort."""
695
+ current = _CURRENT_ABORT.get()
696
+ if current is None:
697
+ return connection
698
+ registry, handle = current
699
+ if not registry._detach(handle):
700
+ return connection
701
+ if connection is not None:
702
+ connection.close()
703
+ return None
704
+
705
+
706
+ def _is_certificate_failure(exc: BaseException) -> bool:
707
+ """Return whether an exception contains certificate-verification evidence."""
708
+ if isinstance(exc, _CERTIFICATE_ERRORS):
709
+ return True
710
+ nested = (exc.__cause__, exc.__context__, *exc.args)
711
+ return any(isinstance(item, _CERTIFICATE_ERRORS) for item in nested)
712
+
713
+
714
+ def _map_unexpected_request_error(
715
+ exc: Exception,
716
+ registry: _AbortRegistry,
717
+ handle: _AbortHandle,
718
+ token: str,
719
+ ) -> TransportError:
720
+ """Translate an unexpected request failure into transport vocabulary."""
721
+ if registry._was_aborted(handle):
722
+ return AbortedError(token)
723
+ return SendError(str(exc))
724
+
725
+
726
+ def _map_urllib3_error(exc: urllib3.exceptions.HTTPError) -> TransportError:
727
+ """Translate a urllib3 exception into the transport vocabulary."""
728
+ unwrapped: BaseException = exc
729
+ if isinstance(exc, urllib3.exceptions.MaxRetryError) and exc.reason is not None:
730
+ unwrapped = exc.reason
731
+ if isinstance(unwrapped, urllib3.exceptions.ProxyError):
732
+ return ProxyError(str(unwrapped))
733
+ if isinstance(unwrapped, urllib3.exceptions.SSLError) and _is_certificate_failure(unwrapped):
734
+ return TLSError(str(unwrapped))
735
+ if isinstance(unwrapped, urllib3.exceptions.ReadTimeoutError):
736
+ return ReadTimeoutError(str(unwrapped))
737
+ if isinstance(
738
+ unwrapped,
739
+ (urllib3.exceptions.NewConnectionError, urllib3.exceptions.ConnectTimeoutError),
740
+ ):
741
+ return ConnectError(str(unwrapped))
742
+ return SendError(str(unwrapped))
743
+
744
+
745
+ def _next_urllib3_stream_chunk(
746
+ attempt: _Urllib3StreamingAttempt,
747
+ chunks: Iterator[bytes],
748
+ total_limited: bool,
749
+ ) -> bytes:
750
+ """Read one chunk while translating every backend body failure."""
751
+ try:
752
+ return next(chunks)
753
+ except StopIteration:
754
+ raise
755
+ except urllib3.exceptions.HTTPError as exc:
756
+ if attempt.registry._was_aborted(attempt.handle):
757
+ raise AbortedError(attempt.token) from exc
758
+ mapped = _map_urllib3_error(exc)
759
+ if total_limited and isinstance(mapped, ReadTimeoutError):
760
+ raise ReadTimeoutError('total streaming timeout exceeded') from exc
761
+ raise mapped from exc
762
+ except urllib3.exceptions.ProxySchemeUnsupported as exc:
763
+ raise ProxyError(str(exc)) from exc
764
+ except Exception as exc:
765
+ if attempt.registry._was_aborted(attempt.handle):
766
+ raise AbortedError(attempt.token) from exc
767
+ raise SendError(str(exc)) from exc
768
+
769
+
770
+ def _raise_if_current_aborted(connection: BaseHTTPConnection) -> None:
771
+ """Close a connection and raise when its active request was aborted."""
772
+ current = _CURRENT_ABORT.get()
773
+ if current is None:
774
+ return
775
+ registry, handle = current
776
+ if registry._was_aborted(handle):
777
+ connection.close()
778
+ raise AbortedError(handle.token)
779
+
780
+
781
+ def _release_urllib3_stream(
782
+ response: urllib3.response.HTTPResponse,
783
+ registry: _AbortRegistry,
784
+ handle: _AbortHandle,
785
+ ) -> None:
786
+ """Release one streaming registry handle and any owned connection."""
787
+ registry._release(handle)
788
+ if response.connection is None:
789
+ return
790
+ try:
791
+ response.close()
792
+ finally:
793
+ response.release_conn()
794
+
795
+
796
+ def _set_stream_read_timeout(
797
+ attempt: _Urllib3StreamingAttempt,
798
+ ) -> bool:
799
+ """Apply the active timeout and report whether total is the limiter."""
800
+ if attempt.deadline is None:
801
+ return False
802
+ remaining = attempt.deadline - time.monotonic()
803
+ if remaining <= 0:
804
+ raise ReadTimeoutError('total streaming timeout exceeded')
805
+ total_limited = attempt.read_timeout is None or remaining <= attempt.read_timeout
806
+ connection = attempt.response.connection
807
+ if connection is None:
808
+ return total_limited
809
+ live_socket = connection.sock
810
+ if live_socket is None:
811
+ return total_limited
812
+ try:
813
+ live_socket.settimeout(
814
+ remaining if attempt.read_timeout is None else min(attempt.read_timeout, remaining)
815
+ )
816
+ except Exception as exc:
817
+ raise _map_unexpected_request_error(
818
+ exc, attempt.registry, attempt.handle, attempt.token
819
+ ) from exc
820
+ return total_limited
821
+
822
+
823
+ def _stream_urllib3_response(
824
+ attempt: _Urllib3StreamingAttempt,
825
+ size: int,
826
+ ) -> Iterator[bytes]:
827
+ """Yield body chunks while mapping errors and enforcing the deadline."""
828
+ chunks = attempt.response.stream(size)
829
+ while True:
830
+ if attempt.registry._was_aborted(attempt.handle):
831
+ raise AbortedError(attempt.token)
832
+ total_limited = _set_stream_read_timeout(attempt)
833
+ context_token = _CURRENT_ABORT.set((attempt.registry, attempt.handle))
834
+ try:
835
+ try:
836
+ chunk = _next_urllib3_stream_chunk(attempt, chunks, total_limited)
837
+ except StopIteration:
838
+ return
839
+ finally:
840
+ _CURRENT_ABORT.reset(context_token)
841
+ if attempt.deadline is not None and time.monotonic() > attempt.deadline:
842
+ raise ReadTimeoutError('total streaming timeout exceeded')
843
+ yield chunk
844
+
845
+
846
+ def body_close(body: object) -> Callable[[], object] | None:
847
+ """Return the body's callable close operation when present.
848
+
849
+ Returns:
850
+ Callable close operation, or `None` when unavailable.
851
+ """
852
+ close = getattr(body, 'close', None)
853
+ if not callable(close):
854
+ return None
855
+ return cast('Callable[[], object]', close)
856
+
857
+
858
+ def content_length(headers: Mapping[str, str]) -> int | None:
859
+ """Parse a nonnegative content length when present.
860
+
861
+ Returns:
862
+ Parsed nonnegative length, or `None` when absent or invalid.
863
+ """
864
+ raw = next(
865
+ (value for name, value in headers.items() if name.casefold() == 'content-length'),
866
+ None,
867
+ )
868
+ if raw is None:
869
+ return None
870
+ try:
871
+ length = int(raw)
872
+ except ValueError:
873
+ return None
874
+ return length if length >= 0 else None
875
+
876
+
877
+ def narrow_total_timeout(
878
+ request: TransportRequest,
879
+ remaining: float | None,
880
+ ) -> TransportRequest:
881
+ """Restrict a request to its nonnegative remaining total timeout.
882
+
883
+ Args:
884
+ request: Original transport request.
885
+ remaining: Remaining total timeout in seconds, when limited.
886
+
887
+ Returns:
888
+ The original request when `remaining` is `None`. Otherwise, a
889
+ replacement whose total timeout is `remaining` clamped to zero and
890
+ capped by any existing total timeout. Headers and body are shared with
891
+ the original request.
892
+ """
893
+ if remaining is None:
894
+ return request
895
+ narrowed_total = max(remaining, 0.0)
896
+ if request.timeout.total is not None:
897
+ narrowed_total = min(request.timeout.total, narrowed_total)
898
+ return replace(
899
+ request,
900
+ timeout=replace(request.timeout, total=narrowed_total),
901
+ )