sg-python-api 0.1.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.
@@ -0,0 +1,1347 @@
1
+ """Sans-I/O helpers for the JSON-RPC protocol."""
2
+
3
+ import dataclasses
4
+ import datetime as dt
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ import sys
10
+ import uuid as _uuid
11
+ from collections.abc import Callable, Mapping, Sequence
12
+ from datetime import UTC
13
+ from email.utils import parsedate_to_datetime
14
+ from types import MappingProxyType
15
+ from typing import Final, Literal, TypeAlias, TypeGuard, TypeVar, cast, overload
16
+
17
+ from . import _errors
18
+ from ._sg_timezone import SG_TIMEZONE
19
+ from ._transport import (
20
+ AbortedError,
21
+ ConnectError,
22
+ ProxyError,
23
+ ReadTimeoutError,
24
+ SendError,
25
+ TLSError,
26
+ TransportError,
27
+ )
28
+
29
+ OperationCategory = Literal[
30
+ 'read',
31
+ 'write',
32
+ 'batch',
33
+ 'human_authentication',
34
+ 'session_authentication',
35
+ 'one_use_auth_exchange',
36
+ 'download',
37
+ 'upload',
38
+ 'unknown',
39
+ ]
40
+ _EndpointRole = Literal[
41
+ 'read',
42
+ 'download',
43
+ 'object_put',
44
+ 'part_put',
45
+ 'allocation',
46
+ 'link',
47
+ 'finalize',
48
+ ]
49
+ _FailureCategory = Literal[
50
+ 'transient',
51
+ 'rate_limit',
52
+ 'maintenance',
53
+ 'proxy_auth',
54
+ 'tls',
55
+ 'authentication',
56
+ 'abort',
57
+ 'configuration',
58
+ ]
59
+ _FailurePhase = Literal['pre_send', 'ambiguous_after_send', 'http_status', 'body_read']
60
+ _RetryAfter: TypeAlias = float | Literal['too-large'] | None
61
+ _T = TypeVar('_T')
62
+
63
+ _ASCTIME_PATTERN = re.compile(
64
+ r'(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) '
65
+ r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) '
66
+ r'(?: [1-9]|[12][0-9]|3[01]) [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}'
67
+ )
68
+ _BACKOFF_CAP = 30.0
69
+ _DATETIME_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$')
70
+ _ENTITY_OPTIMIZATION_KEYS = frozenset(
71
+ {'id', 'type', 'url', 'name', 'content_type', 'local_path', 'storage'}
72
+ )
73
+ _ERR_2FA = 106
74
+ _ERR_AUTH = 102
75
+ _ERR_OXYGEN = 110
76
+ _ERR_SSO = 108
77
+ _FAULT_BY_CODE: Mapping[int, type[_errors.Fault]] = MappingProxyType(
78
+ {
79
+ _ERR_2FA: _errors.MissingTwoFactorAuthenticationFault,
80
+ _ERR_AUTH: _errors.AuthenticationFault,
81
+ _ERR_OXYGEN: _errors.UserCredentialsNotAllowedForOxygenAuthenticationFault,
82
+ _ERR_SSO: _errors.UserCredentialsNotAllowedForSSOAuthenticationFault,
83
+ }
84
+ )
85
+ _HTTP_FORBIDDEN = 403
86
+ _HTTP_LONG_WEEKDAYS = (
87
+ 'Monday',
88
+ 'Tuesday',
89
+ 'Wednesday',
90
+ 'Thursday',
91
+ 'Friday',
92
+ 'Saturday',
93
+ 'Sunday',
94
+ )
95
+ _HTTP_MAINTENANCE = 503
96
+ _HTTP_MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
97
+ _HTTP_PROXY_AUTHENTICATION = 407
98
+ _HTTP_RATE_LIMIT = 429
99
+ _HTTP_SHORT_WEEKDAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
100
+ _HTTP_SUCCESS_MAX_EXCLUSIVE = 300
101
+ _HTTP_SUCCESS_MIN = 200
102
+ _IMF_FIXDATE_PATTERN = re.compile(
103
+ r'(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), [0-9]{2} '
104
+ r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) '
105
+ r'[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT'
106
+ )
107
+ _JITTER_BYTES = 8
108
+ _JITTER_RANGE = 1 << (_JITTER_BYTES * 8)
109
+ _MIN_CONDITION_PARTS = 2
110
+ _MULTIPART_BOUNDARY_PATTERN = re.compile(r"[A-Za-z0-9'+._-]{1,70}")
111
+ _OPTIMIZABLE_RELATIONS = frozenset({'is', 'is_not', 'in', 'not_in'})
112
+ _RETRYABLE_STATUSES = frozenset({502, 503, 504})
113
+ _RETRY_AFTER_TOO_LARGE: Final = 'too-large'
114
+ _RFC850_PATTERN = re.compile(
115
+ r'(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), [0-9]{2}-'
116
+ r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-'
117
+ r'[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT'
118
+ )
119
+ _SAFE_BATCH_REQUEST_TYPES = frozenset({'read'})
120
+ _SAFE_RPC_METHODS = frozenset(
121
+ {
122
+ 'activity_stream_read',
123
+ 'export_page',
124
+ 'followers',
125
+ 'following',
126
+ 'nav_expand',
127
+ 'nav_search_entity',
128
+ 'nav_search_string',
129
+ 'note_thread_read',
130
+ 'preferences_read',
131
+ 'read',
132
+ 'schema_entity_read',
133
+ 'schema_field_read',
134
+ 'schema_read',
135
+ 'summarize',
136
+ 'text_search',
137
+ 'user_subscriptions_read',
138
+ 'work_schedule_read',
139
+ }
140
+ )
141
+ _TERMINAL_FAILURE_CATEGORIES = frozenset(
142
+ {'abort', 'authentication', 'configuration', 'proxy_auth', 'tls'}
143
+ )
144
+ _WRITE_METHODS = frozenset(
145
+ {
146
+ 'create',
147
+ 'delete',
148
+ 'follow',
149
+ 'revive',
150
+ 'schema_field_create',
151
+ 'schema_field_delete',
152
+ 'schema_field_update',
153
+ 'unfollow',
154
+ 'update',
155
+ 'update_project_last_accessed',
156
+ 'user_subscriptions_create',
157
+ 'work_schedule_update',
158
+ }
159
+ )
160
+
161
+
162
+ @dataclasses.dataclass(frozen=True)
163
+ class AuthSpec:
164
+ """Collect authentication values used to build a request payload.
165
+
166
+ Field bindings are frozen. `extra_auth_params` is retained by reference and
167
+ remains mutable.
168
+
169
+ Attributes:
170
+ script_name: Script name for script authentication.
171
+ api_key: API key for script authentication.
172
+ user_login: Login for human-user authentication.
173
+ user_password: Password for human-user authentication.
174
+ session_token: Existing session token.
175
+ auth_token: Optional two-factor authentication token.
176
+ sudo_as_login: Optional user login for sudo impersonation.
177
+ session_uuid: Optional session UUID.
178
+ extra_auth_params: Optional additional authentication parameters.
179
+ """
180
+
181
+ script_name: str | None
182
+ api_key: str | None
183
+ user_login: str | None
184
+ user_password: str | None
185
+ session_token: str | None
186
+ auth_token: str | None
187
+ sudo_as_login: str | None
188
+ session_uuid: str | None
189
+ extra_auth_params: dict[str, object] | None
190
+
191
+
192
+ @dataclasses.dataclass(frozen=True)
193
+ class Failure:
194
+ """Describe one failed attempt in retry-policy vocabulary.
195
+
196
+ Attributes:
197
+ phase: Attempt phase in which failure occurred.
198
+ category: Stable transport or protocol failure category.
199
+ status: HTTP status, when a response was received.
200
+ retry_after: Explicit server delay in seconds, `'too-large'` when it
201
+ exceeds 30 seconds, or `None` when no usable explicit delay is
202
+ available. Eligible retries with `None` use computed backoff;
203
+ `'too-large'` stops retrying.
204
+ """
205
+
206
+ phase: _FailurePhase
207
+ category: _FailureCategory
208
+ status: int | None
209
+ retry_after: _RetryAfter
210
+
211
+
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
+ @dataclasses.dataclass(frozen=True)
241
+ class OperationDescriptor:
242
+ """Describe the replay properties of one logical operation.
243
+
244
+ Attributes:
245
+ category: Stable operation category.
246
+ replay_safe: Whether a completed request may be replayed.
247
+ body_restartable: Whether each attempt can obtain a fresh body.
248
+ endpoint_role: Special endpoint role, when applicable.
249
+ """
250
+
251
+ category: OperationCategory
252
+ replay_safe: bool
253
+ body_restartable: bool
254
+ endpoint_role: _EndpointRole | None
255
+
256
+
257
+ def _backoff(
258
+ operation: OperationDescriptor,
259
+ attempt: int,
260
+ base_interval: float,
261
+ now: float,
262
+ ) -> float | None:
263
+ """Return deterministic full-jitter backoff from caller-supplied time."""
264
+ if attempt < 1 or base_interval < 0 or not math.isfinite(base_interval):
265
+ return None
266
+ if now < 0 or not math.isfinite(now):
267
+ return None
268
+ if base_interval == 0:
269
+ return 0.0
270
+
271
+ ceiling = base_interval
272
+ for _ in range(attempt - 1):
273
+ if ceiling >= _BACKOFF_CAP / 2:
274
+ ceiling = _BACKOFF_CAP
275
+ break
276
+ ceiling *= 2
277
+ ceiling = min(ceiling, _BACKOFF_CAP)
278
+ seed = repr(
279
+ (
280
+ operation.category,
281
+ operation.replay_safe,
282
+ operation.body_restartable,
283
+ operation.endpoint_role,
284
+ attempt,
285
+ base_interval,
286
+ now,
287
+ )
288
+ )
289
+ digest = hashlib.sha256(seed.encode('ascii')).digest()
290
+ fraction = int.from_bytes(digest[:_JITTER_BYTES], 'big') / _JITTER_RANGE
291
+ return ceiling * fraction
292
+
293
+
294
+ def _batch_is_replay_safe(normalized_params: object) -> bool:
295
+ """Return whether every normalized batch member is a known safe request."""
296
+ members = normalized_params
297
+ if isinstance(members, Mapping):
298
+ params = cast('Mapping[object, object]', members)
299
+ members = params.get('requests')
300
+ if not isinstance(members, Sequence) or isinstance(members, (str, bytes, bytearray)):
301
+ return False
302
+ batch_members = cast('Sequence[object]', members)
303
+ if not batch_members:
304
+ return False
305
+
306
+ for member in batch_members:
307
+ if not isinstance(member, Mapping):
308
+ return False
309
+ request = cast('Mapping[object, object]', member)
310
+ request_type = request.get('request_type')
311
+ if request_type not in _SAFE_BATCH_REQUEST_TYPES:
312
+ return False
313
+ return True
314
+
315
+
316
+ def _contains_one_use_auth_token(normalized_params: object) -> bool:
317
+ """Return whether normalized parameters contain a one-use auth token."""
318
+ if isinstance(normalized_params, Mapping):
319
+ params = cast('Mapping[object, object]', normalized_params)
320
+ if params.get('auth_token') is not None:
321
+ return True
322
+ return any(_contains_one_use_auth_token(value) for value in params.values())
323
+ if isinstance(normalized_params, Sequence) and not isinstance(
324
+ normalized_params,
325
+ (str, bytes, bytearray),
326
+ ):
327
+ values = cast('Sequence[object]', normalized_params)
328
+ return any(_contains_one_use_auth_token(value) for value in values)
329
+ return False
330
+
331
+
332
+ def _classify_rpc(
333
+ method_name: str,
334
+ normalized_params: object,
335
+ has_one_use_auth_token: bool,
336
+ ) -> tuple[OperationCategory, bool]:
337
+ """Return the logical category and replay safety of an RPC."""
338
+ category: OperationCategory = 'unknown'
339
+ replay_safe = False
340
+ if method_name == 'batch':
341
+ category = 'batch'
342
+ replay_safe = _batch_is_replay_safe(normalized_params)
343
+ elif method_name == 'authenticate_human_user':
344
+ category = 'human_authentication'
345
+ replay_safe = not has_one_use_auth_token
346
+ elif method_name == 'get_session_token':
347
+ if has_one_use_auth_token or _contains_one_use_auth_token(normalized_params):
348
+ category = 'one_use_auth_exchange'
349
+ else:
350
+ category = 'session_authentication'
351
+ replay_safe = True
352
+ elif method_name in _SAFE_RPC_METHODS:
353
+ category = 'read'
354
+ replay_safe = True
355
+ elif method_name in _WRITE_METHODS:
356
+ category = 'write'
357
+ return category, replay_safe
358
+
359
+
360
+ def _entity_optimization_disabled() -> bool:
361
+ """Read the canonical public runtime setting without import-time coupling."""
362
+ module = sys.modules.get(f'{__package__}.shotgun')
363
+ if module is None:
364
+ raise RuntimeError('shotgun module is not initialized')
365
+ return cast('bool', module.SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION)
366
+
367
+
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
+ def _explicit_delay_is_safe(delay: float) -> bool:
374
+ """Return whether a delay stays within the bounded retry policy."""
375
+ return math.isfinite(delay) and 0 <= delay <= _BACKOFF_CAP
376
+
377
+
378
+ def _failure_is_retryable(failure: Failure) -> bool:
379
+ """Return whether a failure category is eligible in its observed phase."""
380
+ if failure.category in _TERMINAL_FAILURE_CATEGORIES:
381
+ return False
382
+ if failure.phase == 'pre_send':
383
+ return failure.category == 'transient'
384
+ if failure.phase in {'ambiguous_after_send', 'body_read'}:
385
+ return failure.category == 'transient'
386
+ if failure.phase != 'http_status' or failure.status is None:
387
+ return False
388
+ if failure.category == 'transient':
389
+ return failure.status in _RETRYABLE_STATUSES
390
+ return failure.category in {'rate_limit', 'maintenance'}
391
+
392
+
393
+ def _is_object_dict(value: object) -> TypeGuard[dict[object, object]]:
394
+ """Return whether a value is a dictionary with object-typed contents."""
395
+ return isinstance(value, dict)
396
+
397
+
398
+ def _is_object_list(value: object) -> TypeGuard[list[object]]:
399
+ """Return whether a value is a list with object-typed contents."""
400
+ return isinstance(value, list)
401
+
402
+
403
+ def _is_object_sequence(value: object) -> TypeGuard[list[object] | tuple[object, ...]]:
404
+ """Return whether a value is a supported sequence with object contents."""
405
+ return isinstance(value, (list, tuple))
406
+
407
+
408
+ def _is_object_tuple(value: object) -> TypeGuard[tuple[object, ...]]:
409
+ """Return whether a value is a tuple with object-typed contents."""
410
+ return isinstance(value, tuple)
411
+
412
+
413
+ def _logical_operator(filter_operator: object) -> str:
414
+ """Translate an official filter operator to its wire representation."""
415
+ if filter_operator is None or filter_operator == 'all':
416
+ return 'and'
417
+ if filter_operator == 'any':
418
+ return 'or'
419
+ raise _errors.Fault(f'Invalid filter_operator: {filter_operator!r}')
420
+
421
+
422
+ def _parse_http_date(value: str) -> float | None:
423
+ """Parse one of the three exact HTTP-date wire forms as GMT."""
424
+ form: Literal['asctime', 'imf_fixdate', 'rfc850']
425
+ if _IMF_FIXDATE_PATTERN.fullmatch(value):
426
+ form = 'imf_fixdate'
427
+ elif _RFC850_PATTERN.fullmatch(value):
428
+ form = 'rfc850'
429
+ elif _ASCTIME_PATTERN.fullmatch(value):
430
+ form = 'asctime'
431
+ else:
432
+ return None
433
+
434
+ try:
435
+ parsed = parsedate_to_datetime(value)
436
+ except (OverflowError, TypeError, ValueError):
437
+ return None
438
+ if parsed.tzinfo is None:
439
+ if form != 'asctime':
440
+ return None
441
+ parsed = parsed.replace(tzinfo=UTC)
442
+
443
+ short_weekday = _HTTP_SHORT_WEEKDAYS[parsed.weekday()]
444
+ long_weekday = _HTTP_LONG_WEEKDAYS[parsed.weekday()]
445
+ month = _HTTP_MONTHS[parsed.month - 1]
446
+ if form == 'imf_fixdate':
447
+ canonical = (
448
+ f'{short_weekday}, {parsed.day:02d} {month} {parsed.year:04d} '
449
+ f'{parsed.hour:02d}:{parsed.minute:02d}:{parsed.second:02d} GMT'
450
+ )
451
+ elif form == 'rfc850':
452
+ canonical = (
453
+ f'{long_weekday}, {parsed.day:02d}-{month}-{parsed.year % 100:02d} '
454
+ f'{parsed.hour:02d}:{parsed.minute:02d}:{parsed.second:02d} GMT'
455
+ )
456
+ else:
457
+ canonical = (
458
+ f'{short_weekday} {month} {parsed.day:2d} '
459
+ f'{parsed.hour:02d}:{parsed.minute:02d}:{parsed.second:02d} {parsed.year:04d}'
460
+ )
461
+ if canonical != value:
462
+ return None
463
+ return parsed.timestamp()
464
+
465
+
466
+ def _parse_retry_after(headers: Mapping[str, str], now: float) -> _RetryAfter:
467
+ """Parse a case-insensitive `Retry-After` header into delay seconds."""
468
+ value = next(
469
+ (candidate for key, candidate in headers.items() if key.lower() == 'retry-after'),
470
+ None,
471
+ )
472
+ if value is None or not math.isfinite(now):
473
+ return None
474
+
475
+ delay: float | None = None
476
+ value = value.strip()
477
+ if value.isascii() and value.isdigit():
478
+ normalized = value.lstrip('0') or '0'
479
+ cap = str(int(_BACKOFF_CAP))
480
+ if len(normalized) > len(cap) or (len(normalized) == len(cap) and normalized > cap):
481
+ return _RETRY_AFTER_TOO_LARGE
482
+ delay = float(normalized)
483
+ else:
484
+ retry_at = _parse_http_date(value)
485
+ if retry_at is not None:
486
+ delay = max(0.0, retry_at - now)
487
+ if delay is None or not math.isfinite(delay) or delay < 0:
488
+ return None
489
+ if delay > _BACKOFF_CAP:
490
+ return _RETRY_AFTER_TOO_LARGE
491
+ return delay
492
+
493
+
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
+ def _validated_nonnegative_integer(value: object, name: str) -> int:
502
+ """Return a non-boolean, nonnegative integer paging value."""
503
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
504
+ raise _errors.ShotgunError(f'{name} must be a nonnegative integer')
505
+ return value
506
+
507
+
508
+ def _validated_positive_integer(value: object, name: str) -> int:
509
+ """Return a non-boolean, positive integer paging value."""
510
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
511
+ raise _errors.ShotgunError(f'{name} must be a positive integer')
512
+ return value
513
+
514
+
515
+ def _walk_object(data: object, visit: Callable[[object], object]) -> object:
516
+ """Recursively visit leaves while rebuilding supported containers."""
517
+ if _is_object_dict(data):
518
+ return {key: _walk_object(value, visit) for key, value in data.items()}
519
+ if _is_object_list(data):
520
+ return [_walk_object(value, visit) for value in data]
521
+ if _is_object_tuple(data):
522
+ return tuple(_walk_object(value, visit) for value in data)
523
+ return visit(data)
524
+
525
+
526
+ def build_auth_params(spec: AuthSpec) -> dict[str, object]:
527
+ """Build wire authentication parameters from a configuration snapshot.
528
+
529
+ Complete script credentials take precedence over complete user credentials,
530
+ which take precedence over a session token. Extra authentication parameters
531
+ are applied last and can replace generated values.
532
+
533
+ Args:
534
+ spec: Authentication configuration to serialize.
535
+
536
+ Returns:
537
+ A new authentication parameter dictionary.
538
+ """
539
+ params: dict[str, object] = {}
540
+ if spec.script_name is not None and spec.api_key is not None:
541
+ params['script_name'] = spec.script_name
542
+ params['script_key'] = spec.api_key
543
+ elif spec.user_login is not None and spec.user_password is not None:
544
+ params['user_login'] = spec.user_login
545
+ params['user_password'] = spec.user_password
546
+ if spec.auth_token is not None:
547
+ params['auth_token'] = spec.auth_token
548
+ elif spec.session_token is not None:
549
+ params['session_token'] = spec.session_token
550
+
551
+ if spec.sudo_as_login is not None:
552
+ params['sudo_as_login'] = spec.sudo_as_login
553
+ if spec.session_uuid is not None:
554
+ params['session_uuid'] = spec.session_uuid
555
+ if spec.extra_auth_params:
556
+ params.update(spec.extra_auth_params)
557
+ return params
558
+
559
+
560
+ def build_payload(
561
+ method_name: str,
562
+ params: object,
563
+ auth: dict[str, object] | None,
564
+ ) -> dict[str, object]:
565
+ """Build a JSON-RPC request payload.
566
+
567
+ Args:
568
+ method_name: Server method to call.
569
+ params: Method parameters to append when truthy.
570
+ auth: Authentication parameters to append when provided.
571
+
572
+ Returns:
573
+ A request payload with authentication before method parameters.
574
+ """
575
+ call_params: list[object] = []
576
+ if auth is not None:
577
+ call_params.append(auth)
578
+ if params:
579
+ call_params.append(params)
580
+ return {'method_name': method_name, 'params': call_params}
581
+
582
+
583
+ def classify_endpoint(
584
+ endpoint_role: _EndpointRole,
585
+ body_restartable: bool = True,
586
+ ) -> OperationDescriptor:
587
+ """Classify a non-RPC endpoint exchange.
588
+
589
+ Args:
590
+ endpoint_role: Endpoint role used for retry and route decisions.
591
+ body_restartable: Whether the body can restart from byte zero.
592
+
593
+ Returns:
594
+ An immutable descriptor with conservative retry behavior.
595
+ """
596
+ category: OperationCategory = 'unknown'
597
+ replay_safe = False
598
+ known_role: _EndpointRole | None = None
599
+ if endpoint_role in {'read', 'download'}:
600
+ category = 'read' if endpoint_role == 'read' else 'download'
601
+ replay_safe = True
602
+ known_role = endpoint_role
603
+ elif endpoint_role in {'object_put', 'part_put'}:
604
+ category = 'upload'
605
+ replay_safe = True
606
+ known_role = endpoint_role
607
+ elif endpoint_role in {'allocation', 'link', 'finalize'}:
608
+ category = 'upload'
609
+ known_role = endpoint_role
610
+ return OperationDescriptor(
611
+ category=category,
612
+ replay_safe=replay_safe,
613
+ body_restartable=body_restartable,
614
+ endpoint_role=known_role,
615
+ )
616
+
617
+
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
+ def classify(
670
+ method_name: str,
671
+ normalized_params: object,
672
+ body_restartable: bool = True,
673
+ has_one_use_auth_token: bool = False,
674
+ ) -> OperationDescriptor:
675
+ """Classify an operation for phase-aware retry decisions.
676
+
677
+ Args:
678
+ method_name: Normalized RPC method name.
679
+ normalized_params: Normalized method parameters, including every batch
680
+ member.
681
+ body_restartable: Whether the request body can be sent again from byte
682
+ zero.
683
+ has_one_use_auth_token: Whether captured request authentication
684
+ includes a one-use token outside method parameters.
685
+
686
+ Returns:
687
+ An immutable operation descriptor. Unknown inputs are not replay-safe.
688
+ """
689
+ category, replay_safe = _classify_rpc(
690
+ method_name,
691
+ normalized_params,
692
+ has_one_use_auth_token,
693
+ )
694
+ return OperationDescriptor(
695
+ category=category,
696
+ replay_safe=replay_safe,
697
+ body_restartable=body_restartable,
698
+ endpoint_role=None,
699
+ )
700
+
701
+
702
+ def classify_rpc_operation(
703
+ wire_method_name: str,
704
+ logical_method_name: str | None,
705
+ normalized_params: object,
706
+ has_one_use_auth_token: bool,
707
+ ) -> OperationDescriptor:
708
+ """Classify a driver RPC while preserving public `info` semantics.
709
+
710
+ Args:
711
+ wire_method_name: RPC method sent on the wire.
712
+ logical_method_name: Optional logical operation name when it differs.
713
+ normalized_params: Normalized method parameters.
714
+ has_one_use_auth_token: Whether captured authentication has a one-use
715
+ token.
716
+
717
+ Returns:
718
+ A safe read descriptor for the driver's `info` call, otherwise the
719
+ standard logical-method classification.
720
+ """
721
+ if wire_method_name == 'info':
722
+ return OperationDescriptor(
723
+ category='read',
724
+ replay_safe=True,
725
+ body_restartable=True,
726
+ endpoint_role=None,
727
+ )
728
+ return classify(
729
+ logical_method_name or wire_method_name,
730
+ normalized_params,
731
+ has_one_use_auth_token=has_one_use_auth_token,
732
+ )
733
+
734
+
735
+ def classify_transport_failure(
736
+ error: TransportError,
737
+ response_received: bool,
738
+ ) -> Failure:
739
+ """Map transport exceptions to conservative retry-policy vocabulary.
740
+
741
+ Args:
742
+ error: Shared transport exception raised by an adapter or consumer.
743
+ response_received: Whether response headers arrived before failure.
744
+
745
+ Returns:
746
+ Shared retry-policy failure vocabulary.
747
+ """
748
+ if isinstance(error, AbortedError):
749
+ phase: _FailurePhase = 'body_read' if response_received else 'pre_send'
750
+ category: _FailureCategory = 'abort'
751
+ elif isinstance(error, ProxyError):
752
+ phase = 'pre_send'
753
+ category = 'proxy_auth'
754
+ elif isinstance(error, TLSError):
755
+ phase = 'pre_send'
756
+ category = 'tls'
757
+ elif isinstance(error, ConnectError):
758
+ phase = 'pre_send'
759
+ category = 'transient'
760
+ elif isinstance(error, ReadTimeoutError):
761
+ phase = 'body_read'
762
+ category = 'transient'
763
+ elif isinstance(error, SendError):
764
+ phase = 'body_read' if response_received else 'ambiguous_after_send'
765
+ category = 'transient'
766
+ else:
767
+ phase = 'body_read' if response_received else 'ambiguous_after_send'
768
+ category = 'transient'
769
+ return Failure(phase=phase, category=category, status=None, retry_after=None)
770
+
771
+
772
+ def decide( # noqa: PLR0913
773
+ operation: OperationDescriptor,
774
+ attempt: int,
775
+ failure: Failure,
776
+ *,
777
+ max_attempts: int,
778
+ base_interval: float,
779
+ now: float,
780
+ ) -> float | None:
781
+ """Return the retry delay for a failed attempt, or stop retrying.
782
+
783
+ Args:
784
+ operation: Replay properties for the logical operation.
785
+ attempt: One-based number of the failed attempt.
786
+ failure: Phase and category of the failed attempt.
787
+ max_attempts: Maximum total attempts, including the failed attempt.
788
+ base_interval: Initial full-jitter backoff ceiling in seconds.
789
+ now: Caller-supplied, per-attempt high-resolution numeric seed. Callers
790
+ should make this value unique for each attempt. Identical complete
791
+ inputs intentionally reproduce the same jitter without hidden clock
792
+ or global random state.
793
+
794
+ Returns:
795
+ Nonnegative sleep seconds when retry is allowed, otherwise `None`.
796
+ """
797
+ stop_retrying = (
798
+ attempt < 1
799
+ or max_attempts < 1
800
+ or attempt >= max_attempts
801
+ or not operation.body_restartable
802
+ or (failure.phase != 'pre_send' and not operation.replay_safe)
803
+ or not _failure_is_retryable(failure)
804
+ )
805
+ if stop_retrying:
806
+ return None
807
+ if failure.retry_after is None:
808
+ return _backoff(operation, attempt, base_interval, now)
809
+ if failure.retry_after == _RETRY_AFTER_TOO_LARGE:
810
+ return None
811
+ if _explicit_delay_is_safe(failure.retry_after):
812
+ return failure.retry_after
813
+ return None
814
+
815
+
816
+ def decode_response(headers: Mapping[str, str], body: bytes) -> object:
817
+ """Decode an RPC response according to its content type.
818
+
819
+ Args:
820
+ headers: Read-only response headers, queried with `content-type`.
821
+ body: Raw response body.
822
+
823
+ Returns:
824
+ An empty string for an empty body. Otherwise, parsed JSON for
825
+ `application/json` or `text/javascript` content types, or decoded text
826
+ for other content types. An absent or empty content type defaults to
827
+ `application/json`.
828
+
829
+ Raises:
830
+ JSONDecodeError: If a non-empty JSON response is malformed.
831
+ UnicodeDecodeError: If a non-empty response is not valid UTF-8.
832
+ """
833
+ if not body:
834
+ return body.decode('utf-8', errors='replace')
835
+
836
+ content_type = (headers.get('content-type') or 'application/json').lower()
837
+ text = body.decode('utf-8')
838
+ if content_type.startswith(('application/json', 'text/javascript')):
839
+ return json.loads(text)
840
+ return text
841
+
842
+
843
+ def default_user_agent(version: str) -> tuple[str, ...]:
844
+ """Build the official-compatible default user-agent entries.
845
+
846
+ Args:
847
+ version: Package version reported by this implementation.
848
+
849
+ Returns:
850
+ Immutable user-agent entries in wire order.
851
+ """
852
+ return (
853
+ f'shotgun-json ({version})',
854
+ f'Python {sys.version_info.major}.{sys.version_info.minor}',
855
+ 'sg-python-api',
856
+ )
857
+
858
+
859
+ def encode_payload(payload: dict[str, object]) -> bytes:
860
+ """Encode a JSON-RPC payload as UTF-8 JSON.
861
+
862
+ Args:
863
+ payload: JSON-RPC payload to encode.
864
+
865
+ Returns:
866
+ UTF-8 JSON bytes without ASCII escaping.
867
+ """
868
+ return json.dumps(payload, ensure_ascii=False).encode('utf-8')
869
+
870
+
871
+ def extract_results(response: object) -> object:
872
+ """Extract the results member from a response when present.
873
+
874
+ Args:
875
+ response: Decoded RPC response.
876
+
877
+ Returns:
878
+ The results member when present, otherwise the original response.
879
+ """
880
+ if not isinstance(response, dict):
881
+ return response
882
+
883
+ rpc_response = cast('dict[str, object]', response)
884
+ if 'results' in rpc_response:
885
+ return rpc_response['results']
886
+ return rpc_response
887
+
888
+
889
+ def inject_local_paths(records: list[_T], local_path_field: str | None) -> list[_T]:
890
+ """Enrich direct local-link fields with official local path keys in place.
891
+
892
+ Args:
893
+ records: Result records to enrich in place.
894
+ local_path_field: Platform-specific source key, or `None` when
895
+ unavailable.
896
+
897
+ Returns:
898
+ The original records list after any local-link enrichment.
899
+ """
900
+ if local_path_field is None:
901
+ return records
902
+ for record in records:
903
+ if not _is_object_dict(record):
904
+ continue
905
+ for value in record.values():
906
+ if (
907
+ _is_object_dict(value)
908
+ and value.get('link_type') == 'local'
909
+ and local_path_field in value
910
+ ):
911
+ local_path = value[local_path_field]
912
+ value['local_path'] = local_path
913
+ value['url'] = f'file://{local_path or ""}'
914
+ return records
915
+
916
+
917
+ def is_http_success(status: int) -> bool:
918
+ """Return whether an HTTP status is in the successful 2xx range.
919
+
920
+ Args:
921
+ status: HTTP response status code.
922
+
923
+ Returns:
924
+ Whether the status represents success.
925
+ """
926
+ return _HTTP_SUCCESS_MIN <= status < _HTTP_SUCCESS_MAX_EXCLUSIVE
927
+
928
+
929
+ def check_http_status(
930
+ status: int,
931
+ reason: str,
932
+ server: str,
933
+ headers: Mapping[str, str],
934
+ body: bytes,
935
+ ) -> None:
936
+ """Raise a protocol error for a non-success HTTP status.
937
+
938
+ Args:
939
+ status: HTTP response status code.
940
+ reason: HTTP response reason phrase.
941
+ server: Server URL associated with the response.
942
+ headers: Read-only response headers.
943
+ body: Raw response body.
944
+
945
+ Raises:
946
+ ProtocolError: If `status` is outside the 2xx range.
947
+ """
948
+ if is_http_success(status):
949
+ return
950
+
951
+ message = (
952
+ f'{reason}: {body.decode("utf-8", errors="replace")}'
953
+ if status == _HTTP_FORBIDDEN
954
+ else reason
955
+ )
956
+ raise _errors.ProtocolError(server, status, message, dict(headers))
957
+
958
+
959
+ def classify_http_failure(
960
+ status: int,
961
+ headers: Mapping[str, str],
962
+ now: float,
963
+ ) -> Failure | None:
964
+ """Classify a non-success HTTP response conservatively.
965
+
966
+ Args:
967
+ status: HTTP response status code.
968
+ headers: Read-only response headers.
969
+ now: Unix timestamp used to parse an HTTP-date `Retry-After` value.
970
+
971
+ Returns:
972
+ Shared retry-policy failure vocabulary, or `None` for success.
973
+ """
974
+ if is_http_success(status):
975
+ return None
976
+ if status == _HTTP_RATE_LIMIT:
977
+ category: _FailureCategory = 'rate_limit'
978
+ elif status == _HTTP_MAINTENANCE:
979
+ category = 'maintenance'
980
+ elif status in _RETRYABLE_STATUSES:
981
+ category = 'transient'
982
+ elif status == _HTTP_PROXY_AUTHENTICATION:
983
+ category = 'proxy_auth'
984
+ elif status in {_HTTP_FORBIDDEN, 401}:
985
+ category = 'authentication'
986
+ else:
987
+ category = 'configuration'
988
+ retry_after = (
989
+ _parse_retry_after(headers, now)
990
+ if category in {'maintenance', 'rate_limit', 'transient'}
991
+ else None
992
+ )
993
+ return Failure(
994
+ phase='http_status',
995
+ category=category,
996
+ status=status,
997
+ retry_after=retry_after,
998
+ )
999
+
1000
+
1001
+ def optimize_entity(value: object) -> object:
1002
+ """Return an optimized copy of an entity value when possible.
1003
+
1004
+ Args:
1005
+ value: Potential entity value.
1006
+
1007
+ Returns:
1008
+ A shallow dictionary projection retaining only `id`, `type`, `url`,
1009
+ `name`, `content_type`, `local_path`, and `storage`. Returns the
1010
+ original value when it is not a dictionary containing `type` and `id`,
1011
+ or when `shotgun.SHOTGUN_API_DISABLE_ENTITY_OPTIMIZATION` is enabled.
1012
+ """
1013
+ if (
1014
+ _entity_optimization_disabled()
1015
+ or not _is_object_dict(value)
1016
+ or 'type' not in value
1017
+ or 'id' not in value
1018
+ ):
1019
+ return value
1020
+ return {key: item for key, item in value.items() if key in _ENTITY_OPTIMIZATION_KEYS}
1021
+
1022
+
1023
+ def _translate_condition(condition: object, optimize: bool) -> dict[str, object]:
1024
+ """Compile one list- or tuple-syntax condition without mutating it."""
1025
+ if not _is_object_sequence(condition) or len(condition) < _MIN_CONDITION_PARTS:
1026
+ raise _errors.Fault('filter conditions must be lists or tuples with a path and relation')
1027
+
1028
+ path = condition[0]
1029
+ relation = condition[1]
1030
+ if not isinstance(path, str) or not isinstance(relation, str):
1031
+ raise _errors.Fault('filter condition paths and relations must be strings')
1032
+
1033
+ values = list(condition[2:])
1034
+ if len(values) == 1 and _is_object_sequence(values[0]):
1035
+ values = list(values[0])
1036
+ if optimize and path != 'id' and relation in _OPTIMIZABLE_RELATIONS:
1037
+ values = [optimize_entity(value) for value in values]
1038
+ return {'path': path, 'relation': relation, 'values': values}
1039
+
1040
+
1041
+ def _translate_filters(
1042
+ filters: object,
1043
+ filter_operator: str | None,
1044
+ optimize: bool,
1045
+ ) -> dict[str, object]:
1046
+ """Recursively compile supported filter syntax."""
1047
+ if _is_object_dict(filters):
1048
+ operator = _logical_operator(filters.get('filter_operator'))
1049
+ inner = filters.get('filters', [])
1050
+ else:
1051
+ operator = _logical_operator(filter_operator)
1052
+ inner = filters
1053
+ if not _is_object_sequence(inner):
1054
+ raise _errors.Fault('filters must be a list or tuple')
1055
+
1056
+ conditions: list[object] = []
1057
+ for item in inner:
1058
+ if _is_object_dict(item):
1059
+ conditions.append(_translate_filters(item, None, optimize))
1060
+ else:
1061
+ conditions.append(_translate_condition(item, optimize))
1062
+ return {'logical_operator': operator, 'conditions': conditions}
1063
+
1064
+
1065
+ def page_verdict(
1066
+ paging_info: dict[str, object],
1067
+ entities_count: int,
1068
+ records_count: int,
1069
+ per_page: int,
1070
+ limit: int,
1071
+ ) -> Literal['stop', 'continue']:
1072
+ """Decide whether paging stops after the page just consumed.
1073
+
1074
+ Args:
1075
+ paging_info: Paging metadata returned with the consumed page.
1076
+ entities_count: Number of entities on the consumed page.
1077
+ records_count: Total number of entities accumulated so far.
1078
+ per_page: Requested page size.
1079
+ limit: Overall result limit, where zero means unlimited.
1080
+
1081
+ Returns:
1082
+ `'stop'` when no next page should be requested, otherwise `'continue'`.
1083
+
1084
+ Raises:
1085
+ ShotgunError: If counters or the active paging metadata signal are
1086
+ malformed.
1087
+ """
1088
+ entities_count = _validated_nonnegative_integer(entities_count, 'entities_count')
1089
+ records_count = _validated_nonnegative_integer(records_count, 'records_count')
1090
+ per_page = _validated_positive_integer(per_page, 'per_page')
1091
+ limit = _validated_nonnegative_integer(limit, 'limit')
1092
+ if records_count < entities_count:
1093
+ raise _errors.ShotgunError('records_count must include entities_count')
1094
+
1095
+ if entities_count == 0:
1096
+ return 'stop'
1097
+ if limit and records_count >= limit:
1098
+ return 'stop'
1099
+
1100
+ raw_entity_count = paging_info.get('entity_count')
1101
+ if raw_entity_count is not None:
1102
+ entity_count = _validated_nonnegative_integer(
1103
+ raw_entity_count,
1104
+ 'paging_info entity_count',
1105
+ )
1106
+ if records_count >= entity_count:
1107
+ return 'stop'
1108
+ elif 'has_next_page' in paging_info:
1109
+ has_next_page = paging_info['has_next_page']
1110
+ if not isinstance(has_next_page, bool):
1111
+ raise _errors.ShotgunError('paging_info has_next_page must be a boolean')
1112
+ if not has_next_page:
1113
+ return 'stop'
1114
+ elif entities_count < per_page:
1115
+ return 'stop'
1116
+ return 'continue'
1117
+
1118
+
1119
+ def project_entities(value: object) -> object:
1120
+ """Recursively project entity dictionaries into a detached payload.
1121
+
1122
+ Args:
1123
+ value: Request value whose supported containers must be rebuilt.
1124
+
1125
+ Returns:
1126
+ A detached value with direct and nested entities projected.
1127
+ """
1128
+ projected = optimize_entity(value)
1129
+ if _is_object_dict(projected):
1130
+ return {key: project_entities(item) for key, item in projected.items()}
1131
+ if _is_object_list(projected):
1132
+ return [project_entities(item) for item in projected]
1133
+ if _is_object_tuple(projected):
1134
+ return tuple(project_entities(item) for item in projected)
1135
+ return projected
1136
+
1137
+
1138
+ def raise_fault(response: object) -> None:
1139
+ """Raise the mapped fault for an RPC exception response.
1140
+
1141
+ Args:
1142
+ response: Decoded RPC response.
1143
+
1144
+ Raises:
1145
+ Fault: If the response reports an RPC exception.
1146
+ """
1147
+ if not isinstance(response, dict):
1148
+ return
1149
+
1150
+ rpc_response = cast('dict[str, object]', response)
1151
+ if not rpc_response.get('exception'):
1152
+ return
1153
+
1154
+ message = str(rpc_response.get('message', 'Unknown Error'))
1155
+ code = rpc_response.get('error_code')
1156
+ fault_class = (
1157
+ _FAULT_BY_CODE.get(code, _errors.Fault) if isinstance(code, int) else _errors.Fault
1158
+ )
1159
+ raise fault_class(message)
1160
+
1161
+
1162
+ @overload
1163
+ def transform_inbound(data: dict[str, _T], convert_utc: bool) -> dict[str, object]: ...
1164
+
1165
+
1166
+ @overload
1167
+ def transform_inbound(data: list[_T], convert_utc: bool) -> list[object]: ...
1168
+
1169
+
1170
+ @overload
1171
+ def transform_inbound(data: tuple[_T, ...], convert_utc: bool) -> tuple[object, ...]: ...
1172
+
1173
+
1174
+ @overload
1175
+ def transform_inbound(data: object, convert_utc: bool) -> object: ...
1176
+
1177
+
1178
+ def transform_inbound(data: object, convert_utc: bool) -> object:
1179
+ """Transform datetime strings and escaped less-than entities recursively.
1180
+
1181
+ Args:
1182
+ data: Response data whose supported container topology is preserved.
1183
+ convert_utc: If true, convert recognized UTC datetime strings to aware
1184
+ local datetimes. If false, return naive datetimes retaining the UTC
1185
+ wall-clock values.
1186
+
1187
+ Returns:
1188
+ Rebuilt response data with transformed leaf values. Invalid datetime
1189
+ strings remain unchanged.
1190
+ """
1191
+
1192
+ def visit(value: object) -> object:
1193
+ if not isinstance(value, str):
1194
+ return value
1195
+ if _DATETIME_PATTERN.fullmatch(value):
1196
+ try:
1197
+ parsed = dt.datetime(
1198
+ int(value[0:4]),
1199
+ int(value[5:7]),
1200
+ int(value[8:10]),
1201
+ int(value[11:13]),
1202
+ int(value[14:16]),
1203
+ int(value[17:19]),
1204
+ tzinfo=UTC,
1205
+ )
1206
+ if convert_utc:
1207
+ return parsed.astimezone(SG_TIMEZONE.local)
1208
+ return parsed.replace(tzinfo=None)
1209
+ except (OSError, OverflowError, ValueError):
1210
+ return value
1211
+ return value.replace('&lt;', '<')
1212
+
1213
+ return _walk_object(data, visit)
1214
+
1215
+
1216
+ @overload
1217
+ def transform_outbound(data: dict[str, _T], convert_utc: bool) -> dict[str, object]: ...
1218
+
1219
+
1220
+ @overload
1221
+ def transform_outbound(data: list[_T], convert_utc: bool) -> list[object]: ...
1222
+
1223
+
1224
+ @overload
1225
+ def transform_outbound(data: tuple[_T, ...], convert_utc: bool) -> tuple[object, ...]: ...
1226
+
1227
+
1228
+ @overload
1229
+ def transform_outbound(data: object, convert_utc: bool) -> object: ...
1230
+
1231
+
1232
+ def transform_outbound(data: object, convert_utc: bool) -> object:
1233
+ """Transform date and datetime leaves to official wire strings recursively.
1234
+
1235
+ Args:
1236
+ data: Request data whose supported container topology is preserved.
1237
+ convert_utc: If true, convert aware datetimes from their own timezone
1238
+ and naive datetimes from local time to UTC. If false, retain
1239
+ datetime wall-clock values without timezone conversion.
1240
+
1241
+ Returns:
1242
+ Rebuilt request data with dates formatted as `YYYY-MM-DD` and datetimes
1243
+ as `YYYY-MM-DDTHH:MM:SSZ`. Datetime microseconds are omitted.
1244
+ The `Z` suffix is emitted even when conversion is disabled.
1245
+
1246
+ Raises:
1247
+ ValueError: If converting a naive local datetime to UTC exceeds the
1248
+ supported range. The message is `datetime conversion exceeds the
1249
+ supported range`, and the exception has no cause.
1250
+ """
1251
+
1252
+ def visit(value: object) -> object:
1253
+ if isinstance(value, dt.datetime):
1254
+ when = value
1255
+ if convert_utc:
1256
+ if when.tzinfo is None:
1257
+ try:
1258
+ when = when.replace(tzinfo=SG_TIMEZONE.local).astimezone(UTC)
1259
+ except (OverflowError, ValueError):
1260
+ raise ValueError(
1261
+ 'datetime conversion exceeds the supported range'
1262
+ ) from None
1263
+ else:
1264
+ when = when.astimezone(UTC)
1265
+ return (
1266
+ f'{when.year:04d}-{when.month:02d}-{when.day:02d}'
1267
+ f'T{when.hour:02d}:{when.minute:02d}:{when.second:02d}Z'
1268
+ )
1269
+ if isinstance(value, dt.date):
1270
+ return f'{value.year:04d}-{value.month:02d}-{value.day:02d}'
1271
+ return value
1272
+
1273
+ return _walk_object(data, visit)
1274
+
1275
+
1276
+ def require_filter_container(
1277
+ filters: object,
1278
+ filter_operator: str | None = None,
1279
+ ) -> list[object] | tuple[object, ...] | dict[str, object]:
1280
+ """Narrow a filter container while preserving operator validation order.
1281
+
1282
+ Args:
1283
+ filters: Candidate public filter container.
1284
+ filter_operator: Combination mode for a non-dictionary container.
1285
+
1286
+ Returns:
1287
+ The unchanged list, tuple, or dictionary.
1288
+
1289
+ Raises:
1290
+ Fault: If a non-container has an invalid operator or container type.
1291
+ """
1292
+ if not isinstance(filters, (list, tuple, dict)):
1293
+ _logical_operator(filter_operator)
1294
+ raise _errors.Fault('filters must be a list or tuple')
1295
+ return cast('list[object] | tuple[object, ...] | dict[str, object]', filters)
1296
+
1297
+
1298
+ def translate_filters(
1299
+ filters: list[_T] | tuple[_T, ...] | dict[str, _T],
1300
+ filter_operator: str | None = None,
1301
+ optimize: bool = True,
1302
+ ) -> dict[str, object]:
1303
+ """Compile public filter syntax to the JSON-RPC condition tree.
1304
+
1305
+ Args:
1306
+ filters: List-, tuple-, or recursively nested dictionary-syntax
1307
+ filters.
1308
+ filter_operator: Combination mode for list- or tuple-syntax filters.
1309
+ optimize: Whether entity values may be projected to their wire keys.
1310
+
1311
+ Returns:
1312
+ A new condition tree using wire logical operators.
1313
+
1314
+ Raises:
1315
+ Fault: If an operator, filter container, or condition shape is invalid.
1316
+ """
1317
+ return _translate_filters(filters, filter_operator, optimize)
1318
+
1319
+
1320
+ def validate_auth(spec: AuthSpec) -> None:
1321
+ """Validate authentication credential combinations.
1322
+
1323
+ Args:
1324
+ spec: Authentication configuration to validate.
1325
+
1326
+ Raises:
1327
+ ValueError: If credentials are incomplete, conflicting, or absent.
1328
+ """
1329
+ has_script = spec.script_name is not None or spec.api_key is not None
1330
+ has_user = spec.user_login is not None or spec.user_password is not None
1331
+
1332
+ if spec.script_name is not None and spec.api_key is None:
1333
+ raise ValueError('script_name provided without api_key')
1334
+ if spec.api_key is not None and spec.script_name is None:
1335
+ raise ValueError('api_key provided without script_name')
1336
+ if spec.user_login is not None and spec.user_password is None:
1337
+ raise ValueError('login provided without password')
1338
+ if spec.user_password is not None and spec.user_login is None:
1339
+ raise ValueError('password provided without login')
1340
+ if has_script and has_user:
1341
+ raise ValueError('cannot use both script-based and user-based authentication')
1342
+ if spec.session_token is not None and (has_script or has_user):
1343
+ raise ValueError('session_token cannot be combined with other credentials')
1344
+ if spec.auth_token is not None and (spec.user_login is None or spec.user_password is None):
1345
+ raise ValueError('auth_token requires login and password')
1346
+ if not has_script and not has_user and spec.session_token is None:
1347
+ raise ValueError('no authentication credentials provided')