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.
Files changed (56) hide show
  1. {sg_python_api-0.1.1.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.0.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +404 -316
  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 +354 -462
  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 +215 -123
  16. shotgun_api3/_methods.py +153 -412
  17. shotgun_api3/_protocol.py +27 -146
  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 +1031 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +558 -0
  28. shotgun_api3/lib/mockgun/_links.py +440 -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 +2163 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +368 -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 +1446 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +964 -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.1.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.1.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
  56. {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 ExitStack, contextmanager
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.options.arguments.path
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(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:
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=os.fsdecode(raw_path)),
950
+ arguments=dataclasses.replace(options.arguments, path=raw_path),
1007
951
  )
1008
- path = Path(options.arguments.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(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
+ )
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)