sg-python-api 0.1.2__py3-none-any.whl → 0.2.1__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.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
- sg_python_api-0.2.1.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- 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 +296 -422
- 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 +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- 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 +1052 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +553 -0
- shotgun_api3/lib/mockgun/_links.py +598 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2205 -0
- shotgun_api3/lib/mockgun/_retirement.py +463 -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 +1559 -0
- shotgun_api3/lib/mockgun/_summaries.py +965 -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.2.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.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
shotgun_api3/_driver.py
CHANGED
|
@@ -33,7 +33,6 @@ from ._execution import (
|
|
|
33
33
|
build_rpc_request,
|
|
34
34
|
)
|
|
35
35
|
from ._transport import (
|
|
36
|
-
ReadTimeoutError,
|
|
37
36
|
SendError,
|
|
38
37
|
TransportError,
|
|
39
38
|
TransportRequest,
|
|
@@ -46,23 +45,15 @@ _HTTP_REDIRECT = 302
|
|
|
46
45
|
|
|
47
46
|
@dataclasses.dataclass(frozen=True)
|
|
48
47
|
class AttemptControl:
|
|
49
|
-
"""Consume attempt bytes
|
|
48
|
+
"""Consume attempt bytes with progress reporting."""
|
|
50
49
|
|
|
51
50
|
_attempt: int
|
|
52
|
-
_clock: Callable[[], float]
|
|
53
|
-
_deadline: float | None
|
|
54
51
|
_observer: ObserverCallback | None
|
|
55
52
|
_operation: _protocol.OperationCategory
|
|
56
53
|
_route: EventRoute
|
|
57
54
|
|
|
58
|
-
def
|
|
59
|
-
"""Raise when cumulative monotonic attempt time exceeds its limit."""
|
|
60
|
-
if self._deadline is not None and self._clock() > self._deadline:
|
|
61
|
-
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
62
|
-
|
|
63
|
-
def _consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
|
|
55
|
+
def consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
|
|
64
56
|
"""Observe a received buffered body without rebuilding it."""
|
|
65
|
-
self._check_deadline()
|
|
66
57
|
if body:
|
|
67
58
|
notify_observer(
|
|
68
59
|
self._observer,
|
|
@@ -76,22 +67,13 @@ class AttemptControl:
|
|
|
76
67
|
)
|
|
77
68
|
return body
|
|
78
69
|
|
|
79
|
-
def _remaining_timeout(self) -> float | None:
|
|
80
|
-
"""Return the remaining cumulative attempt timeout."""
|
|
81
|
-
if self._deadline is None:
|
|
82
|
-
return None
|
|
83
|
-
remaining = self._deadline - self._clock()
|
|
84
|
-
if remaining < 0:
|
|
85
|
-
raise ReadTimeoutError('total attempt timeout exceeded')
|
|
86
|
-
return remaining
|
|
87
|
-
|
|
88
70
|
def consume_into(
|
|
89
71
|
self,
|
|
90
72
|
chunks: Iterable[bytes],
|
|
91
73
|
sink: Callable[[bytes], object],
|
|
92
74
|
total_bytes: int | None = None,
|
|
93
75
|
) -> None:
|
|
94
|
-
"""Write chunks while
|
|
76
|
+
"""Write chunks while reporting progress.
|
|
95
77
|
|
|
96
78
|
Args:
|
|
97
79
|
chunks: Fresh streaming body chunks for this attempt.
|
|
@@ -99,14 +81,12 @@ class AttemptControl:
|
|
|
99
81
|
total_bytes: Known complete body size, when available.
|
|
100
82
|
|
|
101
83
|
Raises:
|
|
102
|
-
|
|
103
|
-
limit.
|
|
84
|
+
Exception: If reading a chunk or invoking the sink fails.
|
|
104
85
|
"""
|
|
105
86
|
transferred = 0
|
|
106
87
|
for chunk in chunks:
|
|
107
88
|
sink(chunk)
|
|
108
89
|
transferred += len(chunk)
|
|
109
|
-
self._check_deadline()
|
|
110
90
|
if chunk:
|
|
111
91
|
notify_observer(
|
|
112
92
|
self._observer,
|
|
@@ -120,7 +100,7 @@ class AttemptControl:
|
|
|
120
100
|
)
|
|
121
101
|
|
|
122
102
|
def consume(self, chunks: Iterable[bytes], total_bytes: int | None = None) -> bytes:
|
|
123
|
-
"""Collect chunks while
|
|
103
|
+
"""Collect chunks while reporting progress.
|
|
124
104
|
|
|
125
105
|
Args:
|
|
126
106
|
chunks: Fresh buffered or streaming body chunks for this attempt.
|
|
@@ -130,8 +110,7 @@ class AttemptControl:
|
|
|
130
110
|
Concatenated response bytes.
|
|
131
111
|
|
|
132
112
|
Raises:
|
|
133
|
-
|
|
134
|
-
limit.
|
|
113
|
+
Exception: If reading a chunk fails.
|
|
135
114
|
"""
|
|
136
115
|
body = BytesIO()
|
|
137
116
|
self.consume_into(chunks, body.write, total_bytes=total_bytes)
|
|
@@ -265,7 +244,13 @@ class _ObservedRequestBody:
|
|
|
265
244
|
raise
|
|
266
245
|
|
|
267
246
|
def close(self) -> None:
|
|
268
|
-
"""Close the source
|
|
247
|
+
"""Close the source iterator at most once.
|
|
248
|
+
|
|
249
|
+
Later calls do not repeat cleanup even when the first close fails.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
Exception: If the source iterator's cleanup fails.
|
|
253
|
+
"""
|
|
269
254
|
if self._closed:
|
|
270
255
|
return
|
|
271
256
|
self._closed = True
|
|
@@ -351,11 +336,8 @@ class Driver:
|
|
|
351
336
|
) -> AttemptState[AttemptControl]:
|
|
352
337
|
"""Build immutable timing and observer state for one fresh attempt."""
|
|
353
338
|
started = self._clock()
|
|
354
|
-
deadline = None if context.timeout.total is None else started + context.timeout.total
|
|
355
339
|
control = AttemptControl(
|
|
356
340
|
_attempt=attempt,
|
|
357
|
-
_clock=self._clock,
|
|
358
|
-
_deadline=deadline,
|
|
359
341
|
_observer=context.on_progress,
|
|
360
342
|
_operation=operation.category,
|
|
361
343
|
_route=route,
|
|
@@ -393,7 +375,6 @@ class Driver:
|
|
|
393
375
|
response_status = response.status
|
|
394
376
|
response_headers = response.headers
|
|
395
377
|
correlation_id = response_correlation_id(response_headers)
|
|
396
|
-
state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
397
378
|
failure = _protocol.classify_http_failure(
|
|
398
379
|
response_status,
|
|
399
380
|
response_headers,
|
|
@@ -475,7 +456,7 @@ class Driver:
|
|
|
475
456
|
buffered.headers,
|
|
476
457
|
buffered.content,
|
|
477
458
|
)
|
|
478
|
-
body = control.
|
|
459
|
+
body = control.consume_buffered(
|
|
479
460
|
buffered.content,
|
|
480
461
|
total_bytes=len(buffered.content),
|
|
481
462
|
)
|
|
@@ -569,7 +550,6 @@ class Driver:
|
|
|
569
550
|
request: TransportRequest,
|
|
570
551
|
state: AttemptState[AttemptControl],
|
|
571
552
|
*,
|
|
572
|
-
redirected: bool,
|
|
573
553
|
streaming: bool,
|
|
574
554
|
) -> tuple[LiveResponse, '_TerminalResponseBoundary'] | AttemptRetry:
|
|
575
555
|
"""Send once and keep a received response through body cleanup."""
|
|
@@ -579,8 +559,6 @@ class Driver:
|
|
|
579
559
|
try:
|
|
580
560
|
try:
|
|
581
561
|
with exception_capture:
|
|
582
|
-
if redirected:
|
|
583
|
-
request = _request_with_remaining_timeout(request, state.control)
|
|
584
562
|
if streaming:
|
|
585
563
|
response = self._transport.send_streaming(request)
|
|
586
564
|
else:
|
|
@@ -635,6 +613,10 @@ class Driver:
|
|
|
635
613
|
Raises:
|
|
636
614
|
TypeError: If the RPC transport response lacks the buffered
|
|
637
615
|
response contract (`reason` and `content`).
|
|
616
|
+
TransportError: If a terminal or exhausted transport failure
|
|
617
|
+
occurs.
|
|
618
|
+
Exception: If request construction, status validation, response
|
|
619
|
+
consumption, or cleanup fails.
|
|
638
620
|
"""
|
|
639
621
|
normalized_params = copy.deepcopy(params)
|
|
640
622
|
options = ExecutionOptions(logical_method_name=_logical_method_name)
|
|
@@ -667,6 +649,11 @@ class Driver:
|
|
|
667
649
|
|
|
668
650
|
Yields:
|
|
669
651
|
Decoded RPC results in page order.
|
|
652
|
+
|
|
653
|
+
Raises:
|
|
654
|
+
TransportError: If a page exhausts its transport attempts.
|
|
655
|
+
Exception: If building, validating, decoding, or cleaning up a page
|
|
656
|
+
operation fails.
|
|
670
657
|
"""
|
|
671
658
|
if context.page_prefetch <= 0:
|
|
672
659
|
page = first_page
|
|
@@ -718,9 +705,8 @@ class Driver:
|
|
|
718
705
|
context: Immutable operation configuration.
|
|
719
706
|
request_factory: Builder called once for every fresh attempt.
|
|
720
707
|
consumer: Buffered or streaming response consumer using the
|
|
721
|
-
supplied
|
|
722
|
-
options:
|
|
723
|
-
compatibility-warning controls.
|
|
708
|
+
supplied progress control.
|
|
709
|
+
options: Redirect, streaming, and observer controls.
|
|
724
710
|
|
|
725
711
|
Returns:
|
|
726
712
|
The successful consumer result.
|
|
@@ -728,7 +714,8 @@ class Driver:
|
|
|
728
714
|
Raises:
|
|
729
715
|
TransportError: If a terminal or exhausted transport failure
|
|
730
716
|
occurs.
|
|
731
|
-
Exception: If
|
|
717
|
+
Exception: If request or redirect construction, status validation,
|
|
718
|
+
response consumption, or cleanup fails.
|
|
732
719
|
"""
|
|
733
720
|
execution_options = options or ExecutionOptions()
|
|
734
721
|
self._emit_warnings(context)
|
|
@@ -742,7 +729,6 @@ class Driver:
|
|
|
742
729
|
sent = self._send_request(
|
|
743
730
|
request,
|
|
744
731
|
state,
|
|
745
|
-
redirected=redirected,
|
|
746
732
|
streaming=execution_options.streaming,
|
|
747
733
|
)
|
|
748
734
|
if isinstance(sent, AttemptRetry):
|
|
@@ -786,6 +772,7 @@ class Driver:
|
|
|
786
772
|
Raises:
|
|
787
773
|
ValueError: If `attempts` is not positive or `interval` is
|
|
788
774
|
negative.
|
|
775
|
+
Exception: If the predicate or configured sleep fails.
|
|
789
776
|
"""
|
|
790
777
|
if attempts <= 0:
|
|
791
778
|
raise ValueError('poll attempts must be positive')
|
|
@@ -817,13 +804,12 @@ def _observe_request_body(
|
|
|
817
804
|
total_bytes: int | None,
|
|
818
805
|
state: AttemptState[AttemptControl],
|
|
819
806
|
) -> Generator[bytes, None, None]:
|
|
820
|
-
"""Yield request chunks with attempt
|
|
807
|
+
"""Yield request chunks with attempt progress reporting."""
|
|
821
808
|
transferred = 0
|
|
822
809
|
for chunk in chunks:
|
|
823
810
|
yield chunk
|
|
824
811
|
transferred += len(chunk)
|
|
825
812
|
if chunk:
|
|
826
|
-
state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
827
813
|
notify_observer(
|
|
828
814
|
state.context.on_progress,
|
|
829
815
|
ProgressEvent(
|
|
@@ -852,14 +838,3 @@ def _request_with_progress(
|
|
|
852
838
|
state,
|
|
853
839
|
),
|
|
854
840
|
)
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
def _request_with_remaining_timeout(
|
|
858
|
-
request: TransportRequest,
|
|
859
|
-
control: AttemptControl,
|
|
860
|
-
) -> TransportRequest:
|
|
861
|
-
"""Restrict a later hop to its remaining cumulative attempt timeout."""
|
|
862
|
-
return _transport.narrow_total_timeout(
|
|
863
|
-
request,
|
|
864
|
-
control._remaining_timeout(), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
|
|
865
|
-
)
|
shotgun_api3/_execution.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Share pure execution, attempt, and lease logic between the sync and async twins."""
|
|
2
2
|
|
|
3
3
|
import dataclasses
|
|
4
|
-
import uuid
|
|
5
4
|
from collections.abc import Callable, Mapping
|
|
6
5
|
from typing import Generic, Literal, TypeVar
|
|
7
6
|
|
|
@@ -35,7 +34,7 @@ class AttemptState(Generic[_ControlT_co]):
|
|
|
35
34
|
|
|
36
35
|
Attributes:
|
|
37
36
|
context: Configuration captured for the logical operation.
|
|
38
|
-
control: Attempt-local
|
|
37
|
+
control: Attempt-local consumption and progress control.
|
|
39
38
|
number: One-based attempt number.
|
|
40
39
|
operation: Logical operation and replay classification.
|
|
41
40
|
route: Observer route for the attempt.
|
|
@@ -235,7 +234,6 @@ def build_rpc_request(
|
|
|
235
234
|
headers=headers,
|
|
236
235
|
body=_protocol.encode_payload(payload),
|
|
237
236
|
timeout=context.timeout,
|
|
238
|
-
token=uuid.uuid4().hex,
|
|
239
237
|
)
|
|
240
238
|
|
|
241
239
|
|
shotgun_api3/_files.py
CHANGED
|
@@ -8,7 +8,6 @@ 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
12
|
from contextlib import contextmanager
|
|
14
13
|
from pathlib import Path
|
|
@@ -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/'
|
|
@@ -94,7 +92,7 @@ class UploadArguments:
|
|
|
94
92
|
|
|
95
93
|
entity_type: str
|
|
96
94
|
entity_id: int
|
|
97
|
-
path: str
|
|
95
|
+
path: str | bytes
|
|
98
96
|
field_name: str | None = None
|
|
99
97
|
display_name: str | None = None
|
|
100
98
|
tag_list: str | None = None
|
|
@@ -179,6 +177,15 @@ class UploadOptions:
|
|
|
179
177
|
filmstrip: bool
|
|
180
178
|
|
|
181
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
|
+
|
|
182
189
|
@dataclasses.dataclass(frozen=True)
|
|
183
190
|
class PreparedUpload:
|
|
184
191
|
"""Carry validated file metadata with one upload call.
|
|
@@ -187,11 +194,13 @@ class PreparedUpload:
|
|
|
187
194
|
filename: File basename used by upload endpoints.
|
|
188
195
|
file_size: Positive file size captured before network I/O.
|
|
189
196
|
options: Immutable public-call and thumbnail-routing options.
|
|
197
|
+
path: Text source path normalized during upload preparation.
|
|
190
198
|
"""
|
|
191
199
|
|
|
192
200
|
filename: str
|
|
193
201
|
file_size: int
|
|
194
202
|
options: 'UploadOptions'
|
|
203
|
+
path: str
|
|
195
204
|
|
|
196
205
|
|
|
197
206
|
class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
|
|
@@ -399,7 +408,6 @@ def build_download_request(
|
|
|
399
408
|
headers=dict(request_headers),
|
|
400
409
|
body=None,
|
|
401
410
|
timeout=context.timeout,
|
|
402
|
-
token=uuid.uuid4().hex,
|
|
403
411
|
)
|
|
404
412
|
|
|
405
413
|
|
|
@@ -626,6 +634,14 @@ def build_download_redirect(
|
|
|
626
634
|
The `location` key is looked up in lowercase or in a case-insensitive
|
|
627
635
|
response header mapping.
|
|
628
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
|
+
|
|
629
645
|
Raises:
|
|
630
646
|
ShotgunFileDownloadError: If the response has no valid redirect
|
|
631
647
|
location.
|
|
@@ -673,6 +689,34 @@ def build_upload_endpoint_exchange(
|
|
|
673
689
|
)
|
|
674
690
|
|
|
675
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
|
+
|
|
676
720
|
def choose_upload_path(
|
|
677
721
|
server_info: Mapping[str, object],
|
|
678
722
|
entity_type: str,
|
|
@@ -697,18 +741,6 @@ def choose_upload_path(
|
|
|
697
741
|
return 'multipart'
|
|
698
742
|
|
|
699
743
|
|
|
700
|
-
@contextmanager
|
|
701
|
-
def download_temporary(destination: Path) -> Generator[BinaryIO]:
|
|
702
|
-
"""Yield an adjacent named binary temporary file without deleting it on close."""
|
|
703
|
-
with tempfile.NamedTemporaryFile(
|
|
704
|
-
dir=destination.parent,
|
|
705
|
-
prefix=f'.{destination.name}.',
|
|
706
|
-
suffix='.tmp',
|
|
707
|
-
delete=False,
|
|
708
|
-
) as handle:
|
|
709
|
-
yield cast('BinaryIO', handle)
|
|
710
|
-
|
|
711
|
-
|
|
712
744
|
def multipart_ranges(prepared: PreparedUpload) -> tuple[StorageRange, ...]:
|
|
713
745
|
"""Plan ordered storage ranges for one multipart upload.
|
|
714
746
|
|
|
@@ -718,7 +750,7 @@ def multipart_ranges(prepared: PreparedUpload) -> tuple[StorageRange, ...]:
|
|
|
718
750
|
Returns:
|
|
719
751
|
Ordered contiguous ranges covering the complete file.
|
|
720
752
|
"""
|
|
721
|
-
path = prepared.
|
|
753
|
+
path = prepared.path
|
|
722
754
|
return tuple(
|
|
723
755
|
StorageRange(
|
|
724
756
|
length=min(_MULTIPART_CHUNK_SIZE, prepared.file_size - offset),
|
|
@@ -746,11 +778,18 @@ def needs_session_cookie(url: str, origin: _Origin) -> bool:
|
|
|
746
778
|
return candidate == origin
|
|
747
779
|
|
|
748
780
|
|
|
749
|
-
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:
|
|
750
787
|
"""Parse an Attachment ID from the observed link success envelope.
|
|
751
788
|
|
|
752
789
|
Args:
|
|
753
790
|
encoded: Link-completion response text.
|
|
791
|
+
operation: Client operation whose link or share failed.
|
|
792
|
+
entities: Target entities known to the client.
|
|
754
793
|
|
|
755
794
|
Returns:
|
|
756
795
|
Created Attachment ID.
|
|
@@ -760,6 +799,11 @@ def parse_link_completion(encoded: str) -> int:
|
|
|
760
799
|
"""
|
|
761
800
|
marker, separator, payload = encoded.partition(':')
|
|
762
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}')
|
|
763
807
|
if marker != '1' or separator != ':' or not parts:
|
|
764
808
|
raise ShotgunError('upload link returned an invalid response')
|
|
765
809
|
try:
|
|
@@ -900,11 +944,12 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
900
944
|
raise ShotgunError('created_at must be a datetime.datetime instance')
|
|
901
945
|
raw_path = options.arguments.path
|
|
902
946
|
if isinstance(raw_path, bytes):
|
|
947
|
+
raw_path = os.fsdecode(raw_path)
|
|
903
948
|
options = dataclasses.replace(
|
|
904
949
|
options,
|
|
905
|
-
arguments=dataclasses.replace(options.arguments, path=
|
|
950
|
+
arguments=dataclasses.replace(options.arguments, path=raw_path),
|
|
906
951
|
)
|
|
907
|
-
path = Path(
|
|
952
|
+
path = Path(raw_path)
|
|
908
953
|
try:
|
|
909
954
|
is_file = path.is_file()
|
|
910
955
|
file_size = path.stat().st_size if is_file else 0
|
|
@@ -914,7 +959,12 @@ def prepare_upload(options: UploadOptions) -> PreparedUpload:
|
|
|
914
959
|
raise ShotgunError(f"Path must be a valid file, got '{options.arguments.path}'")
|
|
915
960
|
if file_size <= 0:
|
|
916
961
|
raise ShotgunError(f"Path cannot be an empty file: '{options.arguments.path}'")
|
|
917
|
-
return PreparedUpload(
|
|
962
|
+
return PreparedUpload(
|
|
963
|
+
filename=path.name,
|
|
964
|
+
file_size=file_size,
|
|
965
|
+
options=options,
|
|
966
|
+
path=raw_path,
|
|
967
|
+
)
|
|
918
968
|
|
|
919
969
|
|
|
920
970
|
def require_supported_upload_path(
|
|
@@ -938,6 +988,79 @@ def require_supported_upload_path(
|
|
|
938
988
|
return upload_path
|
|
939
989
|
|
|
940
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
|
|
1062
|
+
|
|
1063
|
+
|
|
941
1064
|
def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
|
|
942
1065
|
"""Resolve a supported attachment reference to its download URL.
|
|
943
1066
|
|
|
@@ -1008,6 +1131,22 @@ def split_thumbnail_uploads(
|
|
|
1008
1131
|
return fields, {name: paths[name] for name in _THUMBNAIL_UPLOAD_FIELDS if name in paths}
|
|
1009
1132
|
|
|
1010
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
|
+
|
|
1011
1150
|
def thumbnail_source_ready(
|
|
1012
1151
|
result: object,
|
|
1013
1152
|
field_name: Literal['filmstrip_image', 'image'],
|
|
@@ -1038,6 +1177,24 @@ def thumbnail_source_ready(
|
|
|
1038
1177
|
return isinstance(value, str) and '/images/status/transient/' not in value
|
|
1039
1178
|
|
|
1040
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
|
+
|
|
1041
1198
|
def validate_download_url(url: str) -> None:
|
|
1042
1199
|
"""Reject unusable attachment URLs before download I/O.
|
|
1043
1200
|
|
|
@@ -1051,3 +1208,18 @@ def validate_download_url(url: str) -> None:
|
|
|
1051
1208
|
canonical_origin(url)
|
|
1052
1209
|
except ShotgunError:
|
|
1053
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)
|