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