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.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {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)
|