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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.0.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1031 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +558 -0
  28. shotgun_api3/lib/mockgun/_links.py +440 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2163 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +368 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1446 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +964 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
  56. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,492 @@
1
+ """Validate and select the observed additional filter preset combinations."""
2
+
3
+ import datetime
4
+ from collections.abc import Generator, Mapping
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass
8
+ from typing import cast
9
+
10
+ from ._errors import MockgunError, ServerFaultError
11
+ from ._links import RecordState, hidden
12
+ from ._schema import SiteSchema
13
+ from ._store import FieldValue, Row, Store
14
+
15
+ _ASSUME: ContextVar[Mapping[tuple[str, int], RecordState] | None] = ContextVar(
16
+ 'mockgun_preset_assume', default=None
17
+ )
18
+ _EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)
19
+ _LATEST_MODES = {
20
+ 'Version': ('ENTITIES_CREATED_AT', 'BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT'),
21
+ 'PublishedFile': ('ENTITIES_CREATED_AT', 'VERSION_NUMBER'),
22
+ 'Cut': ('REVISION_NUMBER',),
23
+ }
24
+ _MAX_COMBINED_PRESETS = 2
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Preset:
29
+ """Represent an immutable validated preset and its selection arguments.
30
+
31
+ Attributes:
32
+ name: `LATEST` or `CUT_SHOT_VERSIONS`.
33
+ latest_by: Ranking mode for `LATEST`, otherwise `None`.
34
+ cut_id: Cut identifier for `CUT_SHOT_VERSIONS`, or `None` when no Cut
35
+ was supplied.
36
+ """
37
+
38
+ name: str
39
+ latest_by: str | None = None
40
+ cut_id: int | None = None
41
+
42
+
43
+ def _error(entity_type: str, detail: str, name: str = 'LATEST') -> MockgunError:
44
+ """Name the preset and its affected field or group in an unsettled case."""
45
+ return MockgunError(f'mockgun cannot select {entity_type} {name} preset: {detail}')
46
+
47
+
48
+ def _identity(value: FieldValue) -> tuple[str, int] | None:
49
+ """Return a stored link's identity, treating a null link as its own group."""
50
+ if value is None:
51
+ return None
52
+ link = cast('Row', value)
53
+ return cast('str', link['type']), cast('int', link['id'])
54
+
55
+
56
+ def _cut_items(store: Store, cut_id: int | None) -> list[Row] | None:
57
+ """Resolve reached CutItems with the preset's retirement and stored-cut checks."""
58
+ if cut_id is None or store.row('Cut', cut_id) is None:
59
+ return None
60
+ items: list[Row] = []
61
+ for retired in (False, True):
62
+ for item in store.rows('CutItem', retired=retired):
63
+ # A retired Cut still resolves; reading CutItem.cut through hidden() would
64
+ # incorrectly hide precisely that supported case.
65
+ store.check_known(item, 'cut')
66
+ if _identity(item.get('cut')) != ('Cut', cut_id):
67
+ continue
68
+ assumed = (_ASSUME.get() or {}).get(
69
+ (cast('str', item['type']), cast('int', item['id']))
70
+ )
71
+ if store.tainted(item) or (assumed.retired if assumed is not None else retired):
72
+ raise _error('Version', 'retired CutItem.cut entity link', 'CUT_SHOT_VERSIONS')
73
+ items.append(item)
74
+ return items
75
+
76
+
77
+ def _known_value(store: Store, row: Row, field: str) -> FieldValue:
78
+ """Read a stored preset value after rejecting its uncertainty marker."""
79
+ store.check_known(row, field)
80
+ return row.get(field)
81
+
82
+
83
+ def _linked(store: Store, link: FieldValue, entity_type: str) -> Row | None:
84
+ """Resolve a stored link without schema, retirement or group checks."""
85
+ identity = _identity(link)
86
+ if identity is None:
87
+ return None
88
+ try:
89
+ row = store.row(*identity)
90
+ except ServerFaultError as exc:
91
+ raise _error(entity_type, f'missing linked record {identity}') from exc
92
+ if row is None:
93
+ raise _error(entity_type, f'missing linked record {identity}')
94
+ return row
95
+
96
+
97
+ def _pair(value: FieldValue) -> tuple[int, int]:
98
+ """Encode a nullable integer or creation timestamp without losing microseconds."""
99
+ if value is None:
100
+ return 0, 0
101
+ if isinstance(value, datetime.datetime):
102
+ delta = value.astimezone(datetime.UTC) - _EPOCH
103
+ return 1, (delta.days * 86400 + delta.seconds) * 1000000 + delta.microseconds
104
+ return 1, cast('int', value)
105
+
106
+
107
+ def _read(store: Store, row: Row, field: str, preset: str = 'LATEST') -> FieldValue:
108
+ """Read a declared preset field and reject missing or hidden link targets."""
109
+ entity_type = cast('str', row['type'])
110
+ if not store.schema.declares(entity_type, field):
111
+ raise _error(entity_type, f'{entity_type}.{field} is undeclared', preset)
112
+ store.check_known(row, field)
113
+ value = row.get(field)
114
+ if isinstance(value, dict):
115
+ if hidden(store, row, field, value, assume=_ASSUME.get()):
116
+ raise _error(entity_type, f'{entity_type}.{field} is a hidden entity link', preset)
117
+ _linked(store, value, entity_type)
118
+ return value
119
+
120
+
121
+ def _cut_versions(store: Store, rows: list[Row], cut_id: int | None) -> list[Row]:
122
+ """Select Versions by active CutItems' shot links, ignoring their versions."""
123
+ items = _cut_items(store, cut_id)
124
+ if items is None:
125
+ return []
126
+ shots: set[tuple[str, int] | None] = set()
127
+ for item in items:
128
+ shot = _read(store, item, 'shot', 'CUT_SHOT_VERSIONS')
129
+ identity = _identity(shot)
130
+ if identity is not None and identity[0] == 'Shot':
131
+ shots.add(identity)
132
+ return [
133
+ row for row in rows if _identity(_read(store, row, 'entity', 'CUT_SHOT_VERSIONS')) in shots
134
+ ]
135
+
136
+
137
+ def _step(store: Store, row: Row) -> Row | None:
138
+ """Read pipeline links with their hidden-link and declaration guards."""
139
+ task = _linked(store, _read(store, row, 'sg_task'), 'Version')
140
+ if task is None:
141
+ return None
142
+ step = _linked(store, _read(store, task, 'step'), 'Version')
143
+ if step is not None and _read(store, step, 'list_order') is None:
144
+ raise _error('Version', 'Step.list_order number is null')
145
+ return step
146
+
147
+
148
+ def _validate_fields(schema: SiteSchema, entity_type: str, preset: Preset) -> None:
149
+ """Require declared selection fields even when the candidate set is empty."""
150
+ fields = [(entity_type, 'entity')]
151
+ if preset.name == 'CUT_SHOT_VERSIONS':
152
+ fields += [('CutItem', 'cut'), ('CutItem', 'shot')]
153
+ else:
154
+ if entity_type == 'PublishedFile':
155
+ fields += [(entity_type, field) for field in ('task', 'name', 'published_file_type')]
156
+ if entity_type == 'Cut':
157
+ fields += [('Cut', 'code'), ('Cut', 'revision_number')]
158
+ if preset.latest_by == 'VERSION_NUMBER':
159
+ fields.append((entity_type, 'version_number'))
160
+ else:
161
+ fields.append((entity_type, 'created_at'))
162
+ if preset.latest_by == 'BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT':
163
+ fields += [('Version', 'sg_task'), ('Task', 'step'), ('Step', 'list_order')]
164
+ for kind, field in fields:
165
+ if not schema.declares(kind, field):
166
+ raise _error(entity_type, f'{kind}.{field} is undeclared', preset.name)
167
+
168
+
169
+ def _validate_group(store: Store, entity_type: str, group: list[Row], mode: str) -> None:
170
+ """Check field and pipeline invariants before comparing ranking keys."""
171
+ if entity_type == 'PublishedFile':
172
+ types = {_identity(_read(store, row, 'published_file_type')) for row in group}
173
+ if len(types) > 1:
174
+ raise _error(entity_type, 'published_file_type entity differs within group')
175
+ if mode == 'BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT':
176
+ steps = [step for row in group if (step := _step(store, row)) is not None]
177
+ if steps:
178
+ best = max(cast('int', step['list_order']) for step in steps)
179
+ if len({cast('int', step['id']) for step in steps if step['list_order'] == best}) > 1:
180
+ raise _error(entity_type, 'Step.list_order number has distinct best Steps')
181
+ if (
182
+ mode == 'VERSION_NUMBER'
183
+ and len(group) > 1
184
+ and any(_known_value(store, row, 'version_number') is None for row in group)
185
+ ):
186
+ raise _error(entity_type, 'version_number number is null in a comparison')
187
+ if (
188
+ mode == 'REVISION_NUMBER'
189
+ and len(group) > 1
190
+ and all(_known_value(store, row, 'revision_number') is None for row in group)
191
+ ):
192
+ raise _error(entity_type, 'revision_number number is null throughout group')
193
+
194
+
195
+ def _validate_latest(entity_type: str, mode: object) -> None:
196
+ """Reject unsupported latest modes, preserving ambiguous validation precedence."""
197
+ if entity_type not in _LATEST_MODES:
198
+ if mode is None or not any(mode in modes for modes in _LATEST_MODES.values()):
199
+ raise _error(entity_type, 'unsupported type and latest_by')
200
+ raise ServerFaultError(
201
+ f'API read() LATEST preset is not supported for {entity_type}', error_code=103
202
+ )
203
+ if mode is None:
204
+ raise ServerFaultError('API read() LATEST preset requires latest_by', error_code=104)
205
+ if mode not in _LATEST_MODES[entity_type]:
206
+ raise ServerFaultError(
207
+ f'API read() latest_by {mode} is not valid for {entity_type}', error_code=104
208
+ )
209
+
210
+
211
+ def _validate_item(entity_type: str, item: object) -> Preset:
212
+ """Distinguish one observed rejection from overlapping or unsettled failures."""
213
+ if not isinstance(item, dict):
214
+ raise _error(entity_type, 'preset item is not a dictionary')
215
+ data = cast('dict[str, object]', item)
216
+ if data.keys() - {'preset_name', 'latest_by', 'cut_id'}:
217
+ raise _error(entity_type, 'unknown preset keys')
218
+ if 'cut_id' in data and type(data['cut_id']) is not int:
219
+ raise _error(entity_type, 'cut_id must be an int')
220
+ name, mode = data.get('preset_name'), data.get('latest_by')
221
+ if name == 'LATEST':
222
+ _validate_latest(entity_type, mode)
223
+ elif name == 'CUT_SHOT_VERSIONS':
224
+ if entity_type != 'Version':
225
+ if 'cut_id' not in data:
226
+ raise _error(
227
+ entity_type, 'unsupported type and missing cut_id', 'CUT_SHOT_VERSIONS'
228
+ )
229
+ raise ServerFaultError(
230
+ f'API read() CUT_SHOT_VERSIONS preset is not supported for {entity_type}',
231
+ error_code=103,
232
+ )
233
+ else:
234
+ raise ServerFaultError(f'API read() unknown preset_name {name}', error_code=103)
235
+ if (name == 'LATEST' and 'cut_id' in data) or (
236
+ name == 'CUT_SHOT_VERSIONS' and 'latest_by' in data
237
+ ):
238
+ raise _error(entity_type, 'unsupported preset argument combination')
239
+ return Preset(
240
+ cast('str', name), cast('str | None', mode), cast('int | None', data.get('cut_id'))
241
+ )
242
+
243
+
244
+ @contextmanager
245
+ def assuming(
246
+ assume: Mapping[tuple[str, int], RecordState] | None,
247
+ ) -> Generator[None, None, None]:
248
+ """Use link-state assumptions for this scope and restore the enclosing state.
249
+
250
+ Args:
251
+ assume: States keyed by `(entity_type, entity_id)`, or `None` to
252
+ evaluate this scope without inherited link-state assumptions.
253
+ """
254
+ token = _ASSUME.set(assume)
255
+ try:
256
+ yield
257
+ finally:
258
+ _ASSUME.reset(token)
259
+
260
+
261
+ def latest_groups(store: Store, entity_type: str, rows: list[Row]) -> list[list[Row]]:
262
+ """Group eligible LATEST candidates using their stored membership fields.
263
+
264
+ Versions group by entity, PublishedFiles by entity, task and name, and Cuts
265
+ by code and entity. Non-Cut rows without an entity are excluded;
266
+ PublishedFiles without a published-file type are also excluded.
267
+
268
+ Args:
269
+ store: Site records used to resolve grouping fields and links.
270
+ entity_type: Candidate entity type.
271
+ rows: Candidate rows in encounter order.
272
+
273
+ Returns:
274
+ Groups in first-encounter order, containing the original row objects in
275
+ encounter order.
276
+
277
+ Raises:
278
+ MockgunError: If required grouping fields or linked records are
279
+ undeclared, missing, hidden, unknown, or otherwise unsettled.
280
+ """
281
+ groups: dict[tuple[object, ...], list[Row]] = {}
282
+ for row in rows:
283
+ entity = _identity(_read(store, row, 'entity'))
284
+ if entity_type != 'Cut' and entity is None:
285
+ continue
286
+ if entity_type == 'PublishedFile':
287
+ if _read(store, row, 'published_file_type') is None:
288
+ continue
289
+ task = _identity(_read(store, row, 'task'))
290
+ name = _read(store, row, 'name')
291
+ if task is None or name is None:
292
+ raise _error(entity_type, 'task entity or name text is null')
293
+ key = (entity, task, name)
294
+ elif entity_type == 'Cut':
295
+ code = _read(store, row, 'code')
296
+ if code is None:
297
+ raise _error(entity_type, 'code text is null')
298
+ key = (code, entity)
299
+ else:
300
+ key = (entity,)
301
+ groups.setdefault(key, []).append(row)
302
+ return list(groups.values())
303
+
304
+
305
+ def latest_rank_key(store: Store, entity_type: str, row: Row, latest_by: str) -> tuple[int, ...]:
306
+ """Return a stored-value ranking key comparable within one LATEST group.
307
+
308
+ No marker, schema, hidden-link or group checks run here. Greater keys rank
309
+ higher.
310
+
311
+ Args:
312
+ store: Site records used to resolve task and pipeline-step links.
313
+ entity_type: `Version`, `PublishedFile`, or `Cut`.
314
+ row: Candidate row whose stored values supply the ranking key.
315
+ latest_by: `ENTITIES_CREATED_AT` for Versions or PublishedFiles,
316
+ `VERSION_NUMBER` for PublishedFiles, `REVISION_NUMBER` for Cuts, or
317
+ `BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT` for Versions.
318
+
319
+ Returns:
320
+ A lexicographically comparable key for the requested ranking mode.
321
+
322
+ Raises:
323
+ MockgunError: If a linked record is missing or the entity type and
324
+ ranking mode are unsupported together.
325
+ """
326
+ if latest_by not in _LATEST_MODES.get(entity_type, ()):
327
+ raise _error(entity_type, f'unsupported latest_by {latest_by}')
328
+ if latest_by == 'ENTITIES_CREATED_AT':
329
+ return _pair(row.get('created_at'))
330
+ if latest_by == 'VERSION_NUMBER':
331
+ return _pair(row.get('version_number'))
332
+ if latest_by == 'REVISION_NUMBER':
333
+ return (
334
+ *_pair(row.get('revision_number')),
335
+ *_pair(row.get('created_at')),
336
+ )
337
+ task = _linked(store, row.get('sg_task'), entity_type)
338
+ step = _linked(store, task.get('step'), entity_type) if task is not None else None
339
+ if step is None:
340
+ return (0, 0, 0, *_pair(row.get('created_at')))
341
+ return (
342
+ 1,
343
+ *_pair(step.get('list_order')),
344
+ *_pair(row.get('created_at')),
345
+ )
346
+
347
+
348
+ def _winner(store: Store, entity_type: str, group: list[Row], mode: str) -> Row:
349
+ """Check unsettled group comparisons before choosing a unique greatest key."""
350
+ _validate_group(store, entity_type, group, mode)
351
+ fields = ('version_number',) if mode == 'VERSION_NUMBER' else ('created_at',)
352
+ if mode == 'REVISION_NUMBER':
353
+ fields += ('revision_number',)
354
+ for row in group:
355
+ for field in fields:
356
+ store.check_known(row, field)
357
+ keys = [latest_rank_key(store, entity_type, row, mode) for row in group]
358
+ best_key = max(keys)
359
+ if mode != 'VERSION_NUMBER':
360
+ contenders = [
361
+ row for row, key in zip(group, keys, strict=True) if key[:-2] == best_key[:-2]
362
+ ]
363
+ if len(contenders) > 1 and any(
364
+ _known_value(store, row, 'created_at') is None for row in contenders
365
+ ):
366
+ raise MockgunError(
367
+ f'mockgun cannot find/find_one {entity_type} LATEST preset: '
368
+ 'created_at date_time is null in the deciding comparison'
369
+ )
370
+ winners = [row for row, key in zip(group, keys, strict=True) if key == best_key]
371
+ if len(winners) != 1:
372
+ raise _error(entity_type, f'{mode} ranking key ties')
373
+ return winners[0]
374
+
375
+
376
+ def apply_presets(
377
+ store: Store, entity_type: str, rows: list[Row], presets: tuple[Preset, ...], *, retired: bool
378
+ ) -> list[Row]:
379
+ """Apply validated presets after filters and archive scope, before ordering.
380
+
381
+ Link checks use the assumptions installed by the enclosing `assuming()`
382
+ scope, if any.
383
+
384
+ Args:
385
+ store: Site records used by preset selection.
386
+ entity_type: Candidate entity type.
387
+ rows: Candidates already restricted by filters and archive scope.
388
+ presets: Validated preset combination.
389
+ retired: Whether candidates come from the retired-record scope.
390
+
391
+ Returns:
392
+ Selected original row objects. With no presets, returns `rows` itself.
393
+ A LATEST preset selects no retired rows.
394
+
395
+ Raises:
396
+ MockgunError: If required schema, links, grouping, or ranking are
397
+ unsettled, or CUT_SHOT_VERSIONS is requested for retired rows.
398
+ ServerFaultError: If a required record type is undeclared.
399
+ """
400
+ for preset in presets:
401
+ _validate_fields(store.schema, entity_type, preset)
402
+ cut = next((preset for preset in presets if preset.name == 'CUT_SHOT_VERSIONS'), None)
403
+ if cut is not None:
404
+ if retired:
405
+ raise _error(entity_type, 'retired_only is unsettled', cut.name)
406
+ rows = _cut_versions(store, rows, cut.cut_id)
407
+ latest = next((preset for preset in presets if preset.name == 'LATEST'), None)
408
+ if latest is not None:
409
+ if retired:
410
+ return []
411
+ rows = [
412
+ _winner(store, entity_type, group, cast('str', latest.latest_by))
413
+ for group in latest_groups(store, entity_type, rows)
414
+ ]
415
+ return rows
416
+
417
+
418
+ def preset_dependencies(store: Store, presets: tuple[Preset, ...]) -> list[tuple[Row, str]]:
419
+ """Return external link reads reached even when no candidate survives.
420
+
421
+ `CutItem.cut` is resolved as stored, including a retired Cut. Only the
422
+ reached CutItems' shot links participate in uncertain visibility
423
+ evaluation.
424
+
425
+ Args:
426
+ store: Site records used to resolve the preset's Cut and CutItems.
427
+ presets: Validated preset combination.
428
+
429
+ Returns:
430
+ `(row, field_name)` pairs for live stored CutItem rows whose `shot`
431
+ links must be considered, or an empty list when none are reached.
432
+
433
+ Raises:
434
+ MockgunError: If a reached CutItem's cut membership or retirement state
435
+ is unsettled.
436
+ ServerFaultError: If a required record type is undeclared.
437
+ """
438
+ cut = next((preset for preset in presets if preset.name == 'CUT_SHOT_VERSIONS'), None)
439
+ return [] if cut is None else [(item, 'shot') for item in _cut_items(store, cut.cut_id) or []]
440
+
441
+
442
+ def validate_presets(schema: SiteSchema, entity_type: str, presets: object) -> tuple[Preset, ...]:
443
+ """Validate preset arguments and required fields before reading candidates.
444
+
445
+ Args:
446
+ schema: Loaded field declarations.
447
+ entity_type: Entity type being queried.
448
+ presets: A list of dictionaries naming `LATEST` with `latest_by`, or
449
+ `CUT_SHOT_VERSIONS` with optional `cut_id`. Only Version queries
450
+ support combining those two presets.
451
+
452
+ Returns:
453
+ Validated presets in request order, with duplicate identical LATEST
454
+ entries collapsed.
455
+
456
+ Raises:
457
+ ServerFaultError: If the request has a modeled invalid preset name,
458
+ argument, or entity-type combination.
459
+ MockgunError: If the combination or validation precedence is unsettled,
460
+ or required fields are undeclared.
461
+ """
462
+ if not isinstance(presets, list):
463
+ raise ServerFaultError(
464
+ 'API read() additional_filter_presets must be an array', error_code=103
465
+ )
466
+ parsed: list[Preset] = []
467
+ names: list[object] = []
468
+ faults: list[ServerFaultError] = []
469
+ for item in cast('list[object]', presets):
470
+ try:
471
+ preset = _validate_item(entity_type, item)
472
+ except ServerFaultError as exc:
473
+ faults.append(exc)
474
+ names.append(cast('dict[str, object]', item).get('preset_name'))
475
+ continue
476
+ if preset.name != 'LATEST' or preset not in parsed:
477
+ parsed.append(preset)
478
+ names.append(preset.name)
479
+ if len(names) > 1 and (
480
+ entity_type != 'Version'
481
+ or len(names) != _MAX_COMBINED_PRESETS
482
+ or 'LATEST' not in names
483
+ or 'CUT_SHOT_VERSIONS' not in names
484
+ ):
485
+ raise _error(entity_type, 'unsupported preset combination')
486
+ if len(faults) > 1:
487
+ raise _error(entity_type, 'multiple invalid presets')
488
+ if faults:
489
+ raise faults[0]
490
+ for preset in parsed:
491
+ _validate_fields(schema, entity_type, preset)
492
+ return tuple(parsed)