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.
- sg_python_api-0.1.0.dist-info/METADATA +177 -0
- sg_python_api-0.1.0.dist-info/RECORD +31 -0
- sg_python_api-0.1.0.dist-info/WHEEL +4 -0
- sg_python_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- shotgun_api3/__init__.py +68 -0
- shotgun_api3/_async_client.py +2906 -0
- shotgun_api3/_async_driver.py +945 -0
- shotgun_api3/_async_httpx2.py +662 -0
- shotgun_api3/_async_transport.py +162 -0
- shotgun_api3/_capabilities.py +165 -0
- shotgun_api3/_client.py +2682 -0
- shotgun_api3/_config.py +1463 -0
- shotgun_api3/_crud.py +916 -0
- shotgun_api3/_driver.py +879 -0
- shotgun_api3/_errors.py +49 -0
- shotgun_api3/_execution.py +273 -0
- shotgun_api3/_files.py +1094 -0
- shotgun_api3/_methods.py +1205 -0
- shotgun_api3/_protocol.py +1347 -0
- shotgun_api3/_sentinel.py +5 -0
- shotgun_api3/_sg_timezone.py +5 -0
- shotgun_api3/_signatures.py +53 -0
- shotgun_api3/_transport.py +901 -0
- shotgun_api3/_version.py +3 -0
- shotgun_api3/lib/__init__.py +1 -0
- shotgun_api3/lib/mockgun/__init__.py +16 -0
- shotgun_api3/lib/mockgun/_async.py +315 -0
- shotgun_api3/lib/mockgun/_mockgun.py +1666 -0
- shotgun_api3/lib/sgtimezone.py +209 -0
- shotgun_api3/py.typed +0 -0
- shotgun_api3/shotgun.py +58 -0
|
@@ -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('<', '<')
|
|
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')
|