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,2906 @@
|
|
|
1
|
+
"""Native-async Shotgun client and lifecycle."""
|
|
2
|
+
|
|
3
|
+
# Same-file collaboration follows module-boundary privacy.
|
|
4
|
+
# pyright: reportPrivateUsage=false
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import copy
|
|
8
|
+
import dataclasses
|
|
9
|
+
import datetime as _dt_module
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import urllib.request
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
|
|
17
|
+
from contextlib import ExitStack, asynccontextmanager, suppress
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import MappingProxyType, TracebackType
|
|
20
|
+
from typing import (
|
|
21
|
+
TYPE_CHECKING,
|
|
22
|
+
Any,
|
|
23
|
+
BinaryIO,
|
|
24
|
+
Literal,
|
|
25
|
+
Never,
|
|
26
|
+
ParamSpec,
|
|
27
|
+
Self,
|
|
28
|
+
TypeAlias,
|
|
29
|
+
TypeVar,
|
|
30
|
+
Unpack,
|
|
31
|
+
cast,
|
|
32
|
+
overload,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from . import _config as config_module
|
|
36
|
+
from . import _crud, _errors, _files, _methods, _protocol, _transport
|
|
37
|
+
from ._async_driver import (
|
|
38
|
+
AsyncAttemptControl,
|
|
39
|
+
AsyncDriver,
|
|
40
|
+
AsyncExecutionOptions,
|
|
41
|
+
AsyncLiveResponse,
|
|
42
|
+
)
|
|
43
|
+
from ._async_transport import AsyncStreamingResponse, AsyncTransport
|
|
44
|
+
from ._capabilities import ClientCapabilities, ServerCapabilities
|
|
45
|
+
from ._client import (
|
|
46
|
+
CONSTRUCTOR_SIGNATURE,
|
|
47
|
+
ConstructorOptions,
|
|
48
|
+
Shotgun,
|
|
49
|
+
bind_constructor,
|
|
50
|
+
build_config,
|
|
51
|
+
expose_constructor_signature,
|
|
52
|
+
)
|
|
53
|
+
from ._config import (
|
|
54
|
+
CompatibilityWarningCode,
|
|
55
|
+
Config,
|
|
56
|
+
RequestContext,
|
|
57
|
+
advance_transport_generation,
|
|
58
|
+
readiness_key,
|
|
59
|
+
require_secure_rpc,
|
|
60
|
+
server_host,
|
|
61
|
+
server_page_limit,
|
|
62
|
+
)
|
|
63
|
+
from ._errors import ShotgunError
|
|
64
|
+
from ._execution import SuppressedExceptionCapture, release_lease, retire_transport
|
|
65
|
+
from ._signatures import bind_arguments, expose_signature
|
|
66
|
+
from ._transport import (
|
|
67
|
+
ReadTimeoutError,
|
|
68
|
+
SendError,
|
|
69
|
+
TransportError,
|
|
70
|
+
TransportRequest,
|
|
71
|
+
TransportResponse,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if TYPE_CHECKING:
|
|
75
|
+
from ._async_httpx2 import Httpx2Transport
|
|
76
|
+
|
|
77
|
+
_AuthExchangeKey: TypeAlias = tuple[str, int, int]
|
|
78
|
+
_ASYNC_FILE_BATCH_SIZE = 1024 * 1024
|
|
79
|
+
_ReadinessProgress: TypeAlias = tuple[asyncio.Task[object], asyncio.Event]
|
|
80
|
+
_ReadinessKey: TypeAlias = tuple[str, int]
|
|
81
|
+
_FilesystemParams = ParamSpec('_FilesystemParams')
|
|
82
|
+
_FilesystemResult = TypeVar('_FilesystemResult')
|
|
83
|
+
_UploadResult = TypeVar('_UploadResult')
|
|
84
|
+
|
|
85
|
+
_METHOD_UPLOAD_SIGNATURE = inspect.signature(Shotgun.upload)
|
|
86
|
+
_UPLOAD_SIGNATURE = inspect.signature(Shotgun.upload).replace(
|
|
87
|
+
parameters=tuple(inspect.signature(Shotgun.upload).parameters.values())[1:]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _AsyncDownloadCancellationScope:
|
|
92
|
+
"""Own and join every filesystem worker started by one transfer."""
|
|
93
|
+
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
"""Initialize cancellation retention for one filesystem worker."""
|
|
96
|
+
self._captured_cancellation: asyncio.CancelledError | None = None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def _cancellation(self) -> asyncio.CancelledError | None:
|
|
100
|
+
"""Return the first cancellation captured while joining a worker."""
|
|
101
|
+
return self._captured_cancellation
|
|
102
|
+
|
|
103
|
+
def _capture_cancellation(self, error: BaseException) -> None:
|
|
104
|
+
"""Retain one actively delivered caller cancellation."""
|
|
105
|
+
current = asyncio.current_task()
|
|
106
|
+
if (
|
|
107
|
+
not isinstance(error, asyncio.CancelledError)
|
|
108
|
+
or current is None
|
|
109
|
+
or current.cancelling() == 0
|
|
110
|
+
):
|
|
111
|
+
return
|
|
112
|
+
if self._captured_cancellation is None:
|
|
113
|
+
self._captured_cancellation = error
|
|
114
|
+
current.uncancel()
|
|
115
|
+
|
|
116
|
+
def _raise_if_cancelled(self) -> None:
|
|
117
|
+
"""Propagate a captured cancellation only between worker phases."""
|
|
118
|
+
if self._captured_cancellation is not None:
|
|
119
|
+
raise self._captured_cancellation
|
|
120
|
+
|
|
121
|
+
async def _run(
|
|
122
|
+
self,
|
|
123
|
+
function: Callable[_FilesystemParams, _FilesystemResult],
|
|
124
|
+
*args: _FilesystemParams.args,
|
|
125
|
+
**kwargs: _FilesystemParams.kwargs,
|
|
126
|
+
) -> _FilesystemResult:
|
|
127
|
+
"""Run one worker to completion while retaining cancellation."""
|
|
128
|
+
task = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
|
|
129
|
+
while not task.done():
|
|
130
|
+
try:
|
|
131
|
+
await asyncio.wait({task})
|
|
132
|
+
except asyncio.CancelledError as error:
|
|
133
|
+
self._capture_cancellation(error)
|
|
134
|
+
try:
|
|
135
|
+
return task.result()
|
|
136
|
+
except BaseException:
|
|
137
|
+
self._raise_if_cancelled()
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _AsyncDownloadCleanupCapture:
|
|
142
|
+
"""Capture one cleanup failure without replacing transfer state."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, workers: _AsyncDownloadCancellationScope) -> None:
|
|
145
|
+
"""Initialize an empty async download-cleanup capture."""
|
|
146
|
+
self._workers = workers
|
|
147
|
+
self.exception: BaseException | None = None
|
|
148
|
+
|
|
149
|
+
def __enter__(self) -> Self:
|
|
150
|
+
"""Enter the async download-cleanup boundary."""
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(
|
|
154
|
+
self,
|
|
155
|
+
_exception_type: type[BaseException] | None,
|
|
156
|
+
exception: BaseException | None,
|
|
157
|
+
_traceback: TracebackType | None,
|
|
158
|
+
) -> bool:
|
|
159
|
+
"""Capture and suppress one async download-cleanup exception."""
|
|
160
|
+
self.exception = self._workers._cancellation or exception
|
|
161
|
+
return exception is not None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _AsyncDownloadCleanupError(Exception):
|
|
165
|
+
"""Mark a completed transfer whose required cleanup failed."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclasses.dataclass(frozen=True)
|
|
169
|
+
class _AsyncDownloadExchange:
|
|
170
|
+
"""Own one restartable async streaming download workflow."""
|
|
171
|
+
|
|
172
|
+
context: RequestContext
|
|
173
|
+
file_path: str | None
|
|
174
|
+
headers: Mapping[str, str] = dataclasses.field(repr=False)
|
|
175
|
+
url: str = dataclasses.field(repr=False)
|
|
176
|
+
|
|
177
|
+
def _redirect(self, response: AsyncLiveResponse) -> TransportRequest:
|
|
178
|
+
"""Build one credential-free explicit redirect request."""
|
|
179
|
+
return _files.build_download_redirect(self.context, response.headers)
|
|
180
|
+
|
|
181
|
+
async def _write_file(
|
|
182
|
+
self,
|
|
183
|
+
chunks: AsyncIterator[bytes],
|
|
184
|
+
control: AsyncAttemptControl,
|
|
185
|
+
total_bytes: int | None,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Publish one complete attempt through an adjacent temp file."""
|
|
188
|
+
if self.file_path is None:
|
|
189
|
+
raise AssertionError('file download requires a destination')
|
|
190
|
+
destination = Path(self.file_path)
|
|
191
|
+
stack: ExitStack | None = None
|
|
192
|
+
handle: BinaryIO | None = None
|
|
193
|
+
operation_exception: BaseException | None = None
|
|
194
|
+
temporary: Path | None = None
|
|
195
|
+
workers = _AsyncDownloadCancellationScope()
|
|
196
|
+
try:
|
|
197
|
+
try:
|
|
198
|
+
stack, handle, temporary = await workers._run(
|
|
199
|
+
_open_download_temporary,
|
|
200
|
+
destination,
|
|
201
|
+
)
|
|
202
|
+
workers._raise_if_cancelled()
|
|
203
|
+
opened_handle = handle
|
|
204
|
+
|
|
205
|
+
async def write(chunk: bytes) -> None:
|
|
206
|
+
await workers._run(opened_handle.write, chunk)
|
|
207
|
+
workers._raise_if_cancelled()
|
|
208
|
+
|
|
209
|
+
await control.consume_into(chunks, write, total_bytes=total_bytes)
|
|
210
|
+
await workers._run(handle.flush)
|
|
211
|
+
workers._raise_if_cancelled()
|
|
212
|
+
close_exception = await _capture_download_cleanup(workers, stack.close)
|
|
213
|
+
stack = None
|
|
214
|
+
handle = None
|
|
215
|
+
workers._raise_if_cancelled()
|
|
216
|
+
if close_exception is not None:
|
|
217
|
+
_raise_download_cleanup_error()
|
|
218
|
+
# This is the final cancellation checkpoint before atomic commit.
|
|
219
|
+
# Once `replace` starts, its worker is joined before cancellation
|
|
220
|
+
# propagates, so the destination cannot change after return.
|
|
221
|
+
await asyncio.sleep(0)
|
|
222
|
+
await workers._run(temporary.replace, destination)
|
|
223
|
+
workers._raise_if_cancelled()
|
|
224
|
+
except BaseException as error:
|
|
225
|
+
operation_exception = error
|
|
226
|
+
workers._capture_cancellation(error)
|
|
227
|
+
raise
|
|
228
|
+
finally:
|
|
229
|
+
close_exception: BaseException | None = None
|
|
230
|
+
unlink_exception: BaseException | None = None
|
|
231
|
+
if stack is not None:
|
|
232
|
+
close_exception = await _capture_download_cleanup(workers, stack.close)
|
|
233
|
+
if temporary is not None:
|
|
234
|
+
unlink_exception = await _capture_download_cleanup(
|
|
235
|
+
workers,
|
|
236
|
+
temporary.unlink,
|
|
237
|
+
missing_ok=True,
|
|
238
|
+
)
|
|
239
|
+
workers._raise_if_cancelled()
|
|
240
|
+
if operation_exception is None:
|
|
241
|
+
if close_exception is not None:
|
|
242
|
+
raise _AsyncDownloadCleanupError from None
|
|
243
|
+
if unlink_exception is not None:
|
|
244
|
+
raise _AsyncDownloadCleanupError from None
|
|
245
|
+
return self.file_path
|
|
246
|
+
|
|
247
|
+
async def _consume(
|
|
248
|
+
self,
|
|
249
|
+
response: AsyncLiveResponse,
|
|
250
|
+
control: AsyncAttemptControl,
|
|
251
|
+
) -> bytes | str:
|
|
252
|
+
"""Consume one successful streaming response attempt."""
|
|
253
|
+
if not _protocol.is_http_success(response.status):
|
|
254
|
+
raise _errors.ShotgunFileDownloadError('attachment download failed')
|
|
255
|
+
streaming = cast('AsyncStreamingResponse', response)
|
|
256
|
+
total_bytes = _transport.content_length(response.headers)
|
|
257
|
+
if self.file_path is None:
|
|
258
|
+
chunks = streaming.aiter_chunks()
|
|
259
|
+
return await control.consume(chunks, total_bytes=total_bytes)
|
|
260
|
+
chunks = streaming.aiter_chunks(_ASYNC_FILE_BATCH_SIZE)
|
|
261
|
+
return await self._write_file(chunks, control, total_bytes)
|
|
262
|
+
|
|
263
|
+
async def _execute(
|
|
264
|
+
self,
|
|
265
|
+
driver: AsyncDriver,
|
|
266
|
+
operation: _protocol.OperationDescriptor,
|
|
267
|
+
) -> bytes | str:
|
|
268
|
+
"""Execute the workflow through the common async attempt loop."""
|
|
269
|
+
try:
|
|
270
|
+
return await driver.execute(
|
|
271
|
+
operation,
|
|
272
|
+
self.context,
|
|
273
|
+
lambda _attempt: _files.build_download_request(
|
|
274
|
+
self.context, self.url, self.headers
|
|
275
|
+
),
|
|
276
|
+
self._consume,
|
|
277
|
+
options=AsyncExecutionOptions(
|
|
278
|
+
redirect_factory=self._redirect,
|
|
279
|
+
streaming=True,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
except _AsyncDownloadCleanupError:
|
|
283
|
+
raise SendError('download cleanup failed') from None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclasses.dataclass(frozen=True)
|
|
287
|
+
class _InvocationOptions:
|
|
288
|
+
"""Collect non-payload controls for one client invocation."""
|
|
289
|
+
|
|
290
|
+
auth_spec: _protocol.AuthSpec | None = None
|
|
291
|
+
compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = ()
|
|
292
|
+
include_auth: bool = True
|
|
293
|
+
logical_method_name: str | None = None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclasses.dataclass(frozen=True)
|
|
297
|
+
class _RpcInvocation:
|
|
298
|
+
"""Bind one immutable context to one RPC invocation."""
|
|
299
|
+
|
|
300
|
+
context: RequestContext
|
|
301
|
+
method_name: str
|
|
302
|
+
options: _InvocationOptions
|
|
303
|
+
params: object
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclasses.dataclass
|
|
307
|
+
class _TokenExchangeProgress:
|
|
308
|
+
"""Carry one exchange result until captured auth-context users drain."""
|
|
309
|
+
|
|
310
|
+
owner: asyncio.Task[object]
|
|
311
|
+
event: asyncio.Event
|
|
312
|
+
error: ReadTimeoutError | SendError | asyncio.CancelledError | None = None
|
|
313
|
+
token: str | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _bind_upload(
|
|
317
|
+
args: tuple[object, ...],
|
|
318
|
+
kwargs: dict[str, object],
|
|
319
|
+
) -> _files.UploadArguments:
|
|
320
|
+
"""Bind runtime arguments with upload-specific errors."""
|
|
321
|
+
bound = bind_arguments(_UPLOAD_SIGNATURE, args, kwargs, 'AsyncShotgun.upload()')
|
|
322
|
+
arguments = cast('Callable[..., _files.UploadArguments]', _files.UploadArguments)
|
|
323
|
+
return arguments(**bound.arguments)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def _capture_download_cleanup(
|
|
327
|
+
workers: _AsyncDownloadCancellationScope,
|
|
328
|
+
function: Callable[_FilesystemParams, _FilesystemResult],
|
|
329
|
+
*args: _FilesystemParams.args,
|
|
330
|
+
**kwargs: _FilesystemParams.kwargs,
|
|
331
|
+
) -> BaseException | None:
|
|
332
|
+
"""Run one joined cleanup operation and capture its failure."""
|
|
333
|
+
capture = _AsyncDownloadCleanupCapture(workers)
|
|
334
|
+
with capture:
|
|
335
|
+
await workers._run(function, *args, **kwargs)
|
|
336
|
+
return capture.exception
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _expose_upload_signature(function: Callable[..., object]) -> Callable[..., object]:
|
|
340
|
+
"""Attach the implemented sync upload signature and annotations."""
|
|
341
|
+
expose_signature(function, _METHOD_UPLOAD_SIGNATURE, Shotgun.upload.__doc__)
|
|
342
|
+
object.__setattr__(function, '__annotations__', Shotgun.upload.__annotations__)
|
|
343
|
+
return function
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class AsyncShotgun:
|
|
347
|
+
"""Provide the asynchronous Flow Production Tracking API client surface.
|
|
348
|
+
|
|
349
|
+
Instances are bound to one event loop and may be shared by tasks on that
|
|
350
|
+
loop. Use an async context manager or call `await close()` to release the
|
|
351
|
+
current transport. Closing drains in-flight work; a later operation
|
|
352
|
+
reconnects lazily.
|
|
353
|
+
|
|
354
|
+
Attributes:
|
|
355
|
+
base_url: The constructor's normalized base URL.
|
|
356
|
+
client_caps: Capabilities of this Python client.
|
|
357
|
+
config: The writable client configuration.
|
|
358
|
+
server_caps: Cached capabilities for the configured origin.
|
|
359
|
+
server_info: Cached raw metadata for the configured origin.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
__signature__ = CONSTRUCTOR_SIGNATURE
|
|
363
|
+
|
|
364
|
+
@overload
|
|
365
|
+
def __init__(self, base_url: Never) -> None: ...
|
|
366
|
+
|
|
367
|
+
@overload
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
base_url: str,
|
|
371
|
+
script_name: str | None = None,
|
|
372
|
+
api_key: str | None = None,
|
|
373
|
+
convert_datetimes_to_utc: bool = True,
|
|
374
|
+
http_proxy: str | None = None,
|
|
375
|
+
connect: bool = True,
|
|
376
|
+
ca_certs: str | None = None,
|
|
377
|
+
login: str | None = None,
|
|
378
|
+
password: str | None = None,
|
|
379
|
+
sudo_as_login: str | None = None,
|
|
380
|
+
session_token: str | None = None,
|
|
381
|
+
auth_token: str | None = None,
|
|
382
|
+
) -> None: ...
|
|
383
|
+
|
|
384
|
+
@expose_constructor_signature
|
|
385
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
386
|
+
"""Initialize a client for one site and authentication mode.
|
|
387
|
+
|
|
388
|
+
Supply exactly one authentication mode: `script_name` with `api_key`,
|
|
389
|
+
`login` with `password`, or `session_token`. An optional one-use
|
|
390
|
+
`auth_token` requires `login` and `password`.
|
|
391
|
+
|
|
392
|
+
`base_url` must identify an HTTP or HTTPS site without embedded
|
|
393
|
+
credentials. Authenticated remote operations require HTTPS.
|
|
394
|
+
`http_proxy` accepts `[user:pass@]host[:port]`, with port 8080 when
|
|
395
|
+
omitted. A truthy `ca_certs` selects a CA bundle; otherwise
|
|
396
|
+
`SHOTGUN_API_CACERTS` is consulted before using system trust roots.
|
|
397
|
+
|
|
398
|
+
Constructor validation is synchronous; readiness and authentication
|
|
399
|
+
exchange I/O begins only through an operation, `connect()`, or an async
|
|
400
|
+
context manager. With `connect=False`, entering the context performs no
|
|
401
|
+
I/O.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
*args: Positional values from the documented constructor signature.
|
|
405
|
+
**kwargs: Keyword values from the documented constructor signature.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
TypeError: If arguments do not match the documented signature.
|
|
409
|
+
ValueError: If authentication, URL, or proxy values are invalid.
|
|
410
|
+
"""
|
|
411
|
+
self._initialize(bind_constructor(args, kwargs, 'AsyncShotgun'))
|
|
412
|
+
|
|
413
|
+
async def __aenter__(self) -> Self:
|
|
414
|
+
"""Enter this client and perform any configured eager connection.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
This client.
|
|
418
|
+
"""
|
|
419
|
+
if not self._connect_on_enter:
|
|
420
|
+
return self
|
|
421
|
+
entry_capture = SuppressedExceptionCapture()
|
|
422
|
+
with entry_capture:
|
|
423
|
+
await self.connect()
|
|
424
|
+
if entry_capture.exception is not None:
|
|
425
|
+
await self._close_failed_context_entry(entry_capture.exception)
|
|
426
|
+
return self
|
|
427
|
+
|
|
428
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
429
|
+
"""Close this client's current transport on context exit.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
*exc: Context-manager exception details, if any.
|
|
433
|
+
"""
|
|
434
|
+
await self.close()
|
|
435
|
+
|
|
436
|
+
def _checkin_transport(self, transport: AsyncTransport) -> AsyncTransport | None:
|
|
437
|
+
"""Release a lease on its owning task and return any idle retired transport."""
|
|
438
|
+
with self._transport_lock:
|
|
439
|
+
close_after_release = release_lease(
|
|
440
|
+
self._lease_counts, self._retired_transports, transport
|
|
441
|
+
)
|
|
442
|
+
transport.checkin()
|
|
443
|
+
return close_after_release
|
|
444
|
+
|
|
445
|
+
def _complete_token_exchange(
|
|
446
|
+
self,
|
|
447
|
+
key: _AuthExchangeKey,
|
|
448
|
+
progress: _TokenExchangeProgress,
|
|
449
|
+
error: ReadTimeoutError | SendError | asyncio.CancelledError | None,
|
|
450
|
+
token: str | None,
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Publish one completed token exchange and wake its waiters."""
|
|
453
|
+
with self._token_exchange_lock:
|
|
454
|
+
active = self._token_exchanges.get(key)
|
|
455
|
+
if active is not progress:
|
|
456
|
+
return
|
|
457
|
+
progress.error = error
|
|
458
|
+
progress.token = token
|
|
459
|
+
if (error is None and token is None) or not self._active_auth_contexts.get(key, 0):
|
|
460
|
+
self._token_exchanges.pop(key)
|
|
461
|
+
progress.event.set()
|
|
462
|
+
|
|
463
|
+
async def _finalize_readiness(
|
|
464
|
+
self,
|
|
465
|
+
key: _ReadinessKey,
|
|
466
|
+
owner: asyncio.Task[object],
|
|
467
|
+
event: asyncio.Event,
|
|
468
|
+
capabilities: ServerCapabilities | None,
|
|
469
|
+
published: bool,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Publish current readiness and wake waiters for a matching owner."""
|
|
472
|
+
async with self._readiness_lock:
|
|
473
|
+
progress = self._readiness_in_progress.get(key)
|
|
474
|
+
if progress is not None and progress[0] is owner:
|
|
475
|
+
self._readiness_in_progress.pop(key)
|
|
476
|
+
if (
|
|
477
|
+
capabilities is not None
|
|
478
|
+
and published
|
|
479
|
+
and config_module.readiness_identity(self._config) == key
|
|
480
|
+
):
|
|
481
|
+
self._ready_caps.clear()
|
|
482
|
+
self._ready_caps[key] = capabilities
|
|
483
|
+
event.set()
|
|
484
|
+
|
|
485
|
+
def _initialize(self, options: ConstructorOptions) -> None:
|
|
486
|
+
"""Initialize network-free state from validated constructor options."""
|
|
487
|
+
self._base_url = options.base_url.rstrip('/')
|
|
488
|
+
self._ca_certs = options.ca_certs or os.environ.get('SHOTGUN_API_CACERTS') or None
|
|
489
|
+
self._client_caps = ClientCapabilities()
|
|
490
|
+
self._connect_on_enter = options.connect
|
|
491
|
+
self._config = build_config(options)
|
|
492
|
+
|
|
493
|
+
self._driver: AsyncDriver | None = None
|
|
494
|
+
self._driver_generation: int | None = None
|
|
495
|
+
self._known_transport: AsyncTransport | None = None
|
|
496
|
+
self._lease_counts: dict[int, int] = {}
|
|
497
|
+
self._retired_transports: dict[int, AsyncTransport] = {}
|
|
498
|
+
self._transport: AsyncTransport | None = None
|
|
499
|
+
self._transport_lock = threading.Lock()
|
|
500
|
+
self._transport_owned = False
|
|
501
|
+
|
|
502
|
+
self._readiness_lock = asyncio.Lock()
|
|
503
|
+
self._readiness_in_progress: dict[_ReadinessKey, _ReadinessProgress] = {}
|
|
504
|
+
self._ready_caps: dict[_ReadinessKey, ServerCapabilities] = {}
|
|
505
|
+
self._active_auth_contexts: dict[_AuthExchangeKey, int] = {}
|
|
506
|
+
self._token_exchange_lock = threading.Lock()
|
|
507
|
+
self._token_exchanges: dict[_AuthExchangeKey, _TokenExchangeProgress] = {}
|
|
508
|
+
|
|
509
|
+
async def _invoke(
|
|
510
|
+
self,
|
|
511
|
+
driver: AsyncDriver,
|
|
512
|
+
invocation: _RpcInvocation,
|
|
513
|
+
) -> object:
|
|
514
|
+
"""Transform and delegate one RPC to the common async executor."""
|
|
515
|
+
context = invocation.context
|
|
516
|
+
require_secure_rpc(context, invocation.options.include_auth)
|
|
517
|
+
outbound = _protocol.transform_outbound(
|
|
518
|
+
invocation.params,
|
|
519
|
+
context.convert_datetimes_to_utc,
|
|
520
|
+
)
|
|
521
|
+
result = await driver.call(
|
|
522
|
+
invocation.method_name,
|
|
523
|
+
outbound,
|
|
524
|
+
context,
|
|
525
|
+
include_auth=invocation.options.include_auth,
|
|
526
|
+
compatibility_warning_codes=invocation.options.compatibility_warning_codes,
|
|
527
|
+
_logical_method_name=invocation.options.logical_method_name,
|
|
528
|
+
)
|
|
529
|
+
return _protocol.transform_inbound(result, context.convert_datetimes_to_utc)
|
|
530
|
+
|
|
531
|
+
async def _join_readiness_finalizer(
|
|
532
|
+
self,
|
|
533
|
+
finalizer: asyncio.Task[None],
|
|
534
|
+
*,
|
|
535
|
+
preserve_cancellation: bool,
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Finish readiness finalization before propagating cancellation."""
|
|
538
|
+
cancellation: asyncio.CancelledError | None = None
|
|
539
|
+
current = asyncio.current_task()
|
|
540
|
+
while not finalizer.done():
|
|
541
|
+
try:
|
|
542
|
+
await asyncio.shield(finalizer)
|
|
543
|
+
except asyncio.CancelledError as error:
|
|
544
|
+
if cancellation is None:
|
|
545
|
+
cancellation = error
|
|
546
|
+
if current is not None:
|
|
547
|
+
current.uncancel()
|
|
548
|
+
finalizer.result()
|
|
549
|
+
if cancellation is not None and not preserve_cancellation:
|
|
550
|
+
raise cancellation
|
|
551
|
+
|
|
552
|
+
def _new_transport(self, context: RequestContext) -> AsyncTransport:
|
|
553
|
+
"""Create an httpx2 transport from one immutable context."""
|
|
554
|
+
module = importlib.import_module(f'{__package__}._async_httpx2')
|
|
555
|
+
factory = cast(
|
|
556
|
+
'type[Httpx2Transport]',
|
|
557
|
+
module.Httpx2Transport,
|
|
558
|
+
)
|
|
559
|
+
return factory(
|
|
560
|
+
ca_certs=self._ca_certs,
|
|
561
|
+
proxy_headers=dict(context.proxy_headers) or None,
|
|
562
|
+
proxy_url=context.proxy_url,
|
|
563
|
+
pool_maxsize=context.pool_maxsize,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _acquire_driver(
|
|
567
|
+
self,
|
|
568
|
+
context: RequestContext,
|
|
569
|
+
) -> tuple[AsyncDriver, AsyncTransport, tuple[AsyncTransport, ...]]:
|
|
570
|
+
"""Lease one generation's driver and return idle transports to close."""
|
|
571
|
+
close_after_unlock: list[AsyncTransport] = []
|
|
572
|
+
with self._transport_lock:
|
|
573
|
+
if self._known_transport is not self._transport:
|
|
574
|
+
if self._known_transport is not None:
|
|
575
|
+
retired = retire_transport(
|
|
576
|
+
self._lease_counts, self._retired_transports, self._known_transport
|
|
577
|
+
)
|
|
578
|
+
if retired is not None:
|
|
579
|
+
close_after_unlock.append(retired)
|
|
580
|
+
self._known_transport = self._transport
|
|
581
|
+
self._driver = None
|
|
582
|
+
self._driver_generation = None
|
|
583
|
+
self._transport_owned = False
|
|
584
|
+
|
|
585
|
+
transport = self._transport
|
|
586
|
+
if transport is None:
|
|
587
|
+
transport = self._new_transport(context)
|
|
588
|
+
self._transport = transport
|
|
589
|
+
self._known_transport = transport
|
|
590
|
+
self._transport_owned = True
|
|
591
|
+
elif self._driver_generation not in {None, context.transport_generation}:
|
|
592
|
+
if self._transport_owned:
|
|
593
|
+
retired = retire_transport(
|
|
594
|
+
self._lease_counts, self._retired_transports, transport
|
|
595
|
+
)
|
|
596
|
+
if retired is not None:
|
|
597
|
+
close_after_unlock.append(retired)
|
|
598
|
+
transport = self._new_transport(context)
|
|
599
|
+
self._transport = transport
|
|
600
|
+
self._known_transport = transport
|
|
601
|
+
self._driver = None
|
|
602
|
+
|
|
603
|
+
if self._driver is None:
|
|
604
|
+
self._driver = AsyncDriver(transport)
|
|
605
|
+
self._driver_generation = context.transport_generation
|
|
606
|
+
transport.checkout()
|
|
607
|
+
key = id(transport)
|
|
608
|
+
self._lease_counts[key] = self._lease_counts.get(key, 0) + 1
|
|
609
|
+
driver = self._driver
|
|
610
|
+
return driver, transport, tuple(close_after_unlock)
|
|
611
|
+
|
|
612
|
+
async def _release_failed_acquisition(
|
|
613
|
+
self, transport: AsyncTransport, primary_exception: BaseException
|
|
614
|
+
) -> Never:
|
|
615
|
+
"""Join replacement rollback and raise the selected acquisition outcome."""
|
|
616
|
+
cancellation: asyncio.CancelledError | None = None
|
|
617
|
+
current = asyncio.current_task()
|
|
618
|
+
if (
|
|
619
|
+
isinstance(primary_exception, asyncio.CancelledError)
|
|
620
|
+
and current is not None
|
|
621
|
+
and current.cancelling() > 0
|
|
622
|
+
):
|
|
623
|
+
cancellation = primary_exception
|
|
624
|
+
current.uncancel()
|
|
625
|
+
|
|
626
|
+
release_capture = SuppressedExceptionCapture()
|
|
627
|
+
with release_capture:
|
|
628
|
+
close_after_release = self._checkin_transport(transport)
|
|
629
|
+
if close_after_release is not None:
|
|
630
|
+
|
|
631
|
+
async def close_retired() -> None:
|
|
632
|
+
# Suppress inside the task so cancelled shields cannot report
|
|
633
|
+
# a secondary failure through the event-loop handler.
|
|
634
|
+
with suppress(BaseException):
|
|
635
|
+
await close_after_release.close()
|
|
636
|
+
|
|
637
|
+
cleanup = asyncio.create_task(close_retired())
|
|
638
|
+
while not cleanup.done():
|
|
639
|
+
wait_capture = SuppressedExceptionCapture()
|
|
640
|
+
with wait_capture:
|
|
641
|
+
await asyncio.shield(cleanup)
|
|
642
|
+
error = wait_capture.exception
|
|
643
|
+
if (
|
|
644
|
+
isinstance(error, asyncio.CancelledError)
|
|
645
|
+
and current is not None
|
|
646
|
+
and current.cancelling() > 0
|
|
647
|
+
):
|
|
648
|
+
if cancellation is None:
|
|
649
|
+
cancellation = error
|
|
650
|
+
current.uncancel()
|
|
651
|
+
cleanup.result()
|
|
652
|
+
if cancellation is not None:
|
|
653
|
+
raise cancellation
|
|
654
|
+
raise primary_exception
|
|
655
|
+
|
|
656
|
+
async def _release_transport(self, transport: AsyncTransport) -> None:
|
|
657
|
+
"""Release one client lease and close a fully retired transport."""
|
|
658
|
+
close_after_release = self._checkin_transport(transport)
|
|
659
|
+
if close_after_release is not None:
|
|
660
|
+
await close_after_release.close()
|
|
661
|
+
|
|
662
|
+
@asynccontextmanager
|
|
663
|
+
async def _driver_lease(self, context: RequestContext) -> AsyncGenerator[AsyncDriver]:
|
|
664
|
+
"""Keep one transport alive across readiness and operation RPCs."""
|
|
665
|
+
driver, transport, retired = self._acquire_driver(context)
|
|
666
|
+
exchange_key: _AuthExchangeKey | None = None
|
|
667
|
+
if (
|
|
668
|
+
context.has_one_use_auth_token
|
|
669
|
+
and context.auth_generation is not None
|
|
670
|
+
and context.canonical_origin is not None
|
|
671
|
+
):
|
|
672
|
+
exchange_key = (
|
|
673
|
+
context.canonical_origin,
|
|
674
|
+
context.transport_generation,
|
|
675
|
+
context.auth_generation,
|
|
676
|
+
)
|
|
677
|
+
with self._token_exchange_lock:
|
|
678
|
+
self._active_auth_contexts[exchange_key] = (
|
|
679
|
+
self._active_auth_contexts.get(exchange_key, 0) + 1
|
|
680
|
+
)
|
|
681
|
+
acquisition_capture = SuppressedExceptionCapture()
|
|
682
|
+
try:
|
|
683
|
+
with acquisition_capture:
|
|
684
|
+
for retired_transport in retired:
|
|
685
|
+
await retired_transport.close()
|
|
686
|
+
if acquisition_capture.exception is not None:
|
|
687
|
+
return
|
|
688
|
+
yield driver
|
|
689
|
+
finally:
|
|
690
|
+
if exchange_key is not None:
|
|
691
|
+
with self._token_exchange_lock:
|
|
692
|
+
remaining = self._active_auth_contexts[exchange_key] - 1
|
|
693
|
+
if remaining:
|
|
694
|
+
self._active_auth_contexts[exchange_key] = remaining
|
|
695
|
+
else:
|
|
696
|
+
self._active_auth_contexts.pop(exchange_key)
|
|
697
|
+
progress = self._token_exchanges.get(exchange_key)
|
|
698
|
+
if progress is not None and (
|
|
699
|
+
progress.error is not None or progress.token is not None
|
|
700
|
+
):
|
|
701
|
+
self._token_exchanges.pop(exchange_key)
|
|
702
|
+
if acquisition_capture.exception is not None:
|
|
703
|
+
await self._release_failed_acquisition(transport, acquisition_capture.exception)
|
|
704
|
+
await self._release_transport(transport)
|
|
705
|
+
|
|
706
|
+
async def _request_session_token(
|
|
707
|
+
self,
|
|
708
|
+
context: RequestContext,
|
|
709
|
+
driver: AsyncDriver,
|
|
710
|
+
) -> str:
|
|
711
|
+
"""Request and conditionally publish a token for one captured state."""
|
|
712
|
+
result = await self._invoke(
|
|
713
|
+
driver,
|
|
714
|
+
_RpcInvocation(
|
|
715
|
+
context=context,
|
|
716
|
+
method_name='get_session_token',
|
|
717
|
+
options=_InvocationOptions(),
|
|
718
|
+
params=None,
|
|
719
|
+
),
|
|
720
|
+
)
|
|
721
|
+
if not isinstance(result, dict):
|
|
722
|
+
raise _errors.ShotgunError('get_session_token did not return a session_id')
|
|
723
|
+
session_id = cast('dict[str, object]', result).get('session_id')
|
|
724
|
+
if not isinstance(session_id, str):
|
|
725
|
+
raise _errors.ShotgunError('get_session_token did not return a session_id')
|
|
726
|
+
config_module.publish_session_token(self._config, context, session_id)
|
|
727
|
+
return session_id
|
|
728
|
+
|
|
729
|
+
async def _coalesce_session_token(
|
|
730
|
+
self,
|
|
731
|
+
context: RequestContext,
|
|
732
|
+
driver: AsyncDriver,
|
|
733
|
+
) -> str:
|
|
734
|
+
"""Share one session exchange across one captured auth generation."""
|
|
735
|
+
if context.auth_generation is None or context.canonical_origin is None:
|
|
736
|
+
raise AssertionError('shared session exchange requires shared config state')
|
|
737
|
+
key = (
|
|
738
|
+
context.canonical_origin,
|
|
739
|
+
context.transport_generation,
|
|
740
|
+
context.auth_generation,
|
|
741
|
+
)
|
|
742
|
+
owner = asyncio.current_task()
|
|
743
|
+
if owner is None:
|
|
744
|
+
raise RuntimeError('session exchange requires a running task')
|
|
745
|
+
while True:
|
|
746
|
+
with self._token_exchange_lock:
|
|
747
|
+
progress = self._token_exchanges.get(key)
|
|
748
|
+
if progress is None:
|
|
749
|
+
progress = _TokenExchangeProgress(owner, asyncio.Event())
|
|
750
|
+
self._token_exchanges[key] = progress
|
|
751
|
+
break
|
|
752
|
+
if progress.error is not None:
|
|
753
|
+
raise progress.error
|
|
754
|
+
if progress.token is not None:
|
|
755
|
+
return progress.token
|
|
756
|
+
if progress.owner is owner:
|
|
757
|
+
raise RuntimeError('session exchange cannot recursively wait on itself')
|
|
758
|
+
await progress.event.wait()
|
|
759
|
+
|
|
760
|
+
error: ReadTimeoutError | SendError | asyncio.CancelledError | None = None
|
|
761
|
+
token: str | None = None
|
|
762
|
+
try:
|
|
763
|
+
token = await self._request_session_token(context, driver)
|
|
764
|
+
except (ReadTimeoutError, SendError, asyncio.CancelledError) as exc:
|
|
765
|
+
error = exc
|
|
766
|
+
raise
|
|
767
|
+
finally:
|
|
768
|
+
self._complete_token_exchange(key, progress, error, token)
|
|
769
|
+
if token is None:
|
|
770
|
+
raise AssertionError('session exchange completed without a token')
|
|
771
|
+
return token
|
|
772
|
+
|
|
773
|
+
async def _run_info(
|
|
774
|
+
self,
|
|
775
|
+
context: RequestContext,
|
|
776
|
+
driver: AsyncDriver,
|
|
777
|
+
*,
|
|
778
|
+
accept_ready: bool,
|
|
779
|
+
) -> tuple[dict[str, object], ServerCapabilities]:
|
|
780
|
+
"""Own, validate, and conditionally publish one metadata exchange."""
|
|
781
|
+
key = readiness_key(context)
|
|
782
|
+
owner = asyncio.current_task()
|
|
783
|
+
if owner is None:
|
|
784
|
+
raise RuntimeError('readiness requires a running task')
|
|
785
|
+
while True:
|
|
786
|
+
async with self._readiness_lock:
|
|
787
|
+
cached = self._ready_caps.get(key)
|
|
788
|
+
if accept_ready and cached is not None:
|
|
789
|
+
return cached.server_info, cached
|
|
790
|
+
progress = self._readiness_in_progress.get(key)
|
|
791
|
+
if progress is None:
|
|
792
|
+
event = asyncio.Event()
|
|
793
|
+
self._readiness_in_progress[key] = (owner, event)
|
|
794
|
+
break
|
|
795
|
+
progress_owner, event = progress
|
|
796
|
+
if progress_owner is owner:
|
|
797
|
+
raise RuntimeError('readiness cannot recursively wait on itself')
|
|
798
|
+
await event.wait()
|
|
799
|
+
|
|
800
|
+
capabilities: ServerCapabilities | None = None
|
|
801
|
+
preserve_cancellation = False
|
|
802
|
+
published = False
|
|
803
|
+
try:
|
|
804
|
+
result = await self._invoke(
|
|
805
|
+
driver,
|
|
806
|
+
_RpcInvocation(
|
|
807
|
+
context=context,
|
|
808
|
+
method_name='info',
|
|
809
|
+
options=_InvocationOptions(include_auth=False),
|
|
810
|
+
params=None,
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
if not isinstance(result, dict):
|
|
814
|
+
raise _errors.ShotgunError(
|
|
815
|
+
'The Flow Production Tracking server returned invalid metadata.'
|
|
816
|
+
)
|
|
817
|
+
metadata = cast('dict[str, object]', result)
|
|
818
|
+
server_page_limit(metadata)
|
|
819
|
+
capabilities = ServerCapabilities(server_host(context), metadata)
|
|
820
|
+
published = config_module.publish_server_info(
|
|
821
|
+
self._config, context, capabilities.server_info
|
|
822
|
+
)
|
|
823
|
+
except asyncio.CancelledError:
|
|
824
|
+
preserve_cancellation = True
|
|
825
|
+
raise
|
|
826
|
+
else:
|
|
827
|
+
return metadata, capabilities
|
|
828
|
+
finally:
|
|
829
|
+
finalizer = asyncio.create_task(
|
|
830
|
+
self._finalize_readiness(
|
|
831
|
+
key,
|
|
832
|
+
owner,
|
|
833
|
+
event,
|
|
834
|
+
capabilities,
|
|
835
|
+
published,
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
await self._join_readiness_finalizer(
|
|
839
|
+
finalizer,
|
|
840
|
+
preserve_cancellation=preserve_cancellation,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
async def _ensure_readiness(
|
|
844
|
+
self,
|
|
845
|
+
context: RequestContext,
|
|
846
|
+
driver: AsyncDriver,
|
|
847
|
+
) -> ServerCapabilities:
|
|
848
|
+
"""Validate one origin while coalescing concurrent callers."""
|
|
849
|
+
_metadata, capabilities = await self._run_info(context, driver, accept_ready=True)
|
|
850
|
+
return capabilities
|
|
851
|
+
|
|
852
|
+
async def _get_session_token_for(
|
|
853
|
+
self,
|
|
854
|
+
context: RequestContext,
|
|
855
|
+
driver: AsyncDriver,
|
|
856
|
+
) -> str:
|
|
857
|
+
"""Return a token obtained entirely from one captured request state."""
|
|
858
|
+
if context.session_token is not None:
|
|
859
|
+
return context.session_token
|
|
860
|
+
await self._ensure_readiness(context, driver)
|
|
861
|
+
if context.auth_generation is None or context.canonical_origin is None:
|
|
862
|
+
return await self._request_session_token(context, driver)
|
|
863
|
+
return await self._coalesce_session_token(context, driver)
|
|
864
|
+
|
|
865
|
+
async def _authenticated_context(
|
|
866
|
+
self,
|
|
867
|
+
context: RequestContext,
|
|
868
|
+
driver: AsyncDriver,
|
|
869
|
+
) -> RequestContext:
|
|
870
|
+
"""Replace one-use credentials after their ordered token exchange."""
|
|
871
|
+
if not context.has_one_use_auth_token:
|
|
872
|
+
return context
|
|
873
|
+
token = await self._get_session_token_for(context, driver)
|
|
874
|
+
auth = dict(context.auth)
|
|
875
|
+
for field in (
|
|
876
|
+
'auth_token',
|
|
877
|
+
'script_key',
|
|
878
|
+
'script_name',
|
|
879
|
+
'user_login',
|
|
880
|
+
'user_password',
|
|
881
|
+
):
|
|
882
|
+
auth.pop(field, None)
|
|
883
|
+
auth['session_token'] = token
|
|
884
|
+
extra_auth_params = dict(context.extra_auth_params)
|
|
885
|
+
extra_auth_params.pop('auth_token', None)
|
|
886
|
+
return dataclasses.replace(
|
|
887
|
+
context,
|
|
888
|
+
auth=MappingProxyType(auth),
|
|
889
|
+
extra_auth_params=MappingProxyType(extra_auth_params),
|
|
890
|
+
has_one_use_auth_token=False,
|
|
891
|
+
session_token=token,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
async def _call(
|
|
895
|
+
self,
|
|
896
|
+
method_name: str,
|
|
897
|
+
params: object,
|
|
898
|
+
invocation_options: _InvocationOptions | None = None,
|
|
899
|
+
) -> object:
|
|
900
|
+
"""Run one RPC from one coherent origin and transport snapshot."""
|
|
901
|
+
owned_params = copy.deepcopy(params)
|
|
902
|
+
options = invocation_options or _InvocationOptions()
|
|
903
|
+
context = config_module.snapshot(self._config, options.auth_spec)
|
|
904
|
+
if method_name != 'info':
|
|
905
|
+
require_secure_rpc(context, options.include_auth)
|
|
906
|
+
async with self._driver_lease(context) as driver:
|
|
907
|
+
if method_name == 'info':
|
|
908
|
+
result, _capabilities = await self._run_info(
|
|
909
|
+
context,
|
|
910
|
+
driver,
|
|
911
|
+
accept_ready=False,
|
|
912
|
+
)
|
|
913
|
+
return result
|
|
914
|
+
await self._ensure_readiness(context, driver)
|
|
915
|
+
operation_context = (
|
|
916
|
+
context
|
|
917
|
+
if options.auth_spec is not None
|
|
918
|
+
else await self._authenticated_context(context, driver)
|
|
919
|
+
)
|
|
920
|
+
return await self._invoke(
|
|
921
|
+
driver,
|
|
922
|
+
_RpcInvocation(
|
|
923
|
+
context=operation_context,
|
|
924
|
+
method_name=method_name,
|
|
925
|
+
options=options,
|
|
926
|
+
params=owned_params,
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
@staticmethod
|
|
931
|
+
async def _share_thumbnail_source(
|
|
932
|
+
driver: AsyncDriver,
|
|
933
|
+
context: RequestContext,
|
|
934
|
+
entities: list[Mapping[str, object]],
|
|
935
|
+
source_entity: Mapping[str, object],
|
|
936
|
+
filmstrip_thumbnail: bool,
|
|
937
|
+
) -> int:
|
|
938
|
+
"""Share one captured source thumbnail through the common executor."""
|
|
939
|
+
fields = _files.add_auth_fields(
|
|
940
|
+
_files.build_share_thumbnail_fields(
|
|
941
|
+
entities,
|
|
942
|
+
source_entity,
|
|
943
|
+
filmstrip_thumbnail,
|
|
944
|
+
),
|
|
945
|
+
context.auth,
|
|
946
|
+
)
|
|
947
|
+
exchange = _files.build_upload_endpoint_exchange(
|
|
948
|
+
context.canonical_origin,
|
|
949
|
+
context.user_agent,
|
|
950
|
+
_files.SHARE_THUMBNAIL_OPERATION,
|
|
951
|
+
_files.SHARE_THUMBNAIL,
|
|
952
|
+
fields,
|
|
953
|
+
)
|
|
954
|
+
return await _execute_upload_exchange(
|
|
955
|
+
driver,
|
|
956
|
+
context,
|
|
957
|
+
exchange,
|
|
958
|
+
lambda text, _headers: _files.parse_link_completion(text),
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
async def _thumbnail_source_ready(
|
|
962
|
+
self,
|
|
963
|
+
driver: AsyncDriver,
|
|
964
|
+
context: RequestContext,
|
|
965
|
+
source_entity_type: str,
|
|
966
|
+
source_entity_id: int,
|
|
967
|
+
field_name: Literal['filmstrip_image', 'image'],
|
|
968
|
+
) -> bool:
|
|
969
|
+
"""Return whether one thumbnail field is no longer transient."""
|
|
970
|
+
params = _crud.build_thumbnail_read_params(
|
|
971
|
+
source_entity_type, source_entity_id, field_name, context.records_per_page
|
|
972
|
+
)
|
|
973
|
+
result = await self._invoke(
|
|
974
|
+
driver,
|
|
975
|
+
_RpcInvocation(
|
|
976
|
+
context=context,
|
|
977
|
+
method_name='read',
|
|
978
|
+
options=_InvocationOptions(),
|
|
979
|
+
params=params,
|
|
980
|
+
),
|
|
981
|
+
)
|
|
982
|
+
return _files.thumbnail_source_ready(result, field_name)
|
|
983
|
+
|
|
984
|
+
@staticmethod
|
|
985
|
+
async def _upload_allocation(
|
|
986
|
+
driver: AsyncDriver,
|
|
987
|
+
context: RequestContext,
|
|
988
|
+
prepared: _files.PreparedUpload,
|
|
989
|
+
multipart: bool,
|
|
990
|
+
) -> _files.UploadLinkInfo:
|
|
991
|
+
"""Allocate one direct or multipart upload."""
|
|
992
|
+
options = prepared.options
|
|
993
|
+
arguments = options.arguments
|
|
994
|
+
fields = _files.add_auth_fields(
|
|
995
|
+
_files.build_upload_link_fields(
|
|
996
|
+
prepared.filename,
|
|
997
|
+
options.upload_type,
|
|
998
|
+
multipart,
|
|
999
|
+
arguments.created_at,
|
|
1000
|
+
),
|
|
1001
|
+
context.auth,
|
|
1002
|
+
)
|
|
1003
|
+
exchange = _files.build_upload_endpoint_exchange(
|
|
1004
|
+
context.canonical_origin,
|
|
1005
|
+
context.user_agent,
|
|
1006
|
+
_protocol.classify_endpoint('allocation'),
|
|
1007
|
+
_files.GET_UPLOAD_LINK_INFO,
|
|
1008
|
+
fields,
|
|
1009
|
+
)
|
|
1010
|
+
return await _execute_upload_exchange(
|
|
1011
|
+
driver,
|
|
1012
|
+
context,
|
|
1013
|
+
exchange,
|
|
1014
|
+
lambda text, _headers: _files.UploadLinkInfo(text, multipart=multipart),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
@staticmethod
|
|
1018
|
+
async def _upload_direct(
|
|
1019
|
+
driver: AsyncDriver,
|
|
1020
|
+
context: RequestContext,
|
|
1021
|
+
prepared: _files.PreparedUpload,
|
|
1022
|
+
upload_info: _files.UploadLinkInfo,
|
|
1023
|
+
) -> None:
|
|
1024
|
+
"""Stream one complete file to stable object storage."""
|
|
1025
|
+
exchange = _files.build_storage_upload_exchange(
|
|
1026
|
+
context.user_agent,
|
|
1027
|
+
_protocol.classify_endpoint('object_put'),
|
|
1028
|
+
upload_info.upload_url,
|
|
1029
|
+
_files.StorageRange(
|
|
1030
|
+
length=prepared.file_size,
|
|
1031
|
+
offset=0,
|
|
1032
|
+
path=prepared.options.arguments.path,
|
|
1033
|
+
),
|
|
1034
|
+
chunk_size=_ASYNC_FILE_BATCH_SIZE,
|
|
1035
|
+
)
|
|
1036
|
+
await _execute_upload_exchange(
|
|
1037
|
+
driver,
|
|
1038
|
+
context,
|
|
1039
|
+
exchange,
|
|
1040
|
+
lambda _text, _headers: None,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
@staticmethod
|
|
1044
|
+
async def _upload_form(
|
|
1045
|
+
driver: AsyncDriver,
|
|
1046
|
+
context: RequestContext,
|
|
1047
|
+
prepared: _files.PreparedUpload,
|
|
1048
|
+
) -> Never:
|
|
1049
|
+
"""Send the observed form request without guessing a success result."""
|
|
1050
|
+
if context.canonical_origin is None:
|
|
1051
|
+
raise ValueError('server parameters must be configured before upload I/O')
|
|
1052
|
+
exchange = _files.build_form_upload_exchange(
|
|
1053
|
+
context.canonical_origin,
|
|
1054
|
+
context.user_agent,
|
|
1055
|
+
prepared,
|
|
1056
|
+
context.auth,
|
|
1057
|
+
chunk_size=_ASYNC_FILE_BATCH_SIZE,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
def reject_success(_text: str, _headers: Mapping[str, str]) -> Never:
|
|
1061
|
+
raise ShotgunError('form upload success response is not established')
|
|
1062
|
+
|
|
1063
|
+
await _execute_upload_exchange(driver, context, exchange, reject_success)
|
|
1064
|
+
raise AssertionError('form upload response parser returned')
|
|
1065
|
+
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
async def _upload_link(
|
|
1068
|
+
driver: AsyncDriver,
|
|
1069
|
+
context: RequestContext,
|
|
1070
|
+
prepared: _files.PreparedUpload,
|
|
1071
|
+
upload_info: _files.UploadLinkInfo,
|
|
1072
|
+
) -> int:
|
|
1073
|
+
"""Link one stored object to its receiving entity."""
|
|
1074
|
+
fields = _files.add_auth_fields(
|
|
1075
|
+
_files.build_link_file_fields(
|
|
1076
|
+
_files.build_link_entity_fields(prepared),
|
|
1077
|
+
upload_info.encoded,
|
|
1078
|
+
),
|
|
1079
|
+
context.auth,
|
|
1080
|
+
)
|
|
1081
|
+
exchange = _files.build_upload_endpoint_exchange(
|
|
1082
|
+
context.canonical_origin,
|
|
1083
|
+
context.user_agent,
|
|
1084
|
+
_protocol.classify_endpoint('link'),
|
|
1085
|
+
_files.LINK_FILE,
|
|
1086
|
+
fields,
|
|
1087
|
+
)
|
|
1088
|
+
return await _execute_upload_exchange(
|
|
1089
|
+
driver,
|
|
1090
|
+
context,
|
|
1091
|
+
exchange,
|
|
1092
|
+
lambda text, _headers: _files.parse_link_completion(text),
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
@staticmethod
|
|
1096
|
+
async def _upload_multipart(
|
|
1097
|
+
driver: AsyncDriver,
|
|
1098
|
+
context: RequestContext,
|
|
1099
|
+
prepared: _files.PreparedUpload,
|
|
1100
|
+
upload_info: _files.UploadLinkInfo,
|
|
1101
|
+
) -> None:
|
|
1102
|
+
"""Stream ordered parts and finalize one multipart allocation."""
|
|
1103
|
+
etags: list[str] = []
|
|
1104
|
+
for part_number, storage_range in enumerate(
|
|
1105
|
+
_files.multipart_ranges(prepared),
|
|
1106
|
+
start=1,
|
|
1107
|
+
):
|
|
1108
|
+
fields = _files.add_auth_fields(
|
|
1109
|
+
_files.build_part_fields(upload_info, prepared.filename, part_number),
|
|
1110
|
+
context.auth,
|
|
1111
|
+
)
|
|
1112
|
+
part_exchange = _files.build_upload_endpoint_exchange(
|
|
1113
|
+
context.canonical_origin,
|
|
1114
|
+
context.user_agent,
|
|
1115
|
+
_protocol.classify_endpoint('allocation'),
|
|
1116
|
+
_files.GET_PART_LINK,
|
|
1117
|
+
fields,
|
|
1118
|
+
)
|
|
1119
|
+
part_url = await _execute_upload_exchange(
|
|
1120
|
+
driver,
|
|
1121
|
+
context,
|
|
1122
|
+
part_exchange,
|
|
1123
|
+
lambda text, _headers: _files.parse_part_link(text),
|
|
1124
|
+
)
|
|
1125
|
+
put_exchange = _files.build_storage_upload_exchange(
|
|
1126
|
+
context.user_agent,
|
|
1127
|
+
_protocol.classify_endpoint('part_put'),
|
|
1128
|
+
part_url,
|
|
1129
|
+
storage_range,
|
|
1130
|
+
chunk_size=_ASYNC_FILE_BATCH_SIZE,
|
|
1131
|
+
)
|
|
1132
|
+
etag = await _execute_upload_exchange(
|
|
1133
|
+
driver,
|
|
1134
|
+
context,
|
|
1135
|
+
put_exchange,
|
|
1136
|
+
_files.parse_part_etag,
|
|
1137
|
+
)
|
|
1138
|
+
etags.append(etag)
|
|
1139
|
+
|
|
1140
|
+
fields = _files.add_auth_fields(
|
|
1141
|
+
_files.build_complete_fields(upload_info, prepared.filename, etags),
|
|
1142
|
+
context.auth,
|
|
1143
|
+
)
|
|
1144
|
+
complete_exchange = _files.build_upload_endpoint_exchange(
|
|
1145
|
+
context.canonical_origin,
|
|
1146
|
+
context.user_agent,
|
|
1147
|
+
_protocol.classify_endpoint('finalize'),
|
|
1148
|
+
_files.COMPLETE_MULTIPART,
|
|
1149
|
+
fields,
|
|
1150
|
+
)
|
|
1151
|
+
await _execute_upload_exchange(
|
|
1152
|
+
driver,
|
|
1153
|
+
context,
|
|
1154
|
+
complete_exchange,
|
|
1155
|
+
lambda text, _headers: _files.parse_multipart_completion(text),
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
async def _upload_for(
|
|
1159
|
+
self,
|
|
1160
|
+
driver: AsyncDriver,
|
|
1161
|
+
context: RequestContext,
|
|
1162
|
+
prepared: _files.PreparedUpload,
|
|
1163
|
+
) -> int:
|
|
1164
|
+
"""Execute one prepared upload through a captured driver lease."""
|
|
1165
|
+
arguments = prepared.options.arguments
|
|
1166
|
+
capabilities = await self._ensure_readiness(context, driver)
|
|
1167
|
+
operation_context = await self._authenticated_context(context, driver)
|
|
1168
|
+
upload_path = _files.choose_upload_path(
|
|
1169
|
+
capabilities.server_info,
|
|
1170
|
+
arguments.entity_type,
|
|
1171
|
+
arguments.field_name,
|
|
1172
|
+
prepared.file_size,
|
|
1173
|
+
)
|
|
1174
|
+
if upload_path == 'form':
|
|
1175
|
+
return await self._upload_form(driver, operation_context, prepared)
|
|
1176
|
+
multipart = upload_path == 'multipart'
|
|
1177
|
+
upload_info = await self._upload_allocation(
|
|
1178
|
+
driver,
|
|
1179
|
+
operation_context,
|
|
1180
|
+
prepared,
|
|
1181
|
+
multipart,
|
|
1182
|
+
)
|
|
1183
|
+
if multipart:
|
|
1184
|
+
await self._upload_multipart(
|
|
1185
|
+
driver,
|
|
1186
|
+
operation_context,
|
|
1187
|
+
prepared,
|
|
1188
|
+
upload_info,
|
|
1189
|
+
)
|
|
1190
|
+
else:
|
|
1191
|
+
await self._upload_direct(driver, operation_context, prepared, upload_info)
|
|
1192
|
+
return await self._upload_link(
|
|
1193
|
+
driver,
|
|
1194
|
+
operation_context,
|
|
1195
|
+
prepared,
|
|
1196
|
+
upload_info,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
async def _upload(self, options: _files.UploadOptions) -> int:
|
|
1200
|
+
"""Execute one evidence-backed upload workflow."""
|
|
1201
|
+
prepared = await asyncio.to_thread(_files.prepare_upload, options)
|
|
1202
|
+
context = config_module.snapshot(self._config)
|
|
1203
|
+
require_secure_rpc(context, include_auth=True)
|
|
1204
|
+
async with self._driver_lease(context) as driver:
|
|
1205
|
+
return await self._upload_for(driver, context, prepared)
|
|
1206
|
+
|
|
1207
|
+
async def _upload_thumbnail_for(
|
|
1208
|
+
self,
|
|
1209
|
+
driver: AsyncDriver,
|
|
1210
|
+
context: RequestContext,
|
|
1211
|
+
prepared: _files.PreparedUpload,
|
|
1212
|
+
) -> int:
|
|
1213
|
+
"""Execute the exact direct-storage thumbnail workflow."""
|
|
1214
|
+
upload_info = await self._upload_allocation(
|
|
1215
|
+
driver,
|
|
1216
|
+
context,
|
|
1217
|
+
prepared,
|
|
1218
|
+
multipart=False,
|
|
1219
|
+
)
|
|
1220
|
+
await self._upload_direct(driver, context, prepared, upload_info)
|
|
1221
|
+
return await self._upload_link(driver, context, prepared, upload_info)
|
|
1222
|
+
|
|
1223
|
+
@overload
|
|
1224
|
+
async def activity_stream_read(self, entity_type: Never) -> dict[str, object]: ...
|
|
1225
|
+
|
|
1226
|
+
@overload
|
|
1227
|
+
async def activity_stream_read(
|
|
1228
|
+
self,
|
|
1229
|
+
entity_type: str,
|
|
1230
|
+
entity_id: int,
|
|
1231
|
+
entity_fields: dict[str, list[str]] | None = None,
|
|
1232
|
+
min_id: int | None = None,
|
|
1233
|
+
max_id: int | None = None,
|
|
1234
|
+
limit: int | None = None,
|
|
1235
|
+
) -> dict[str, object]: ...
|
|
1236
|
+
|
|
1237
|
+
@_methods.expose_activity_stream_read_signature
|
|
1238
|
+
async def activity_stream_read(
|
|
1239
|
+
self,
|
|
1240
|
+
*args: object,
|
|
1241
|
+
**kwargs: object,
|
|
1242
|
+
) -> dict[str, object]:
|
|
1243
|
+
"""Read the activity stream for one entity.
|
|
1244
|
+
|
|
1245
|
+
Args:
|
|
1246
|
+
*args: Positional arguments from the documented activity-stream
|
|
1247
|
+
signature.
|
|
1248
|
+
**kwargs: Keyword arguments from the documented activity-stream
|
|
1249
|
+
signature.
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
Activity stream data returned by the server.
|
|
1253
|
+
|
|
1254
|
+
Raises:
|
|
1255
|
+
TypeError: If arguments do not match the documented signature.
|
|
1256
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1257
|
+
or if the server result has the wrong type.
|
|
1258
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1259
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1260
|
+
policy.
|
|
1261
|
+
"""
|
|
1262
|
+
options = _methods.bind_activity_stream_read(args, kwargs, 'AsyncShotgun')
|
|
1263
|
+
result = await self._call(
|
|
1264
|
+
'activity_stream',
|
|
1265
|
+
_methods.build_activity_stream_read_params(options),
|
|
1266
|
+
_InvocationOptions(logical_method_name='activity_stream_read'),
|
|
1267
|
+
)
|
|
1268
|
+
return _crud.require_dictionary_result(result, 'activity_stream_read')
|
|
1269
|
+
|
|
1270
|
+
def add_user_agent(self, agent: str) -> None:
|
|
1271
|
+
"""Append a user-agent fragment.
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
agent: User-agent fragment visible to subsequent requests.
|
|
1275
|
+
"""
|
|
1276
|
+
self._config.append_user_agent(agent)
|
|
1277
|
+
|
|
1278
|
+
async def authenticate_human_user(
|
|
1279
|
+
self,
|
|
1280
|
+
user_login: str,
|
|
1281
|
+
user_password: str,
|
|
1282
|
+
auth_token: str | None = None,
|
|
1283
|
+
) -> dict[str, object] | None:
|
|
1284
|
+
"""Authenticate a human user without changing client authentication.
|
|
1285
|
+
|
|
1286
|
+
Args:
|
|
1287
|
+
user_login: Login of the user to authenticate.
|
|
1288
|
+
user_password: Password for `user_login`.
|
|
1289
|
+
auth_token: Optional two-factor authentication token.
|
|
1290
|
+
|
|
1291
|
+
Returns:
|
|
1292
|
+
The matching `HumanUser` entity, or `None` when no active user
|
|
1293
|
+
matches or the server rejects the credentials.
|
|
1294
|
+
|
|
1295
|
+
Raises:
|
|
1296
|
+
MissingTwoFactorAuthenticationFault: If two-factor authentication
|
|
1297
|
+
is required but missing.
|
|
1298
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1299
|
+
or if the server result has the wrong type.
|
|
1300
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1301
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1302
|
+
policy.
|
|
1303
|
+
"""
|
|
1304
|
+
plan = _methods.build_authentication_plan(
|
|
1305
|
+
user_login,
|
|
1306
|
+
user_password,
|
|
1307
|
+
auth_token,
|
|
1308
|
+
)
|
|
1309
|
+
try:
|
|
1310
|
+
result = await self._call(
|
|
1311
|
+
'read',
|
|
1312
|
+
plan.params,
|
|
1313
|
+
_InvocationOptions(
|
|
1314
|
+
auth_spec=plan.auth_spec,
|
|
1315
|
+
logical_method_name='authenticate_human_user',
|
|
1316
|
+
),
|
|
1317
|
+
)
|
|
1318
|
+
except _errors.AuthenticationFault:
|
|
1319
|
+
return None
|
|
1320
|
+
return _methods.require_human_authentication_result(result)
|
|
1321
|
+
|
|
1322
|
+
@property
|
|
1323
|
+
def base_url(self) -> str:
|
|
1324
|
+
"""Return the constructor's normalized base URL.
|
|
1325
|
+
|
|
1326
|
+
Returns:
|
|
1327
|
+
The site URL without a trailing slash.
|
|
1328
|
+
"""
|
|
1329
|
+
return self._base_url
|
|
1330
|
+
|
|
1331
|
+
async def batch(self, requests: list[dict[str, Any]]) -> list[object]:
|
|
1332
|
+
"""Run create, update, and delete requests in one transaction.
|
|
1333
|
+
|
|
1334
|
+
Args:
|
|
1335
|
+
requests: Ordered request dictionaries to validate and send.
|
|
1336
|
+
|
|
1337
|
+
Returns:
|
|
1338
|
+
Results in the same order as the supplied requests.
|
|
1339
|
+
|
|
1340
|
+
Raises:
|
|
1341
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1342
|
+
or if a request is malformed or has an unsupported request
|
|
1343
|
+
type, or if the server result is not a list.
|
|
1344
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1345
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1346
|
+
policy.
|
|
1347
|
+
"""
|
|
1348
|
+
calls = _crud.build_batch_calls(requests)
|
|
1349
|
+
if not calls:
|
|
1350
|
+
return []
|
|
1351
|
+
return _crud.require_list_result(await self._call('batch', calls), 'batch')
|
|
1352
|
+
|
|
1353
|
+
@property
|
|
1354
|
+
def client_caps(self) -> ClientCapabilities:
|
|
1355
|
+
"""Return capabilities of this Python client.
|
|
1356
|
+
|
|
1357
|
+
Returns:
|
|
1358
|
+
Local platform capability metadata.
|
|
1359
|
+
"""
|
|
1360
|
+
return self._client_caps
|
|
1361
|
+
|
|
1362
|
+
async def _find(self, options: _crud.FindOptions) -> list[dict[str, object]]:
|
|
1363
|
+
"""Run one paged read operation from one immutable context."""
|
|
1364
|
+
prepared = _crud.prepare_find(options)
|
|
1365
|
+
context = config_module.snapshot(self._config)
|
|
1366
|
+
require_secure_rpc(context, include_auth=True)
|
|
1367
|
+
records: list[dict[str, object]] = []
|
|
1368
|
+
|
|
1369
|
+
async with self._driver_lease(context) as driver:
|
|
1370
|
+
capabilities = await self._ensure_readiness(context, driver)
|
|
1371
|
+
operation_context = dataclasses.replace(
|
|
1372
|
+
context,
|
|
1373
|
+
records_per_page=server_page_limit(capabilities.server_info),
|
|
1374
|
+
)
|
|
1375
|
+
plan = _crud.build_find_plan(
|
|
1376
|
+
prepared,
|
|
1377
|
+
operation_context.records_per_page,
|
|
1378
|
+
)
|
|
1379
|
+
if not plan.multi_page:
|
|
1380
|
+
operation_context = dataclasses.replace(
|
|
1381
|
+
operation_context,
|
|
1382
|
+
page_prefetch=0,
|
|
1383
|
+
)
|
|
1384
|
+
operation_context = await self._authenticated_context(
|
|
1385
|
+
operation_context,
|
|
1386
|
+
driver,
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
def build_page_params(page_number: int) -> object:
|
|
1390
|
+
return _crud.build_page_params(
|
|
1391
|
+
plan,
|
|
1392
|
+
page_number,
|
|
1393
|
+
operation_context.convert_datetimes_to_utc,
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
pages = driver.call_pages(
|
|
1397
|
+
build_page_params,
|
|
1398
|
+
plan.paging['current_page'],
|
|
1399
|
+
'read',
|
|
1400
|
+
operation_context,
|
|
1401
|
+
)
|
|
1402
|
+
try:
|
|
1403
|
+
async for raw_result in pages:
|
|
1404
|
+
page = _crud.validate_read_page(
|
|
1405
|
+
raw_result,
|
|
1406
|
+
convert_utc=operation_context.convert_datetimes_to_utc,
|
|
1407
|
+
records_count=len(records),
|
|
1408
|
+
plan=plan,
|
|
1409
|
+
)
|
|
1410
|
+
records.extend(page.entities)
|
|
1411
|
+
if page.stop:
|
|
1412
|
+
break
|
|
1413
|
+
finally:
|
|
1414
|
+
await pages.aclose()
|
|
1415
|
+
return _crud.finalize_find(
|
|
1416
|
+
records,
|
|
1417
|
+
options,
|
|
1418
|
+
plan,
|
|
1419
|
+
self.client_caps.local_path_field,
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
async def close(self) -> None:
|
|
1423
|
+
"""Swap out and drain the current transport without aborting work."""
|
|
1424
|
+
close_after_unlock: list[AsyncTransport] = []
|
|
1425
|
+
with self._transport_lock:
|
|
1426
|
+
advance_transport_generation(self._config)
|
|
1427
|
+
if self._known_transport is not self._transport:
|
|
1428
|
+
if self._known_transport is not None:
|
|
1429
|
+
self._retired_transports.pop(id(self._known_transport), None)
|
|
1430
|
+
close_after_unlock.append(self._known_transport)
|
|
1431
|
+
self._known_transport = self._transport
|
|
1432
|
+
transport = self._transport
|
|
1433
|
+
self._driver = None
|
|
1434
|
+
self._driver_generation = None
|
|
1435
|
+
self._known_transport = None
|
|
1436
|
+
self._transport = None
|
|
1437
|
+
self._transport_owned = False
|
|
1438
|
+
if transport is not None:
|
|
1439
|
+
self._retired_transports.pop(id(transport), None)
|
|
1440
|
+
if transport not in close_after_unlock:
|
|
1441
|
+
close_after_unlock.append(transport)
|
|
1442
|
+
pending_closes = iter(close_after_unlock)
|
|
1443
|
+
try:
|
|
1444
|
+
for retired_transport in pending_closes:
|
|
1445
|
+
await retired_transport.close()
|
|
1446
|
+
except BaseException:
|
|
1447
|
+
for retired_transport in pending_closes:
|
|
1448
|
+
with suppress(BaseException):
|
|
1449
|
+
await retired_transport.close()
|
|
1450
|
+
raise
|
|
1451
|
+
async with self._readiness_lock:
|
|
1452
|
+
current_key = config_module.readiness_identity(self._config)
|
|
1453
|
+
current = self._ready_caps.get(current_key) if current_key is not None else None
|
|
1454
|
+
self._ready_caps.clear()
|
|
1455
|
+
if current_key is not None and current is not None:
|
|
1456
|
+
self._ready_caps[current_key] = current
|
|
1457
|
+
|
|
1458
|
+
async def _close_failed_context_entry(self, entry_error: BaseException) -> Never:
|
|
1459
|
+
"""Close after failed eager entry and raise the selected primary outcome."""
|
|
1460
|
+
cancellation: asyncio.CancelledError | None = None
|
|
1461
|
+
current = asyncio.current_task()
|
|
1462
|
+
if isinstance(entry_error, asyncio.CancelledError):
|
|
1463
|
+
cancellation = entry_error
|
|
1464
|
+
if current is not None and current.cancelling() > 0:
|
|
1465
|
+
current.uncancel()
|
|
1466
|
+
|
|
1467
|
+
cleanup = asyncio.create_task(self.close())
|
|
1468
|
+
while not cleanup.done():
|
|
1469
|
+
wait_capture = SuppressedExceptionCapture()
|
|
1470
|
+
with wait_capture:
|
|
1471
|
+
await asyncio.shield(cleanup)
|
|
1472
|
+
error = wait_capture.exception
|
|
1473
|
+
if (
|
|
1474
|
+
isinstance(error, asyncio.CancelledError)
|
|
1475
|
+
and current is not None
|
|
1476
|
+
and current.cancelling() > 0
|
|
1477
|
+
):
|
|
1478
|
+
if cancellation is None:
|
|
1479
|
+
cancellation = error
|
|
1480
|
+
current.uncancel()
|
|
1481
|
+
|
|
1482
|
+
cleanup_capture = SuppressedExceptionCapture()
|
|
1483
|
+
with cleanup_capture:
|
|
1484
|
+
cleanup.result()
|
|
1485
|
+
if cancellation is not None:
|
|
1486
|
+
raise cancellation
|
|
1487
|
+
raise entry_error
|
|
1488
|
+
|
|
1489
|
+
@property
|
|
1490
|
+
def config(self) -> Config:
|
|
1491
|
+
"""Return the writable client configuration.
|
|
1492
|
+
|
|
1493
|
+
Returns:
|
|
1494
|
+
The locked client configuration object.
|
|
1495
|
+
"""
|
|
1496
|
+
return self._config
|
|
1497
|
+
|
|
1498
|
+
async def connect(self) -> None:
|
|
1499
|
+
"""Validate readiness and complete deferred one-use authentication.
|
|
1500
|
+
|
|
1501
|
+
Raises:
|
|
1502
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs.
|
|
1503
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1504
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1505
|
+
policy.
|
|
1506
|
+
"""
|
|
1507
|
+
context = config_module.snapshot(self._config)
|
|
1508
|
+
if context.has_one_use_auth_token:
|
|
1509
|
+
require_secure_rpc(context, include_auth=True)
|
|
1510
|
+
async with self._driver_lease(context) as driver:
|
|
1511
|
+
await self._ensure_readiness(context, driver)
|
|
1512
|
+
if context.has_one_use_auth_token:
|
|
1513
|
+
await self._get_session_token_for(context, driver)
|
|
1514
|
+
|
|
1515
|
+
async def create(
|
|
1516
|
+
self,
|
|
1517
|
+
entity_type: str,
|
|
1518
|
+
data: dict[str, Any],
|
|
1519
|
+
return_fields: list[str] | None = None,
|
|
1520
|
+
) -> dict[str, object]:
|
|
1521
|
+
"""Create an entity with the supplied field values.
|
|
1522
|
+
|
|
1523
|
+
Args:
|
|
1524
|
+
entity_type: Entity type to create.
|
|
1525
|
+
data: Field values for the new entity.
|
|
1526
|
+
return_fields: Fields to return in addition to the entity identity.
|
|
1527
|
+
|
|
1528
|
+
Returns:
|
|
1529
|
+
The created entity data returned by the server.
|
|
1530
|
+
|
|
1531
|
+
Raises:
|
|
1532
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1533
|
+
or if `data`, its field names, `return_fields`, or the server
|
|
1534
|
+
result is invalid.
|
|
1535
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1536
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1537
|
+
policy.
|
|
1538
|
+
"""
|
|
1539
|
+
params = _crud.build_create_params(entity_type, data, return_fields)
|
|
1540
|
+
return _crud.require_dictionary_result(await self._call('create', params), 'create')
|
|
1541
|
+
|
|
1542
|
+
async def delete(self, entity_type: str, entity_id: int) -> bool:
|
|
1543
|
+
"""Retire an entity.
|
|
1544
|
+
|
|
1545
|
+
Args:
|
|
1546
|
+
entity_type: Entity type to retire.
|
|
1547
|
+
entity_id: Numeric entity identifier.
|
|
1548
|
+
|
|
1549
|
+
Returns:
|
|
1550
|
+
Whether the server retired the entity.
|
|
1551
|
+
|
|
1552
|
+
Raises:
|
|
1553
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1554
|
+
or if the server result is not a boolean.
|
|
1555
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1556
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1557
|
+
policy.
|
|
1558
|
+
"""
|
|
1559
|
+
params = _crud.build_entity_action_params(entity_type, entity_id)
|
|
1560
|
+
return _crud.require_boolean_result(await self._call('delete', params), 'delete')
|
|
1561
|
+
|
|
1562
|
+
@overload
|
|
1563
|
+
async def download_attachment(
|
|
1564
|
+
self,
|
|
1565
|
+
attachment: int | Mapping[str, object] | None,
|
|
1566
|
+
file_path: str | None = None,
|
|
1567
|
+
attachment_id: int | None = None,
|
|
1568
|
+
) -> bytes | str | None: ...
|
|
1569
|
+
|
|
1570
|
+
@overload
|
|
1571
|
+
async def download_attachment(
|
|
1572
|
+
self,
|
|
1573
|
+
*,
|
|
1574
|
+
file_path: str | None = None,
|
|
1575
|
+
attachment_id: int,
|
|
1576
|
+
) -> bytes | str | None: ...
|
|
1577
|
+
|
|
1578
|
+
async def download_attachment(
|
|
1579
|
+
self,
|
|
1580
|
+
attachment: int | Mapping[str, object] | None = False,
|
|
1581
|
+
file_path: str | None = None,
|
|
1582
|
+
attachment_id: int | None = None,
|
|
1583
|
+
) -> bytes | str | None:
|
|
1584
|
+
"""Download an attachment to memory or an atomic destination path.
|
|
1585
|
+
|
|
1586
|
+
Args:
|
|
1587
|
+
attachment: Attachment ID, Attachment dictionary, URL dictionary,
|
|
1588
|
+
or `None`.
|
|
1589
|
+
file_path: Optional destination path.
|
|
1590
|
+
attachment_id: Legacy attachment ID used when `attachment` is
|
|
1591
|
+
omitted.
|
|
1592
|
+
|
|
1593
|
+
Returns:
|
|
1594
|
+
Downloaded bytes, the supplied destination path, or `None`.
|
|
1595
|
+
|
|
1596
|
+
Raises:
|
|
1597
|
+
TypeError: If no attachment is given or the reference is a string.
|
|
1598
|
+
ValueError: If an entity dictionary references a different type.
|
|
1599
|
+
ShotgunError: If another attachment reference is unsupported or a
|
|
1600
|
+
setup RPC reports a fault; the fault keeps its specific type.
|
|
1601
|
+
ShotgunFileDownloadError: If the resolved URL is invalid or setup,
|
|
1602
|
+
transfer, or destination I/O fails.
|
|
1603
|
+
ValueError: If server settings are incomplete or authenticated HTTP
|
|
1604
|
+
violates the configured origin security policy.
|
|
1605
|
+
"""
|
|
1606
|
+
if attachment is False:
|
|
1607
|
+
if attachment_id is None:
|
|
1608
|
+
raise TypeError('attachment or attachment_id is required')
|
|
1609
|
+
attachment = attachment_id
|
|
1610
|
+
context = config_module.snapshot(self._config)
|
|
1611
|
+
if context.canonical_origin is None or context.site_url is None:
|
|
1612
|
+
raise ValueError('server parameters must be configured before download I/O')
|
|
1613
|
+
url = _files.resolve_attachment_url(attachment, context.site_url)
|
|
1614
|
+
if url is None:
|
|
1615
|
+
return None
|
|
1616
|
+
_files.validate_download_url(url)
|
|
1617
|
+
origin = _files.canonical_origin(context.canonical_origin)
|
|
1618
|
+
use_session_cookie = _files.needs_session_cookie(url, origin)
|
|
1619
|
+
if use_session_cookie:
|
|
1620
|
+
require_secure_rpc(context, include_auth=True)
|
|
1621
|
+
operation = _protocol.classify_endpoint('download')
|
|
1622
|
+
|
|
1623
|
+
try:
|
|
1624
|
+
async with self._driver_lease(context) as driver:
|
|
1625
|
+
headers = {'user-agent': context.user_agent}
|
|
1626
|
+
if use_session_cookie:
|
|
1627
|
+
if context.session_token is None:
|
|
1628
|
+
token = await self._get_session_token_for(context, driver)
|
|
1629
|
+
else:
|
|
1630
|
+
await self._ensure_readiness(context, driver)
|
|
1631
|
+
token = context.session_token
|
|
1632
|
+
headers['cookie'] = f'_session_id={token}'
|
|
1633
|
+
exchange = _AsyncDownloadExchange(
|
|
1634
|
+
context=context,
|
|
1635
|
+
file_path=file_path,
|
|
1636
|
+
headers=headers,
|
|
1637
|
+
url=url,
|
|
1638
|
+
)
|
|
1639
|
+
return await exchange._execute(driver, operation)
|
|
1640
|
+
except _errors.ShotgunFileDownloadError:
|
|
1641
|
+
raise
|
|
1642
|
+
except (TransportError, _errors.ProtocolError, OSError):
|
|
1643
|
+
raise _errors.ShotgunFileDownloadError('attachment download failed') from None
|
|
1644
|
+
|
|
1645
|
+
@overload
|
|
1646
|
+
async def export_page(
|
|
1647
|
+
self,
|
|
1648
|
+
page_id: int,
|
|
1649
|
+
page_format: str,
|
|
1650
|
+
/,
|
|
1651
|
+
layout_name: str | None = None,
|
|
1652
|
+
) -> str: ...
|
|
1653
|
+
|
|
1654
|
+
@overload
|
|
1655
|
+
async def export_page(
|
|
1656
|
+
self,
|
|
1657
|
+
page_id: int,
|
|
1658
|
+
**kwargs: Unpack[_methods.ExportPageKeywordArguments],
|
|
1659
|
+
) -> str: ...
|
|
1660
|
+
|
|
1661
|
+
@_methods.expose_export_page_signature
|
|
1662
|
+
async def export_page(self, *args: object, **kwargs: object) -> str:
|
|
1663
|
+
"""Export a page using the documented arguments.
|
|
1664
|
+
|
|
1665
|
+
Args:
|
|
1666
|
+
*args: Positional values from the documented `export_page`
|
|
1667
|
+
signature.
|
|
1668
|
+
**kwargs: Keyword values from the documented `export_page`
|
|
1669
|
+
signature.
|
|
1670
|
+
|
|
1671
|
+
Returns:
|
|
1672
|
+
Exported page text returned by the server.
|
|
1673
|
+
|
|
1674
|
+
Raises:
|
|
1675
|
+
TypeError: If arguments do not match the documented signature.
|
|
1676
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1677
|
+
or if an argument or the server result has the wrong type.
|
|
1678
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1679
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1680
|
+
policy.
|
|
1681
|
+
"""
|
|
1682
|
+
options = _methods.bind_export_page(args, kwargs, 'AsyncShotgun')
|
|
1683
|
+
result = await self._call(
|
|
1684
|
+
'export_page',
|
|
1685
|
+
_methods.build_export_page_params(options),
|
|
1686
|
+
)
|
|
1687
|
+
return _methods.require_string_result(result, 'export_page')
|
|
1688
|
+
|
|
1689
|
+
@overload
|
|
1690
|
+
async def find(self, entity_type: Never) -> list[dict[str, object]]: ...
|
|
1691
|
+
|
|
1692
|
+
@overload
|
|
1693
|
+
async def find(
|
|
1694
|
+
self,
|
|
1695
|
+
entity_type: str,
|
|
1696
|
+
filters: list[Any] | tuple[Any, ...] | dict[str, Any],
|
|
1697
|
+
fields: list[str] | None = None,
|
|
1698
|
+
order: list[dict[str, str]] | None = None,
|
|
1699
|
+
filter_operator: str | None = None,
|
|
1700
|
+
limit: int = 0,
|
|
1701
|
+
retired_only: bool = False,
|
|
1702
|
+
page: int = 0,
|
|
1703
|
+
include_archived_projects: bool = True,
|
|
1704
|
+
additional_filter_presets: object = None,
|
|
1705
|
+
) -> list[dict[str, object]]: ...
|
|
1706
|
+
|
|
1707
|
+
@_crud.expose_find_signature
|
|
1708
|
+
async def find(self, *args: object, **kwargs: object) -> list[dict[str, object]]:
|
|
1709
|
+
"""Find entities matching the supplied filters.
|
|
1710
|
+
|
|
1711
|
+
Args:
|
|
1712
|
+
*args: Positional values from the documented `find` signature.
|
|
1713
|
+
**kwargs: Keyword values from the documented `find` signature.
|
|
1714
|
+
|
|
1715
|
+
Returns:
|
|
1716
|
+
Matching entities in server order.
|
|
1717
|
+
|
|
1718
|
+
Raises:
|
|
1719
|
+
TypeError: If arguments do not match the documented signature.
|
|
1720
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1721
|
+
or if filters, paging, ordering, or a server result is invalid.
|
|
1722
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1723
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1724
|
+
policy.
|
|
1725
|
+
"""
|
|
1726
|
+
return await self._find(_crud.bind_find(args, kwargs, 'AsyncShotgun'))
|
|
1727
|
+
|
|
1728
|
+
@overload
|
|
1729
|
+
async def find_one(self, entity_type: Never) -> dict[str, object] | None: ...
|
|
1730
|
+
|
|
1731
|
+
@overload
|
|
1732
|
+
async def find_one(
|
|
1733
|
+
self,
|
|
1734
|
+
entity_type: str,
|
|
1735
|
+
filters: list[Any] | tuple[Any, ...] | dict[str, Any],
|
|
1736
|
+
fields: list[str] | None = None,
|
|
1737
|
+
order: list[dict[str, str]] | None = None,
|
|
1738
|
+
filter_operator: str | None = None,
|
|
1739
|
+
retired_only: bool = False,
|
|
1740
|
+
include_archived_projects: bool = True,
|
|
1741
|
+
additional_filter_presets: object = None,
|
|
1742
|
+
) -> dict[str, object] | None: ...
|
|
1743
|
+
|
|
1744
|
+
@_crud.expose_find_one_signature
|
|
1745
|
+
async def find_one(self, *args: object, **kwargs: object) -> dict[str, object] | None:
|
|
1746
|
+
"""Return the first entity matching the supplied filters.
|
|
1747
|
+
|
|
1748
|
+
Args:
|
|
1749
|
+
*args: Positional values from the documented `find_one` signature.
|
|
1750
|
+
**kwargs: Keyword values from the documented `find_one` signature.
|
|
1751
|
+
|
|
1752
|
+
Returns:
|
|
1753
|
+
The first matching entity, or `None` when no entity matches.
|
|
1754
|
+
|
|
1755
|
+
Raises:
|
|
1756
|
+
TypeError: If arguments do not match the documented signature.
|
|
1757
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1758
|
+
or if filters, paging, ordering, or a server result is invalid.
|
|
1759
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1760
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1761
|
+
policy.
|
|
1762
|
+
"""
|
|
1763
|
+
options = _crud.bind_find_one(args, kwargs, 'AsyncShotgun')
|
|
1764
|
+
return _crud.first_result(await self._find(_crud.find_options_from_one(options)))
|
|
1765
|
+
|
|
1766
|
+
async def follow(
|
|
1767
|
+
self,
|
|
1768
|
+
user: dict[str, Any],
|
|
1769
|
+
entity: dict[str, Any],
|
|
1770
|
+
) -> dict[str, object]:
|
|
1771
|
+
"""Make a user follow an entity.
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
user: User entity that will follow the target.
|
|
1775
|
+
entity: Entity to follow.
|
|
1776
|
+
|
|
1777
|
+
Returns:
|
|
1778
|
+
Follow result returned by the server.
|
|
1779
|
+
|
|
1780
|
+
Raises:
|
|
1781
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1782
|
+
or if the server result has the wrong type.
|
|
1783
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1784
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1785
|
+
policy.
|
|
1786
|
+
"""
|
|
1787
|
+
result = await self._call('follow', _methods.build_follow_params(user, entity))
|
|
1788
|
+
return _crud.require_dictionary_result(result, 'follow')
|
|
1789
|
+
|
|
1790
|
+
async def followers(self, entity: dict[str, Any]) -> list[dict[str, object]]:
|
|
1791
|
+
"""List users following an entity.
|
|
1792
|
+
|
|
1793
|
+
Args:
|
|
1794
|
+
entity: Entity whose followers to list.
|
|
1795
|
+
|
|
1796
|
+
Returns:
|
|
1797
|
+
Following user entities returned by the server.
|
|
1798
|
+
|
|
1799
|
+
Raises:
|
|
1800
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1801
|
+
or if the server result has the wrong type.
|
|
1802
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1803
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1804
|
+
policy.
|
|
1805
|
+
"""
|
|
1806
|
+
result = await self._call('followers', _methods.build_followers_params(entity))
|
|
1807
|
+
return _methods.require_dictionary_list_result(result, 'followers')
|
|
1808
|
+
|
|
1809
|
+
async def following(
|
|
1810
|
+
self,
|
|
1811
|
+
user: dict[str, Any],
|
|
1812
|
+
project: dict[str, Any] | None = None,
|
|
1813
|
+
entity_type: str | None = None,
|
|
1814
|
+
) -> list[dict[str, object]]:
|
|
1815
|
+
"""List entities a user follows with optional restrictions.
|
|
1816
|
+
|
|
1817
|
+
Args:
|
|
1818
|
+
user: User entity whose followed entities to list.
|
|
1819
|
+
project: Optional project entity restriction.
|
|
1820
|
+
entity_type: Optional entity-type restriction.
|
|
1821
|
+
|
|
1822
|
+
Returns:
|
|
1823
|
+
Followed entities returned by the server.
|
|
1824
|
+
|
|
1825
|
+
Raises:
|
|
1826
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1827
|
+
or if the server result has the wrong type.
|
|
1828
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1829
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1830
|
+
policy.
|
|
1831
|
+
"""
|
|
1832
|
+
params = _methods.build_following_params(user, project, entity_type)
|
|
1833
|
+
result = await self._call('following', params)
|
|
1834
|
+
return _methods.require_dictionary_list_result(result, 'following')
|
|
1835
|
+
|
|
1836
|
+
async def get_attachment_download_url(
|
|
1837
|
+
self, attachment: int | Mapping[str, object] | None
|
|
1838
|
+
) -> str | None:
|
|
1839
|
+
"""Resolve an attachment reference to its download URL.
|
|
1840
|
+
|
|
1841
|
+
Args:
|
|
1842
|
+
attachment: Attachment ID, Attachment dictionary, URL dictionary,
|
|
1843
|
+
or `None`.
|
|
1844
|
+
|
|
1845
|
+
Returns:
|
|
1846
|
+
The supplied URL, a site attachment URL, or `None`.
|
|
1847
|
+
|
|
1848
|
+
Raises:
|
|
1849
|
+
TypeError: If the attachment reference is a string.
|
|
1850
|
+
ValueError: If an entity dictionary references a different type or
|
|
1851
|
+
server parameters have not been configured.
|
|
1852
|
+
ShotgunError: If the attachment reference is unsupported.
|
|
1853
|
+
"""
|
|
1854
|
+
if attachment is None:
|
|
1855
|
+
return None
|
|
1856
|
+
if isinstance(attachment, Mapping):
|
|
1857
|
+
mapping = cast('Mapping[object, object]', attachment)
|
|
1858
|
+
url = mapping.get('url')
|
|
1859
|
+
if isinstance(url, str):
|
|
1860
|
+
return url
|
|
1861
|
+
context = config_module.snapshot(self._config)
|
|
1862
|
+
if context.site_url is None:
|
|
1863
|
+
raise ValueError('server parameters must be configured before URL generation')
|
|
1864
|
+
return _files.resolve_attachment_url(cast('object', attachment), context.site_url)
|
|
1865
|
+
|
|
1866
|
+
async def get_auth_cookie_handler(self) -> urllib.request.HTTPCookieProcessor:
|
|
1867
|
+
"""Return a session-cookie handler bound to the captured origin.
|
|
1868
|
+
|
|
1869
|
+
Returns:
|
|
1870
|
+
Stdlib handler containing one exact-origin session cookie.
|
|
1871
|
+
|
|
1872
|
+
Raises:
|
|
1873
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1874
|
+
or if token exchange returns an invalid result.
|
|
1875
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1876
|
+
ValueError: If server parameters have not been configured or
|
|
1877
|
+
authenticated HTTP violates the configured origin policy.
|
|
1878
|
+
"""
|
|
1879
|
+
context = config_module.snapshot(self._config)
|
|
1880
|
+
if context.canonical_origin is None:
|
|
1881
|
+
raise ValueError('server parameters must be configured before cookie creation')
|
|
1882
|
+
origin = _files.canonical_origin(context.canonical_origin)
|
|
1883
|
+
if context.session_token is None:
|
|
1884
|
+
require_secure_rpc(context, include_auth=True)
|
|
1885
|
+
async with self._driver_lease(context) as driver:
|
|
1886
|
+
token = await self._get_session_token_for(context, driver)
|
|
1887
|
+
return _files.build_auth_cookie_handler(token, origin)
|
|
1888
|
+
|
|
1889
|
+
async def get_session_token(self) -> str:
|
|
1890
|
+
"""Return or obtain a session token for the current credentials.
|
|
1891
|
+
|
|
1892
|
+
Returns:
|
|
1893
|
+
The existing or newly exchanged session token.
|
|
1894
|
+
|
|
1895
|
+
Raises:
|
|
1896
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1897
|
+
or if the RPC result lacks the expected session ID.
|
|
1898
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1899
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1900
|
+
policy.
|
|
1901
|
+
"""
|
|
1902
|
+
context = config_module.snapshot(self._config)
|
|
1903
|
+
if context.session_token is not None:
|
|
1904
|
+
return context.session_token
|
|
1905
|
+
require_secure_rpc(context, include_auth=True)
|
|
1906
|
+
async with self._driver_lease(context) as driver:
|
|
1907
|
+
return await self._get_session_token_for(context, driver)
|
|
1908
|
+
|
|
1909
|
+
async def info(self) -> dict[str, object]:
|
|
1910
|
+
"""Fetch and publish unauthenticated server metadata.
|
|
1911
|
+
|
|
1912
|
+
Returns:
|
|
1913
|
+
Raw server metadata after version-floor validation.
|
|
1914
|
+
|
|
1915
|
+
Raises:
|
|
1916
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs.
|
|
1917
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1918
|
+
ValueError: If server parameters are missing or the configured
|
|
1919
|
+
origin lacks a valid host.
|
|
1920
|
+
"""
|
|
1921
|
+
result = await self._call('info', None, _InvocationOptions(include_auth=False))
|
|
1922
|
+
return cast('dict[str, object]', result)
|
|
1923
|
+
|
|
1924
|
+
async def nav_expand(
|
|
1925
|
+
self,
|
|
1926
|
+
path: str,
|
|
1927
|
+
seed_entity_field: str | None = None,
|
|
1928
|
+
entity_fields: dict[str, list[str]] | None = None,
|
|
1929
|
+
) -> dict[str, object]:
|
|
1930
|
+
"""Expand one level of the navigation hierarchy at `path`.
|
|
1931
|
+
|
|
1932
|
+
Args:
|
|
1933
|
+
path: Navigation path to expand.
|
|
1934
|
+
seed_entity_field: Optional field used to seed the path.
|
|
1935
|
+
entity_fields: Optional fields for linked entity types.
|
|
1936
|
+
|
|
1937
|
+
Returns:
|
|
1938
|
+
Expanded navigation data returned by the server.
|
|
1939
|
+
|
|
1940
|
+
Raises:
|
|
1941
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1942
|
+
or if the server result has the wrong type.
|
|
1943
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1944
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1945
|
+
policy.
|
|
1946
|
+
"""
|
|
1947
|
+
params = _methods.build_nav_expand_params(
|
|
1948
|
+
path,
|
|
1949
|
+
seed_entity_field,
|
|
1950
|
+
entity_fields,
|
|
1951
|
+
)
|
|
1952
|
+
return _crud.require_dictionary_result(
|
|
1953
|
+
await self._call('nav_expand', params),
|
|
1954
|
+
'nav_expand',
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
async def nav_search_entity(
|
|
1958
|
+
self,
|
|
1959
|
+
root_path: str,
|
|
1960
|
+
entity: dict[str, Any],
|
|
1961
|
+
seed_entity_field: str | None = None,
|
|
1962
|
+
) -> list[dict[str, object]]:
|
|
1963
|
+
"""Search the navigation hierarchy for an entity.
|
|
1964
|
+
|
|
1965
|
+
Args:
|
|
1966
|
+
root_path: Navigation path under which to search.
|
|
1967
|
+
entity: Entity to find.
|
|
1968
|
+
seed_entity_field: Optional field used to seed the path.
|
|
1969
|
+
|
|
1970
|
+
Returns:
|
|
1971
|
+
Matching navigation data returned by the server.
|
|
1972
|
+
|
|
1973
|
+
Raises:
|
|
1974
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
1975
|
+
or if the server result has the wrong type.
|
|
1976
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
1977
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
1978
|
+
policy.
|
|
1979
|
+
"""
|
|
1980
|
+
params = _methods.build_nav_search_entity_params(
|
|
1981
|
+
root_path,
|
|
1982
|
+
entity,
|
|
1983
|
+
seed_entity_field,
|
|
1984
|
+
)
|
|
1985
|
+
result = await self._call('nav_search_entity', params)
|
|
1986
|
+
return _methods.require_dictionary_list_result(result, 'nav_search_entity')
|
|
1987
|
+
|
|
1988
|
+
async def nav_search_string(
|
|
1989
|
+
self,
|
|
1990
|
+
root_path: str,
|
|
1991
|
+
search_string: str,
|
|
1992
|
+
seed_entity_field: str | None = None,
|
|
1993
|
+
) -> list[dict[str, object]]:
|
|
1994
|
+
"""Search the navigation hierarchy by string.
|
|
1995
|
+
|
|
1996
|
+
Args:
|
|
1997
|
+
root_path: Navigation path under which to search.
|
|
1998
|
+
search_string: String to match.
|
|
1999
|
+
seed_entity_field: Optional field used to seed the path.
|
|
2000
|
+
|
|
2001
|
+
Returns:
|
|
2002
|
+
Matching navigation data returned by the server.
|
|
2003
|
+
|
|
2004
|
+
Raises:
|
|
2005
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2006
|
+
or if the server result has the wrong type.
|
|
2007
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2008
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2009
|
+
policy.
|
|
2010
|
+
"""
|
|
2011
|
+
params = _methods.build_nav_search_string_params(
|
|
2012
|
+
root_path,
|
|
2013
|
+
search_string,
|
|
2014
|
+
seed_entity_field,
|
|
2015
|
+
)
|
|
2016
|
+
result = await self._call('nav_search_string', params)
|
|
2017
|
+
return _methods.require_dictionary_list_result(result, 'nav_search_string')
|
|
2018
|
+
|
|
2019
|
+
async def note_thread_read(
|
|
2020
|
+
self,
|
|
2021
|
+
note_id: int,
|
|
2022
|
+
entity_fields: dict[str, list[str]] | None = None,
|
|
2023
|
+
) -> list[dict[str, object]]:
|
|
2024
|
+
"""Read a note and its replies and attachments as a flat thread.
|
|
2025
|
+
|
|
2026
|
+
Args:
|
|
2027
|
+
note_id: Numeric identifier of the note.
|
|
2028
|
+
entity_fields: Optional fields for linked entity types.
|
|
2029
|
+
|
|
2030
|
+
Returns:
|
|
2031
|
+
Note thread records returned by the server.
|
|
2032
|
+
|
|
2033
|
+
Raises:
|
|
2034
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2035
|
+
or if the server result has the wrong type.
|
|
2036
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2037
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2038
|
+
policy.
|
|
2039
|
+
"""
|
|
2040
|
+
params = _methods.build_note_thread_read_params(note_id, entity_fields)
|
|
2041
|
+
result = await self._call('note_thread_read', params)
|
|
2042
|
+
return _methods.require_dictionary_list_result(result, 'note_thread_read')
|
|
2043
|
+
|
|
2044
|
+
async def preferences_read(self, prefs: list[str] | None = None) -> dict[str, object]:
|
|
2045
|
+
"""Read site preferences, optionally restricted by name.
|
|
2046
|
+
|
|
2047
|
+
Args:
|
|
2048
|
+
prefs: Preference names to read, or `None` for all defaults.
|
|
2049
|
+
|
|
2050
|
+
Returns:
|
|
2051
|
+
Preference values returned by the server.
|
|
2052
|
+
|
|
2053
|
+
Raises:
|
|
2054
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2055
|
+
or if the server result has the wrong type.
|
|
2056
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2057
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2058
|
+
policy.
|
|
2059
|
+
"""
|
|
2060
|
+
params = _methods.build_preferences_read_params(prefs)
|
|
2061
|
+
result = await self._call('preferences_read', params)
|
|
2062
|
+
return _crud.require_dictionary_result(result, 'preferences_read')
|
|
2063
|
+
|
|
2064
|
+
def reset_user_agent(self) -> None:
|
|
2065
|
+
"""Restore this client's shared default user-agent fragments."""
|
|
2066
|
+
self._config.reset_user_agent()
|
|
2067
|
+
|
|
2068
|
+
async def revive(self, entity_type: str, entity_id: int) -> bool:
|
|
2069
|
+
"""Restore a retired entity.
|
|
2070
|
+
|
|
2071
|
+
Args:
|
|
2072
|
+
entity_type: Entity type to restore.
|
|
2073
|
+
entity_id: Numeric entity identifier.
|
|
2074
|
+
|
|
2075
|
+
Returns:
|
|
2076
|
+
Whether the server restored the entity.
|
|
2077
|
+
|
|
2078
|
+
Raises:
|
|
2079
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2080
|
+
or if the server result is not a boolean.
|
|
2081
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2082
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2083
|
+
policy.
|
|
2084
|
+
"""
|
|
2085
|
+
params = _crud.build_entity_action_params(entity_type, entity_id)
|
|
2086
|
+
return _crud.require_boolean_result(await self._call('revive', params), 'revive')
|
|
2087
|
+
|
|
2088
|
+
async def schema_entity_read(
|
|
2089
|
+
self,
|
|
2090
|
+
project_entity: dict[str, Any] | None = None,
|
|
2091
|
+
) -> dict[str, object]:
|
|
2092
|
+
"""Return entity-type schema, optionally for one project.
|
|
2093
|
+
|
|
2094
|
+
Args:
|
|
2095
|
+
project_entity: Optional project entity restricting the schema.
|
|
2096
|
+
|
|
2097
|
+
Returns:
|
|
2098
|
+
Entity-type schema data returned by the server.
|
|
2099
|
+
|
|
2100
|
+
Raises:
|
|
2101
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2102
|
+
or if the server result is invalid.
|
|
2103
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2104
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2105
|
+
policy.
|
|
2106
|
+
"""
|
|
2107
|
+
warnings_codes = _methods.consume_compatibility_warnings(
|
|
2108
|
+
_methods.SCHEMA_ENTITY_READ_WARNINGS,
|
|
2109
|
+
self,
|
|
2110
|
+
)
|
|
2111
|
+
result = await self._call(
|
|
2112
|
+
'schema_entity_read',
|
|
2113
|
+
_methods.build_schema_entity_read_params(project_entity),
|
|
2114
|
+
_InvocationOptions(compatibility_warning_codes=warnings_codes),
|
|
2115
|
+
)
|
|
2116
|
+
return _crud.require_dictionary_result(result, 'schema_entity_read')
|
|
2117
|
+
|
|
2118
|
+
async def entity_types(self) -> dict[str, object]:
|
|
2119
|
+
"""Return entity schema through the deprecated alias.
|
|
2120
|
+
|
|
2121
|
+
Returns:
|
|
2122
|
+
Entity-type schema data returned by the server.
|
|
2123
|
+
|
|
2124
|
+
Raises:
|
|
2125
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2126
|
+
or if the server result is not a dictionary.
|
|
2127
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2128
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2129
|
+
policy.
|
|
2130
|
+
"""
|
|
2131
|
+
_methods.warn_entity_types_alias()
|
|
2132
|
+
with _methods.entity_types_alias(self):
|
|
2133
|
+
return await self.schema_entity_read()
|
|
2134
|
+
|
|
2135
|
+
async def schema_field_create(
|
|
2136
|
+
self,
|
|
2137
|
+
entity_type: str,
|
|
2138
|
+
data_type: str,
|
|
2139
|
+
display_name: str,
|
|
2140
|
+
properties: dict[str, Any] | None = None,
|
|
2141
|
+
) -> str:
|
|
2142
|
+
"""Create a field and return its internal name.
|
|
2143
|
+
|
|
2144
|
+
Args:
|
|
2145
|
+
entity_type: Entity type on which to create the field.
|
|
2146
|
+
data_type: Data type for the new field.
|
|
2147
|
+
display_name: Display name for the new field.
|
|
2148
|
+
properties: Optional additional field properties.
|
|
2149
|
+
|
|
2150
|
+
Returns:
|
|
2151
|
+
Internal name of the created field.
|
|
2152
|
+
|
|
2153
|
+
Raises:
|
|
2154
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2155
|
+
or if the server result is invalid.
|
|
2156
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2157
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2158
|
+
policy.
|
|
2159
|
+
"""
|
|
2160
|
+
params = _methods.build_schema_field_create_params(
|
|
2161
|
+
entity_type,
|
|
2162
|
+
data_type,
|
|
2163
|
+
display_name,
|
|
2164
|
+
properties,
|
|
2165
|
+
)
|
|
2166
|
+
result = await self._call('schema_field_create', params)
|
|
2167
|
+
return _methods.require_string_result(result, 'schema_field_create')
|
|
2168
|
+
|
|
2169
|
+
async def schema_field_delete(self, entity_type: str, field_name: str) -> bool:
|
|
2170
|
+
"""Delete a field.
|
|
2171
|
+
|
|
2172
|
+
Args:
|
|
2173
|
+
entity_type: Entity type containing the field.
|
|
2174
|
+
field_name: Internal field name to delete.
|
|
2175
|
+
|
|
2176
|
+
Returns:
|
|
2177
|
+
Whether the server deleted the field.
|
|
2178
|
+
|
|
2179
|
+
Raises:
|
|
2180
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2181
|
+
or if the server result is invalid.
|
|
2182
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2183
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2184
|
+
policy.
|
|
2185
|
+
"""
|
|
2186
|
+
params = _methods.build_schema_field_delete_params(entity_type, field_name)
|
|
2187
|
+
result = await self._call('schema_field_delete', params)
|
|
2188
|
+
return _crud.require_boolean_result(result, 'schema_field_delete')
|
|
2189
|
+
|
|
2190
|
+
async def schema_field_read(
|
|
2191
|
+
self,
|
|
2192
|
+
entity_type: str,
|
|
2193
|
+
field_name: str | None = None,
|
|
2194
|
+
project_entity: dict[str, Any] | None = None,
|
|
2195
|
+
) -> dict[str, object]:
|
|
2196
|
+
"""Return field schema, optionally for one field or project.
|
|
2197
|
+
|
|
2198
|
+
Args:
|
|
2199
|
+
entity_type: Entity type whose fields to return.
|
|
2200
|
+
field_name: Optional internal field name to return.
|
|
2201
|
+
project_entity: Optional project entity restricting the schema.
|
|
2202
|
+
|
|
2203
|
+
Returns:
|
|
2204
|
+
Field schema data returned by the server.
|
|
2205
|
+
|
|
2206
|
+
Raises:
|
|
2207
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2208
|
+
or if the server result is invalid.
|
|
2209
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2210
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2211
|
+
policy.
|
|
2212
|
+
"""
|
|
2213
|
+
warning_codes = _methods.consume_compatibility_warnings(
|
|
2214
|
+
_methods.SCHEMA_FIELD_READ_WARNINGS,
|
|
2215
|
+
self,
|
|
2216
|
+
)
|
|
2217
|
+
result = await self._call(
|
|
2218
|
+
'schema_field_read',
|
|
2219
|
+
_methods.build_schema_field_read_params(
|
|
2220
|
+
entity_type,
|
|
2221
|
+
field_name,
|
|
2222
|
+
project_entity,
|
|
2223
|
+
),
|
|
2224
|
+
_InvocationOptions(compatibility_warning_codes=warning_codes),
|
|
2225
|
+
)
|
|
2226
|
+
return _crud.require_dictionary_result(result, 'schema_field_read')
|
|
2227
|
+
|
|
2228
|
+
async def schema(self, entity_type: str) -> dict[str, object]:
|
|
2229
|
+
"""Return field schema through the deprecated alias.
|
|
2230
|
+
|
|
2231
|
+
Args:
|
|
2232
|
+
entity_type: Entity type whose fields to return.
|
|
2233
|
+
|
|
2234
|
+
Returns:
|
|
2235
|
+
Field schema data returned by the server.
|
|
2236
|
+
|
|
2237
|
+
Raises:
|
|
2238
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2239
|
+
or if the server result is invalid.
|
|
2240
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2241
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2242
|
+
policy.
|
|
2243
|
+
"""
|
|
2244
|
+
_methods.warn_schema_alias()
|
|
2245
|
+
with _methods.schema_alias(self):
|
|
2246
|
+
return await self.schema_field_read(entity_type)
|
|
2247
|
+
|
|
2248
|
+
async def schema_field_update(
|
|
2249
|
+
self,
|
|
2250
|
+
entity_type: str,
|
|
2251
|
+
field_name: str,
|
|
2252
|
+
properties: dict[str, Any],
|
|
2253
|
+
project_entity: dict[str, Any] | None = None,
|
|
2254
|
+
) -> bool:
|
|
2255
|
+
"""Update field properties.
|
|
2256
|
+
|
|
2257
|
+
Args:
|
|
2258
|
+
entity_type: Entity type containing the field.
|
|
2259
|
+
field_name: Internal field name to update.
|
|
2260
|
+
properties: Field properties to update.
|
|
2261
|
+
project_entity: Optional project entity restricting the update.
|
|
2262
|
+
|
|
2263
|
+
Returns:
|
|
2264
|
+
Whether the server updated the field.
|
|
2265
|
+
|
|
2266
|
+
Raises:
|
|
2267
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2268
|
+
or if the server result is invalid.
|
|
2269
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2270
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2271
|
+
policy.
|
|
2272
|
+
"""
|
|
2273
|
+
params = _methods.build_schema_field_update_params(
|
|
2274
|
+
entity_type,
|
|
2275
|
+
field_name,
|
|
2276
|
+
properties,
|
|
2277
|
+
project_entity,
|
|
2278
|
+
)
|
|
2279
|
+
result = await self._call('schema_field_update', params)
|
|
2280
|
+
return _crud.require_boolean_result(result, 'schema_field_update')
|
|
2281
|
+
|
|
2282
|
+
async def schema_read(
|
|
2283
|
+
self,
|
|
2284
|
+
project_entity: dict[str, Any] | None = None,
|
|
2285
|
+
) -> dict[str, object]:
|
|
2286
|
+
"""Return the full schema, optionally for one project.
|
|
2287
|
+
|
|
2288
|
+
Args:
|
|
2289
|
+
project_entity: Optional project entity restricting the schema.
|
|
2290
|
+
|
|
2291
|
+
Returns:
|
|
2292
|
+
Full schema data returned by the server.
|
|
2293
|
+
|
|
2294
|
+
Raises:
|
|
2295
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2296
|
+
or if the server result is invalid.
|
|
2297
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2298
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2299
|
+
policy.
|
|
2300
|
+
"""
|
|
2301
|
+
params = _methods.build_schema_entity_read_params(project_entity)
|
|
2302
|
+
result = await self._call('schema_read', params)
|
|
2303
|
+
return _crud.require_dictionary_result(result, 'schema_read')
|
|
2304
|
+
|
|
2305
|
+
@property
|
|
2306
|
+
def server_caps(self) -> ServerCapabilities:
|
|
2307
|
+
"""Return cached capabilities for the configured origin.
|
|
2308
|
+
|
|
2309
|
+
Returns:
|
|
2310
|
+
Server capability metadata at or above the supported floor.
|
|
2311
|
+
|
|
2312
|
+
Raises:
|
|
2313
|
+
ShotgunError: If no awaitable operation has populated the cache.
|
|
2314
|
+
"""
|
|
2315
|
+
capabilities = self._ready_caps.get(readiness_key(config_module.snapshot(self._config)))
|
|
2316
|
+
if capabilities is None:
|
|
2317
|
+
raise _errors.ShotgunError(
|
|
2318
|
+
'server_caps is not populated yet on AsyncShotgun: '
|
|
2319
|
+
'await connect() or any RPC first'
|
|
2320
|
+
)
|
|
2321
|
+
return capabilities
|
|
2322
|
+
|
|
2323
|
+
@property
|
|
2324
|
+
def server_info(self) -> dict[str, object]:
|
|
2325
|
+
"""Return cached raw metadata for the configured origin.
|
|
2326
|
+
|
|
2327
|
+
Returns:
|
|
2328
|
+
The metadata that populated `server_caps`.
|
|
2329
|
+
|
|
2330
|
+
Raises:
|
|
2331
|
+
ShotgunError: If no awaitable operation has populated the cache.
|
|
2332
|
+
"""
|
|
2333
|
+
return self.server_caps.server_info
|
|
2334
|
+
|
|
2335
|
+
def set_session_uuid(self, session_uuid: str) -> None:
|
|
2336
|
+
"""Set the browser session UUID for subsequent requests.
|
|
2337
|
+
|
|
2338
|
+
Args:
|
|
2339
|
+
session_uuid: Browser session UUID to publish.
|
|
2340
|
+
"""
|
|
2341
|
+
self._config.set_session_uuid(session_uuid)
|
|
2342
|
+
|
|
2343
|
+
async def share_thumbnail(
|
|
2344
|
+
self,
|
|
2345
|
+
entities: list[dict[str, Any]],
|
|
2346
|
+
thumbnail_path: str | None = None,
|
|
2347
|
+
source_entity: dict[str, Any] | None = None,
|
|
2348
|
+
filmstrip_thumbnail: bool = False,
|
|
2349
|
+
**kwargs: object,
|
|
2350
|
+
) -> int:
|
|
2351
|
+
"""Associate one thumbnail with one or more entities.
|
|
2352
|
+
|
|
2353
|
+
Supply exactly one of `thumbnail_path` and `source_entity`. A local
|
|
2354
|
+
thumbnail is uploaded to the first target before it is shared with the
|
|
2355
|
+
remaining targets.
|
|
2356
|
+
|
|
2357
|
+
Args:
|
|
2358
|
+
entities: Nonempty list of target entities with `type` and `id`.
|
|
2359
|
+
thumbnail_path: Optional local thumbnail path to upload once.
|
|
2360
|
+
source_entity: Optional entity whose existing thumbnail is shared.
|
|
2361
|
+
filmstrip_thumbnail: Whether to share a filmstrip thumbnail.
|
|
2362
|
+
**kwargs: Additional upload arguments for path-based sharing;
|
|
2363
|
+
`field_name` is not accepted. Ignored for an existing source.
|
|
2364
|
+
|
|
2365
|
+
Returns:
|
|
2366
|
+
Attachment ID representing the source thumbnail.
|
|
2367
|
+
|
|
2368
|
+
Raises:
|
|
2369
|
+
TypeError: If forwarded arguments do not match the upload signature
|
|
2370
|
+
or include `field_name`.
|
|
2371
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2372
|
+
or if the source selection, entities, or transport fails.
|
|
2373
|
+
ShotgunThumbnailNotReady: If an uploaded source stays transient.
|
|
2374
|
+
ProtocolError: If an upload endpoint returns an HTTP error.
|
|
2375
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2376
|
+
policy.
|
|
2377
|
+
"""
|
|
2378
|
+
share = _files.prepare_thumbnail_share(
|
|
2379
|
+
entities,
|
|
2380
|
+
thumbnail_path,
|
|
2381
|
+
source_entity,
|
|
2382
|
+
filmstrip_thumbnail,
|
|
2383
|
+
)
|
|
2384
|
+
if share.thumbnail_path is not None and 'field_name' in kwargs:
|
|
2385
|
+
raise TypeError("AsyncShotgun.upload() got multiple values for argument 'field_name'")
|
|
2386
|
+
prepared: _files.PreparedUpload | None = None
|
|
2387
|
+
if share.thumbnail_path is not None:
|
|
2388
|
+
arguments = _bind_upload(
|
|
2389
|
+
(
|
|
2390
|
+
share.source_entity_type,
|
|
2391
|
+
share.source_entity_id,
|
|
2392
|
+
share.thumbnail_path,
|
|
2393
|
+
),
|
|
2394
|
+
{
|
|
2395
|
+
**kwargs,
|
|
2396
|
+
'field_name': (
|
|
2397
|
+
'filmstrip_thumb_image' if filmstrip_thumbnail else 'thumb_image'
|
|
2398
|
+
),
|
|
2399
|
+
},
|
|
2400
|
+
)
|
|
2401
|
+
prepared = await asyncio.to_thread(
|
|
2402
|
+
_files.prepare_upload,
|
|
2403
|
+
_files.UploadOptions(
|
|
2404
|
+
arguments=arguments,
|
|
2405
|
+
upload_type='Thumbnail',
|
|
2406
|
+
filmstrip=filmstrip_thumbnail,
|
|
2407
|
+
),
|
|
2408
|
+
)
|
|
2409
|
+
context = config_module.snapshot(self._config)
|
|
2410
|
+
require_secure_rpc(context, include_auth=True)
|
|
2411
|
+
async with self._driver_lease(context) as driver:
|
|
2412
|
+
await self._ensure_readiness(context, driver)
|
|
2413
|
+
operation_context = await self._authenticated_context(context, driver)
|
|
2414
|
+
if prepared is not None:
|
|
2415
|
+
attachment_id = await self._upload_thumbnail_for(
|
|
2416
|
+
driver,
|
|
2417
|
+
operation_context,
|
|
2418
|
+
prepared,
|
|
2419
|
+
)
|
|
2420
|
+
if len(share.entities) == 1:
|
|
2421
|
+
return attachment_id
|
|
2422
|
+
field_name: Literal['filmstrip_image', 'image'] = (
|
|
2423
|
+
'filmstrip_image' if filmstrip_thumbnail else 'image'
|
|
2424
|
+
)
|
|
2425
|
+
ready = await driver.poll(
|
|
2426
|
+
lambda: self._thumbnail_source_ready(
|
|
2427
|
+
driver,
|
|
2428
|
+
operation_context,
|
|
2429
|
+
share.source_entity_type,
|
|
2430
|
+
share.source_entity_id,
|
|
2431
|
+
field_name,
|
|
2432
|
+
),
|
|
2433
|
+
attempts=25,
|
|
2434
|
+
interval=operation_context.rpc_attempt_interval / 1000.0,
|
|
2435
|
+
)
|
|
2436
|
+
if not ready:
|
|
2437
|
+
raise _errors.ShotgunThumbnailNotReady('thumbnail source did not become ready')
|
|
2438
|
+
return await self._share_thumbnail_source(
|
|
2439
|
+
driver,
|
|
2440
|
+
operation_context,
|
|
2441
|
+
share.entities[1:],
|
|
2442
|
+
share.source_entity,
|
|
2443
|
+
filmstrip_thumbnail,
|
|
2444
|
+
)
|
|
2445
|
+
return await self._share_thumbnail_source(
|
|
2446
|
+
driver,
|
|
2447
|
+
operation_context,
|
|
2448
|
+
share.entities,
|
|
2449
|
+
share.source_entity,
|
|
2450
|
+
filmstrip_thumbnail,
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
@overload
|
|
2454
|
+
async def summarize(self, entity_type: Never) -> dict[str, object]: ...
|
|
2455
|
+
|
|
2456
|
+
@overload
|
|
2457
|
+
async def summarize(
|
|
2458
|
+
self,
|
|
2459
|
+
entity_type: str,
|
|
2460
|
+
filters: list[Any] | tuple[Any, ...] | dict[str, Any],
|
|
2461
|
+
summary_fields: list[dict[str, Any]],
|
|
2462
|
+
filter_operator: str | None = None,
|
|
2463
|
+
grouping: list[dict[str, Any]] | None = None,
|
|
2464
|
+
include_archived_projects: bool = True,
|
|
2465
|
+
) -> dict[str, object]: ...
|
|
2466
|
+
|
|
2467
|
+
@_methods.expose_summarize_signature
|
|
2468
|
+
async def summarize(self, *args: object, **kwargs: object) -> dict[str, object]:
|
|
2469
|
+
"""Summarize field data returned by a query.
|
|
2470
|
+
|
|
2471
|
+
Args:
|
|
2472
|
+
*args: Positional values from the documented `summarize` signature.
|
|
2473
|
+
**kwargs: Keyword values from the documented `summarize` signature.
|
|
2474
|
+
|
|
2475
|
+
Returns:
|
|
2476
|
+
Dict with `summaries` and optional `groups`.
|
|
2477
|
+
|
|
2478
|
+
Raises:
|
|
2479
|
+
TypeError: If arguments do not match the documented signature.
|
|
2480
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2481
|
+
or if arguments or the server result are invalid.
|
|
2482
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2483
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2484
|
+
policy.
|
|
2485
|
+
"""
|
|
2486
|
+
options = _methods.bind_summarize(args, kwargs, 'AsyncShotgun')
|
|
2487
|
+
params = _methods.build_summarize_params(options)
|
|
2488
|
+
result = await self._call('summarize', params)
|
|
2489
|
+
return _crud.require_dictionary_result(result, 'summarize')
|
|
2490
|
+
|
|
2491
|
+
async def text_search(
|
|
2492
|
+
self,
|
|
2493
|
+
text: str,
|
|
2494
|
+
entity_types: dict[str, Any],
|
|
2495
|
+
project_ids: list[int] | None = None,
|
|
2496
|
+
limit: int | None = None,
|
|
2497
|
+
) -> dict[str, object]:
|
|
2498
|
+
"""Search entity display names matching `text`.
|
|
2499
|
+
|
|
2500
|
+
Args:
|
|
2501
|
+
text: Text to match.
|
|
2502
|
+
entity_types: Mapping of entity type to filters.
|
|
2503
|
+
project_ids: Optional project identifier allowlist.
|
|
2504
|
+
limit: Maximum results to return.
|
|
2505
|
+
|
|
2506
|
+
Returns:
|
|
2507
|
+
Match data returned by the server.
|
|
2508
|
+
|
|
2509
|
+
Raises:
|
|
2510
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2511
|
+
or if arguments or the server result are invalid.
|
|
2512
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2513
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2514
|
+
policy.
|
|
2515
|
+
"""
|
|
2516
|
+
params = _methods.build_text_search_params(
|
|
2517
|
+
text,
|
|
2518
|
+
entity_types,
|
|
2519
|
+
project_ids,
|
|
2520
|
+
limit,
|
|
2521
|
+
)
|
|
2522
|
+
result = await self._call(
|
|
2523
|
+
'query_display_name_cache',
|
|
2524
|
+
params,
|
|
2525
|
+
_InvocationOptions(logical_method_name='text_search'),
|
|
2526
|
+
)
|
|
2527
|
+
return _crud.require_dictionary_result(result, 'text_search')
|
|
2528
|
+
|
|
2529
|
+
async def unfollow(
|
|
2530
|
+
self,
|
|
2531
|
+
user: dict[str, Any],
|
|
2532
|
+
entity: dict[str, Any],
|
|
2533
|
+
) -> dict[str, object]:
|
|
2534
|
+
"""Make a user stop following an entity.
|
|
2535
|
+
|
|
2536
|
+
Args:
|
|
2537
|
+
user: User entity that will stop following the target.
|
|
2538
|
+
entity: Entity to stop following.
|
|
2539
|
+
|
|
2540
|
+
Returns:
|
|
2541
|
+
Unfollow result returned by the server.
|
|
2542
|
+
|
|
2543
|
+
Raises:
|
|
2544
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2545
|
+
or if an argument or the server result is invalid.
|
|
2546
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2547
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2548
|
+
policy.
|
|
2549
|
+
"""
|
|
2550
|
+
result = await self._call('unfollow', _methods.build_follow_params(user, entity))
|
|
2551
|
+
return _crud.require_dictionary_result(result, 'unfollow')
|
|
2552
|
+
|
|
2553
|
+
async def update(
|
|
2554
|
+
self,
|
|
2555
|
+
entity_type: str,
|
|
2556
|
+
entity_id: int,
|
|
2557
|
+
data: dict[str, Any],
|
|
2558
|
+
multi_entity_update_modes: dict[str, str] | None = None,
|
|
2559
|
+
) -> dict[str, object]:
|
|
2560
|
+
"""Update fields on an existing entity.
|
|
2561
|
+
|
|
2562
|
+
Args:
|
|
2563
|
+
entity_type: Entity type to update.
|
|
2564
|
+
entity_id: Numeric entity identifier.
|
|
2565
|
+
data: Replacement field values.
|
|
2566
|
+
multi_entity_update_modes: Per-field multi-entity update modes.
|
|
2567
|
+
|
|
2568
|
+
Returns:
|
|
2569
|
+
The updated entity data returned by the server.
|
|
2570
|
+
|
|
2571
|
+
Raises:
|
|
2572
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2573
|
+
or if `data`, its field names, `multi_entity_update_modes`, or
|
|
2574
|
+
the server result is invalid.
|
|
2575
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2576
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2577
|
+
policy.
|
|
2578
|
+
"""
|
|
2579
|
+
params = _crud.build_update_params(
|
|
2580
|
+
entity_type,
|
|
2581
|
+
entity_id,
|
|
2582
|
+
data,
|
|
2583
|
+
multi_entity_update_modes,
|
|
2584
|
+
)
|
|
2585
|
+
return _crud.require_dictionary_result(await self._call('update', params), 'update')
|
|
2586
|
+
|
|
2587
|
+
async def update_project_last_accessed(
|
|
2588
|
+
self,
|
|
2589
|
+
project: dict[str, Any],
|
|
2590
|
+
user: dict[str, Any] | None = None,
|
|
2591
|
+
) -> None:
|
|
2592
|
+
"""Record that a project was accessed by the current or given user.
|
|
2593
|
+
|
|
2594
|
+
Args:
|
|
2595
|
+
project: Project entity whose access time to update.
|
|
2596
|
+
user: Optional user entity to attribute the access to.
|
|
2597
|
+
|
|
2598
|
+
Raises:
|
|
2599
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs.
|
|
2600
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2601
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2602
|
+
policy.
|
|
2603
|
+
"""
|
|
2604
|
+
await self._call(
|
|
2605
|
+
'update_project_last_accessed_by_current_user',
|
|
2606
|
+
_methods.build_update_project_last_accessed_params(project, user),
|
|
2607
|
+
_InvocationOptions(logical_method_name='update_project_last_accessed'),
|
|
2608
|
+
)
|
|
2609
|
+
|
|
2610
|
+
@overload
|
|
2611
|
+
async def upload(self, entity_type: Never) -> int: ...
|
|
2612
|
+
|
|
2613
|
+
@overload
|
|
2614
|
+
async def upload(
|
|
2615
|
+
self,
|
|
2616
|
+
entity_type: str,
|
|
2617
|
+
entity_id: int,
|
|
2618
|
+
path: str | bytes,
|
|
2619
|
+
field_name: str | None = None,
|
|
2620
|
+
display_name: str | None = None,
|
|
2621
|
+
tag_list: str | None = None,
|
|
2622
|
+
created_at: _dt_module.datetime | None = None,
|
|
2623
|
+
) -> int: ...
|
|
2624
|
+
|
|
2625
|
+
@_expose_upload_signature
|
|
2626
|
+
async def upload(self, *args: object, **kwargs: object) -> int:
|
|
2627
|
+
"""Upload a file or thumbnail and return its Attachment ID.
|
|
2628
|
+
|
|
2629
|
+
A successful HTTP response from the legacy form-upload route still
|
|
2630
|
+
raises `ShotgunError`; no Attachment ID is established for that route.
|
|
2631
|
+
The form request has already been sent when this error is raised.
|
|
2632
|
+
|
|
2633
|
+
Args:
|
|
2634
|
+
*args: Positional arguments from the documented upload signature.
|
|
2635
|
+
**kwargs: Keyword arguments from the documented upload signature.
|
|
2636
|
+
|
|
2637
|
+
Returns:
|
|
2638
|
+
Created Attachment ID.
|
|
2639
|
+
|
|
2640
|
+
Raises:
|
|
2641
|
+
TypeError: If arguments do not match the documented signature.
|
|
2642
|
+
ShotgunError: If the file or an upload response is invalid, an RPC
|
|
2643
|
+
fault or terminal transport failure occurs, or the legacy
|
|
2644
|
+
form-upload route succeeds.
|
|
2645
|
+
ProtocolError: If an upload endpoint returns an HTTP error.
|
|
2646
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2647
|
+
policy.
|
|
2648
|
+
"""
|
|
2649
|
+
arguments = _bind_upload(args, kwargs)
|
|
2650
|
+
thumbnail_fields = {
|
|
2651
|
+
'filmstrip_image',
|
|
2652
|
+
'filmstrip_thumb_image',
|
|
2653
|
+
'image',
|
|
2654
|
+
'thumb_image',
|
|
2655
|
+
}
|
|
2656
|
+
upload_type: Literal['Attachment', 'Thumbnail'] = (
|
|
2657
|
+
'Thumbnail' if arguments.field_name in thumbnail_fields else 'Attachment'
|
|
2658
|
+
)
|
|
2659
|
+
return await self._upload(
|
|
2660
|
+
_files.UploadOptions(
|
|
2661
|
+
arguments=arguments,
|
|
2662
|
+
upload_type=upload_type,
|
|
2663
|
+
filmstrip=arguments.field_name in {'filmstrip_image', 'filmstrip_thumb_image'},
|
|
2664
|
+
)
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
async def upload_filmstrip_thumbnail(
|
|
2668
|
+
self,
|
|
2669
|
+
entity_type: str,
|
|
2670
|
+
entity_id: int,
|
|
2671
|
+
path: str,
|
|
2672
|
+
**kwargs: Unpack[_files.ThumbnailUploadKeywordArguments],
|
|
2673
|
+
) -> int:
|
|
2674
|
+
"""Upload a filmstrip thumbnail through `upload`.
|
|
2675
|
+
|
|
2676
|
+
Args:
|
|
2677
|
+
entity_type: Entity type receiving the thumbnail.
|
|
2678
|
+
entity_id: Numeric identifier of the receiving entity.
|
|
2679
|
+
path: Existing non-empty image path.
|
|
2680
|
+
**kwargs: Additional arguments forwarded to `upload`.
|
|
2681
|
+
|
|
2682
|
+
Returns:
|
|
2683
|
+
Created Attachment ID.
|
|
2684
|
+
|
|
2685
|
+
Raises:
|
|
2686
|
+
TypeError: If forwarded arguments do not match `upload`.
|
|
2687
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2688
|
+
or if the file, upload response, or transport fails.
|
|
2689
|
+
ProtocolError: If an upload endpoint returns an HTTP error.
|
|
2690
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2691
|
+
policy.
|
|
2692
|
+
"""
|
|
2693
|
+
return await self.upload(
|
|
2694
|
+
entity_type,
|
|
2695
|
+
entity_id,
|
|
2696
|
+
path,
|
|
2697
|
+
field_name='filmstrip_thumb_image',
|
|
2698
|
+
**kwargs,
|
|
2699
|
+
)
|
|
2700
|
+
|
|
2701
|
+
async def upload_thumbnail(
|
|
2702
|
+
self,
|
|
2703
|
+
entity_type: str,
|
|
2704
|
+
entity_id: int,
|
|
2705
|
+
path: str,
|
|
2706
|
+
**kwargs: Unpack[_files.ThumbnailUploadKeywordArguments],
|
|
2707
|
+
) -> int:
|
|
2708
|
+
"""Upload a static thumbnail through `upload`.
|
|
2709
|
+
|
|
2710
|
+
Args:
|
|
2711
|
+
entity_type: Entity type receiving the thumbnail.
|
|
2712
|
+
entity_id: Numeric identifier of the receiving entity.
|
|
2713
|
+
path: Existing non-empty image path.
|
|
2714
|
+
**kwargs: Additional arguments forwarded to `upload`.
|
|
2715
|
+
|
|
2716
|
+
Returns:
|
|
2717
|
+
Created Attachment ID.
|
|
2718
|
+
|
|
2719
|
+
Raises:
|
|
2720
|
+
TypeError: If forwarded arguments do not match `upload`.
|
|
2721
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2722
|
+
or if the file, upload response, or transport fails.
|
|
2723
|
+
ProtocolError: If an upload endpoint returns an HTTP error.
|
|
2724
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2725
|
+
policy.
|
|
2726
|
+
"""
|
|
2727
|
+
return await self.upload(
|
|
2728
|
+
entity_type,
|
|
2729
|
+
entity_id,
|
|
2730
|
+
path,
|
|
2731
|
+
field_name='thumb_image',
|
|
2732
|
+
**kwargs,
|
|
2733
|
+
)
|
|
2734
|
+
|
|
2735
|
+
async def user_subscriptions_create(self, users: list[dict[str, Any]]) -> bool:
|
|
2736
|
+
"""Assign subscriptions to users.
|
|
2737
|
+
|
|
2738
|
+
Args:
|
|
2739
|
+
users: Subscription dictionaries containing `user_id` keys.
|
|
2740
|
+
|
|
2741
|
+
Returns:
|
|
2742
|
+
Whether the server created the subscriptions.
|
|
2743
|
+
|
|
2744
|
+
Raises:
|
|
2745
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2746
|
+
or if arguments or the server result are invalid.
|
|
2747
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2748
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2749
|
+
policy.
|
|
2750
|
+
"""
|
|
2751
|
+
params = _methods.build_user_subscriptions_create_params(users)
|
|
2752
|
+
result = await self._call('user_subscriptions_create', params)
|
|
2753
|
+
return _crud.require_boolean_result(result, 'user_subscriptions_create')
|
|
2754
|
+
|
|
2755
|
+
async def user_subscriptions_read(self) -> list[dict[str, object]]:
|
|
2756
|
+
"""List the current user's subscriptions.
|
|
2757
|
+
|
|
2758
|
+
Returns:
|
|
2759
|
+
User subscription dictionaries returned by the server.
|
|
2760
|
+
|
|
2761
|
+
Raises:
|
|
2762
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2763
|
+
or if the server result is invalid.
|
|
2764
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2765
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2766
|
+
policy.
|
|
2767
|
+
"""
|
|
2768
|
+
result = await self._call('user_subscriptions_read', None)
|
|
2769
|
+
return _methods.require_dictionary_list_result(result, 'user_subscriptions_read')
|
|
2770
|
+
|
|
2771
|
+
async def work_schedule_read(
|
|
2772
|
+
self,
|
|
2773
|
+
start_date: _dt_module.date | str,
|
|
2774
|
+
end_date: _dt_module.date | str,
|
|
2775
|
+
project: dict[str, Any] | None = None,
|
|
2776
|
+
user: dict[str, Any] | None = None,
|
|
2777
|
+
) -> dict[str, object]:
|
|
2778
|
+
"""Read working-day rules between two dates.
|
|
2779
|
+
|
|
2780
|
+
Args:
|
|
2781
|
+
start_date: Inclusive date or exact `YYYY-MM-DD` string.
|
|
2782
|
+
end_date: Inclusive date or exact `YYYY-MM-DD` string.
|
|
2783
|
+
project: Optional project entity restriction.
|
|
2784
|
+
user: Optional user entity restriction.
|
|
2785
|
+
|
|
2786
|
+
Returns:
|
|
2787
|
+
Work schedule data returned by the server.
|
|
2788
|
+
|
|
2789
|
+
Raises:
|
|
2790
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2791
|
+
or if dates, restrictions, or the server result are invalid.
|
|
2792
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2793
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2794
|
+
policy.
|
|
2795
|
+
"""
|
|
2796
|
+
params = _methods.build_work_schedule_read_params(
|
|
2797
|
+
start_date,
|
|
2798
|
+
end_date,
|
|
2799
|
+
project,
|
|
2800
|
+
user,
|
|
2801
|
+
)
|
|
2802
|
+
result = await self._call('work_schedule_read', params)
|
|
2803
|
+
return _crud.require_dictionary_result(result, 'work_schedule_read')
|
|
2804
|
+
|
|
2805
|
+
@overload
|
|
2806
|
+
async def work_schedule_update(self, date: Never) -> dict[str, object]: ...
|
|
2807
|
+
|
|
2808
|
+
@overload
|
|
2809
|
+
async def work_schedule_update(
|
|
2810
|
+
self,
|
|
2811
|
+
date: _dt_module.date | str,
|
|
2812
|
+
working: bool,
|
|
2813
|
+
description: str | None = None,
|
|
2814
|
+
project: dict[str, Any] | None = None,
|
|
2815
|
+
user: dict[str, Any] | None = None,
|
|
2816
|
+
recalculate_field: str | None = None,
|
|
2817
|
+
) -> dict[str, object]: ...
|
|
2818
|
+
|
|
2819
|
+
@_methods.expose_work_schedule_update_signature
|
|
2820
|
+
async def work_schedule_update(
|
|
2821
|
+
self,
|
|
2822
|
+
*args: object,
|
|
2823
|
+
**kwargs: object,
|
|
2824
|
+
) -> dict[str, object]:
|
|
2825
|
+
"""Update a work schedule using the documented arguments.
|
|
2826
|
+
|
|
2827
|
+
Args:
|
|
2828
|
+
*args: Positional values from the documented public signature.
|
|
2829
|
+
**kwargs: Keyword values from the documented public signature.
|
|
2830
|
+
|
|
2831
|
+
Returns:
|
|
2832
|
+
Updated work schedule data returned by the server.
|
|
2833
|
+
|
|
2834
|
+
Raises:
|
|
2835
|
+
TypeError: If arguments do not match the documented signature.
|
|
2836
|
+
ShotgunError: If an RPC fault or terminal transport failure occurs,
|
|
2837
|
+
or if arguments or the server result are invalid.
|
|
2838
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
2839
|
+
ValueError: If authenticated HTTP violates the configured origin
|
|
2840
|
+
policy.
|
|
2841
|
+
"""
|
|
2842
|
+
options = _methods.bind_work_schedule_update(args, kwargs, 'AsyncShotgun')
|
|
2843
|
+
params = _methods.build_work_schedule_update_params(options)
|
|
2844
|
+
result = await self._call('work_schedule_update', params)
|
|
2845
|
+
return _crud.require_dictionary_result(result, 'work_schedule_update')
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
def _open_download_temporary(destination: Path) -> tuple[ExitStack, BinaryIO, Path]:
|
|
2849
|
+
"""Open one adjacent download temporary file."""
|
|
2850
|
+
stack = ExitStack()
|
|
2851
|
+
handle = stack.enter_context(_files.download_temporary(destination))
|
|
2852
|
+
return stack, handle, Path(handle.name)
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def _raise_download_cleanup_error() -> Never:
|
|
2856
|
+
"""Raise the internal completed-transfer cleanup marker."""
|
|
2857
|
+
raise _AsyncDownloadCleanupError
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
async def _single_chunk(content: bytes) -> AsyncIterator[bytes]:
|
|
2861
|
+
"""Yield one buffered response body through async consumption control."""
|
|
2862
|
+
yield content
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
async def _execute_upload_exchange(
|
|
2866
|
+
driver: AsyncDriver,
|
|
2867
|
+
context: RequestContext,
|
|
2868
|
+
exchange: _files.UploadExchange,
|
|
2869
|
+
parser: Callable[[str, Mapping[str, str]], _UploadResult],
|
|
2870
|
+
) -> _UploadResult:
|
|
2871
|
+
"""Run one upload exchange through the common async executor."""
|
|
2872
|
+
|
|
2873
|
+
def request_factory(_attempt: int) -> TransportRequest:
|
|
2874
|
+
return TransportRequest(
|
|
2875
|
+
method=exchange.method,
|
|
2876
|
+
url=exchange.url,
|
|
2877
|
+
headers=dict(exchange.headers),
|
|
2878
|
+
body=exchange.body_factory(),
|
|
2879
|
+
timeout=context.timeout,
|
|
2880
|
+
token=uuid.uuid4().hex,
|
|
2881
|
+
)
|
|
2882
|
+
|
|
2883
|
+
async def consume(
|
|
2884
|
+
response: AsyncLiveResponse,
|
|
2885
|
+
control: AsyncAttemptControl,
|
|
2886
|
+
) -> _UploadResult:
|
|
2887
|
+
buffered = cast('TransportResponse', response)
|
|
2888
|
+
_protocol.check_http_status(
|
|
2889
|
+
buffered.status,
|
|
2890
|
+
buffered.reason,
|
|
2891
|
+
context.canonical_origin or '',
|
|
2892
|
+
buffered.headers,
|
|
2893
|
+
buffered.content,
|
|
2894
|
+
)
|
|
2895
|
+
body = await control.consume(
|
|
2896
|
+
_single_chunk(buffered.content),
|
|
2897
|
+
total_bytes=len(buffered.content),
|
|
2898
|
+
)
|
|
2899
|
+
return parser(body.decode('utf-8', errors='replace'), buffered.headers)
|
|
2900
|
+
|
|
2901
|
+
return await driver.execute(
|
|
2902
|
+
exchange.operation,
|
|
2903
|
+
context,
|
|
2904
|
+
request_factory,
|
|
2905
|
+
consume,
|
|
2906
|
+
)
|