sg-python-api 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.1.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +404 -316
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +354 -462
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +215 -123
- shotgun_api3/_methods.py +153 -412
- shotgun_api3/_protocol.py +27 -146
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.1.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.1.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.1.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
shotgun_api3/_files.py
CHANGED
|
@@ -8,9 +8,8 @@ import os
|
|
|
8
8
|
import tempfile
|
|
9
9
|
import urllib.parse
|
|
10
10
|
import urllib.request
|
|
11
|
-
import uuid
|
|
12
11
|
from collections.abc import Callable, Generator, Iterator, Mapping, Sequence
|
|
13
|
-
from contextlib import
|
|
12
|
+
from contextlib import contextmanager
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
from typing import BinaryIO, Literal, TypeAlias, TypedDict, cast
|
|
16
15
|
|
|
@@ -27,7 +26,6 @@ SHARE_THUMBNAIL = '/upload/share_thumbnail'
|
|
|
27
26
|
SHARE_THUMBNAIL_OPERATION = _protocol.OperationDescriptor(
|
|
28
27
|
category='upload',
|
|
29
28
|
replay_safe=False,
|
|
30
|
-
body_restartable=True,
|
|
31
29
|
endpoint_role=None,
|
|
32
30
|
)
|
|
33
31
|
_FILE_SERVE_ATTACHMENT = '/file_serve/attachment/'
|
|
@@ -35,7 +33,6 @@ _MULTIPART_CHUNK_SIZE = 20_000_000
|
|
|
35
33
|
_Origin: TypeAlias = tuple[str, str, int]
|
|
36
34
|
_PART_LINK_FIELD_COUNT = 2
|
|
37
35
|
_THUMBNAIL_UPLOAD_FIELDS = ('image', 'filmstrip_image')
|
|
38
|
-
_UPLOAD_FILE = '/upload/upload_file'
|
|
39
36
|
|
|
40
37
|
|
|
41
38
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -95,7 +92,7 @@ class UploadArguments:
|
|
|
95
92
|
|
|
96
93
|
entity_type: str
|
|
97
94
|
entity_id: int
|
|
98
|
-
path: str
|
|
95
|
+
path: str | bytes
|
|
99
96
|
field_name: str | None = None
|
|
100
97
|
display_name: str | None = None
|
|
101
98
|
tag_list: str | None = None
|
|
@@ -180,6 +177,15 @@ class UploadOptions:
|
|
|
180
177
|
filmstrip: bool
|
|
181
178
|
|
|
182
179
|
|
|
180
|
+
@dataclasses.dataclass(frozen=True)
|
|
181
|
+
class _DriverLeaseDecision:
|
|
182
|
+
"""Describe the shared transport-generation transition for a driver lease."""
|
|
183
|
+
|
|
184
|
+
known_changed: bool
|
|
185
|
+
replace_owned_transport: bool
|
|
186
|
+
reset_driver: bool
|
|
187
|
+
|
|
188
|
+
|
|
183
189
|
@dataclasses.dataclass(frozen=True)
|
|
184
190
|
class PreparedUpload:
|
|
185
191
|
"""Carry validated file metadata with one upload call.
|
|
@@ -188,11 +194,13 @@ class PreparedUpload:
|
|
|
188
194
|
filename: File basename used by upload endpoints.
|
|
189
195
|
file_size: Positive file size captured before network I/O.
|
|
190
196
|
options: Immutable public-call and thumbnail-routing options.
|
|
197
|
+
path: Text source path normalized during upload preparation.
|
|
191
198
|
"""
|
|
192
199
|
|
|
193
200
|
filename: str
|
|
194
201
|
file_size: int
|
|
195
202
|
options: 'UploadOptions'
|
|
203
|
+
path: str
|
|
196
204
|
|
|
197
205
|
|
|
198
206
|
class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
|
|
@@ -246,17 +254,6 @@ class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
|
|
|
246
254
|
)
|
|
247
255
|
|
|
248
256
|
|
|
249
|
-
def _build_form_upload_fields(prepared: PreparedUpload) -> dict[str, str]:
|
|
250
|
-
"""Build the non-authenticated legacy form-upload fields."""
|
|
251
|
-
arguments = prepared.options.arguments
|
|
252
|
-
return {
|
|
253
|
-
'entity_type': arguments.entity_type,
|
|
254
|
-
'entity_id': str(arguments.entity_id),
|
|
255
|
-
'field_name': arguments.field_name or '',
|
|
256
|
-
'display_name': arguments.display_name or prepared.filename,
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
257
|
def _entity_reference(entity: Mapping[str, object]) -> str:
|
|
261
258
|
"""Serialize one entity as the upload endpoint reference grammar."""
|
|
262
259
|
entity_type = entity.get('type')
|
|
@@ -295,48 +292,6 @@ def _iter_file(
|
|
|
295
292
|
remaining -= len(chunk)
|
|
296
293
|
|
|
297
294
|
|
|
298
|
-
def _multipart_content_length(
|
|
299
|
-
plan: _protocol.MultipartPlan,
|
|
300
|
-
file_sizes: Mapping[str, int],
|
|
301
|
-
) -> int:
|
|
302
|
-
"""Return the exact byte length of a multipart plan."""
|
|
303
|
-
return sum(
|
|
304
|
-
file_sizes[segment.name] if isinstance(segment, _protocol.FileSlot) else len(segment)
|
|
305
|
-
for segment in plan.segments
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def _pump_multipart(
|
|
310
|
-
plan: _protocol.MultipartPlan,
|
|
311
|
-
opened: Mapping[str, BinaryIO],
|
|
312
|
-
chunk_size: int = 65536,
|
|
313
|
-
) -> Generator[bytes, None, None]:
|
|
314
|
-
"""Yield multipart framing while reading file slots incrementally."""
|
|
315
|
-
if chunk_size <= 0:
|
|
316
|
-
raise ValueError('multipart chunk size must be positive')
|
|
317
|
-
for segment in plan.segments:
|
|
318
|
-
if isinstance(segment, _protocol.FileSlot):
|
|
319
|
-
handle = opened[segment.name]
|
|
320
|
-
while chunk := handle.read(chunk_size):
|
|
321
|
-
yield chunk
|
|
322
|
-
else:
|
|
323
|
-
yield segment
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _pump_multipart_from_disk(
|
|
327
|
-
plan: _protocol.MultipartPlan,
|
|
328
|
-
paths: Mapping[str, str],
|
|
329
|
-
chunk_size: int = 65536,
|
|
330
|
-
) -> Generator[bytes, None, None]:
|
|
331
|
-
"""Pump multipart framing through lazily opened disk files."""
|
|
332
|
-
with ExitStack() as stack:
|
|
333
|
-
opened: dict[str, BinaryIO] = {}
|
|
334
|
-
for segment in plan.segments:
|
|
335
|
-
if isinstance(segment, _protocol.FileSlot) and segment.name not in opened:
|
|
336
|
-
opened[segment.name] = stack.enter_context(Path(paths[segment.name]).open('rb'))
|
|
337
|
-
yield from _pump_multipart(plan, opened, chunk_size)
|
|
338
|
-
|
|
339
|
-
|
|
340
295
|
def _requires_direct_upload(
|
|
341
296
|
server_info: Mapping[str, object],
|
|
342
297
|
entity_type: str,
|
|
@@ -453,7 +408,6 @@ def build_download_request(
|
|
|
453
408
|
headers=dict(request_headers),
|
|
454
409
|
body=None,
|
|
455
410
|
timeout=context.timeout,
|
|
456
|
-
token=uuid.uuid4().hex,
|
|
457
411
|
)
|
|
458
412
|
|
|
459
413
|
|
|
@@ -680,6 +634,14 @@ def build_download_redirect(
|
|
|
680
634
|
The `location` key is looked up in lowercase or in a case-insensitive
|
|
681
635
|
response header mapping.
|
|
682
636
|
|
|
637
|
+
Args:
|
|
638
|
+
context: Captured request settings supplying the user-agent.
|
|
639
|
+
response_headers: Redirect response headers with a lowercase `location`
|
|
640
|
+
key or case-insensitive lookup.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
A credential-free download request for the validated redirect URL.
|
|
644
|
+
|
|
683
645
|
Raises:
|
|
684
646
|
ShotgunFileDownloadError: If the response has no valid redirect
|
|
685
647
|
location.
|
|
@@ -694,53 +656,6 @@ def build_download_redirect(
|
|
|
694
656
|
return build_download_request(context, location, {'user-agent': context.user_agent})
|
|
695
657
|
|
|
696
658
|
|
|
697
|
-
def build_form_upload_exchange(
|
|
698
|
-
canonical_origin: str,
|
|
699
|
-
user_agent: str,
|
|
700
|
-
prepared: PreparedUpload,
|
|
701
|
-
auth: Mapping[str, object],
|
|
702
|
-
chunk_size: int = 65536,
|
|
703
|
-
) -> UploadExchange:
|
|
704
|
-
"""Plan one restartable legacy multipart form upload.
|
|
705
|
-
|
|
706
|
-
The body can be rebuilt, but the operation is not replay-safe after
|
|
707
|
-
transmission.
|
|
708
|
-
|
|
709
|
-
Args:
|
|
710
|
-
canonical_origin: Captured site origin.
|
|
711
|
-
user_agent: Captured user-agent header value.
|
|
712
|
-
prepared: Validated upload metadata and arguments.
|
|
713
|
-
auth: Captured authentication form fields.
|
|
714
|
-
chunk_size: Maximum file bytes read per iteration.
|
|
715
|
-
|
|
716
|
-
Returns:
|
|
717
|
-
Complete restartable form-upload exchange.
|
|
718
|
-
"""
|
|
719
|
-
fields = add_auth_fields(_build_form_upload_fields(prepared), auth)
|
|
720
|
-
slot = _protocol.FileSlot(
|
|
721
|
-
name='file',
|
|
722
|
-
filename=prepared.filename,
|
|
723
|
-
content_type='application/octet-stream',
|
|
724
|
-
)
|
|
725
|
-
plan = _protocol.build_multipart_plan(fields, [slot])
|
|
726
|
-
path = prepared.options.arguments.path
|
|
727
|
-
return UploadExchange(
|
|
728
|
-
body_factory=lambda: _pump_multipart_from_disk(
|
|
729
|
-
plan,
|
|
730
|
-
{'file': path},
|
|
731
|
-
chunk_size,
|
|
732
|
-
),
|
|
733
|
-
headers={
|
|
734
|
-
'content-length': str(_multipart_content_length(plan, {'file': prepared.file_size})),
|
|
735
|
-
'content-type': plan.content_type,
|
|
736
|
-
'user-agent': user_agent,
|
|
737
|
-
},
|
|
738
|
-
method='POST',
|
|
739
|
-
operation=SHARE_THUMBNAIL_OPERATION,
|
|
740
|
-
url=f'{canonical_origin}{_UPLOAD_FILE}',
|
|
741
|
-
)
|
|
742
|
-
|
|
743
|
-
|
|
744
659
|
def build_upload_endpoint_exchange(
|
|
745
660
|
canonical_origin: str | None,
|
|
746
661
|
user_agent: str,
|
|
@@ -774,6 +689,34 @@ def build_upload_endpoint_exchange(
|
|
|
774
689
|
)
|
|
775
690
|
|
|
776
691
|
|
|
692
|
+
def choose_driver_lease(
|
|
693
|
+
known_transport: object | None,
|
|
694
|
+
current_transport: object | None,
|
|
695
|
+
driver_generation: int | None,
|
|
696
|
+
transport_generation: int,
|
|
697
|
+
transport_owned: bool,
|
|
698
|
+
) -> _DriverLeaseDecision:
|
|
699
|
+
"""Choose the transport and driver changes for one captured generation.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
known_transport: Previously observed transport.
|
|
703
|
+
current_transport: Currently configured transport.
|
|
704
|
+
driver_generation: Generation of the cached driver, if any.
|
|
705
|
+
transport_generation: Captured configuration generation.
|
|
706
|
+
transport_owned: Whether the client created its transport.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Changes to apply while holding the client's transport lock.
|
|
710
|
+
"""
|
|
711
|
+
known_changed = known_transport is not current_transport
|
|
712
|
+
stale = not known_changed and driver_generation not in {None, transport_generation}
|
|
713
|
+
return _DriverLeaseDecision(
|
|
714
|
+
known_changed=known_changed,
|
|
715
|
+
replace_owned_transport=stale and transport_owned,
|
|
716
|
+
reset_driver=known_changed or stale,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
777
720
|
def choose_upload_path(
|
|
778
721
|
server_info: Mapping[str, object],
|
|
779
722
|
entity_type: str,
|
|
@@ -798,18 +741,6 @@ def choose_upload_path(
|
|
|
798
741
|
return 'multipart'
|
|
799
742
|
|
|
800
743
|
|
|
801
|
-
@contextmanager
|
|
802
|
-
def download_temporary(destination: Path) -> Generator[BinaryIO]:
|
|
803
|
-
"""Yield an adjacent named binary temporary file without deleting it on close."""
|
|
804
|
-
with tempfile.NamedTemporaryFile(
|
|
805
|
-
dir=destination.parent,
|
|
806
|
-
prefix=f'.{destination.name}.',
|
|
807
|
-
suffix='.tmp',
|
|
808
|
-
delete=False,
|
|
809
|
-
) as handle:
|
|
810
|
-
yield cast('BinaryIO', handle)
|
|
811
|
-
|
|
812
|
-
|
|
813
744
|
def multipart_ranges(prepared: PreparedUpload) -> tuple[StorageRange, ...]:
|
|
814
745
|
"""Plan ordered storage ranges for one multipart upload.
|
|
815
746
|
|
|
@@ -819,7 +750,7 @@ def multipart_ranges(prepared: PreparedUpload) -> tuple[StorageRange, ...]:
|
|
|
819
750
|
Returns:
|
|
820
751
|
Ordered contiguous ranges covering the complete file.
|
|
821
752
|
"""
|
|
822
|
-
path = prepared.
|
|
753
|
+
path = prepared.path
|
|
823
754
|
return tuple(
|
|
824
755
|
StorageRange(
|
|
825
756
|
length=min(_MULTIPART_CHUNK_SIZE, prepared.file_size - offset),
|
|
@@ -847,11 +778,18 @@ def needs_session_cookie(url: str, origin: _Origin) -> bool:
|
|
|
847
778
|
return candidate == origin
|
|
848
779
|
|
|
849
780
|
|
|
850
|
-
def parse_link_completion(
|
|
781
|
+
def parse_link_completion(
|
|
782
|
+
encoded: str,
|
|
783
|
+
*,
|
|
784
|
+
operation: str | None = None,
|
|
785
|
+
entities: Sequence[Mapping[str, object]] = (),
|
|
786
|
+
) -> int:
|
|
851
787
|
"""Parse an Attachment ID from the observed link success envelope.
|
|
852
788
|
|
|
853
789
|
Args:
|
|
854
790
|
encoded: Link-completion response text.
|
|
791
|
+
operation: Client operation whose link or share failed.
|
|
792
|
+
entities: Target entities known to the client.
|
|
855
793
|
|
|
856
794
|
Returns:
|
|
857
795
|
Created Attachment ID.
|
|
@@ -861,6 +799,11 @@ def parse_link_completion(encoded: str) -> int:
|
|
|
861
799
|
"""
|
|
862
800
|
marker, separator, payload = encoded.partition(':')
|
|
863
801
|
parts = payload.split()
|
|
802
|
+
if marker == '0' and separator == ':' and parts and operation and entities:
|
|
803
|
+
targets = ', '.join(
|
|
804
|
+
f'entity_type={entity["type"]} entity_id={entity["id"]}' for entity in entities
|
|
805
|
+
)
|
|
806
|
+
raise ShotgunError(f'{operation} failed for {targets}')
|
|
864
807
|
if marker != '1' or separator != ':' or not parts:
|
|
865
808
|
raise ShotgunError('upload link returned an invalid response')
|
|
866
809
|
try:
|
|
@@ -1001,11 +944,12 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
1001
944
|
raise ShotgunError('created_at must be a datetime.datetime instance')
|
|
1002
945
|
raw_path = options.arguments.path
|
|
1003
946
|
if isinstance(raw_path, bytes):
|
|
947
|
+
raw_path = os.fsdecode(raw_path)
|
|
1004
948
|
options = dataclasses.replace(
|
|
1005
949
|
options,
|
|
1006
|
-
arguments=dataclasses.replace(options.arguments, path=
|
|
950
|
+
arguments=dataclasses.replace(options.arguments, path=raw_path),
|
|
1007
951
|
)
|
|
1008
|
-
path = Path(
|
|
952
|
+
path = Path(raw_path)
|
|
1009
953
|
try:
|
|
1010
954
|
is_file = path.is_file()
|
|
1011
955
|
file_size = path.stat().st_size if is_file else 0
|
|
@@ -1015,7 +959,106 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
1015
959
|
raise ShotgunError(f"Path must be a valid file, got '{options.arguments.path}'")
|
|
1016
960
|
if file_size <= 0:
|
|
1017
961
|
raise ShotgunError(f"Path cannot be an empty file: '{options.arguments.path}'")
|
|
1018
|
-
return PreparedUpload(
|
|
962
|
+
return PreparedUpload(
|
|
963
|
+
filename=path.name,
|
|
964
|
+
file_size=file_size,
|
|
965
|
+
options=options,
|
|
966
|
+
path=raw_path,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def require_supported_upload_path(
|
|
971
|
+
upload_path: Literal['form', 'direct', 'multipart'],
|
|
972
|
+
) -> Literal['direct', 'multipart']:
|
|
973
|
+
"""Reject the unsupported legacy form route before upload I/O.
|
|
974
|
+
|
|
975
|
+
Args:
|
|
976
|
+
upload_path: Route selected from server capabilities.
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
The selected supported route.
|
|
980
|
+
|
|
981
|
+
Raises:
|
|
982
|
+
ShotgunError: If the server requires legacy form uploads.
|
|
983
|
+
"""
|
|
984
|
+
if upload_path == 'form':
|
|
985
|
+
raise ShotgunError(
|
|
986
|
+
'server requires legacy form uploads, which this client does not support'
|
|
987
|
+
)
|
|
988
|
+
return upload_path
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def choose_prepared_upload_path(
|
|
992
|
+
server_info: Mapping[str, object],
|
|
993
|
+
prepared: PreparedUpload,
|
|
994
|
+
*,
|
|
995
|
+
sharing_thumbnail: bool = False,
|
|
996
|
+
) -> Literal['direct', 'multipart']:
|
|
997
|
+
"""Choose a supported route for one prepared upload or thumbnail share.
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
server_info: Immutable server capability data.
|
|
1001
|
+
prepared: Validated upload source and arguments.
|
|
1002
|
+
sharing_thumbnail: Whether a local thumbnail share needs direct
|
|
1003
|
+
storage.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
The supported direct or multipart route.
|
|
1007
|
+
|
|
1008
|
+
Raises:
|
|
1009
|
+
ShotgunError: If the server requires legacy form uploads.
|
|
1010
|
+
"""
|
|
1011
|
+
arguments = prepared.options.arguments
|
|
1012
|
+
route = require_supported_upload_path(
|
|
1013
|
+
choose_upload_path(
|
|
1014
|
+
server_info,
|
|
1015
|
+
arguments.entity_type,
|
|
1016
|
+
arguments.field_name,
|
|
1017
|
+
prepared.file_size,
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
return 'direct' if sharing_thumbnail else route
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@contextmanager
|
|
1024
|
+
def download_temporary(destination: Path) -> Generator[BinaryIO]:
|
|
1025
|
+
"""Yield an adjacent named binary temporary file without deleting it on close.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
destination: Final destination whose directory and basename determine
|
|
1029
|
+
the temporary file's location and prefix.
|
|
1030
|
+
|
|
1031
|
+
Yields:
|
|
1032
|
+
An open binary file whose path is available through `name`. The handle
|
|
1033
|
+
is closed on context exit, but the caller remains responsible for
|
|
1034
|
+
removing or publishing the file.
|
|
1035
|
+
|
|
1036
|
+
Raises:
|
|
1037
|
+
OSError: If creating or closing the temporary file fails.
|
|
1038
|
+
"""
|
|
1039
|
+
with tempfile.NamedTemporaryFile(
|
|
1040
|
+
dir=destination.parent,
|
|
1041
|
+
prefix=f'.{destination.name}.',
|
|
1042
|
+
suffix='.tmp',
|
|
1043
|
+
delete=False,
|
|
1044
|
+
) as handle:
|
|
1045
|
+
yield cast('BinaryIO', handle)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@contextmanager
|
|
1049
|
+
def download_destination_io(destination: Path) -> Generator[None]:
|
|
1050
|
+
"""Name the destination when its own filesystem operation fails.
|
|
1051
|
+
|
|
1052
|
+
Raises:
|
|
1053
|
+
ShotgunFileDownloadError: If the enclosed operation raises `OSError`.
|
|
1054
|
+
"""
|
|
1055
|
+
try:
|
|
1056
|
+
yield
|
|
1057
|
+
except OSError as error:
|
|
1058
|
+
reason = error.strerror or str(error)
|
|
1059
|
+
raise ShotgunFileDownloadError(
|
|
1060
|
+
f'attachment download failed for destination {destination}: {reason}'
|
|
1061
|
+
) from None
|
|
1019
1062
|
|
|
1020
1063
|
|
|
1021
1064
|
def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
|
|
@@ -1088,6 +1131,22 @@ def split_thumbnail_uploads(
|
|
|
1088
1131
|
return fields, {name: paths[name] for name in _THUMBNAIL_UPLOAD_FIELDS if name in paths}
|
|
1089
1132
|
|
|
1090
1133
|
|
|
1134
|
+
def thumbnail_fields(
|
|
1135
|
+
filmstrip_thumbnail: bool,
|
|
1136
|
+
) -> tuple[Literal['filmstrip_thumb_image', 'thumb_image'], Literal['filmstrip_image', 'image']]:
|
|
1137
|
+
"""Map a thumbnail kind to its upload and visible entity fields.
|
|
1138
|
+
|
|
1139
|
+
Args:
|
|
1140
|
+
filmstrip_thumbnail: Whether the thumbnail is a filmstrip.
|
|
1141
|
+
|
|
1142
|
+
Returns:
|
|
1143
|
+
The upload field and corresponding visible entity field.
|
|
1144
|
+
"""
|
|
1145
|
+
if filmstrip_thumbnail:
|
|
1146
|
+
return 'filmstrip_thumb_image', 'filmstrip_image'
|
|
1147
|
+
return 'thumb_image', 'image'
|
|
1148
|
+
|
|
1149
|
+
|
|
1091
1150
|
def thumbnail_source_ready(
|
|
1092
1151
|
result: object,
|
|
1093
1152
|
field_name: Literal['filmstrip_image', 'image'],
|
|
@@ -1118,6 +1177,24 @@ def thumbnail_source_ready(
|
|
|
1118
1177
|
return isinstance(value, str) and '/images/status/transient/' not in value
|
|
1119
1178
|
|
|
1120
1179
|
|
|
1180
|
+
def upload_options(arguments: UploadArguments) -> UploadOptions:
|
|
1181
|
+
"""Classify a file field for its upload protocol and filmstrip flag.
|
|
1182
|
+
|
|
1183
|
+
Args:
|
|
1184
|
+
arguments: Bound public upload arguments.
|
|
1185
|
+
|
|
1186
|
+
Returns:
|
|
1187
|
+
Upload endpoint options for the selected field class.
|
|
1188
|
+
"""
|
|
1189
|
+
filmstrip = arguments.field_name in {'filmstrip_image', 'filmstrip_thumb_image'}
|
|
1190
|
+
thumbnail = filmstrip or arguments.field_name in {'image', 'thumb_image'}
|
|
1191
|
+
return UploadOptions(
|
|
1192
|
+
arguments=arguments,
|
|
1193
|
+
upload_type='Thumbnail' if thumbnail else 'Attachment',
|
|
1194
|
+
filmstrip=filmstrip,
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
|
|
1121
1198
|
def validate_download_url(url: str) -> None:
|
|
1122
1199
|
"""Reject unusable attachment URLs before download I/O.
|
|
1123
1200
|
|
|
@@ -1131,3 +1208,18 @@ def validate_download_url(url: str) -> None:
|
|
|
1131
1208
|
canonical_origin(url)
|
|
1132
1209
|
except ShotgunError:
|
|
1133
1210
|
raise ShotgunFileDownloadError('attachment download failed') from None
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def write_download_chunk(destination: Path, handle: BinaryIO, chunk: bytes) -> None:
|
|
1214
|
+
"""Write one download chunk and describe destination I/O failures.
|
|
1215
|
+
|
|
1216
|
+
Args:
|
|
1217
|
+
destination: Final destination named in a write failure.
|
|
1218
|
+
handle: Open binary output handle for the current attempt.
|
|
1219
|
+
chunk: Bytes to write.
|
|
1220
|
+
|
|
1221
|
+
Raises:
|
|
1222
|
+
ShotgunFileDownloadError: If writing the chunk raises `OSError`.
|
|
1223
|
+
"""
|
|
1224
|
+
with download_destination_io(destination):
|
|
1225
|
+
handle.write(chunk)
|