sg-python-api 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.0.dist-info → sg_python_api-0.1.2.dist-info}/METADATA +15 -28
- {sg_python_api-0.1.0.dist-info → sg_python_api-0.1.2.dist-info}/RECORD +13 -13
- shotgun_api3/_async_client.py +151 -108
- shotgun_api3/_async_driver.py +2 -14
- shotgun_api3/_client.py +152 -119
- shotgun_api3/_config.py +2 -10
- shotgun_api3/_driver.py +4 -18
- shotgun_api3/_files.py +66 -107
- shotgun_api3/_methods.py +104 -80
- shotgun_api3/_protocol.py +0 -93
- shotgun_api3/_version.py +1 -1
- {sg_python_api-0.1.0.dist-info → sg_python_api-0.1.2.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.0.dist-info → sg_python_api-0.1.2.dist-info}/licenses/LICENSE +0 -0
shotgun_api3/_driver.py
CHANGED
|
@@ -12,7 +12,6 @@ from typing import Literal, Protocol, TypeVar, cast, runtime_checkable
|
|
|
12
12
|
|
|
13
13
|
from . import _protocol, _transport
|
|
14
14
|
from ._config import (
|
|
15
|
-
CompatibilityWarningCode,
|
|
16
15
|
EventRoute,
|
|
17
16
|
ObserverCallback,
|
|
18
17
|
Outcome,
|
|
@@ -181,14 +180,11 @@ class ExecutionOptions:
|
|
|
181
180
|
"""Control executor behavior that is not part of retry classification.
|
|
182
181
|
|
|
183
182
|
Attributes:
|
|
184
|
-
compatibility_warning_codes: Compatibility warnings emitted before
|
|
185
|
-
attempts.
|
|
186
183
|
logical_method_name: Optional observer-facing operation name.
|
|
187
184
|
redirect_factory: Optional request factory for redirect responses.
|
|
188
185
|
streaming: Whether the response body is consumed as a stream.
|
|
189
186
|
"""
|
|
190
187
|
|
|
191
|
-
compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = ()
|
|
192
188
|
logical_method_name: str | None = None
|
|
193
189
|
redirect_factory: 'Callable[[LiveResponse], TransportRequest] | None' = None
|
|
194
190
|
streaming: bool = False
|
|
@@ -443,13 +439,9 @@ class Driver:
|
|
|
443
439
|
raise AssertionError('response consumption ended without an outcome')
|
|
444
440
|
return attempt_result
|
|
445
441
|
|
|
446
|
-
def _emit_warnings(
|
|
447
|
-
self,
|
|
448
|
-
context: RequestContext,
|
|
449
|
-
warning_codes: tuple[CompatibilityWarningCode, ...],
|
|
450
|
-
) -> None:
|
|
442
|
+
def _emit_warnings(self, context: RequestContext) -> None:
|
|
451
443
|
"""Publish fixed-message compatibility warnings."""
|
|
452
|
-
for event in compatibility_warning_events(
|
|
444
|
+
for event in compatibility_warning_events(context.timeout):
|
|
453
445
|
notify_observer(context.on_compatibility_warning, event)
|
|
454
446
|
|
|
455
447
|
def _execute_rpc(
|
|
@@ -623,7 +615,6 @@ class Driver:
|
|
|
623
615
|
params: object,
|
|
624
616
|
context: RequestContext,
|
|
625
617
|
include_auth: bool = True,
|
|
626
|
-
compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = (),
|
|
627
618
|
*,
|
|
628
619
|
_logical_method_name: str | None = None,
|
|
629
620
|
) -> object:
|
|
@@ -635,8 +626,6 @@ class Driver:
|
|
|
635
626
|
context: Immutable operation configuration.
|
|
636
627
|
include_auth: Whether to include the captured authentication
|
|
637
628
|
payload.
|
|
638
|
-
compatibility_warning_codes: Sanitized compatibility notices
|
|
639
|
-
attached by the client surface.
|
|
640
629
|
_logical_method_name: Optional logical operation name when it
|
|
641
630
|
differs from the wire method.
|
|
642
631
|
|
|
@@ -648,10 +637,7 @@ class Driver:
|
|
|
648
637
|
response contract (`reason` and `content`).
|
|
649
638
|
"""
|
|
650
639
|
normalized_params = copy.deepcopy(params)
|
|
651
|
-
options = ExecutionOptions(
|
|
652
|
-
compatibility_warning_codes=compatibility_warning_codes,
|
|
653
|
-
logical_method_name=_logical_method_name,
|
|
654
|
-
)
|
|
640
|
+
options = ExecutionOptions(logical_method_name=_logical_method_name)
|
|
655
641
|
return self._execute_rpc(
|
|
656
642
|
method_name,
|
|
657
643
|
normalized_params,
|
|
@@ -745,7 +731,7 @@ class Driver:
|
|
|
745
731
|
Exception: If status validation or response consumption fails.
|
|
746
732
|
"""
|
|
747
733
|
execution_options = options or ExecutionOptions()
|
|
748
|
-
self._emit_warnings(context
|
|
734
|
+
self._emit_warnings(context)
|
|
749
735
|
route = event_route(operation)
|
|
750
736
|
for attempt in range(1, context.max_rpc_attempts + 1):
|
|
751
737
|
state = self._attempt_state(operation, context, route, attempt)
|
shotgun_api3/_files.py
CHANGED
|
@@ -10,7 +10,7 @@ import urllib.parse
|
|
|
10
10
|
import urllib.request
|
|
11
11
|
import uuid
|
|
12
12
|
from collections.abc import Callable, Generator, Iterator, Mapping, Sequence
|
|
13
|
-
from contextlib import
|
|
13
|
+
from contextlib import contextmanager
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import BinaryIO, Literal, TypeAlias, TypedDict, cast
|
|
16
16
|
|
|
@@ -34,7 +34,7 @@ _FILE_SERVE_ATTACHMENT = '/file_serve/attachment/'
|
|
|
34
34
|
_MULTIPART_CHUNK_SIZE = 20_000_000
|
|
35
35
|
_Origin: TypeAlias = tuple[str, str, int]
|
|
36
36
|
_PART_LINK_FIELD_COUNT = 2
|
|
37
|
-
|
|
37
|
+
_THUMBNAIL_UPLOAD_FIELDS = ('image', 'filmstrip_image')
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -245,17 +245,6 @@ class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
|
|
|
245
245
|
)
|
|
246
246
|
|
|
247
247
|
|
|
248
|
-
def _build_form_upload_fields(prepared: PreparedUpload) -> dict[str, str]:
|
|
249
|
-
"""Build the non-authenticated legacy form-upload fields."""
|
|
250
|
-
arguments = prepared.options.arguments
|
|
251
|
-
return {
|
|
252
|
-
'entity_type': arguments.entity_type,
|
|
253
|
-
'entity_id': str(arguments.entity_id),
|
|
254
|
-
'field_name': arguments.field_name or '',
|
|
255
|
-
'display_name': arguments.display_name or prepared.filename,
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
248
|
def _entity_reference(entity: Mapping[str, object]) -> str:
|
|
260
249
|
"""Serialize one entity as the upload endpoint reference grammar."""
|
|
261
250
|
entity_type = entity.get('type')
|
|
@@ -294,48 +283,6 @@ def _iter_file(
|
|
|
294
283
|
remaining -= len(chunk)
|
|
295
284
|
|
|
296
285
|
|
|
297
|
-
def _multipart_content_length(
|
|
298
|
-
plan: _protocol.MultipartPlan,
|
|
299
|
-
file_sizes: Mapping[str, int],
|
|
300
|
-
) -> int:
|
|
301
|
-
"""Return the exact byte length of a multipart plan."""
|
|
302
|
-
return sum(
|
|
303
|
-
file_sizes[segment.name] if isinstance(segment, _protocol.FileSlot) else len(segment)
|
|
304
|
-
for segment in plan.segments
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def _pump_multipart(
|
|
309
|
-
plan: _protocol.MultipartPlan,
|
|
310
|
-
opened: Mapping[str, BinaryIO],
|
|
311
|
-
chunk_size: int = 65536,
|
|
312
|
-
) -> Generator[bytes, None, None]:
|
|
313
|
-
"""Yield multipart framing while reading file slots incrementally."""
|
|
314
|
-
if chunk_size <= 0:
|
|
315
|
-
raise ValueError('multipart chunk size must be positive')
|
|
316
|
-
for segment in plan.segments:
|
|
317
|
-
if isinstance(segment, _protocol.FileSlot):
|
|
318
|
-
handle = opened[segment.name]
|
|
319
|
-
while chunk := handle.read(chunk_size):
|
|
320
|
-
yield chunk
|
|
321
|
-
else:
|
|
322
|
-
yield segment
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def _pump_multipart_from_disk(
|
|
326
|
-
plan: _protocol.MultipartPlan,
|
|
327
|
-
paths: Mapping[str, str],
|
|
328
|
-
chunk_size: int = 65536,
|
|
329
|
-
) -> Generator[bytes, None, None]:
|
|
330
|
-
"""Pump multipart framing through lazily opened disk files."""
|
|
331
|
-
with ExitStack() as stack:
|
|
332
|
-
opened: dict[str, BinaryIO] = {}
|
|
333
|
-
for segment in plan.segments:
|
|
334
|
-
if isinstance(segment, _protocol.FileSlot) and segment.name not in opened:
|
|
335
|
-
opened[segment.name] = stack.enter_context(Path(paths[segment.name]).open('rb'))
|
|
336
|
-
yield from _pump_multipart(plan, opened, chunk_size)
|
|
337
|
-
|
|
338
|
-
|
|
339
286
|
def _requires_direct_upload(
|
|
340
287
|
server_info: Mapping[str, object],
|
|
341
288
|
entity_type: str,
|
|
@@ -693,53 +640,6 @@ def build_download_redirect(
|
|
|
693
640
|
return build_download_request(context, location, {'user-agent': context.user_agent})
|
|
694
641
|
|
|
695
642
|
|
|
696
|
-
def build_form_upload_exchange(
|
|
697
|
-
canonical_origin: str,
|
|
698
|
-
user_agent: str,
|
|
699
|
-
prepared: PreparedUpload,
|
|
700
|
-
auth: Mapping[str, object],
|
|
701
|
-
chunk_size: int = 65536,
|
|
702
|
-
) -> UploadExchange:
|
|
703
|
-
"""Plan one restartable legacy multipart form upload.
|
|
704
|
-
|
|
705
|
-
The body can be rebuilt, but the operation is not replay-safe after
|
|
706
|
-
transmission.
|
|
707
|
-
|
|
708
|
-
Args:
|
|
709
|
-
canonical_origin: Captured site origin.
|
|
710
|
-
user_agent: Captured user-agent header value.
|
|
711
|
-
prepared: Validated upload metadata and arguments.
|
|
712
|
-
auth: Captured authentication form fields.
|
|
713
|
-
chunk_size: Maximum file bytes read per iteration.
|
|
714
|
-
|
|
715
|
-
Returns:
|
|
716
|
-
Complete restartable form-upload exchange.
|
|
717
|
-
"""
|
|
718
|
-
fields = add_auth_fields(_build_form_upload_fields(prepared), auth)
|
|
719
|
-
slot = _protocol.FileSlot(
|
|
720
|
-
name='file',
|
|
721
|
-
filename=prepared.filename,
|
|
722
|
-
content_type='application/octet-stream',
|
|
723
|
-
)
|
|
724
|
-
plan = _protocol.build_multipart_plan(fields, [slot])
|
|
725
|
-
path = prepared.options.arguments.path
|
|
726
|
-
return UploadExchange(
|
|
727
|
-
body_factory=lambda: _pump_multipart_from_disk(
|
|
728
|
-
plan,
|
|
729
|
-
{'file': path},
|
|
730
|
-
chunk_size,
|
|
731
|
-
),
|
|
732
|
-
headers={
|
|
733
|
-
'content-length': str(_multipart_content_length(plan, {'file': prepared.file_size})),
|
|
734
|
-
'content-type': plan.content_type,
|
|
735
|
-
'user-agent': user_agent,
|
|
736
|
-
},
|
|
737
|
-
method='POST',
|
|
738
|
-
operation=SHARE_THUMBNAIL_OPERATION,
|
|
739
|
-
url=f'{canonical_origin}{_UPLOAD_FILE}',
|
|
740
|
-
)
|
|
741
|
-
|
|
742
|
-
|
|
743
643
|
def build_upload_endpoint_exchange(
|
|
744
644
|
canonical_origin: str | None,
|
|
745
645
|
user_agent: str,
|
|
@@ -992,8 +892,8 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
992
892
|
Captured filename, size, and options for one upload.
|
|
993
893
|
|
|
994
894
|
Raises:
|
|
995
|
-
ShotgunError: If the path does not name a
|
|
996
|
-
is not a datetime.
|
|
895
|
+
ShotgunError: If the path does not name a file, the file is empty, or
|
|
896
|
+
`created_at` is not a datetime.
|
|
997
897
|
"""
|
|
998
898
|
created_at = cast('object', options.arguments.created_at)
|
|
999
899
|
if created_at is not None and not isinstance(created_at, datetime.datetime):
|
|
@@ -1006,14 +906,38 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
1006
906
|
)
|
|
1007
907
|
path = Path(options.arguments.path)
|
|
1008
908
|
try:
|
|
1009
|
-
|
|
909
|
+
is_file = path.is_file()
|
|
910
|
+
file_size = path.stat().st_size if is_file else 0
|
|
1010
911
|
except OSError:
|
|
1011
|
-
file_size = 0
|
|
912
|
+
is_file, file_size = False, 0
|
|
913
|
+
if not is_file:
|
|
914
|
+
raise ShotgunError(f"Path must be a valid file, got '{options.arguments.path}'")
|
|
1012
915
|
if file_size <= 0:
|
|
1013
|
-
raise ShotgunError(
|
|
916
|
+
raise ShotgunError(f"Path cannot be an empty file: '{options.arguments.path}'")
|
|
1014
917
|
return PreparedUpload(filename=path.name, file_size=file_size, options=options)
|
|
1015
918
|
|
|
1016
919
|
|
|
920
|
+
def require_supported_upload_path(
|
|
921
|
+
upload_path: Literal['form', 'direct', 'multipart'],
|
|
922
|
+
) -> Literal['direct', 'multipart']:
|
|
923
|
+
"""Reject the unsupported legacy form route before upload I/O.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
upload_path: Route selected from server capabilities.
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
The selected supported route.
|
|
930
|
+
|
|
931
|
+
Raises:
|
|
932
|
+
ShotgunError: If the server requires legacy form uploads.
|
|
933
|
+
"""
|
|
934
|
+
if upload_path == 'form':
|
|
935
|
+
raise ShotgunError(
|
|
936
|
+
'server requires legacy form uploads, which this client does not support'
|
|
937
|
+
)
|
|
938
|
+
return upload_path
|
|
939
|
+
|
|
940
|
+
|
|
1017
941
|
def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
|
|
1018
942
|
"""Resolve a supported attachment reference to its download URL.
|
|
1019
943
|
|
|
@@ -1049,6 +973,41 @@ def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
|
|
|
1049
973
|
raise ShotgunError('attachment must be an id or dictionary')
|
|
1050
974
|
|
|
1051
975
|
|
|
976
|
+
def split_thumbnail_uploads(
|
|
977
|
+
data: object,
|
|
978
|
+
*,
|
|
979
|
+
keep_image_none: bool,
|
|
980
|
+
) -> tuple[object, dict[str, object]]:
|
|
981
|
+
"""Separate `image` and `filmstrip_image` paths from entity field data.
|
|
982
|
+
|
|
983
|
+
Autodesk's client uploads these paths after the entity write instead of
|
|
984
|
+
sending them as field values.
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
data: Public field mapping. A non-dictionary is returned unchanged so
|
|
988
|
+
that the caller's own validation reports it.
|
|
989
|
+
keep_image_none: Whether an `image` value of `None` stays in the field
|
|
990
|
+
data, which lets an update clear both thumbnails.
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
A copy of the field data without the separated fields, and the
|
|
994
|
+
non-empty paths to upload, with `image` before `filmstrip_image`.
|
|
995
|
+
"""
|
|
996
|
+
if not isinstance(data, dict):
|
|
997
|
+
return data, {}
|
|
998
|
+
mapping = cast('dict[object, object]', data)
|
|
999
|
+
fields: dict[object, object] = {}
|
|
1000
|
+
paths: dict[str, object] = {}
|
|
1001
|
+
for name, value in mapping.items():
|
|
1002
|
+
if name not in _THUMBNAIL_UPLOAD_FIELDS or (
|
|
1003
|
+
keep_image_none and name == 'image' and value is None
|
|
1004
|
+
):
|
|
1005
|
+
fields[name] = value
|
|
1006
|
+
elif value:
|
|
1007
|
+
paths[cast('str', name)] = value
|
|
1008
|
+
return fields, {name: paths[name] for name in _THUMBNAIL_UPLOAD_FIELDS if name in paths}
|
|
1009
|
+
|
|
1010
|
+
|
|
1052
1011
|
def thumbnail_source_ready(
|
|
1053
1012
|
result: object,
|
|
1054
1013
|
field_name: Literal['filmstrip_image', 'image'],
|
shotgun_api3/_methods.py
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
"""Shared pure plans for synchronous and asynchronous client methods."""
|
|
2
2
|
|
|
3
|
-
import contextvars
|
|
4
3
|
import dataclasses
|
|
5
4
|
import datetime
|
|
6
5
|
import inspect
|
|
7
|
-
import
|
|
8
|
-
from collections.abc import Callable, Generator
|
|
9
|
-
from contextlib import contextmanager
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
10
7
|
from typing import Any, NotRequired, TypedDict, cast
|
|
11
8
|
|
|
12
9
|
from . import _crud, _protocol
|
|
13
|
-
from ._config import CompatibilityWarningCode
|
|
14
10
|
from ._errors import ShotgunError
|
|
15
11
|
from ._signatures import bind_arguments, expose_signature
|
|
16
12
|
|
|
@@ -68,6 +64,9 @@ _ACTIVITY_STREAM_READ_SIGNATURE = inspect.Signature(
|
|
|
68
64
|
)
|
|
69
65
|
_EXPORT_PAGE_DOCSTRING = """Export a page using the documented arguments.
|
|
70
66
|
|
|
67
|
+
The Page or its layout must be marked API Exportable in the web UI,
|
|
68
|
+
or the server rejects the export.
|
|
69
|
+
|
|
71
70
|
Args:
|
|
72
71
|
page_id: Numeric page identifier.
|
|
73
72
|
format: Required nonempty export format name.
|
|
@@ -111,10 +110,6 @@ _METHOD_EXPORT_PAGE_SIGNATURE = inspect.Signature(
|
|
|
111
110
|
),
|
|
112
111
|
return_annotation=str,
|
|
113
112
|
)
|
|
114
|
-
_PENDING_COMPATIBILITY_WARNINGS = tuple[
|
|
115
|
-
object,
|
|
116
|
-
tuple[CompatibilityWarningCode, ...],
|
|
117
|
-
]
|
|
118
113
|
_SCHEDULE_DATE_LENGTH = 10
|
|
119
114
|
_SUMMARIZE_DOCSTRING = """Summarize field data returned by a query.
|
|
120
115
|
|
|
@@ -241,12 +236,6 @@ _WORK_SCHEDULE_UPDATE_SIGNATURE = inspect.Signature(
|
|
|
241
236
|
_WORK_SCHEDULE_UPDATE_PARAMETERS,
|
|
242
237
|
return_annotation=dict[str, object],
|
|
243
238
|
)
|
|
244
|
-
SCHEMA_ENTITY_READ_WARNINGS: contextvars.ContextVar[_PENDING_COMPATIBILITY_WARNINGS | None] = (
|
|
245
|
-
contextvars.ContextVar('SCHEMA_ENTITY_READ_WARNINGS', default=None)
|
|
246
|
-
)
|
|
247
|
-
SCHEMA_FIELD_READ_WARNINGS: contextvars.ContextVar[_PENDING_COMPATIBILITY_WARNINGS | None] = (
|
|
248
|
-
contextvars.ContextVar('SCHEMA_FIELD_READ_WARNINGS', default=None)
|
|
249
|
-
)
|
|
250
239
|
|
|
251
240
|
|
|
252
241
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -342,6 +331,18 @@ def _entity(value: object) -> object:
|
|
|
342
331
|
return _protocol.optimize_entity(value)
|
|
343
332
|
|
|
344
333
|
|
|
334
|
+
def _is_subscription_value(value: object) -> bool:
|
|
335
|
+
"""Check the supported subscription value forms."""
|
|
336
|
+
return (
|
|
337
|
+
value is None
|
|
338
|
+
or isinstance(value, str)
|
|
339
|
+
or (
|
|
340
|
+
isinstance(value, list)
|
|
341
|
+
and all(isinstance(item, str) for item in cast('list[object]', value))
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
345
346
|
def _project_entity(value: object) -> object:
|
|
346
347
|
"""Optimize an optional project entity."""
|
|
347
348
|
return None if value is None else _entity(value)
|
|
@@ -934,11 +935,38 @@ def build_user_subscriptions_create_params(users: object) -> dict[str, object]:
|
|
|
934
935
|
users,
|
|
935
936
|
'user_subscriptions_create users must be a list',
|
|
936
937
|
)
|
|
937
|
-
|
|
938
|
+
entry_message = (
|
|
939
|
+
'user_subscriptions_create users must contain dictionaries with '
|
|
940
|
+
'humanUserId and subscription keys'
|
|
941
|
+
)
|
|
942
|
+
params: dict[str, object] = {}
|
|
938
943
|
for member in validated:
|
|
939
|
-
if not isinstance(member, dict)
|
|
940
|
-
raise ShotgunError(
|
|
941
|
-
|
|
944
|
+
if not isinstance(member, dict):
|
|
945
|
+
raise ShotgunError(entry_message)
|
|
946
|
+
entry = cast('dict[str, object]', member)
|
|
947
|
+
if 'humanUserId' not in entry or 'subscription' not in entry:
|
|
948
|
+
raise ShotgunError(entry_message)
|
|
949
|
+
user_id = entry['humanUserId']
|
|
950
|
+
if isinstance(user_id, bool) or not isinstance(user_id, (str, int)):
|
|
951
|
+
raise ShotgunError(
|
|
952
|
+
'user_subscriptions_create users must contain string or integer humanUserId values'
|
|
953
|
+
)
|
|
954
|
+
if not _is_subscription_value(entry['subscription']):
|
|
955
|
+
raise ShotgunError(
|
|
956
|
+
'user_subscriptions_create users must contain valid subscription values'
|
|
957
|
+
)
|
|
958
|
+
key = str(user_id)
|
|
959
|
+
if key in params:
|
|
960
|
+
raise ShotgunError(
|
|
961
|
+
'user_subscriptions_create users must not contain duplicate user IDs'
|
|
962
|
+
)
|
|
963
|
+
subscription = entry['subscription']
|
|
964
|
+
params[key] = (
|
|
965
|
+
cast('list[str]', subscription).copy()
|
|
966
|
+
if isinstance(subscription, list)
|
|
967
|
+
else subscription
|
|
968
|
+
)
|
|
969
|
+
return params
|
|
942
970
|
|
|
943
971
|
|
|
944
972
|
def build_work_schedule_read_params(
|
|
@@ -1007,43 +1035,13 @@ def build_work_schedule_update_params(
|
|
|
1007
1035
|
return params
|
|
1008
1036
|
|
|
1009
1037
|
|
|
1010
|
-
def
|
|
1011
|
-
|
|
1012
|
-
owner: object,
|
|
1013
|
-
) -> tuple[CompatibilityWarningCode, ...]:
|
|
1014
|
-
"""Consume operation-local compatibility warnings exactly once.
|
|
1015
|
-
|
|
1016
|
-
Args:
|
|
1017
|
-
warning_context: Context-local warning state.
|
|
1018
|
-
owner: Client instance expected to own the warning.
|
|
1038
|
+
def entity_types_alias_error() -> ShotgunError:
|
|
1039
|
+
"""Build the error raised by the deprecated `entity_types` alias.
|
|
1019
1040
|
|
|
1020
1041
|
Returns:
|
|
1021
|
-
|
|
1022
|
-
"""
|
|
1023
|
-
pending = warning_context.get()
|
|
1024
|
-
if pending is None or pending[0] is not owner:
|
|
1025
|
-
return ()
|
|
1026
|
-
warning_context.set(None)
|
|
1027
|
-
return pending[1]
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
@contextmanager
|
|
1031
|
-
def entity_types_alias(owner: object) -> Generator[None, None, None]:
|
|
1032
|
-
"""Scope the deprecated `entity_types` compatibility notice.
|
|
1033
|
-
|
|
1034
|
-
Args:
|
|
1035
|
-
owner: Client instance invoking the alias.
|
|
1036
|
-
|
|
1037
|
-
Yields:
|
|
1038
|
-
Control while the public target method consumes the warning.
|
|
1042
|
+
A `ShotgunError` naming `schema_entity_read()` as the replacement.
|
|
1039
1043
|
"""
|
|
1040
|
-
|
|
1041
|
-
(owner, ('deprecated-entity-types-alias',)),
|
|
1042
|
-
)
|
|
1043
|
-
try:
|
|
1044
|
-
yield
|
|
1045
|
-
finally:
|
|
1046
|
-
SCHEMA_ENTITY_READ_WARNINGS.reset(token)
|
|
1044
|
+
return ShotgunError('Deprecated: use schema_entity_read() instead')
|
|
1047
1045
|
|
|
1048
1046
|
|
|
1049
1047
|
def expose_activity_stream_read_signature(
|
|
@@ -1168,38 +1166,64 @@ def require_string_result(value: object, method_name: str) -> str:
|
|
|
1168
1166
|
return value
|
|
1169
1167
|
|
|
1170
1168
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
"""Scope the deprecated `schema` compatibility notice.
|
|
1169
|
+
def require_user_subscriptions_create_result(value: object) -> bool:
|
|
1170
|
+
"""Interpret a mapping of per-user subscription assignment reports.
|
|
1174
1171
|
|
|
1175
1172
|
Args:
|
|
1176
|
-
|
|
1173
|
+
value: Raw result returned by the server.
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
`True` if every report is `None`, otherwise `False`.
|
|
1177
1177
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1178
|
+
Raises:
|
|
1179
|
+
ShotgunError: If the result is not a mapping with string keys.
|
|
1180
1180
|
"""
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1181
|
+
message = 'user_subscriptions_create returned an invalid result mapping'
|
|
1182
|
+
if not isinstance(value, Mapping):
|
|
1183
|
+
raise ShotgunError(message)
|
|
1184
|
+
reports = cast('Mapping[object, object]', value)
|
|
1185
|
+
if any(not isinstance(key, str) for key in reports):
|
|
1186
|
+
raise ShotgunError(message)
|
|
1187
|
+
return all(report is None for report in reports.values())
|
|
1188
1188
|
|
|
1189
1189
|
|
|
1190
|
-
def
|
|
1191
|
-
"""
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
stacklevel=3,
|
|
1196
|
-
)
|
|
1190
|
+
def require_user_subscriptions_result(value: object) -> list[dict[str, object]]:
|
|
1191
|
+
"""Convert a subscription mapping to ordered public entries.
|
|
1192
|
+
|
|
1193
|
+
Args:
|
|
1194
|
+
value: Raw subscription mapping returned by the server.
|
|
1197
1195
|
|
|
1196
|
+
Returns:
|
|
1197
|
+
Entries with unchanged string `humanUserId` and `subscription` values
|
|
1198
|
+
of `None`, string, or list of strings. Lists are copied.
|
|
1198
1199
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
)
|
|
1200
|
+
Raises:
|
|
1201
|
+
ShotgunError: If the result is not a valid subscription mapping.
|
|
1202
|
+
"""
|
|
1203
|
+
if not isinstance(value, Mapping):
|
|
1204
|
+
raise ShotgunError('user_subscriptions_read did not return a dictionary')
|
|
1205
|
+
subscriptions: list[dict[str, object]] = []
|
|
1206
|
+
for user_id, subscription in cast('Mapping[object, object]', value).items():
|
|
1207
|
+
if not isinstance(user_id, str) or not user_id:
|
|
1208
|
+
raise ShotgunError('user_subscriptions_read returned an invalid user ID')
|
|
1209
|
+
if not _is_subscription_value(subscription):
|
|
1210
|
+
raise ShotgunError('user_subscriptions_read returned an invalid subscription')
|
|
1211
|
+
public_subscription = (
|
|
1212
|
+
cast('list[str]', subscription).copy()
|
|
1213
|
+
if isinstance(subscription, list)
|
|
1214
|
+
else subscription
|
|
1215
|
+
)
|
|
1216
|
+
subscriptions.append({'humanUserId': user_id, 'subscription': public_subscription})
|
|
1217
|
+
return subscriptions
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def schema_alias_error(entity_type: object) -> ShotgunError:
|
|
1221
|
+
"""Build the error raised by the deprecated `schema` alias.
|
|
1222
|
+
|
|
1223
|
+
Args:
|
|
1224
|
+
entity_type: Caller-supplied entity type, rendered with `str()`.
|
|
1225
|
+
|
|
1226
|
+
Returns:
|
|
1227
|
+
A `ShotgunError` naming the equivalent `schema_field_read()` call.
|
|
1228
|
+
"""
|
|
1229
|
+
return ShotgunError(f"Deprecated: use schema_field_read('{entity_type}') instead")
|
shotgun_api3/_protocol.py
CHANGED
|
@@ -7,7 +7,6 @@ import json
|
|
|
7
7
|
import math
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
-
import uuid as _uuid
|
|
11
10
|
from collections.abc import Callable, Mapping, Sequence
|
|
12
11
|
from datetime import UTC
|
|
13
12
|
from email.utils import parsedate_to_datetime
|
|
@@ -107,7 +106,6 @@ _IMF_FIXDATE_PATTERN = re.compile(
|
|
|
107
106
|
_JITTER_BYTES = 8
|
|
108
107
|
_JITTER_RANGE = 1 << (_JITTER_BYTES * 8)
|
|
109
108
|
_MIN_CONDITION_PARTS = 2
|
|
110
|
-
_MULTIPART_BOUNDARY_PATTERN = re.compile(r"[A-Za-z0-9'+._-]{1,70}")
|
|
111
109
|
_OPTIMIZABLE_RELATIONS = frozenset({'is', 'is_not', 'in', 'not_in'})
|
|
112
110
|
_RETRYABLE_STATUSES = frozenset({502, 503, 504})
|
|
113
111
|
_RETRY_AFTER_TOO_LARGE: Final = 'too-large'
|
|
@@ -209,34 +207,6 @@ class Failure:
|
|
|
209
207
|
retry_after: _RetryAfter
|
|
210
208
|
|
|
211
209
|
|
|
212
|
-
@dataclasses.dataclass(frozen=True)
|
|
213
|
-
class FileSlot:
|
|
214
|
-
"""Placeholder for file bytes inside a multipart plan.
|
|
215
|
-
|
|
216
|
-
Attributes:
|
|
217
|
-
name: Multipart form field name.
|
|
218
|
-
filename: Filename exposed in the multipart part.
|
|
219
|
-
content_type: Media type exposed in the multipart part.
|
|
220
|
-
"""
|
|
221
|
-
|
|
222
|
-
name: str
|
|
223
|
-
filename: str
|
|
224
|
-
content_type: str
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
@dataclasses.dataclass(frozen=True)
|
|
228
|
-
class MultipartPlan:
|
|
229
|
-
"""Byte segments and file slots forming a multipart/form-data body.
|
|
230
|
-
|
|
231
|
-
Attributes:
|
|
232
|
-
content_type: Multipart content type including its boundary.
|
|
233
|
-
segments: Ordered encoded segments and file placeholders.
|
|
234
|
-
"""
|
|
235
|
-
|
|
236
|
-
content_type: str
|
|
237
|
-
segments: list[bytes | FileSlot]
|
|
238
|
-
|
|
239
|
-
|
|
240
210
|
@dataclasses.dataclass(frozen=True)
|
|
241
211
|
class OperationDescriptor:
|
|
242
212
|
"""Describe the replay properties of one logical operation.
|
|
@@ -365,11 +335,6 @@ def _entity_optimization_disabled() -> bool:
|
|
|
365
335
|
return cast('bool', module.SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION)
|
|
366
336
|
|
|
367
337
|
|
|
368
|
-
def _escape_disposition(value: str) -> str:
|
|
369
|
-
"""Make a value safe inside a Content-Disposition quoted-string."""
|
|
370
|
-
return value.replace('\\', '\\\\').replace('"', '%22').replace('\r', '').replace('\n', '')
|
|
371
|
-
|
|
372
|
-
|
|
373
338
|
def _explicit_delay_is_safe(delay: float) -> bool:
|
|
374
339
|
"""Return whether a delay stays within the bounded retry policy."""
|
|
375
340
|
return math.isfinite(delay) and 0 <= delay <= _BACKOFF_CAP
|
|
@@ -491,13 +456,6 @@ def _parse_retry_after(headers: Mapping[str, str], now: float) -> _RetryAfter:
|
|
|
491
456
|
return delay
|
|
492
457
|
|
|
493
458
|
|
|
494
|
-
def _validate_multipart_boundary(boundary: str) -> str:
|
|
495
|
-
"""Return a boundary safe for multipart framing and an unquoted header."""
|
|
496
|
-
if not _MULTIPART_BOUNDARY_PATTERN.fullmatch(boundary):
|
|
497
|
-
raise ValueError('multipart boundary must be 1 to 70 safe ASCII characters')
|
|
498
|
-
return boundary
|
|
499
|
-
|
|
500
|
-
|
|
501
459
|
def _validated_nonnegative_integer(value: object, name: str) -> int:
|
|
502
460
|
"""Return a non-boolean, nonnegative integer paging value."""
|
|
503
461
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
@@ -615,57 +573,6 @@ def classify_endpoint(
|
|
|
615
573
|
)
|
|
616
574
|
|
|
617
575
|
|
|
618
|
-
def build_multipart_plan(
|
|
619
|
-
fields: dict[str, str],
|
|
620
|
-
files: list[FileSlot],
|
|
621
|
-
boundary: str | None = None,
|
|
622
|
-
) -> MultipartPlan:
|
|
623
|
-
"""Build a pure multipart/form-data framing plan.
|
|
624
|
-
|
|
625
|
-
Args:
|
|
626
|
-
fields: Text form fields in multipart emission order.
|
|
627
|
-
files: File placeholders in multipart emission order.
|
|
628
|
-
boundary: Explicit boundary of 1-70 ASCII letters, digits, apostrophes,
|
|
629
|
-
plus signs, periods, underscores, or hyphens; `None` generates one.
|
|
630
|
-
|
|
631
|
-
Returns:
|
|
632
|
-
A content type and ordered framing segments for the multipart body.
|
|
633
|
-
|
|
634
|
-
Raises:
|
|
635
|
-
ValueError: If an explicit boundary is empty or unsafe for unquoted
|
|
636
|
-
use.
|
|
637
|
-
"""
|
|
638
|
-
multipart_boundary = (
|
|
639
|
-
_uuid.uuid4().hex if boundary is None else _validate_multipart_boundary(boundary)
|
|
640
|
-
)
|
|
641
|
-
segments: list[bytes | FileSlot] = []
|
|
642
|
-
for name, value in fields.items():
|
|
643
|
-
segments.append(
|
|
644
|
-
(
|
|
645
|
-
f'--{multipart_boundary}\r\nContent-Disposition: form-data; '
|
|
646
|
-
f'name="{_escape_disposition(name)}"'
|
|
647
|
-
f'\r\n\r\n{value}\r\n'
|
|
648
|
-
).encode()
|
|
649
|
-
)
|
|
650
|
-
for slot in files:
|
|
651
|
-
segments.append(
|
|
652
|
-
(
|
|
653
|
-
f'--{multipart_boundary}\r\nContent-Disposition: form-data; '
|
|
654
|
-
f'name="{_escape_disposition(slot.name)}"; '
|
|
655
|
-
f'filename="{_escape_disposition(slot.filename)}"'
|
|
656
|
-
f'\r\nContent-Type: {slot.content_type}'
|
|
657
|
-
'\r\n\r\n'
|
|
658
|
-
).encode()
|
|
659
|
-
)
|
|
660
|
-
segments.append(slot)
|
|
661
|
-
segments.append(b'\r\n')
|
|
662
|
-
segments.append(f'--{multipart_boundary}--\r\n'.encode())
|
|
663
|
-
return MultipartPlan(
|
|
664
|
-
content_type=f'multipart/form-data; boundary={multipart_boundary}',
|
|
665
|
-
segments=segments,
|
|
666
|
-
)
|
|
667
|
-
|
|
668
|
-
|
|
669
576
|
def classify(
|
|
670
577
|
method_name: str,
|
|
671
578
|
normalized_params: object,
|