sg-python-api 0.1.2__py3-none-any.whl → 0.2.1__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.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
- sg_python_api-0.2.1.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1052 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +553 -0
- shotgun_api3/lib/mockgun/_links.py +598 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2205 -0
- shotgun_api3/lib/mockgun/_retirement.py +463 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1559 -0
- shotgun_api3/lib/mockgun/_summaries.py +965 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
shotgun_api3/_async_driver.py
CHANGED
|
@@ -13,7 +13,7 @@ from io import BytesIO
|
|
|
13
13
|
from typing import Protocol, TypeVar, cast
|
|
14
14
|
|
|
15
15
|
from . import _protocol, _transport
|
|
16
|
-
from ._async_transport import AsyncTransport
|
|
16
|
+
from ._async_transport import AsyncTransport, AsyncUploadObserver
|
|
17
17
|
from ._config import (
|
|
18
18
|
EventRoute,
|
|
19
19
|
ObserverCallback,
|
|
@@ -37,7 +37,6 @@ from ._execution import (
|
|
|
37
37
|
build_rpc_request,
|
|
38
38
|
)
|
|
39
39
|
from ._transport import (
|
|
40
|
-
ReadTimeoutError,
|
|
41
40
|
SendError,
|
|
42
41
|
TransportError,
|
|
43
42
|
TransportRequest,
|
|
@@ -51,23 +50,15 @@ _HTTP_REDIRECT = 302
|
|
|
51
50
|
|
|
52
51
|
@dataclasses.dataclass(frozen=True)
|
|
53
52
|
class AsyncAttemptControl:
|
|
54
|
-
"""Consume async attempt bytes
|
|
53
|
+
"""Consume async attempt bytes with progress reporting."""
|
|
55
54
|
|
|
56
55
|
_attempt: int
|
|
57
|
-
_clock: Callable[[], float]
|
|
58
|
-
_deadline: float | None
|
|
59
56
|
_observer: ObserverCallback | None
|
|
60
57
|
_operation: _protocol.OperationCategory
|
|
61
58
|
_route: EventRoute
|
|
62
59
|
|
|
63
|
-
def
|
|
64
|
-
"""Raise when cumulative monotonic attempt time exceeds its limit."""
|
|
65
|
-
if self._deadline is not None and self._clock() > self._deadline:
|
|
66
|
-
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
67
|
-
|
|
68
|
-
async def _consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
|
|
60
|
+
async def consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
|
|
69
61
|
"""Observe a received buffered body without rebuilding it."""
|
|
70
|
-
self._check_deadline()
|
|
71
62
|
if body:
|
|
72
63
|
notify_observer(
|
|
73
64
|
self._observer,
|
|
@@ -81,22 +72,13 @@ class AsyncAttemptControl:
|
|
|
81
72
|
)
|
|
82
73
|
return body
|
|
83
74
|
|
|
84
|
-
def _remaining_timeout(self) -> float | None:
|
|
85
|
-
"""Return the remaining cumulative attempt timeout."""
|
|
86
|
-
if self._deadline is None:
|
|
87
|
-
return None
|
|
88
|
-
remaining = self._deadline - self._clock()
|
|
89
|
-
if remaining < 0:
|
|
90
|
-
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
91
|
-
return remaining
|
|
92
|
-
|
|
93
75
|
async def consume_into(
|
|
94
76
|
self,
|
|
95
77
|
chunks: AsyncIterator[bytes],
|
|
96
78
|
sink: Callable[[bytes], object | Awaitable[object]],
|
|
97
79
|
total_bytes: int | None = None,
|
|
98
80
|
) -> None:
|
|
99
|
-
"""Write async chunks
|
|
81
|
+
"""Write async chunks with progress reporting.
|
|
100
82
|
|
|
101
83
|
Args:
|
|
102
84
|
chunks: Fresh response-body chunks for this attempt.
|
|
@@ -104,8 +86,7 @@ class AsyncAttemptControl:
|
|
|
104
86
|
total_bytes: Known complete body size, when available.
|
|
105
87
|
|
|
106
88
|
Raises:
|
|
107
|
-
|
|
108
|
-
limit.
|
|
89
|
+
Exception: If reading a chunk or invoking the sink fails.
|
|
109
90
|
"""
|
|
110
91
|
transferred = 0
|
|
111
92
|
async for chunk in chunks:
|
|
@@ -113,7 +94,6 @@ class AsyncAttemptControl:
|
|
|
113
94
|
if inspect.isawaitable(sink_result):
|
|
114
95
|
await sink_result
|
|
115
96
|
transferred += len(chunk)
|
|
116
|
-
self._check_deadline()
|
|
117
97
|
if chunk:
|
|
118
98
|
notify_observer(
|
|
119
99
|
self._observer,
|
|
@@ -131,7 +111,7 @@ class AsyncAttemptControl:
|
|
|
131
111
|
chunks: AsyncIterator[bytes],
|
|
132
112
|
total_bytes: int | None = None,
|
|
133
113
|
) -> bytes:
|
|
134
|
-
"""Collect async chunks while
|
|
114
|
+
"""Collect async chunks while reporting progress.
|
|
135
115
|
|
|
136
116
|
Args:
|
|
137
117
|
chunks: Fresh response-body chunks for this attempt.
|
|
@@ -141,8 +121,7 @@ class AsyncAttemptControl:
|
|
|
141
121
|
Concatenated response bytes.
|
|
142
122
|
|
|
143
123
|
Raises:
|
|
144
|
-
|
|
145
|
-
limit.
|
|
124
|
+
Exception: If reading a chunk fails.
|
|
146
125
|
"""
|
|
147
126
|
body = BytesIO()
|
|
148
127
|
await self.consume_into(chunks, body.write, total_bytes=total_bytes)
|
|
@@ -239,11 +218,8 @@ class AsyncDriver:
|
|
|
239
218
|
) -> AttemptState[AsyncAttemptControl]:
|
|
240
219
|
"""Build immutable timing and observer state for one fresh attempt."""
|
|
241
220
|
started = self._clock()
|
|
242
|
-
deadline = None if context.timeout.total is None else started + context.timeout.total
|
|
243
221
|
control = AsyncAttemptControl(
|
|
244
222
|
_attempt=attempt,
|
|
245
|
-
_clock=self._clock,
|
|
246
|
-
_deadline=deadline,
|
|
247
223
|
_observer=context.on_progress,
|
|
248
224
|
_operation=operation.category,
|
|
249
225
|
_route=route,
|
|
@@ -296,7 +272,6 @@ class AsyncDriver:
|
|
|
296
272
|
response_status = response.status
|
|
297
273
|
response_headers = response.headers
|
|
298
274
|
correlation_id = response_correlation_id(response_headers)
|
|
299
|
-
state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
300
275
|
failure = _protocol.classify_http_failure(
|
|
301
276
|
response_status,
|
|
302
277
|
response_headers,
|
|
@@ -416,7 +391,6 @@ class AsyncDriver:
|
|
|
416
391
|
request: TransportRequest,
|
|
417
392
|
state: AttemptState[AsyncAttemptControl],
|
|
418
393
|
*,
|
|
419
|
-
redirected: bool,
|
|
420
394
|
streaming: bool,
|
|
421
395
|
) -> AsyncLiveResponse | AttemptRetry:
|
|
422
396
|
"""Send once and close the request body under response ownership."""
|
|
@@ -425,8 +399,6 @@ class AsyncDriver:
|
|
|
425
399
|
response_status: int | None = None
|
|
426
400
|
try:
|
|
427
401
|
try:
|
|
428
|
-
if redirected:
|
|
429
|
-
request = _request_with_remaining_timeout(request, state.control)
|
|
430
402
|
if streaming:
|
|
431
403
|
response = await self._transport.send_streaming(request)
|
|
432
404
|
else:
|
|
@@ -496,7 +468,6 @@ class AsyncDriver:
|
|
|
496
468
|
sent = await self._send_request(
|
|
497
469
|
request,
|
|
498
470
|
state,
|
|
499
|
-
redirected=redirected,
|
|
500
471
|
streaming=execution_options.streaming,
|
|
501
472
|
)
|
|
502
473
|
if isinstance(sent, AttemptRetry):
|
|
@@ -561,7 +532,7 @@ class AsyncDriver:
|
|
|
561
532
|
buffered.headers,
|
|
562
533
|
buffered.content,
|
|
563
534
|
)
|
|
564
|
-
body = await control.
|
|
535
|
+
body = await control.consume_buffered(
|
|
565
536
|
buffered.content,
|
|
566
537
|
total_bytes=len(buffered.content),
|
|
567
538
|
)
|
|
@@ -636,6 +607,11 @@ class AsyncDriver:
|
|
|
636
607
|
|
|
637
608
|
Yields:
|
|
638
609
|
Decoded RPC results in page order.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
TransportError: If a page exhausts its transport attempts.
|
|
613
|
+
Exception: If building, validating, decoding, or cleaning up a page
|
|
614
|
+
operation fails.
|
|
639
615
|
"""
|
|
640
616
|
if context.page_prefetch <= 0:
|
|
641
617
|
page = first_page
|
|
@@ -686,6 +662,7 @@ class AsyncDriver:
|
|
|
686
662
|
Raises:
|
|
687
663
|
ValueError: If `attempts` is not positive or `interval` is
|
|
688
664
|
negative.
|
|
665
|
+
Exception: If the predicate or configured sleep fails.
|
|
689
666
|
"""
|
|
690
667
|
if attempts <= 0:
|
|
691
668
|
raise ValueError('poll attempts must be positive')
|
|
@@ -721,7 +698,7 @@ class _AsyncBufferedResponse(AsyncLiveResponse, Protocol):
|
|
|
721
698
|
...
|
|
722
699
|
|
|
723
700
|
|
|
724
|
-
class _ObservedRequestBody(Iterator[bytes]):
|
|
701
|
+
class _ObservedRequestBody(Iterator[bytes], AsyncUploadObserver):
|
|
725
702
|
"""Publish progress while owning exact-once request-body cleanup."""
|
|
726
703
|
|
|
727
704
|
def __init__(
|
|
@@ -753,7 +730,13 @@ class _ObservedRequestBody(Iterator[bytes]):
|
|
|
753
730
|
return chunk
|
|
754
731
|
|
|
755
732
|
def close(self) -> None:
|
|
756
|
-
"""Close the source iterator
|
|
733
|
+
"""Close the source iterator at most once.
|
|
734
|
+
|
|
735
|
+
Later calls do not repeat cleanup even when the first close fails.
|
|
736
|
+
|
|
737
|
+
Raises:
|
|
738
|
+
Exception: If the source iterator's cleanup fails.
|
|
739
|
+
"""
|
|
757
740
|
with self._condition:
|
|
758
741
|
if self._closed:
|
|
759
742
|
return
|
|
@@ -781,15 +764,10 @@ class _ObservedRequestBody(Iterator[bytes]):
|
|
|
781
764
|
|
|
782
765
|
Args:
|
|
783
766
|
chunk: Chunk pulled from the source iterator.
|
|
784
|
-
|
|
785
|
-
Raises:
|
|
786
|
-
ReadTimeoutError: If the attempt deadline passed before the chunk
|
|
787
|
-
could be returned.
|
|
788
767
|
"""
|
|
789
768
|
self._transferred += len(chunk)
|
|
790
769
|
if not chunk:
|
|
791
770
|
return
|
|
792
|
-
self._state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
793
771
|
notify_observer(
|
|
794
772
|
self._state.context.on_progress,
|
|
795
773
|
ProgressEvent(
|
|
@@ -807,6 +785,9 @@ class _ObservedRequestBody(Iterator[bytes]):
|
|
|
807
785
|
Returns:
|
|
808
786
|
The next chunk, or `None` when the source is exhausted or the body
|
|
809
787
|
is closing or closed.
|
|
788
|
+
|
|
789
|
+
Raises:
|
|
790
|
+
Exception: If the source iterator fails.
|
|
810
791
|
"""
|
|
811
792
|
with self._condition:
|
|
812
793
|
chunks = self._chunks
|
|
@@ -890,17 +871,6 @@ def _request_with_progress(
|
|
|
890
871
|
)
|
|
891
872
|
|
|
892
873
|
|
|
893
|
-
def _request_with_remaining_timeout(
|
|
894
|
-
request: TransportRequest,
|
|
895
|
-
control: AsyncAttemptControl,
|
|
896
|
-
) -> TransportRequest:
|
|
897
|
-
"""Restrict a redirect hop to its remaining cumulative attempt timeout."""
|
|
898
|
-
return _transport.narrow_total_timeout(
|
|
899
|
-
request,
|
|
900
|
-
control._remaining_timeout(), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
901
|
-
)
|
|
902
|
-
|
|
903
|
-
|
|
904
874
|
def _retain_detached_cleanup(task: asyncio.Task[None]) -> None:
|
|
905
875
|
"""Retain a detached cleanup task until its completion is observed."""
|
|
906
876
|
_DETACHED_CLEANUP_TASKS.add(task)
|
shotgun_api3/_async_httpx2.py
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import dataclasses
|
|
5
5
|
import ssl
|
|
6
|
-
from collections.abc import AsyncIterator, Callable, Iterator
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
7
7
|
from contextlib import suppress
|
|
8
8
|
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
9
|
-
from typing import Self, cast
|
|
9
|
+
from typing import Self, TypedDict, cast
|
|
10
10
|
|
|
11
11
|
import httpx2
|
|
12
12
|
|
|
13
|
-
from ._async_transport import AsyncStreamingResponse
|
|
13
|
+
from ._async_transport import AsyncStreamingResponse, AsyncUploadObserver
|
|
14
|
+
from ._connections import SocketOption, keepalive_socket_options, tune_socket
|
|
14
15
|
from ._transport import (
|
|
15
16
|
CaseInsensitiveHeaders,
|
|
16
17
|
ConnectError,
|
|
@@ -37,12 +38,15 @@ class Httpx2Transport:
|
|
|
37
38
|
closes the transport.
|
|
38
39
|
"""
|
|
39
40
|
|
|
40
|
-
def __init__(
|
|
41
|
+
def __init__( # noqa: PLR0913
|
|
41
42
|
self,
|
|
42
43
|
ca_certs: str | None = None,
|
|
43
44
|
proxy_url: str | None = None,
|
|
44
45
|
proxy_headers: dict[str, str] | None = None,
|
|
45
46
|
pool_maxsize: int = 10,
|
|
47
|
+
*,
|
|
48
|
+
max_connection_idle_secs: float | None = 60.0,
|
|
49
|
+
tcp_keepalive_timeout_secs: int | None = 60,
|
|
46
50
|
) -> None:
|
|
47
51
|
"""Initialize one shared async HTTP transport.
|
|
48
52
|
|
|
@@ -51,6 +55,14 @@ class Httpx2Transport:
|
|
|
51
55
|
proxy_url: Optional proxy URL.
|
|
52
56
|
proxy_headers: Optional headers sent to the proxy.
|
|
53
57
|
pool_maxsize: Maximum concurrent and keep-alive connections.
|
|
58
|
+
max_connection_idle_secs: Validated idle limit in seconds, or
|
|
59
|
+
`None` to disable expiry.
|
|
60
|
+
tcp_keepalive_timeout_secs: Validated nominal keepalive target in
|
|
61
|
+
seconds, or `None` to leave sockets untuned.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
OSError: If the certificate-authority bundle cannot be read or
|
|
65
|
+
loaded into the SSL context.
|
|
54
66
|
"""
|
|
55
67
|
verify: ssl.SSLContext | bool = True
|
|
56
68
|
if ca_certs is not None:
|
|
@@ -66,9 +78,14 @@ class Httpx2Transport:
|
|
|
66
78
|
limits=httpx2.Limits(
|
|
67
79
|
max_connections=pool_maxsize,
|
|
68
80
|
max_keepalive_connections=pool_maxsize,
|
|
81
|
+
keepalive_expiry=max_connection_idle_secs,
|
|
69
82
|
),
|
|
70
83
|
timeout=None,
|
|
71
84
|
)
|
|
85
|
+
self._request_options: _TraceRequestOptions = {}
|
|
86
|
+
if tcp_keepalive_timeout_secs is not None:
|
|
87
|
+
options = keepalive_socket_options(tcp_keepalive_timeout_secs)
|
|
88
|
+
self._request_options['extensions'] = {'trace': _keepalive_trace(options)}
|
|
72
89
|
self._active = 0
|
|
73
90
|
self._body_cleanup_tasks: set[asyncio.Task[None]] = set()
|
|
74
91
|
self._closed = False
|
|
@@ -121,18 +138,12 @@ class Httpx2Transport:
|
|
|
121
138
|
async def _open_stream(
|
|
122
139
|
self,
|
|
123
140
|
request: httpx2.Request,
|
|
124
|
-
deadline: float | None,
|
|
125
141
|
) -> httpx2.Response:
|
|
126
|
-
"""Send through the response-header phase
|
|
142
|
+
"""Send through the response-header phase and map backend errors."""
|
|
127
143
|
try:
|
|
128
|
-
|
|
129
|
-
return await self._client.send(request, stream=True)
|
|
130
|
-
async with asyncio.timeout_at(deadline):
|
|
131
|
-
return await self._client.send(request, stream=True)
|
|
144
|
+
return await self._client.send(request, stream=True)
|
|
132
145
|
except TimeoutError as error:
|
|
133
|
-
|
|
134
|
-
raise SendError(str(error)) from error
|
|
135
|
-
raise ReadTimeoutError('total timeout exceeded before response body') from error
|
|
146
|
+
raise SendError(str(error)) from error
|
|
136
147
|
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
137
148
|
raise _map_httpx2_error(error) from error
|
|
138
149
|
|
|
@@ -219,6 +230,7 @@ class Httpx2Transport:
|
|
|
219
230
|
headers=request.headers,
|
|
220
231
|
content=body,
|
|
221
232
|
timeout=_to_httpx2_timeout(request.timeout),
|
|
233
|
+
**self._request_options,
|
|
222
234
|
)
|
|
223
235
|
except BaseException:
|
|
224
236
|
self._retain_body_cleanup(body)
|
|
@@ -258,7 +270,8 @@ class Httpx2Transport:
|
|
|
258
270
|
|
|
259
271
|
Raises:
|
|
260
272
|
TransportError: If the current task owns a lease whose release
|
|
261
|
-
would depend on this call returning
|
|
273
|
+
would depend on this call returning, or backend pool cleanup
|
|
274
|
+
fails.
|
|
262
275
|
"""
|
|
263
276
|
owner = _current_task()
|
|
264
277
|
if self._has_lease(owner) or self._stream_owners.get(owner, 0) > 0:
|
|
@@ -295,22 +308,13 @@ class Httpx2Transport:
|
|
|
295
308
|
TransportError: If httpx2 cannot complete the request.
|
|
296
309
|
"""
|
|
297
310
|
self._require_send_available(_current_task())
|
|
298
|
-
total = request.timeout.total
|
|
299
|
-
if total is None:
|
|
300
|
-
try:
|
|
301
|
-
return await self._send_once(request)
|
|
302
|
-
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
303
|
-
raise _map_httpx2_error(error) from error
|
|
304
311
|
try:
|
|
305
|
-
|
|
306
|
-
return await self._send_once(request)
|
|
307
|
-
except TimeoutError as error:
|
|
308
|
-
raise ReadTimeoutError('total timeout exceeded') from error
|
|
312
|
+
return await self._send_once(request)
|
|
309
313
|
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
310
314
|
raise _map_httpx2_error(error) from error
|
|
311
315
|
|
|
312
316
|
async def send_streaming(self, request: TransportRequest) -> AsyncStreamingResponse:
|
|
313
|
-
"""Send one request and stream its body
|
|
317
|
+
"""Send one request and stream its body.
|
|
314
318
|
|
|
315
319
|
Args:
|
|
316
320
|
request: Request expressed in shared transport types.
|
|
@@ -319,13 +323,10 @@ class Httpx2Transport:
|
|
|
319
323
|
A single-use streaming response in shared transport types.
|
|
320
324
|
|
|
321
325
|
Raises:
|
|
322
|
-
TransportError: If the request
|
|
326
|
+
TransportError: If the request or response fails.
|
|
323
327
|
"""
|
|
324
328
|
current_task = _current_task()
|
|
325
329
|
self._require_send_available(current_task)
|
|
326
|
-
deadline = None
|
|
327
|
-
if request.timeout.total is not None:
|
|
328
|
-
deadline = asyncio.get_running_loop().time() + request.timeout.total
|
|
329
330
|
body = _adapt_body(request.body)
|
|
330
331
|
try:
|
|
331
332
|
outgoing = self._client.build_request(
|
|
@@ -334,6 +335,7 @@ class Httpx2Transport:
|
|
|
334
335
|
headers=request.headers,
|
|
335
336
|
content=body,
|
|
336
337
|
timeout=_to_httpx2_timeout(request.timeout),
|
|
338
|
+
**self._request_options,
|
|
337
339
|
)
|
|
338
340
|
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
339
341
|
self._retain_body_cleanup(body)
|
|
@@ -344,7 +346,7 @@ class Httpx2Transport:
|
|
|
344
346
|
|
|
345
347
|
owner = self._begin_stream()
|
|
346
348
|
try:
|
|
347
|
-
response = await self._open_stream(outgoing
|
|
349
|
+
response = await self._open_stream(outgoing)
|
|
348
350
|
except BaseException:
|
|
349
351
|
self._retain_body_cleanup(body)
|
|
350
352
|
self._release_stream(owner)
|
|
@@ -359,7 +361,6 @@ class Httpx2Transport:
|
|
|
359
361
|
|
|
360
362
|
body_owner = _ResponseBody(
|
|
361
363
|
response,
|
|
362
|
-
deadline,
|
|
363
364
|
lambda: self._release_stream(owner),
|
|
364
365
|
lambda task: self._reserve_stream_task(task, owner),
|
|
365
366
|
)
|
|
@@ -387,12 +388,10 @@ class _ResponseBody:
|
|
|
387
388
|
def __init__(
|
|
388
389
|
self,
|
|
389
390
|
response: httpx2.Response,
|
|
390
|
-
deadline: float | None,
|
|
391
391
|
release: Callable[[], None],
|
|
392
392
|
reserve: Callable[[asyncio.Task[object]], None],
|
|
393
393
|
) -> None:
|
|
394
|
-
"""Initialize one
|
|
395
|
-
self._deadline = deadline
|
|
394
|
+
"""Initialize one async response body."""
|
|
396
395
|
self._pending: asyncio.Task[bytes] | None = None
|
|
397
396
|
self._release = release
|
|
398
397
|
self._reserve = reserve
|
|
@@ -429,7 +428,7 @@ class _ResponseBody:
|
|
|
429
428
|
"""Run one retained response pull behind a shielded consumer wait."""
|
|
430
429
|
if self._pending is not None:
|
|
431
430
|
raise TransportError('async response already has an active pull')
|
|
432
|
-
pull = asyncio.create_task(_next_response_chunk(iterator
|
|
431
|
+
pull = asyncio.create_task(_next_response_chunk(iterator))
|
|
433
432
|
self._pending = pull
|
|
434
433
|
try:
|
|
435
434
|
self._reserve(pull)
|
|
@@ -452,7 +451,14 @@ class _ResponseBody:
|
|
|
452
451
|
self._pending = None
|
|
453
452
|
|
|
454
453
|
async def close(self) -> None:
|
|
455
|
-
"""Wait for cleanup unless the calling task is cancelling.
|
|
454
|
+
"""Wait for cleanup unless the calling task is cancelling.
|
|
455
|
+
|
|
456
|
+
An already-cancelling caller leaves the retained cleanup task running
|
|
457
|
+
without waiting for it.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
TransportError: If awaited backend response cleanup fails.
|
|
461
|
+
"""
|
|
456
462
|
cleanup = self._start()
|
|
457
463
|
current = asyncio.current_task()
|
|
458
464
|
if current is not None and current.cancelling():
|
|
@@ -460,7 +466,7 @@ class _ResponseBody:
|
|
|
460
466
|
await asyncio.shield(cleanup)
|
|
461
467
|
|
|
462
468
|
async def stream(self, size: int) -> AsyncIterator[bytes]:
|
|
463
|
-
"""Yield mapped
|
|
469
|
+
"""Yield response chunks with mapped backend errors.
|
|
464
470
|
|
|
465
471
|
Args:
|
|
466
472
|
size: Maximum preferred chunk size in bytes.
|
|
@@ -469,7 +475,7 @@ class _ResponseBody:
|
|
|
469
475
|
Response body chunks.
|
|
470
476
|
|
|
471
477
|
Raises:
|
|
472
|
-
TransportError: If reading the response stream fails.
|
|
478
|
+
TransportError: If reading or closing the response stream fails.
|
|
473
479
|
"""
|
|
474
480
|
try:
|
|
475
481
|
iterator = self._response.aiter_bytes(size)
|
|
@@ -505,7 +511,7 @@ class _ThreadedBody(AsyncIterator[bytes]):
|
|
|
505
511
|
try:
|
|
506
512
|
pulled = await asyncio.shield(pull)
|
|
507
513
|
except asyncio.CancelledError:
|
|
508
|
-
self.
|
|
514
|
+
self.start_close()
|
|
509
515
|
raise
|
|
510
516
|
except Exception as error:
|
|
511
517
|
raise SendError(str(error)) from error
|
|
@@ -528,7 +534,7 @@ class _ThreadedBody(AsyncIterator[bytes]):
|
|
|
528
534
|
await asyncio.shield(pending)
|
|
529
535
|
await _close_body(self._chunks)
|
|
530
536
|
|
|
531
|
-
def
|
|
537
|
+
def start_close(self) -> asyncio.Task[None]:
|
|
532
538
|
"""Start idempotent cleanup and retain its task."""
|
|
533
539
|
if self._close_task is None:
|
|
534
540
|
self._close_task = asyncio.create_task(self._finish_close())
|
|
@@ -536,14 +542,27 @@ class _ThreadedBody(AsyncIterator[bytes]):
|
|
|
536
542
|
return self._close_task
|
|
537
543
|
|
|
538
544
|
async def aclose(self) -> None:
|
|
539
|
-
"""Close the underlying iterator exactly once and off-loop.
|
|
540
|
-
|
|
545
|
+
"""Close the underlying iterator exactly once and off-loop.
|
|
546
|
+
|
|
547
|
+
An already-cancelling caller leaves the retained cleanup task running
|
|
548
|
+
without waiting for it.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
SendError: If awaited iterator cleanup fails.
|
|
552
|
+
"""
|
|
553
|
+
close_task = self.start_close()
|
|
541
554
|
current = asyncio.current_task()
|
|
542
555
|
if current is not None and current.cancelling():
|
|
543
556
|
return
|
|
544
557
|
await asyncio.shield(close_task)
|
|
545
558
|
|
|
546
559
|
|
|
560
|
+
class _TraceRequestOptions(TypedDict, total=False):
|
|
561
|
+
"""Omit the trace extension entirely when socket tuning is disabled."""
|
|
562
|
+
|
|
563
|
+
extensions: dict[str, Callable[[str, Mapping[str, object]], Awaitable[None]]]
|
|
564
|
+
|
|
565
|
+
|
|
547
566
|
def _adapt_body(
|
|
548
567
|
body: bytes | Iterator[bytes] | None,
|
|
549
568
|
) -> bytes | _ThreadedBody | None:
|
|
@@ -595,6 +614,24 @@ def _is_tls_failure(error: BaseException) -> bool:
|
|
|
595
614
|
return False
|
|
596
615
|
|
|
597
616
|
|
|
617
|
+
def _keepalive_trace(
|
|
618
|
+
options: tuple[SocketOption, ...],
|
|
619
|
+
) -> Callable[[str, Mapping[str, object]], Awaitable[None]]:
|
|
620
|
+
"""Tune the socket exposed by each completed TCP connection trace event."""
|
|
621
|
+
|
|
622
|
+
async def trace(event: str, info: Mapping[str, object]) -> None:
|
|
623
|
+
if event != 'connection.connect_tcp.complete':
|
|
624
|
+
return
|
|
625
|
+
get_extra_info = getattr(info['return_value'], 'get_extra_info', None)
|
|
626
|
+
if get_extra_info is None:
|
|
627
|
+
return
|
|
628
|
+
sock = get_extra_info('socket')
|
|
629
|
+
if sock is not None:
|
|
630
|
+
tune_socket(sock, options)
|
|
631
|
+
|
|
632
|
+
return trace
|
|
633
|
+
|
|
634
|
+
|
|
598
635
|
def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> TransportError:
|
|
599
636
|
"""Map an httpx2 failure into the shared transport vocabulary."""
|
|
600
637
|
if isinstance(error, httpx2.ProxyError):
|
|
@@ -604,38 +641,34 @@ def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> Transport
|
|
|
604
641
|
return TLSError(str(error))
|
|
605
642
|
return ConnectError(str(error))
|
|
606
643
|
if isinstance(error, httpx2.PoolTimeout):
|
|
607
|
-
return ConnectError(str(error))
|
|
644
|
+
return ConnectError(str(error) or 'timed out waiting for a connection from the pool')
|
|
608
645
|
if isinstance(error, httpx2.ReadTimeout):
|
|
609
646
|
return ReadTimeoutError(str(error))
|
|
610
647
|
return SendError(str(error))
|
|
611
648
|
|
|
612
649
|
|
|
613
650
|
def _next_chunk(chunks: Iterator[bytes]) -> _PulledChunk:
|
|
614
|
-
"""Pull one chunk and capture
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
651
|
+
"""Pull one chunk and capture its event-loop observer when supported."""
|
|
652
|
+
if (
|
|
653
|
+
isinstance(chunks, AsyncUploadObserver)
|
|
654
|
+
and callable(chunks.pull_for_async)
|
|
655
|
+
and callable(chunks.observe_async_chunk)
|
|
656
|
+
):
|
|
618
657
|
return _PulledChunk(
|
|
619
|
-
chunk=
|
|
620
|
-
observe=
|
|
658
|
+
chunk=chunks.pull_for_async(),
|
|
659
|
+
observe=chunks.observe_async_chunk,
|
|
621
660
|
)
|
|
622
661
|
return _PulledChunk(chunk=next(chunks, None), observe=None)
|
|
623
662
|
|
|
624
663
|
|
|
625
664
|
async def _next_response_chunk(
|
|
626
665
|
iterator: AsyncIterator[bytes],
|
|
627
|
-
deadline: float | None,
|
|
628
666
|
) -> bytes:
|
|
629
|
-
"""Read one response chunk
|
|
667
|
+
"""Read one response chunk and map backend errors."""
|
|
630
668
|
try:
|
|
631
|
-
|
|
632
|
-
return await anext(iterator)
|
|
633
|
-
async with asyncio.timeout_at(deadline):
|
|
634
|
-
return await anext(iterator)
|
|
669
|
+
return await anext(iterator)
|
|
635
670
|
except TimeoutError as error:
|
|
636
|
-
|
|
637
|
-
raise SendError(str(error)) from error
|
|
638
|
-
raise ReadTimeoutError('total timeout exceeded during response body') from error
|
|
671
|
+
raise SendError(str(error)) from error
|
|
639
672
|
except (httpx2.HTTPError, httpx2.StreamError) as error:
|
|
640
673
|
current = asyncio.current_task()
|
|
641
674
|
if current is not None and current.cancelling():
|
|
@@ -648,7 +681,7 @@ def _start_adapted_body_cleanup(
|
|
|
648
681
|
) -> asyncio.Task[None] | None:
|
|
649
682
|
"""Start and return request-body cleanup without delaying an error path."""
|
|
650
683
|
if isinstance(body, _ThreadedBody):
|
|
651
|
-
return body.
|
|
684
|
+
return body.start_close()
|
|
652
685
|
return None
|
|
653
686
|
|
|
654
687
|
|
shotgun_api3/_async_transport.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Async transport vocabulary and streaming response handling."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from abc import abstractmethod
|
|
4
5
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
5
6
|
from contextlib import suppress
|
|
6
|
-
from typing import Protocol
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
7
8
|
|
|
8
9
|
from ._transport import (
|
|
9
10
|
CaseInsensitiveHeaders,
|
|
@@ -57,7 +58,14 @@ class AsyncStreamingResponse:
|
|
|
57
58
|
raise TransportError('streaming response is closed')
|
|
58
59
|
|
|
59
60
|
async def close(self) -> None:
|
|
60
|
-
"""
|
|
61
|
+
"""Run the response cleanup callback at most once.
|
|
62
|
+
|
|
63
|
+
The callback is cleared before invocation, so a failed cleanup is not
|
|
64
|
+
retried by later calls.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
Exception: If the supplied cleanup callback fails.
|
|
68
|
+
"""
|
|
61
69
|
close_fn = self._close_fn
|
|
62
70
|
self._close_fn = None
|
|
63
71
|
if close_fn is not None:
|
|
@@ -98,10 +106,37 @@ class AsyncStreamingResponse:
|
|
|
98
106
|
return self._iterate(size)
|
|
99
107
|
|
|
100
108
|
|
|
109
|
+
@runtime_checkable
|
|
110
|
+
class AsyncUploadObserver(Protocol):
|
|
111
|
+
"""Separate worker-thread upload reads from event-loop progress callbacks.
|
|
112
|
+
|
|
113
|
+
Implementations pull chunks without observing them. The async transport
|
|
114
|
+
invokes the observer on its event loop after each worker-thread pull.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def observe_async_chunk(self, chunk: bytes) -> None:
|
|
119
|
+
"""Account for one uploaded chunk on the event loop.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
chunk: Chunk returned by `pull_for_async()`.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def pull_for_async(self) -> bytes | None:
|
|
128
|
+
"""Pull a chunk without invoking its progress observer.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The next chunk, or `None` when no more chunks are available.
|
|
132
|
+
"""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
|
|
101
136
|
class AsyncTransport(Protocol):
|
|
102
137
|
"""HTTP backend operations required by the async driver.
|
|
103
138
|
|
|
104
|
-
Task cancellation
|
|
139
|
+
Task cancellation interrupts asynchronous transport operations. The
|
|
105
140
|
synchronous lease pair brackets one complete logical RPC so draining close
|
|
106
141
|
operations cannot interrupt a retry loop between attempts.
|
|
107
142
|
"""
|
shotgun_api3/_capabilities.py
CHANGED
|
@@ -18,10 +18,11 @@ class ClientCapabilities:
|
|
|
18
18
|
"""Represent local platform details used by compatibility helpers.
|
|
19
19
|
|
|
20
20
|
Attributes:
|
|
21
|
-
platform:
|
|
22
|
-
py_version: Python version.
|
|
21
|
+
platform: `linux`, `mac`, or `windows`, or `None` on other platforms.
|
|
22
|
+
py_version: Python version as a `major.minor` string.
|
|
23
23
|
ssl_version: SSL library version.
|
|
24
|
-
local_path_field:
|
|
24
|
+
local_path_field: `local_path_<platform>`, or `None` when the platform
|
|
25
|
+
has no supported local-file field.
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
def __init__(self) -> None:
|