sg-python-api 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

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