sg-python-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sg_python_api-0.1.0.dist-info/METADATA +177 -0
- sg_python_api-0.1.0.dist-info/RECORD +31 -0
- sg_python_api-0.1.0.dist-info/WHEEL +4 -0
- sg_python_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- shotgun_api3/__init__.py +68 -0
- shotgun_api3/_async_client.py +2906 -0
- shotgun_api3/_async_driver.py +945 -0
- shotgun_api3/_async_httpx2.py +662 -0
- shotgun_api3/_async_transport.py +162 -0
- shotgun_api3/_capabilities.py +165 -0
- shotgun_api3/_client.py +2682 -0
- shotgun_api3/_config.py +1463 -0
- shotgun_api3/_crud.py +916 -0
- shotgun_api3/_driver.py +879 -0
- shotgun_api3/_errors.py +49 -0
- shotgun_api3/_execution.py +273 -0
- shotgun_api3/_files.py +1094 -0
- shotgun_api3/_methods.py +1205 -0
- shotgun_api3/_protocol.py +1347 -0
- shotgun_api3/_sentinel.py +5 -0
- shotgun_api3/_sg_timezone.py +5 -0
- shotgun_api3/_signatures.py +53 -0
- shotgun_api3/_transport.py +901 -0
- shotgun_api3/_version.py +3 -0
- shotgun_api3/lib/__init__.py +1 -0
- shotgun_api3/lib/mockgun/__init__.py +16 -0
- shotgun_api3/lib/mockgun/_async.py +315 -0
- shotgun_api3/lib/mockgun/_mockgun.py +1666 -0
- shotgun_api3/lib/sgtimezone.py +209 -0
- shotgun_api3/py.typed +0 -0
- shotgun_api3/shotgun.py +58 -0
|
@@ -0,0 +1,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
|
+
)
|