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
shotgun_api3/_crud.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
"""Shared pure builders and validators for CRUD and query operations."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import dataclasses
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
|
+
|
|
9
|
+
from . import _protocol
|
|
10
|
+
from ._errors import ShotgunError
|
|
11
|
+
from ._signatures import bind_arguments, expose_signature
|
|
12
|
+
|
|
13
|
+
_FIND_DOCSTRING = """Find entities matching the supplied filters.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
entity_type: Entity type to find.
|
|
17
|
+
filters: Filters in list or dict syntax.
|
|
18
|
+
fields: Optional fields to return; defaults to `id`.
|
|
19
|
+
order: Optional field and direction sort specifications.
|
|
20
|
+
filter_operator: `'all'` or `'any'` for list-syntax filters.
|
|
21
|
+
limit: Maximum entities to return, or `0` for no limit.
|
|
22
|
+
retired_only: Return retired entities instead of active entities.
|
|
23
|
+
page: Specific page to return, or `0` to read all pages.
|
|
24
|
+
include_archived_projects: Include entities from archived projects.
|
|
25
|
+
additional_filter_presets: Optional server-side filter presets.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Matching entities in server order.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
TypeError: If arguments do not match the documented signature.
|
|
32
|
+
ShotgunError: If an RPC fault or terminal transport failure
|
|
33
|
+
occurs, or if filters, paging, ordering, or a server result is invalid.
|
|
34
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
35
|
+
ValueError: If authenticated HTTP violates the configured origin policy.
|
|
36
|
+
"""
|
|
37
|
+
_FIND_ONE_DOCSTRING = """Return the first entity matching the supplied filters.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
entity_type: Entity type to find.
|
|
41
|
+
filters: Filters in list or dict syntax.
|
|
42
|
+
fields: Optional fields to return; defaults to `id`.
|
|
43
|
+
order: Optional field and direction sort specifications.
|
|
44
|
+
filter_operator: `'all'` or `'any'` for list-syntax filters.
|
|
45
|
+
retired_only: Return a retired entity instead of an active entity.
|
|
46
|
+
include_archived_projects: Include entities from archived projects.
|
|
47
|
+
additional_filter_presets: Optional server-side filter presets.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The first matching entity, or `None` when no entity matches.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
TypeError: If arguments do not match the documented signature.
|
|
54
|
+
ShotgunError: If an RPC fault or terminal transport failure
|
|
55
|
+
occurs, or if filters, paging, ordering, or a server result is invalid.
|
|
56
|
+
ProtocolError: If a server request returns an HTTP error.
|
|
57
|
+
ValueError: If authenticated HTTP violates the configured origin policy.
|
|
58
|
+
"""
|
|
59
|
+
_FIND_ONE_PARAMETERS = (
|
|
60
|
+
inspect.Parameter('entity_type', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str),
|
|
61
|
+
inspect.Parameter(
|
|
62
|
+
'filters',
|
|
63
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
64
|
+
annotation=list[Any] | tuple[Any, ...] | dict[str, Any],
|
|
65
|
+
),
|
|
66
|
+
inspect.Parameter(
|
|
67
|
+
'fields',
|
|
68
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
69
|
+
default=None,
|
|
70
|
+
annotation=list[str] | None,
|
|
71
|
+
),
|
|
72
|
+
inspect.Parameter(
|
|
73
|
+
'order',
|
|
74
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
75
|
+
default=None,
|
|
76
|
+
annotation=list[dict[str, str]] | None,
|
|
77
|
+
),
|
|
78
|
+
inspect.Parameter(
|
|
79
|
+
'filter_operator',
|
|
80
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
81
|
+
default=None,
|
|
82
|
+
annotation=str | None,
|
|
83
|
+
),
|
|
84
|
+
inspect.Parameter(
|
|
85
|
+
'retired_only',
|
|
86
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
87
|
+
default=False,
|
|
88
|
+
annotation=bool,
|
|
89
|
+
),
|
|
90
|
+
inspect.Parameter(
|
|
91
|
+
'include_archived_projects',
|
|
92
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
93
|
+
default=True,
|
|
94
|
+
annotation=bool,
|
|
95
|
+
),
|
|
96
|
+
inspect.Parameter(
|
|
97
|
+
'additional_filter_presets',
|
|
98
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
99
|
+
default=None,
|
|
100
|
+
annotation=object,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
_FIND_ONE_SIGNATURE = inspect.Signature(
|
|
104
|
+
_FIND_ONE_PARAMETERS,
|
|
105
|
+
return_annotation=dict[str, object] | None,
|
|
106
|
+
)
|
|
107
|
+
_FIND_PARAMETERS = (
|
|
108
|
+
inspect.Parameter('entity_type', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str),
|
|
109
|
+
inspect.Parameter(
|
|
110
|
+
'filters',
|
|
111
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
112
|
+
annotation=list[Any] | tuple[Any, ...] | dict[str, Any],
|
|
113
|
+
),
|
|
114
|
+
inspect.Parameter(
|
|
115
|
+
'fields',
|
|
116
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
117
|
+
default=None,
|
|
118
|
+
annotation=list[str] | None,
|
|
119
|
+
),
|
|
120
|
+
inspect.Parameter(
|
|
121
|
+
'order',
|
|
122
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
123
|
+
default=None,
|
|
124
|
+
annotation=list[dict[str, str]] | None,
|
|
125
|
+
),
|
|
126
|
+
inspect.Parameter(
|
|
127
|
+
'filter_operator',
|
|
128
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
129
|
+
default=None,
|
|
130
|
+
annotation=str | None,
|
|
131
|
+
),
|
|
132
|
+
inspect.Parameter(
|
|
133
|
+
'limit',
|
|
134
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
135
|
+
default=0,
|
|
136
|
+
annotation=int,
|
|
137
|
+
),
|
|
138
|
+
inspect.Parameter(
|
|
139
|
+
'retired_only',
|
|
140
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
141
|
+
default=False,
|
|
142
|
+
annotation=bool,
|
|
143
|
+
),
|
|
144
|
+
inspect.Parameter(
|
|
145
|
+
'page',
|
|
146
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
147
|
+
default=0,
|
|
148
|
+
annotation=int,
|
|
149
|
+
),
|
|
150
|
+
inspect.Parameter(
|
|
151
|
+
'include_archived_projects',
|
|
152
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
153
|
+
default=True,
|
|
154
|
+
annotation=bool,
|
|
155
|
+
),
|
|
156
|
+
inspect.Parameter(
|
|
157
|
+
'additional_filter_presets',
|
|
158
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
159
|
+
default=None,
|
|
160
|
+
annotation=object,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
_FIND_SIGNATURE = inspect.Signature(
|
|
164
|
+
_FIND_PARAMETERS,
|
|
165
|
+
return_annotation=list[dict[str, object]],
|
|
166
|
+
)
|
|
167
|
+
_METHOD_FIND_ONE_SIGNATURE = inspect.Signature(
|
|
168
|
+
(
|
|
169
|
+
inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
|
|
170
|
+
*_FIND_ONE_PARAMETERS,
|
|
171
|
+
),
|
|
172
|
+
return_annotation=dict[str, object] | None,
|
|
173
|
+
)
|
|
174
|
+
_METHOD_FIND_SIGNATURE = inspect.Signature(
|
|
175
|
+
(
|
|
176
|
+
inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
|
|
177
|
+
*_FIND_PARAMETERS,
|
|
178
|
+
),
|
|
179
|
+
return_annotation=list[dict[str, object]],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclasses.dataclass(frozen=True)
|
|
184
|
+
class _FindOneOptions:
|
|
185
|
+
"""Collect exact public `find_one` arguments for validation."""
|
|
186
|
+
|
|
187
|
+
entity_type: str
|
|
188
|
+
filters: list[Any] | tuple[Any, ...] | dict[str, Any]
|
|
189
|
+
fields: list[str] | None = None
|
|
190
|
+
order: list[dict[str, str]] | None = None
|
|
191
|
+
filter_operator: str | None = None
|
|
192
|
+
retired_only: bool = False
|
|
193
|
+
include_archived_projects: bool = True
|
|
194
|
+
additional_filter_presets: object = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclasses.dataclass(frozen=True)
|
|
198
|
+
class FindOptions:
|
|
199
|
+
"""Collect the exact public `find` arguments for runtime validation.
|
|
200
|
+
|
|
201
|
+
Attributes:
|
|
202
|
+
entity_type: Type of entity to query.
|
|
203
|
+
filters: Query filters or filter preset structure.
|
|
204
|
+
fields: Optional fields to return.
|
|
205
|
+
order: Optional field ordering directives.
|
|
206
|
+
filter_operator: Optional logical operator joining filters.
|
|
207
|
+
limit: Maximum number of records, or zero for no limit.
|
|
208
|
+
retired_only: Whether to return only retired entities.
|
|
209
|
+
page: One-based page number, or zero for automatic paging.
|
|
210
|
+
include_archived_projects: Whether results may include archived
|
|
211
|
+
projects.
|
|
212
|
+
additional_filter_presets: Optional server-side filter presets.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
entity_type: str
|
|
216
|
+
filters: list[Any] | tuple[Any, ...] | dict[str, Any]
|
|
217
|
+
fields: list[str] | None = None
|
|
218
|
+
order: list[dict[str, str]] | None = None
|
|
219
|
+
filter_operator: str | None = None
|
|
220
|
+
limit: int = 0
|
|
221
|
+
retired_only: bool = False
|
|
222
|
+
page: int = 0
|
|
223
|
+
include_archived_projects: bool = True
|
|
224
|
+
additional_filter_presets: object = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclasses.dataclass(frozen=True)
|
|
228
|
+
class _FindPlan:
|
|
229
|
+
"""Carry paging decisions for one read operation."""
|
|
230
|
+
|
|
231
|
+
limit: int
|
|
232
|
+
multi_page: bool
|
|
233
|
+
paging: dict[str, int]
|
|
234
|
+
params: dict[str, object]
|
|
235
|
+
single_page: bool
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclasses.dataclass(frozen=True)
|
|
239
|
+
class _PreparedFind:
|
|
240
|
+
"""Carry detached find inputs until server paging is known."""
|
|
241
|
+
|
|
242
|
+
limit: int
|
|
243
|
+
page: int
|
|
244
|
+
params: dict[str, object]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclasses.dataclass(frozen=True)
|
|
248
|
+
class _ReadPage:
|
|
249
|
+
"""Carry validated page entities and a stop decision."""
|
|
250
|
+
|
|
251
|
+
entities: list[dict[str, object]]
|
|
252
|
+
stop: bool
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_fields(
|
|
256
|
+
data: dict[str, object],
|
|
257
|
+
modes: dict[str, str] | None = None,
|
|
258
|
+
) -> list[dict[str, object]]:
|
|
259
|
+
"""Build independent wire field entries without mutating caller data."""
|
|
260
|
+
fields: list[dict[str, object]] = []
|
|
261
|
+
for name, value in data.items():
|
|
262
|
+
field = {
|
|
263
|
+
'field_name': name,
|
|
264
|
+
'value': _protocol.project_entities(value),
|
|
265
|
+
}
|
|
266
|
+
if modes is not None and name in modes:
|
|
267
|
+
field['multi_entity_update_mode'] = modes[name]
|
|
268
|
+
fields.append(field)
|
|
269
|
+
return fields
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_sorts(order: list[dict[str, str]] | None) -> list[dict[str, str]]:
|
|
273
|
+
"""Validate and copy read ordering into wire sort entries."""
|
|
274
|
+
sorts: list[dict[str, str]] = []
|
|
275
|
+
for index, item in enumerate(cast('list[object]', order or [])):
|
|
276
|
+
if not isinstance(item, dict):
|
|
277
|
+
raise ShotgunError(f'order item {index} must be a dictionary')
|
|
278
|
+
member = cast('dict[str, object]', item)
|
|
279
|
+
field_name = member.get('field_name')
|
|
280
|
+
if not isinstance(field_name, str) or not field_name:
|
|
281
|
+
raise ShotgunError(f"order item {index} is missing 'field_name'")
|
|
282
|
+
direction = member.get('direction', 'asc')
|
|
283
|
+
if not isinstance(direction, str) or direction not in {'asc', 'desc'}:
|
|
284
|
+
raise ShotgunError(f'order item {index} has invalid direction {direction!r}')
|
|
285
|
+
sorts.append({'field_name': field_name, 'direction': direction})
|
|
286
|
+
return sorts
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _require_modes(value: object, message: str) -> dict[str, str] | None:
|
|
290
|
+
"""Return dynamically validated optional update modes."""
|
|
291
|
+
if value is None:
|
|
292
|
+
return None
|
|
293
|
+
if not isinstance(value, dict):
|
|
294
|
+
raise ShotgunError(message)
|
|
295
|
+
mapping = cast('dict[object, object]', value)
|
|
296
|
+
if any(not isinstance(key, str) or not isinstance(item, str) for key, item in mapping.items()):
|
|
297
|
+
raise ShotgunError(message)
|
|
298
|
+
return cast('dict[str, str]', value)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _require_return_fields(value: object, message: str) -> list[str]:
|
|
302
|
+
"""Return copied string fields without substituting a default."""
|
|
303
|
+
if value is None:
|
|
304
|
+
return []
|
|
305
|
+
if not isinstance(value, list):
|
|
306
|
+
raise ShotgunError(message)
|
|
307
|
+
fields = cast('list[object]', value)
|
|
308
|
+
if any(not isinstance(field, str) for field in fields):
|
|
309
|
+
raise ShotgunError(message)
|
|
310
|
+
return cast('list[str]', list(fields))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_field_names(data: dict[object, object], message: str) -> None:
|
|
314
|
+
"""Require nonempty string field-name keys."""
|
|
315
|
+
if any(not isinstance(name, str) or not name for name in data):
|
|
316
|
+
raise ShotgunError(message)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _batch_create_call(index: int, member: dict[str, object]) -> dict[str, object]:
|
|
320
|
+
"""Build one validated batch create call."""
|
|
321
|
+
if 'data' not in member:
|
|
322
|
+
raise ShotgunError(f"batch create request {index} is missing 'data'")
|
|
323
|
+
data = member['data']
|
|
324
|
+
if not isinstance(data, dict):
|
|
325
|
+
raise ShotgunError(f'batch create request {index} data must be a dictionary')
|
|
326
|
+
validated_data = cast('dict[object, object]', data)
|
|
327
|
+
_validate_field_names(
|
|
328
|
+
validated_data,
|
|
329
|
+
f'batch create request {index} field names must be nonempty strings',
|
|
330
|
+
)
|
|
331
|
+
return_fields = _require_return_fields(
|
|
332
|
+
member.get('return_fields'),
|
|
333
|
+
f'batch create request {index} return_fields must be a list of strings or None',
|
|
334
|
+
) or ['id']
|
|
335
|
+
return {
|
|
336
|
+
'request_type': 'create',
|
|
337
|
+
'type': member['entity_type'],
|
|
338
|
+
'fields': _build_fields(cast('dict[str, object]', validated_data)),
|
|
339
|
+
'return_fields': return_fields,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _validate_nonnegative_integer(value: object, name: str) -> None:
|
|
344
|
+
"""Require a non-boolean, nonnegative integer argument."""
|
|
345
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
346
|
+
raise ShotgunError(f'{name} must be a nonnegative integer')
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def bind_find(
|
|
350
|
+
args: tuple[object, ...],
|
|
351
|
+
kwargs: dict[str, object],
|
|
352
|
+
client_name: str,
|
|
353
|
+
) -> FindOptions:
|
|
354
|
+
"""Bind runtime arguments with client-specific `find` errors.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
args: Positional public arguments.
|
|
358
|
+
kwargs: Keyword public arguments.
|
|
359
|
+
client_name: Public client class name for error attribution.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Bound find options retaining the supplied argument values without
|
|
363
|
+
copying mutable containers.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
TypeError: If the arguments do not match the public signature.
|
|
367
|
+
"""
|
|
368
|
+
bound = bind_arguments(_FIND_SIGNATURE, args, kwargs, f'{client_name}.find()')
|
|
369
|
+
options = cast('Callable[..., FindOptions]', FindOptions)
|
|
370
|
+
return options(**bound.arguments)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def bind_find_one(
|
|
374
|
+
args: tuple[object, ...],
|
|
375
|
+
kwargs: dict[str, object],
|
|
376
|
+
client_name: str,
|
|
377
|
+
) -> _FindOneOptions:
|
|
378
|
+
"""Bind runtime arguments with client-specific `find_one` errors.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
args: Positional public arguments.
|
|
382
|
+
kwargs: Keyword public arguments.
|
|
383
|
+
client_name: Public client class name for error attribution.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Bound find-one options retaining the supplied argument values without
|
|
387
|
+
copying mutable containers.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
TypeError: If the arguments do not match the public signature.
|
|
391
|
+
"""
|
|
392
|
+
bound = bind_arguments(_FIND_ONE_SIGNATURE, args, kwargs, f'{client_name}.find_one()')
|
|
393
|
+
options = cast('Callable[..., _FindOneOptions]', _FindOneOptions)
|
|
394
|
+
return options(**bound.arguments)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def build_entity_action_params(entity_type: str, entity_id: int) -> dict[str, object]:
|
|
398
|
+
"""Build parameters for delete and revive operations.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
entity_type: Entity type to mutate.
|
|
402
|
+
entity_id: Numeric entity identifier.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Detached entity identity parameters.
|
|
406
|
+
"""
|
|
407
|
+
return {'type': entity_type, 'id': entity_id}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def build_find_plan(prepared: _PreparedFind, per_page: int) -> _FindPlan:
|
|
411
|
+
"""Finalize one prepared find with the server page limit.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
prepared: Validated and detached find inputs.
|
|
415
|
+
per_page: Readiness-derived server page limit.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
A paging plan with mutable parameter and paging dictionaries.
|
|
419
|
+
Nested parameter values are shared with the prepared inputs.
|
|
420
|
+
"""
|
|
421
|
+
params = dict(prepared.params)
|
|
422
|
+
paging = {
|
|
423
|
+
'entities_per_page': per_page,
|
|
424
|
+
'current_page': max(prepared.page, 1),
|
|
425
|
+
}
|
|
426
|
+
if prepared.limit and prepared.limit <= per_page:
|
|
427
|
+
paging['entities_per_page'] = prepared.limit
|
|
428
|
+
params['paging'] = paging
|
|
429
|
+
single_page = prepared.page > 0
|
|
430
|
+
multi_page = not single_page and (prepared.limit == 0 or prepared.limit > per_page)
|
|
431
|
+
return _FindPlan(
|
|
432
|
+
limit=prepared.limit,
|
|
433
|
+
multi_page=multi_page,
|
|
434
|
+
paging=paging,
|
|
435
|
+
params=params,
|
|
436
|
+
single_page=single_page,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def build_page_params(plan: _FindPlan, page_number: int, convert_utc: bool) -> object:
|
|
441
|
+
"""Build transformed parameters for one independent read page.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
plan: Shared find plan.
|
|
445
|
+
page_number: Page number to request.
|
|
446
|
+
convert_utc: Captured outbound datetime conversion mode.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Detached transformed page parameters.
|
|
450
|
+
"""
|
|
451
|
+
built = dict(plan.params)
|
|
452
|
+
built['paging'] = {**plan.paging, 'current_page': page_number}
|
|
453
|
+
return _protocol.transform_outbound(built, convert_utc)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def expose_find_one_signature(function: Callable[..., object]) -> Callable[..., object]:
|
|
457
|
+
"""Expose the documented `find_one` signature and documentation.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
function: Variadic method implementation.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
The same function with its public runtime contract attached.
|
|
464
|
+
"""
|
|
465
|
+
return expose_signature(function, _METHOD_FIND_ONE_SIGNATURE, _FIND_ONE_DOCSTRING)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def expose_find_signature(function: Callable[..., object]) -> Callable[..., object]:
|
|
469
|
+
"""Expose the documented `find` signature and documentation.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
function: Variadic method implementation.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
The same function with its public runtime contract attached.
|
|
476
|
+
"""
|
|
477
|
+
return expose_signature(function, _METHOD_FIND_SIGNATURE, _FIND_DOCSTRING)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def finalize_find(
|
|
481
|
+
records: list[dict[str, object]],
|
|
482
|
+
options: FindOptions,
|
|
483
|
+
plan: _FindPlan,
|
|
484
|
+
local_path_field: str | None,
|
|
485
|
+
) -> list[dict[str, object]]:
|
|
486
|
+
"""Apply shared limit and local-path result normalization.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
records: Validated records accumulated in wire order.
|
|
490
|
+
options: Bound public find arguments.
|
|
491
|
+
plan: Shared find plan.
|
|
492
|
+
local_path_field: Platform-specific local path source key.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Final normalized result list.
|
|
496
|
+
"""
|
|
497
|
+
if options.limit and not plan.single_page:
|
|
498
|
+
records = records[: options.limit]
|
|
499
|
+
return _protocol.inject_local_paths(records, local_path_field)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def find_options_from_one(options: _FindOneOptions) -> FindOptions:
|
|
503
|
+
"""Convert bound find-one arguments into a one-result find plan.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
options: Bound public find-one arguments.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Equivalent one-page find options.
|
|
510
|
+
"""
|
|
511
|
+
return FindOptions(
|
|
512
|
+
entity_type=options.entity_type,
|
|
513
|
+
filters=options.filters,
|
|
514
|
+
fields=options.fields,
|
|
515
|
+
order=options.order,
|
|
516
|
+
filter_operator=options.filter_operator,
|
|
517
|
+
limit=1,
|
|
518
|
+
retired_only=options.retired_only,
|
|
519
|
+
page=1,
|
|
520
|
+
include_archived_projects=options.include_archived_projects,
|
|
521
|
+
additional_filter_presets=options.additional_filter_presets,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def first_result(results: list[dict[str, object]]) -> dict[str, object] | None:
|
|
526
|
+
"""Return the first find result when present.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
results: Validated find result list.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
First entity or `None`.
|
|
533
|
+
"""
|
|
534
|
+
return results[0] if results else None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def prepare_find(options: FindOptions) -> _PreparedFind:
|
|
538
|
+
"""Validate and detach one find before operation I/O.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
options: Bound public find arguments.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Detached inputs ready for server-aware paging.
|
|
545
|
+
|
|
546
|
+
Raises:
|
|
547
|
+
ShotgunError: If paging or ordering is invalid.
|
|
548
|
+
Fault: If filter syntax is invalid.
|
|
549
|
+
"""
|
|
550
|
+
_validate_nonnegative_integer(options.limit, 'limit')
|
|
551
|
+
_validate_nonnegative_integer(options.page, 'page')
|
|
552
|
+
translated_filters = copy.deepcopy(
|
|
553
|
+
_protocol.translate_filters(
|
|
554
|
+
_protocol.require_filter_container(options.filters, options.filter_operator),
|
|
555
|
+
options.filter_operator,
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
params: dict[str, object] = {
|
|
559
|
+
'type': copy.deepcopy(options.entity_type),
|
|
560
|
+
'return_fields': copy.deepcopy(list(options.fields)) if options.fields else ['id'],
|
|
561
|
+
'filters': translated_filters,
|
|
562
|
+
'return_only': 'retired' if options.retired_only else 'active',
|
|
563
|
+
'paging': {'current_page': max(options.page, 1)},
|
|
564
|
+
'api_return_image_urls': True,
|
|
565
|
+
}
|
|
566
|
+
sorts = _read_sorts(options.order)
|
|
567
|
+
if sorts:
|
|
568
|
+
params['sorts'] = sorts
|
|
569
|
+
if not options.include_archived_projects:
|
|
570
|
+
params['include_archived_projects'] = False
|
|
571
|
+
if options.additional_filter_presets is not None:
|
|
572
|
+
params['additional_filter_presets'] = copy.deepcopy(options.additional_filter_presets)
|
|
573
|
+
return _PreparedFind(limit=options.limit, page=options.page, params=params)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def build_thumbnail_read_params(
|
|
577
|
+
entity_type: str,
|
|
578
|
+
entity_id: int,
|
|
579
|
+
field_name: Literal['filmstrip_image', 'image'],
|
|
580
|
+
records_per_page: int,
|
|
581
|
+
) -> dict[str, object]:
|
|
582
|
+
"""Build the single-page read for a source thumbnail field.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
entity_type: Entity type owning the source thumbnail.
|
|
586
|
+
entity_id: Identifier of the source entity.
|
|
587
|
+
field_name: Thumbnail field to check.
|
|
588
|
+
records_per_page: Server page limit from readiness.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Parameters for a one-entity read.
|
|
592
|
+
"""
|
|
593
|
+
return build_find_plan(
|
|
594
|
+
prepare_find(
|
|
595
|
+
FindOptions(
|
|
596
|
+
entity_type=entity_type,
|
|
597
|
+
filters=[['id', 'is', entity_id]],
|
|
598
|
+
fields=[field_name],
|
|
599
|
+
limit=1,
|
|
600
|
+
page=1,
|
|
601
|
+
)
|
|
602
|
+
),
|
|
603
|
+
records_per_page,
|
|
604
|
+
).params
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def require_boolean_result(value: object, method_name: str) -> bool:
|
|
608
|
+
"""Return an exact boolean RPC result or raise a value-free error.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
value: RPC result.
|
|
612
|
+
method_name: Logical method name for the error.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Validated boolean.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
ShotgunError: If the result is not a boolean.
|
|
619
|
+
"""
|
|
620
|
+
if not isinstance(value, bool):
|
|
621
|
+
raise ShotgunError(f'{method_name} did not return a boolean')
|
|
622
|
+
return value
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def require_dictionary(value: object, message: str) -> dict[object, object]:
|
|
626
|
+
"""Return a dictionary after validating its container type.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
value: Candidate mapping.
|
|
630
|
+
message: Value-free validation error.
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
The original dictionary. Key and value types are not checked.
|
|
634
|
+
|
|
635
|
+
Raises:
|
|
636
|
+
ShotgunError: If the value is not a dictionary.
|
|
637
|
+
"""
|
|
638
|
+
if not isinstance(value, dict):
|
|
639
|
+
raise ShotgunError(message)
|
|
640
|
+
return cast('dict[object, object]', value)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def build_create_params(
|
|
644
|
+
entity_type: str,
|
|
645
|
+
data: object,
|
|
646
|
+
return_fields: list[str] | None,
|
|
647
|
+
) -> dict[str, object]:
|
|
648
|
+
"""Build validated detached create parameters.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
entity_type: Entity type to create.
|
|
652
|
+
data: Public field mapping.
|
|
653
|
+
return_fields: Optional requested return fields.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Create RPC parameters.
|
|
657
|
+
|
|
658
|
+
Raises:
|
|
659
|
+
ShotgunError: If the data mapping, field names, or return fields are
|
|
660
|
+
invalid.
|
|
661
|
+
"""
|
|
662
|
+
validated_data = require_dictionary(data, 'create data must be a dictionary')
|
|
663
|
+
_validate_field_names(validated_data, 'create field names must be nonempty strings')
|
|
664
|
+
message = 'create return_fields must be an iterable of strings or None'
|
|
665
|
+
if isinstance(return_fields, (str, bytes)):
|
|
666
|
+
raise ShotgunError(message)
|
|
667
|
+
use_default = not return_fields
|
|
668
|
+
try:
|
|
669
|
+
collected_fields = [] if return_fields is None else list(return_fields)
|
|
670
|
+
except TypeError as error:
|
|
671
|
+
raise ShotgunError(message) from error
|
|
672
|
+
fields = _require_return_fields(collected_fields, message)
|
|
673
|
+
return {
|
|
674
|
+
'type': entity_type,
|
|
675
|
+
'fields': _build_fields(cast('dict[str, object]', validated_data)),
|
|
676
|
+
'return_fields': ['id'] if use_default else fields,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def build_update_params(
|
|
681
|
+
entity_type: str,
|
|
682
|
+
entity_id: int,
|
|
683
|
+
data: object,
|
|
684
|
+
multi_entity_update_modes: object,
|
|
685
|
+
) -> dict[str, object]:
|
|
686
|
+
"""Build validated detached update parameters.
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
entity_type: Entity type to update.
|
|
690
|
+
entity_id: Numeric entity identifier.
|
|
691
|
+
data: Public field mapping.
|
|
692
|
+
multi_entity_update_modes: Optional per-field update modes.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
Update RPC parameters.
|
|
696
|
+
|
|
697
|
+
Raises:
|
|
698
|
+
ShotgunError: If data, field names, or modes are invalid.
|
|
699
|
+
"""
|
|
700
|
+
validated_data = require_dictionary(data, 'update data must be a dictionary')
|
|
701
|
+
_validate_field_names(validated_data, 'update field names must be nonempty strings')
|
|
702
|
+
validated_modes = _require_modes(
|
|
703
|
+
multi_entity_update_modes,
|
|
704
|
+
'multi_entity_update_modes must map strings to strings',
|
|
705
|
+
)
|
|
706
|
+
return {
|
|
707
|
+
'type': entity_type,
|
|
708
|
+
'id': entity_id,
|
|
709
|
+
'fields': _build_fields(cast('dict[str, object]', validated_data), validated_modes),
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def require_dictionary_result(value: object, method_name: str) -> dict[str, object]:
|
|
714
|
+
"""Return a dictionary RPC result or raise a value-free error.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
value: RPC result.
|
|
718
|
+
method_name: Logical method name for the error.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
Validated dictionary.
|
|
722
|
+
|
|
723
|
+
Raises:
|
|
724
|
+
ShotgunError: If the result is not a dictionary.
|
|
725
|
+
"""
|
|
726
|
+
if not isinstance(value, dict):
|
|
727
|
+
raise ShotgunError(f'{method_name} did not return a dictionary')
|
|
728
|
+
return cast('dict[str, object]', value)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def require_entity_id(value: object, message: str) -> int:
|
|
732
|
+
"""Return a non-boolean integer entity identifier.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
value: Candidate identifier.
|
|
736
|
+
message: Value-free validation error.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
Validated identifier.
|
|
740
|
+
|
|
741
|
+
Raises:
|
|
742
|
+
ShotgunError: If the value is not an integer identifier.
|
|
743
|
+
"""
|
|
744
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
745
|
+
raise ShotgunError(message)
|
|
746
|
+
return value
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _batch_delete_call(index: int, member: dict[str, object]) -> dict[str, object]:
|
|
750
|
+
"""Build one validated batch delete call."""
|
|
751
|
+
if 'entity_id' not in member:
|
|
752
|
+
raise ShotgunError(f"batch delete request {index} is missing 'entity_id'")
|
|
753
|
+
entity_id = require_entity_id(
|
|
754
|
+
member['entity_id'],
|
|
755
|
+
f'batch delete request {index} entity_id must be an integer',
|
|
756
|
+
)
|
|
757
|
+
return {
|
|
758
|
+
'request_type': 'delete',
|
|
759
|
+
'type': member['entity_type'],
|
|
760
|
+
'id': entity_id,
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _batch_update_call(index: int, member: dict[str, object]) -> dict[str, object]:
|
|
765
|
+
"""Build one validated batch update call."""
|
|
766
|
+
if 'entity_id' not in member:
|
|
767
|
+
raise ShotgunError(f"batch update request {index} is missing 'entity_id'")
|
|
768
|
+
entity_id = require_entity_id(
|
|
769
|
+
member['entity_id'],
|
|
770
|
+
f'batch update request {index} entity_id must be an integer',
|
|
771
|
+
)
|
|
772
|
+
if 'data' not in member:
|
|
773
|
+
raise ShotgunError(f"batch update request {index} is missing 'data'")
|
|
774
|
+
data = member['data']
|
|
775
|
+
if not isinstance(data, dict):
|
|
776
|
+
raise ShotgunError(f'batch update request {index} data must be a dictionary')
|
|
777
|
+
validated_data = cast('dict[object, object]', data)
|
|
778
|
+
_validate_field_names(
|
|
779
|
+
validated_data,
|
|
780
|
+
f'batch update request {index} field names must be nonempty strings',
|
|
781
|
+
)
|
|
782
|
+
modes = _require_modes(
|
|
783
|
+
member.get('multi_entity_update_modes'),
|
|
784
|
+
f'batch update request {index} modes must map strings to strings',
|
|
785
|
+
)
|
|
786
|
+
return {
|
|
787
|
+
'request_type': 'update',
|
|
788
|
+
'type': member['entity_type'],
|
|
789
|
+
'id': entity_id,
|
|
790
|
+
'fields': _build_fields(cast('dict[str, object]', validated_data), modes),
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _batch_call(index: int, request: object) -> dict[str, object]:
|
|
795
|
+
"""Validate and build one batch member without exposing its values."""
|
|
796
|
+
if not isinstance(request, dict):
|
|
797
|
+
raise ShotgunError(f'batch request {index} must be a dictionary')
|
|
798
|
+
member = cast('dict[str, object]', request)
|
|
799
|
+
if 'request_type' not in member:
|
|
800
|
+
raise ShotgunError(f"batch request {index} is missing 'request_type'")
|
|
801
|
+
if 'entity_type' not in member:
|
|
802
|
+
raise ShotgunError(f"batch request {index} is missing 'entity_type'")
|
|
803
|
+
entity_type = member['entity_type']
|
|
804
|
+
if not isinstance(entity_type, str) or not entity_type:
|
|
805
|
+
raise ShotgunError(f'batch request {index} has invalid entity_type')
|
|
806
|
+
request_type = member['request_type']
|
|
807
|
+
if not isinstance(request_type, str) or not request_type:
|
|
808
|
+
raise ShotgunError(f'batch request {index} has invalid request_type')
|
|
809
|
+
if request_type == 'create':
|
|
810
|
+
return _batch_create_call(index, member)
|
|
811
|
+
if request_type == 'update':
|
|
812
|
+
return _batch_update_call(index, member)
|
|
813
|
+
if request_type == 'delete':
|
|
814
|
+
return _batch_delete_call(index, member)
|
|
815
|
+
raise ShotgunError(f'batch request {index} has invalid request_type')
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def require_list(value: object, message: str) -> list[object]:
|
|
819
|
+
"""Return a dynamically validated list.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
value: Candidate list.
|
|
823
|
+
message: Value-free validation error.
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
Validated list.
|
|
827
|
+
|
|
828
|
+
Raises:
|
|
829
|
+
ShotgunError: If the value is not a list.
|
|
830
|
+
"""
|
|
831
|
+
if not isinstance(value, list):
|
|
832
|
+
raise ShotgunError(message)
|
|
833
|
+
return cast('list[object]', value)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def build_batch_calls(requests: object) -> list[dict[str, object]]:
|
|
837
|
+
"""Validate and build detached calls for one batch request.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
requests: Public batch request value.
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Validated wire calls in caller order.
|
|
844
|
+
|
|
845
|
+
Raises:
|
|
846
|
+
ShotgunError: If the batch or a member is malformed.
|
|
847
|
+
"""
|
|
848
|
+
validated_requests = require_list(requests, 'batch requests must be a list')
|
|
849
|
+
return [_batch_call(index, request) for index, request in enumerate(validated_requests)]
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def require_list_result(value: object, method_name: str) -> list[object]:
|
|
853
|
+
"""Return a list RPC result or raise a value-free error.
|
|
854
|
+
|
|
855
|
+
Args:
|
|
856
|
+
value: RPC result.
|
|
857
|
+
method_name: Logical method name for the error.
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
Validated list.
|
|
861
|
+
|
|
862
|
+
Raises:
|
|
863
|
+
ShotgunError: If the result is not a list.
|
|
864
|
+
"""
|
|
865
|
+
if not isinstance(value, list):
|
|
866
|
+
raise ShotgunError(f'{method_name} did not return a list')
|
|
867
|
+
return cast('list[object]', value)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def validate_read_page(
|
|
871
|
+
raw_result: object,
|
|
872
|
+
*,
|
|
873
|
+
convert_utc: bool,
|
|
874
|
+
records_count: int,
|
|
875
|
+
plan: _FindPlan,
|
|
876
|
+
) -> _ReadPage:
|
|
877
|
+
"""Normalize and validate one read page with its stop decision.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
raw_result: Raw driver result.
|
|
881
|
+
convert_utc: Captured inbound datetime conversion mode.
|
|
882
|
+
records_count: Count accumulated before this page.
|
|
883
|
+
plan: Shared find plan.
|
|
884
|
+
|
|
885
|
+
Returns:
|
|
886
|
+
Validated entities and stop decision.
|
|
887
|
+
|
|
888
|
+
Raises:
|
|
889
|
+
ShotgunError: If the read result is malformed.
|
|
890
|
+
"""
|
|
891
|
+
inbound = _protocol.transform_inbound(raw_result, convert_utc)
|
|
892
|
+
if not isinstance(inbound, dict):
|
|
893
|
+
raise ShotgunError('read did not return a dictionary')
|
|
894
|
+
result = cast('dict[str, object]', inbound)
|
|
895
|
+
raw_entities = result.get('entities', [])
|
|
896
|
+
if not isinstance(raw_entities, list):
|
|
897
|
+
raise ShotgunError('read did not return an entities list')
|
|
898
|
+
entities = cast('list[object]', raw_entities)
|
|
899
|
+
if any(not isinstance(entity, dict) for entity in entities):
|
|
900
|
+
raise ShotgunError('read returned a non-dictionary entity')
|
|
901
|
+
raw_paging_info = result.get('paging_info', {})
|
|
902
|
+
if not isinstance(raw_paging_info, dict):
|
|
903
|
+
raise ShotgunError('read did not return a paging_info dictionary')
|
|
904
|
+
paging_info = cast('dict[str, object]', raw_paging_info)
|
|
905
|
+
next_count = records_count + len(entities)
|
|
906
|
+
verdict = _protocol.page_verdict(
|
|
907
|
+
paging_info,
|
|
908
|
+
len(entities),
|
|
909
|
+
next_count,
|
|
910
|
+
plan.paging['entities_per_page'],
|
|
911
|
+
plan.limit,
|
|
912
|
+
)
|
|
913
|
+
return _ReadPage(
|
|
914
|
+
entities=cast('list[dict[str, object]]', entities),
|
|
915
|
+
stop=plan.single_page or verdict == 'stop',
|
|
916
|
+
)
|