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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.1.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1052 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +553 -0
  28. shotgun_api3/lib/mockgun/_links.py +598 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2205 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +463 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1559 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +965 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
  56. {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 under deadline and progress control."""
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 _check_deadline(self) -> None:
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 enforcing deadline and progress controls.
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
- ReadTimeoutError: If cumulative attempt time exceeds the total
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 enforcing the total attempt deadline.
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
- ReadTimeoutError: If cumulative attempt time exceeds the total
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 once even if its cleanup raises."""
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._consume_buffered( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
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 deadline/progress control.
722
- options: Explicit redirect, streaming, and sanitized
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 status validation or response consumption fails.
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 deadline and progress reporting."""
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
- )
@@ -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 deadline and progress control.
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.options.arguments.path
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(encoded: str) -> int:
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=os.fsdecode(raw_path)),
950
+ arguments=dataclasses.replace(options.arguments, path=raw_path),
906
951
  )
907
- path = Path(options.arguments.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(filename=path.name, file_size=file_size, options=options)
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)