sg-python-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )