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,2163 @@
1
+ """Evaluate existing mockgun filters, ordering, projection, and server paging."""
2
+
3
+ import copy
4
+ import datetime
5
+ import re
6
+ from collections.abc import Collection, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from itertools import combinations, groupby, product
9
+ from math import prod
10
+ from string import ascii_letters, ascii_lowercase, ascii_uppercase, digits
11
+ from typing import Protocol, Self, TypeAlias, cast
12
+
13
+ from ._dates import in_window, validate_relative
14
+ from ._errors import MockgunError, ServerFaultError
15
+ from ._events import check_meta
16
+ from ._links import (
17
+ RecordState,
18
+ deciding_record,
19
+ derived_name,
20
+ display_name,
21
+ hidden,
22
+ named_link,
23
+ )
24
+ from ._presets import (
25
+ Preset,
26
+ apply_presets,
27
+ assuming,
28
+ latest_groups,
29
+ latest_rank_key,
30
+ preset_dependencies,
31
+ validate_presets,
32
+ )
33
+ from ._schema import SiteSchema
34
+ from ._sessions import RequestContext
35
+ from ._store import UPLOAD_LINK_REASON_PREFIX, FieldValue, Row, Store
36
+ from ._wire import format_wire_datetime, parse_wire_datetime
37
+
38
+ _Bit: TypeAlias = tuple[str, int, str, str, int]
39
+ _Decision: TypeAlias = 'bool | _Failure | _Fork'
40
+ _StateReads: TypeAlias = dict[tuple[str, int], set[tuple[str, str, int] | None]]
41
+ _STATE_LIMIT = 64
42
+ _ASCII_LOWER = str.maketrans(ascii_uppercase, ascii_lowercase)
43
+ _COLLATION_CHARACTERS = frozenset(ascii_letters + digits)
44
+ _COLLATION_IGNORED = str.maketrans('', '', '_-. ')
45
+ _EntityLink: TypeAlias = dict[str, FieldValue]
46
+ _LINK_PATH_PARTS = 3
47
+ _NOTE_LINK_PAIR = {'Asset', 'Shot'}
48
+ _NUMERIC_TYPES = frozenset({'date', 'date_time', 'duration', 'float', 'number', 'percent'})
49
+ _ORDER_TYPES = _NUMERIC_TYPES | {
50
+ 'checkbox',
51
+ 'entity',
52
+ 'entity_type',
53
+ 'image',
54
+ 'list',
55
+ 'multi_entity',
56
+ 'status_list',
57
+ 'text',
58
+ 'url',
59
+ }
60
+ _RELATIVE_RELATIONS = frozenset(
61
+ {
62
+ 'in_last',
63
+ 'in_next',
64
+ 'not_in_last',
65
+ 'not_in_next',
66
+ 'in_calendar_day',
67
+ 'in_calendar_week',
68
+ 'in_calendar_month',
69
+ 'in_calendar_year',
70
+ }
71
+ )
72
+ _UNFILTERABLE_TYPES = frozenset({'calculated', 'serializable', 'summary', 'url'})
73
+ _VALID_RELATIONS: dict[str, tuple[str, ...]] = {
74
+ 'checkbox': ('is', 'is_not'),
75
+ 'color': ('is', 'is_not', 'in', 'not_in'),
76
+ 'date': (
77
+ 'is',
78
+ 'is_not',
79
+ 'greater_than',
80
+ 'less_than',
81
+ 'in_last',
82
+ 'not_in_last',
83
+ 'in_next',
84
+ 'not_in_next',
85
+ 'in_calendar_week',
86
+ 'in_calendar_month',
87
+ 'in_calendar_day',
88
+ 'in_calendar_year',
89
+ 'between',
90
+ 'in',
91
+ 'not_in',
92
+ ),
93
+ 'date_time': (
94
+ 'is',
95
+ 'is_not',
96
+ 'greater_than',
97
+ 'less_than',
98
+ 'in_last',
99
+ 'not_in_last',
100
+ 'in_next',
101
+ 'not_in_next',
102
+ 'in_calendar_week',
103
+ 'in_calendar_month',
104
+ 'in_calendar_day',
105
+ 'in_calendar_year',
106
+ 'between',
107
+ 'in',
108
+ 'not_in',
109
+ ),
110
+ 'duration': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
111
+ 'entity': (
112
+ 'is',
113
+ 'is_not',
114
+ 'name_contains',
115
+ 'name_not_contains',
116
+ 'name_is',
117
+ 'type_is',
118
+ 'type_is_not',
119
+ 'in',
120
+ 'not_in',
121
+ ),
122
+ 'float': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
123
+ 'image': ('is', 'is_not'),
124
+ 'list': ('is', 'is_not', 'in', 'not_in'),
125
+ 'multi_entity': (
126
+ 'is',
127
+ 'is_not',
128
+ 'name_contains',
129
+ 'name_not_contains',
130
+ 'name_is',
131
+ 'type_is',
132
+ 'type_is_not',
133
+ 'in',
134
+ 'not_in',
135
+ ),
136
+ 'number': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
137
+ 'percent': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
138
+ 'pivot_column': ('is', 'is_not'),
139
+ 'status_list': ('is', 'is_not', 'in', 'not_in'),
140
+ 'text': (
141
+ 'contains',
142
+ 'not_contains',
143
+ 'is',
144
+ 'is_not',
145
+ 'starts_with',
146
+ 'ends_with',
147
+ 'in',
148
+ 'not_in',
149
+ ),
150
+ }
151
+
152
+
153
+ class _ComparableFieldValue(Protocol):
154
+ """Describe values with ordered comparisons."""
155
+
156
+ def __gt__(self, other: Self, /) -> bool:
157
+ """Return whether this value sorts after another value."""
158
+ ...
159
+
160
+ def __le__(self, other: Self, /) -> bool:
161
+ """Return whether this value does not sort after another value."""
162
+ ...
163
+
164
+ def __lt__(self, other: Self, /) -> bool:
165
+ """Return whether this value sorts before another value."""
166
+ ...
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ReadScope:
171
+ """Immutable selection context for retirement and archive scope.
172
+
173
+ Attributes:
174
+ retired: Select retired records instead of active records.
175
+ context: Actor and timestamp captured for the request.
176
+ include_archived_projects: Whether archived Projects and their records
177
+ remain visible.
178
+ presets: Additional filter presets to validate and apply before
179
+ ordering.
180
+ """
181
+
182
+ retired: bool
183
+ context: RequestContext
184
+ include_archived_projects: bool = True
185
+ presets: object = None
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class _Failure:
190
+ """Keep a reachable evaluation error while reducing symbolic conditions."""
191
+
192
+ message: str
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class _Fork:
197
+ """Share the two outcomes of one retirement or outgoing visibility bit."""
198
+
199
+ bit: _Bit
200
+ off: _Decision
201
+ on: _Decision
202
+
203
+
204
+ @dataclass
205
+ class _Candidate:
206
+ """Keep scope, group admission, and selection separate until reduction ends."""
207
+
208
+ row: Row
209
+ member: _Decision
210
+ archive: _Decision
211
+ selected: _Decision
212
+
213
+
214
+ class _RowReader:
215
+ """Read stored fields and link visibility for selection or projection."""
216
+
217
+ def __init__(self, store: Store, schema: SiteSchema) -> None:
218
+ """Bind row sources without requiring a selection scope."""
219
+ self._store = store
220
+ self._schema = schema
221
+ self._assume: dict[tuple[str, int], RecordState] | None = None
222
+
223
+ def _direct_field_value(
224
+ self, entity_type: str, row: Row, field_name: str, *, membership: bool = False
225
+ ) -> FieldValue:
226
+ """Read a stored field or compute its non-retained display cache."""
227
+ if entity_type == 'Project' and field_name == 'last_accessed_by_current_user':
228
+ context = self._store.context
229
+ actor = context.actor if context is not None else None
230
+ value = (
231
+ self._store.side_get(
232
+ 'last_access', (cast('int', row['id']), actor.entity_type, actor.entity_id)
233
+ )
234
+ if actor is not None
235
+ else None
236
+ )
237
+ if value is None:
238
+ raise MockgunError(
239
+ 'mockgun cannot read Project.last_accessed_by_current_user '
240
+ f'on record {row["id"]}: '
241
+ 'actor has never accessed the Project'
242
+ )
243
+ return value
244
+ if '__unknown' in row or (not membership and '__unordered' in row):
245
+ self._store.check_known(row, field_name, membership=membership)
246
+ if entity_type == 'EventLogEntry' and field_name == 'meta':
247
+ check_meta(self._store, row)
248
+ if field_name == 'cached_display_name' and entity_type != 'EventLogEntry':
249
+ return display_name(self._store, entity_type, cast('int', row['id']))
250
+ return row.get(field_name)
251
+
252
+ def _hidden(self, row: Row, field: str, link: Mapping[str, FieldValue]) -> bool:
253
+ """Resolve link visibility under the reduced unit's concrete assumption."""
254
+ return hidden(self._store, row, field, link, assume=self._assume)
255
+
256
+ def _visible_value(self, row: Row, field_name: str, value: FieldValue) -> FieldValue:
257
+ """Suppress listed retired links while preserving stored values."""
258
+ if isinstance(value, dict):
259
+ return None if self._hidden(row, field_name, value) else value
260
+ if isinstance(value, list):
261
+ return [
262
+ member
263
+ for member in value
264
+ if not isinstance(member, dict) or not self._hidden(row, field_name, member)
265
+ ]
266
+ return value
267
+
268
+
269
+ class _Projection(_RowReader):
270
+ """Project detached fields with explicit context for session-specific ordering."""
271
+
272
+ def __init__(self, store: Store, schema: SiteSchema, context: RequestContext | None) -> None:
273
+ """Bind projection with its explicit context for session-specific ordering."""
274
+ super().__init__(store, schema)
275
+ self._projection_context = context
276
+
277
+ def _project_value(self, entity_type: str, row: Row, path: str) -> tuple[bool, FieldValue]:
278
+ """Follow one valid single-link path and distinguish omission from null."""
279
+ if entity_type == 'EventLogEntry' and path == 'audit_trail':
280
+ return False, None
281
+ parts = path.split('.', 2)
282
+ if len(parts) != _LINK_PATH_PARTS:
283
+ value = self._direct_field_value(entity_type, row, path)
284
+ visible = self._visible_value(row, path, value)
285
+ return True, _ordered_note_links(row, path, value, visible, self._projection_context)
286
+ field, linked_type, rest = parts
287
+ link_value = self._direct_field_value(entity_type, row, field)
288
+ if self._visible_value(row, field, link_value) != link_value:
289
+ raise MockgunError(
290
+ f'mockgun cannot project {entity_type}.{path}: hidden {entity_type}.{field} link'
291
+ )
292
+ if link_value is None:
293
+ if not self._schema.declares(linked_type):
294
+ raise MockgunError(
295
+ f'mockgun cannot project {entity_type}.{path}: '
296
+ f'{linked_type} is not a declared entity type'
297
+ )
298
+ return True, None
299
+ if not isinstance(link_value, dict):
300
+ raise MockgunError(f'mockgun cannot project {entity_type}.{path}: invalid link')
301
+ if link_value['type'] != linked_type:
302
+ return False, None
303
+ if not self._schema.declares(linked_type):
304
+ return False, None
305
+ linked_row = self._store.row(linked_type, cast('int', link_value['id']))
306
+ if linked_row is None:
307
+ raise MockgunError(
308
+ f'mockgun cannot project {entity_type}.{path}: missing {linked_type} link'
309
+ )
310
+ return self._project_value(linked_type, linked_row, rest)
311
+
312
+ def _return_field_type(self, entity_type: str, path: str) -> str | None:
313
+ """Resolve a readable path, omitting statically invalid positions."""
314
+ parts = path.split('.', 2)
315
+ field = parts[0] if len(parts) == _LINK_PATH_PARTS else path
316
+ if field not in {'id', 'type'} and not self._schema.declares(entity_type, field):
317
+ return None
318
+ field_type = self._schema.field_type(entity_type, field)
319
+ if len(parts) != _LINK_PATH_PARTS:
320
+ return field_type
321
+ if field_type != 'entity':
322
+ return None
323
+ linked_type, rest = parts[1:]
324
+ if not self._schema.declares(linked_type):
325
+ return 'unknown'
326
+ return self._return_field_type(linked_type, rest)
327
+
328
+ def project(self, rows: list[Row], return_fields: list[str]) -> list[dict[str, object]]:
329
+ """Project detached output fields and format declared datetime values.
330
+
331
+ Args:
332
+ rows: Stored rows in the desired result order.
333
+ return_fields: Direct or linked field paths to project.
334
+
335
+ Returns:
336
+ Detached dictionaries in input order, always containing `type` and
337
+ `id`. Unsupported projection paths are omitted when the projection
338
+ rules permit omission.
339
+
340
+ Raises:
341
+ ServerFaultError: If resolving a declared field or linked entity
342
+ encounters a modeled schema fault.
343
+ MockgunError: If projected values or linked identity state are
344
+ unknown or unsupported.
345
+ OverflowError: If converting a projected datetime to UTC exceeds
346
+ the supported datetime range.
347
+ ValueError: If the platform cannot convert a naive datetime.
348
+ OSError: If local timezone conversion fails.
349
+ """
350
+ result: list[dict[str, object]] = []
351
+ field_types = (
352
+ {
353
+ name: self._return_field_type(cast('str', rows[0]['type']), name)
354
+ for name in return_fields
355
+ }
356
+ if rows
357
+ else {}
358
+ )
359
+ for row in rows:
360
+ entity_type = cast('str', row['type'])
361
+ item: dict[str, object] = {}
362
+ for name in return_fields:
363
+ field_type = field_types[name]
364
+ if field_type is None:
365
+ continue
366
+ present, value = self._project_value(entity_type, row, name)
367
+ if not present:
368
+ continue
369
+ if field_type in {'entity', 'multi_entity'}:
370
+ if isinstance(value, list):
371
+ value = [
372
+ named_link(self._store, cast('_EntityLink', item)) for item in value
373
+ ]
374
+ elif isinstance(value, dict):
375
+ value = named_link(self._store, value)
376
+ elif field_type == 'date_time':
377
+ value = _format_datetimes(value)
378
+ item[name] = copy.deepcopy(value)
379
+ item['type'] = entity_type
380
+ item['id'] = row['id']
381
+ result.append(item)
382
+ return result
383
+
384
+
385
+ class _Query(_RowReader):
386
+ """Evaluate a query against one schema and store snapshot."""
387
+
388
+ def __init__(
389
+ self,
390
+ store: Store,
391
+ schema: SiteSchema,
392
+ scope: ReadScope,
393
+ ) -> None:
394
+ """Bind the store and schema for one serialized operation."""
395
+ super().__init__(store, schema)
396
+ self._scope = scope
397
+ self._external: _Decision = True
398
+ self._nodes: dict[tuple[_Bit, _Decision, _Decision], _Fork] = {}
399
+ self._choices: dict[tuple[_Decision, _Decision, _Decision], _Decision] = {}
400
+
401
+ def _compare_value(
402
+ self, field_type: str, actual: FieldValue, relation: str, values: list[FieldValue]
403
+ ) -> bool:
404
+ """Use request time for relative filters and scalar comparison otherwise."""
405
+ if field_type in {'date', 'date_time'} and relation in _RELATIVE_RELATIONS:
406
+ return in_window(field_type, relation, values, actual, now=self._scope.context.now)
407
+ return _compare(field_type, actual, relation, values)
408
+
409
+ def _entity_order_keys(self, values: list[FieldValue]) -> list[FieldValue]:
410
+ """Read names only for distinct non-null targets that must be compared."""
411
+ identities = {
412
+ (cast('str', value['type']), cast('int', value['id']))
413
+ for value in values
414
+ if isinstance(value, dict)
415
+ }
416
+ if len(identities) <= 1:
417
+ return [None if value is None else 0 for value in values]
418
+ names: dict[tuple[str, int], str] = {}
419
+ for identity in identities:
420
+ name = display_name(self._store, *identity)
421
+ if name is None or derived_name(self._store, *identity):
422
+ raise MockgunError('entity order needs a modeled, non-derived name')
423
+ names[identity] = name
424
+ if len({name.translate(_ASCII_LOWER) for name in names.values()}) != len(names):
425
+ raise MockgunError('distinct entity targets have equal display names')
426
+ return [
427
+ None
428
+ if value is None
429
+ else names[
430
+ (
431
+ cast('str', cast('_EntityLink', value)['type']),
432
+ cast('int', cast('_EntityLink', value)['id']),
433
+ )
434
+ ]
435
+ for value in values
436
+ ]
437
+
438
+ def _filter_field_type(self, entity_type: str, path: str) -> str:
439
+ """Resolve filter fields with the server's missing-field fault."""
440
+ if entity_type == 'Project' and path == 'last_accessed_by_current_user':
441
+ raise MockgunError(
442
+ 'mockgun cannot read Project.last_accessed_by_current_user: '
443
+ 'filtering and ordering are unsettled'
444
+ )
445
+ parts = path.split('.', 2)
446
+ field = parts[0] if len(parts) == _LINK_PATH_PARTS else path
447
+ if (
448
+ self._schema.declares(entity_type)
449
+ and field not in {'id', 'type'}
450
+ and not self._schema.declares(entity_type, field)
451
+ ):
452
+ raise ServerFaultError(
453
+ f"API read() {entity_type}.{field} doesn't exist", error_code=103
454
+ )
455
+ field_type = self._schema.field_type(entity_type, field)
456
+ if len(parts) == _LINK_PATH_PARTS:
457
+ if field_type not in {'entity', 'multi_entity'}:
458
+ return self._schema.field_type(entity_type, path)
459
+ return self._filter_field_type(parts[1], parts[2])
460
+ return field_type
461
+
462
+ def _fork(self, bit: _Bit, off: _Decision, on: _Decision) -> _Decision:
463
+ """Discard a bit when its two outcomes are identical."""
464
+ if off == on:
465
+ return off
466
+ return self._nodes.setdefault((bit, off, on), _Fork(bit, off, on))
467
+
468
+ def _choose(self, condition: _Decision, yes: _Decision, no: _Decision) -> _Decision:
469
+ """Preserve short circuits and errors while composing ordered branches."""
470
+ if isinstance(condition, bool):
471
+ return yes if condition else no
472
+ if isinstance(condition, _Failure):
473
+ return condition
474
+ key = (condition, yes, no)
475
+ if key in self._choices:
476
+ return self._choices[key]
477
+ bit = min(item.bit for item in key if isinstance(item, _Fork))
478
+ branches: list[_Decision] = []
479
+ for enabled in (False, True):
480
+ parts = [
481
+ (item.on if enabled else item.off)
482
+ if isinstance(item, _Fork) and item.bit == bit
483
+ else item
484
+ for item in key
485
+ ]
486
+ branches.append(self._choose(parts[0], yes=parts[1], no=parts[2]))
487
+ result = self._fork(bit, off=branches[0], on=branches[1])
488
+ self._choices[key] = result
489
+ return result
490
+
491
+ def _all(self, left: _Decision, right: _Decision) -> _Decision:
492
+ """Compose ordered conjunction without suppressing an earlier error."""
493
+ return self._choose(left, yes=right, no=False)
494
+
495
+ def _any(self, left: _Decision, right: _Decision) -> _Decision:
496
+ """Compose ordered disjunction without suppressing an earlier error."""
497
+ return self._choose(left, yes=True, no=right)
498
+
499
+ def _archive_guard(self, left: _Candidate, right: _Candidate) -> _Decision:
500
+ """Reject LATEST groups mixing archived and active Projects before filtering."""
501
+ different = self._choose(
502
+ left.archive, yes=self._choose(right.archive, yes=False, no=True), no=right.archive
503
+ )
504
+ together = self._all(left.member, right.member)
505
+ return self._choose(
506
+ self._all(together, different),
507
+ yes=_Failure('LATEST group mixes archived and active Projects'),
508
+ no=True,
509
+ )
510
+
511
+ def _errors(self, value: _Decision) -> _Decision:
512
+ """Retain only branches capable of raising, with successful leaves equal."""
513
+ if isinstance(value, bool):
514
+ return True
515
+ if isinstance(value, _Failure):
516
+ return value
517
+ return self._fork(value.bit, off=self._errors(value.off), on=self._errors(value.on))
518
+
519
+ def _guard_uploaded_file(self, row: Row, path: str) -> None:
520
+ """Reject only upload-marked Attachment file dictionaries along this path."""
521
+ if path != 'this_file' and not path.endswith('.Attachment.this_file'):
522
+ return
523
+ if row['type'] == 'Attachment' and path == 'this_file':
524
+ unknown = cast('dict[str, FieldValue]', row.get('__unknown', {}))
525
+ reason = unknown.get('this_file')
526
+ if isinstance(reason, str) and reason.startswith(UPLOAD_LINK_REASON_PREFIX):
527
+ self._store.check_known(row, 'this_file')
528
+ return
529
+ parts = path.split('.', 2)
530
+ if len(parts) != _LINK_PATH_PARTS:
531
+ return
532
+ value = row.get(parts[0])
533
+ links = value if isinstance(value, list) else [value]
534
+ for link in links:
535
+ if isinstance(link, dict) and link.get('type') == parts[1]:
536
+ target = self._store.row(parts[1], cast('int', link['id']))
537
+ if target is not None:
538
+ self._guard_uploaded_file(target, parts[2])
539
+
540
+ def _has_uncertainty(self) -> bool:
541
+ """Check whether taints or a retired Cut can affect any query path."""
542
+ if any(self._store.tainted_rows(kind) for kind in self._schema.fields):
543
+ return True
544
+ return self._schema.declares('Cut') and bool(self._store.rows('Cut', retired=True))
545
+
546
+ def _hidden_filter_value(
547
+ self,
548
+ row: Row,
549
+ field_name: str,
550
+ value: FieldValue,
551
+ relation: str,
552
+ values: list[FieldValue],
553
+ ) -> FieldValue:
554
+ """Apply the narrow modeled filter rule for hidden links."""
555
+ visible = self._visible_value(row, field_name, value)
556
+ if visible != value and (relation != 'is' or not values or values[0] is None):
557
+ raise MockgunError(f'hidden {row["type"]}.{field_name} link has unmodeled {relation}')
558
+ return visible
559
+
560
+ def _in_archive_scope(self, entity_type: str, row: Row, scope: ReadScope) -> bool:
561
+ """Keep rows outside an archived Project when the flag excludes archives."""
562
+ if scope.include_archived_projects or not self._schema.declares('Project', 'archived'):
563
+ return True
564
+ if entity_type == 'Project':
565
+ return row.get('archived') is not True
566
+ if not self._schema.declares(entity_type, 'project') or (
567
+ self._schema.field_type(entity_type, 'project') != 'entity'
568
+ ):
569
+ return True
570
+ project = self._visible_value(row, 'project', row.get('project'))
571
+ if isinstance(project, dict) and project.get('type') == 'Project':
572
+ project_id = project.get('id')
573
+ if isinstance(project_id, int):
574
+ project_row = self._store.row('Project', project_id)
575
+ return project_row is None or project_row.get('archived') is not True
576
+ return True
577
+
578
+ def _linked_row(self, link: _EntityLink) -> Row:
579
+ """Resolve a stored link, rejecting a target type absent from the schema."""
580
+ entity_id = cast('int', link['id'])
581
+ try:
582
+ row = self._store.row(cast('str', link['type']), entity_id)
583
+ except ServerFaultError as exc:
584
+ raise MockgunError(f'stored linked target is outside the schema: {link!r}') from exc
585
+ if row is None:
586
+ raise KeyError(entity_id)
587
+ return row
588
+
589
+ def _members(self, left: _Decision, right: _Decision) -> _Decision:
590
+ """Evaluate every multi-link member, as the ordinary deep filter does."""
591
+ return self._all(self._errors(left), self._choose(right, yes=True, no=left))
592
+
593
+ def _name_relation_value(self, value: FieldValue) -> FieldValue:
594
+ """Resolve every required name and reject unresolved comparison semantics."""
595
+ if value is None:
596
+ return None
597
+ if isinstance(value, list):
598
+ return [self._name_relation_value(member) for member in value]
599
+ link = cast('_EntityLink', value)
600
+ kind, entity_id = cast('str', link['type']), cast('int', link['id'])
601
+ name = display_name(self._store, kind, entity_id)
602
+ if name is None or derived_name(self._store, kind, entity_id):
603
+ raise MockgunError(f'{kind} display-name comparison is not modeled')
604
+ return {'type': kind, 'id': entity_id, 'name': name}
605
+
606
+ def _matches_direct(
607
+ self, entity_type: str, row: Row, path: str, relation: str, values: list[FieldValue]
608
+ ) -> bool:
609
+ """Reduce identity conditions before reading link visibility."""
610
+ field_type = self._schema.field_type(entity_type, path)
611
+ actual = self._direct_field_value(entity_type, row, path, membership=True)
612
+ if (
613
+ field_type in {'entity', 'multi_entity'}
614
+ and relation == 'is'
615
+ and values
616
+ and values[0] is not None
617
+ ):
618
+ # A hidden nonmatching identity cannot change this condition.
619
+ if not self._compare_value(field_type, actual, relation, values):
620
+ return False
621
+ if isinstance(actual, list):
622
+ actual = [
623
+ member
624
+ for member in actual
625
+ if self._compare_value('entity', member, relation, values)
626
+ ]
627
+ if field_type in {'entity', 'multi_entity'}:
628
+ actual = self._hidden_filter_value(row, path, actual, relation, values)
629
+ if field_type == 'checkbox' and actual is None:
630
+ raise MockgunError('stored checkbox is null')
631
+ if field_type in {'entity', 'multi_entity'} and relation.startswith('name_'):
632
+ actual = self._name_relation_value(actual)
633
+ return self._compare_value(field_type, actual, relation, values)
634
+
635
+ def _matches_field(
636
+ self,
637
+ entity_type: str,
638
+ row: Row,
639
+ path: str,
640
+ relation: str,
641
+ values: list[FieldValue],
642
+ ) -> bool:
643
+ """Traverse filter links, rejecting a wrong-type path only if null matches."""
644
+ field_type = self._schema.field_type(entity_type, path)
645
+ parts = path.split('.', 2)
646
+ if len(parts) != _LINK_PATH_PARTS:
647
+ return self._matches_direct(entity_type, row, path, relation, values)
648
+ field, linked_type, rest = parts
649
+ actual = self._direct_field_value(entity_type, row, field, membership=True)
650
+ actual = self._hidden_filter_value(row, field, actual, relation, values)
651
+ links = actual if isinstance(actual, list) else [actual]
652
+ matches: list[bool] = []
653
+ for link in links:
654
+ if link is None:
655
+ matches.append(self._compare_value(field_type, None, relation, values))
656
+ continue
657
+ if not isinstance(link, dict):
658
+ raise ServerFaultError(
659
+ f'linked field path {entity_type}.{path} contains a non-entity value'
660
+ )
661
+ linked_row = self._linked_row(link)
662
+ if link['type'] != linked_type:
663
+ if self._compare_value(field_type, None, relation, values):
664
+ raise MockgunError('a different linked type matches as null')
665
+ matches.append(False)
666
+ continue
667
+ matches.append(self._matches_field(linked_type, linked_row, rest, relation, values))
668
+ return any(matches)
669
+
670
+ def _matches_tree(
671
+ self,
672
+ entity_type: str,
673
+ row: Row,
674
+ tree: dict[str, object],
675
+ ) -> bool:
676
+ """Evaluate a compiled logical condition tree for one row."""
677
+ conditions = cast('list[dict[str, object]]', tree['conditions'])
678
+ for condition in conditions:
679
+ if 'logical_operator' in condition:
680
+ matches = self._matches_tree(entity_type, row, condition)
681
+ else:
682
+ field_name = cast('str', condition['path'])
683
+ relation = cast('str', condition['relation'])
684
+ values = cast('list[FieldValue]', condition['values'])
685
+ field_type = self._schema.field_type(entity_type, field_name)
686
+ try:
687
+ matches = self._matches_field(entity_type, row, field_name, relation, values)
688
+ except (MockgunError, TypeError, AttributeError, KeyError) as exc:
689
+ raise MockgunError(
690
+ f'mockgun cannot evaluate {entity_type}.{field_name} '
691
+ f'{field_type} {relation} {values!r}: {exc}'
692
+ ) from exc
693
+ if tree['logical_operator'] == 'and' and not matches:
694
+ return False
695
+ if tree['logical_operator'] == 'or' and matches:
696
+ return True
697
+ return tree['logical_operator'] == 'and'
698
+
699
+ def _order_field(self, entity_type: str, path: str) -> tuple[str, str, str]:
700
+ """Validate a complete order path and locate its terminal schema field."""
701
+ if entity_type == 'Project' and path == 'last_accessed_by_current_user':
702
+ raise MockgunError(
703
+ 'mockgun cannot read Project.last_accessed_by_current_user: '
704
+ 'filtering and ordering are unsettled'
705
+ )
706
+ parts = path.split('.', 2)
707
+ field_name = parts[0]
708
+ field_type = self._schema.field_type(entity_type, field_name)
709
+ if len(parts) == 1:
710
+ return entity_type, field_name, field_type
711
+ if len(parts) != _LINK_PATH_PARTS or field_type != 'entity':
712
+ raise MockgunError('order paths must traverse single-entity fields')
713
+ return self._order_field(parts[1], parts[2])
714
+
715
+ def _order_keys(
716
+ self, values: list[FieldValue], field: tuple[str, str, str]
717
+ ) -> list[FieldValue]:
718
+ """Apply the terminal data type's key and uncertainty rules to a tie group."""
719
+ entity_type, field_name, field_type = field
720
+ if field_type not in {'multi_entity', 'url', 'image', 'text'} and all(
721
+ value == values[0] for value in values
722
+ ):
723
+ return [0] * len(values)
724
+ if field_type in {'multi_entity', 'url', 'image'}:
725
+ occupied = [
726
+ bool(value) if field_type == 'multi_entity' else value is not None
727
+ for value in values
728
+ ]
729
+ if sum(occupied) > 1:
730
+ raise MockgunError('multiple opaque values have unsettled order')
731
+ return [0 if present else None for present in occupied]
732
+ if field_type == 'checkbox' and None in values:
733
+ raise MockgunError('null checkbox has unsettled order')
734
+ if field_type == 'status_list':
735
+ metadata = cast('dict[str, object]', self._schema.fields[entity_type][field_name])
736
+ properties = cast('dict[str, object]', metadata.get('properties', {}))
737
+ valid = cast('dict[str, object]', properties.get('valid_values', {})).get('value')
738
+ if isinstance(valid, list):
739
+ allowed = cast('list[FieldValue]', valid)
740
+ if any(value is not None and value not in allowed for value in values):
741
+ raise MockgunError('status value is outside valid_values')
742
+ return [None if value is None else allowed.index(value) for value in values]
743
+ if field_type == 'entity':
744
+ values = self._entity_order_keys(values)
745
+ if field_type in {'text', 'entity'}:
746
+ names = {value for value in values if isinstance(value, str)}
747
+ check_collation(names, entity_type=entity_type, field_name=field_name)
748
+ return [collation_key(value) if isinstance(value, str) else value for value in values]
749
+ return values
750
+
751
+ def _order_value(self, entity_type: str, row: Row, path: str) -> FieldValue:
752
+ """Resolve an order value, rejecting hidden and invalid intermediate links."""
753
+ parts = path.split('.', 2)
754
+ field_name = parts[0]
755
+ value = self._direct_field_value(entity_type, row, field_name)
756
+ if self._visible_value(row, field_name, value) != value:
757
+ raise MockgunError('hidden link has unmodeled order')
758
+ if len(parts) == 1 or value is None:
759
+ return value
760
+ if not isinstance(value, dict) or value.get('type') != parts[1]:
761
+ raise MockgunError('order path has a different linked type')
762
+ return self._order_value(parts[1], self._linked_row(value), parts[2])
763
+
764
+ def _order_group(
765
+ self, rows: list[Row], entity_type: str, entry: dict[str, str]
766
+ ) -> list[list[Row]]:
767
+ """Refine one earlier-key tie group while keeping nulls and final IDs stable."""
768
+ if len(rows) <= 1:
769
+ return [rows]
770
+ path = entry['field_name']
771
+ field = self._order_field(entity_type, path)
772
+ try:
773
+ values = [self._order_value(entity_type, row, path) for row in rows]
774
+ keys = self._order_keys(values, field)
775
+ populated = [
776
+ (key, row) for key, row in zip(keys, rows, strict=True) if key is not None
777
+ ]
778
+ nulls = [row for key, row in zip(keys, rows, strict=True) if key is None]
779
+ populated.sort(
780
+ key=lambda pair: cast('_ComparableFieldValue', pair[0]),
781
+ reverse=entry['direction'] == 'desc',
782
+ )
783
+ groups = [
784
+ [row for _, row in group]
785
+ for _, group in groupby(populated, key=lambda pair: pair[0])
786
+ ]
787
+ if nulls:
788
+ if field[2] == 'image' and entry['direction'] == 'asc':
789
+ groups.insert(0, nulls)
790
+ else:
791
+ groups.append(nulls)
792
+ except (MockgunError, TypeError, AttributeError, KeyError) as exc:
793
+ raise MockgunError(
794
+ f'mockgun cannot order {entity_type}.{path} {field[2]}: {exc}'
795
+ ) from exc
796
+ return groups
797
+
798
+ def _ordinary_rows(
799
+ self,
800
+ entity_type: str,
801
+ adapted: dict[str, object],
802
+ presets: tuple[Preset, ...],
803
+ scope: ReadScope,
804
+ ) -> list[Row]:
805
+ """Keep the established evaluator when no uncertain state is present."""
806
+ source_rows = self._store.rows(entity_type, retired=scope.retired)
807
+ latest = any(preset.name == 'LATEST' for preset in presets)
808
+ if not latest:
809
+ source_rows = [
810
+ row for row in source_rows if self._in_archive_scope(entity_type, row, scope)
811
+ ]
812
+ rows = [row for row in source_rows if self._matches_tree(entity_type, row, adapted)]
813
+ if not scope.include_archived_projects and latest:
814
+ with assuming(None):
815
+ for group in latest_groups(self._store, entity_type, rows):
816
+ if len({self._in_archive_scope(entity_type, row, scope) for row in group}) > 1:
817
+ raise MockgunError(
818
+ f'mockgun cannot select {entity_type} LATEST preset: '
819
+ 'project entity group mixes archived and active Projects'
820
+ )
821
+ rows = [row for row in rows if self._in_archive_scope(entity_type, row, scope)]
822
+ with assuming(None):
823
+ return apply_presets(self._store, entity_type, rows, presets, retired=scope.retired)
824
+
825
+ def _preset_paths(
826
+ self, row: Row, presets: tuple[Preset, ...], *, group_only: bool
827
+ ) -> list[tuple[Row, str]]:
828
+ """List guarded link reads in preset order, respecting membership exits."""
829
+ if not presets:
830
+ return []
831
+ kind = cast('str', row['type'])
832
+ paths = [(row, 'entity')]
833
+ if kind != 'Cut' and row.get('entity') is None:
834
+ return paths
835
+ if kind == 'PublishedFile':
836
+ paths.append((row, 'published_file_type'))
837
+ if row.get('published_file_type') is None:
838
+ return paths
839
+ paths.append((row, 'task'))
840
+ if not group_only and any(
841
+ preset.latest_by == 'BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT'
842
+ for preset in presets
843
+ ):
844
+ paths.append((row, 'sg_task'))
845
+ task = row.get('sg_task')
846
+ if isinstance(task, dict):
847
+ paths.append((self._linked_row(task), 'step'))
848
+ return paths
849
+
850
+ @staticmethod
851
+ def _reads(value: _Decision) -> _StateReads:
852
+ """Collect reachable reduced bits without generating any possible states."""
853
+ reads: _StateReads = {}
854
+ pending = [value]
855
+ visited: set[_Fork] = set()
856
+ while pending:
857
+ item = pending.pop()
858
+ if not isinstance(item, _Fork) or item in visited:
859
+ continue
860
+ visited.add(item)
861
+ kind, entity_id, field, target, target_id = item.bit
862
+ token = (field, target, target_id) if field else None
863
+ reads.setdefault((kind, entity_id), set()).add(token)
864
+ pending.extend((item.off, item.on))
865
+ return reads
866
+
867
+ def _select_assumed(
868
+ self,
869
+ entity_type: str,
870
+ rows: list[Row],
871
+ adapted: dict[str, object],
872
+ presets: tuple[Preset, ...],
873
+ ) -> list[Row]:
874
+ """Evaluate one final reduced state, keeping the ordinary guard order."""
875
+ scope = self._scope
876
+ assume = self._assume or {}
877
+ latest = any(preset.name == 'LATEST' for preset in presets)
878
+ selected: list[Row] = []
879
+ for row in rows:
880
+ identity = (entity_type, cast('int', row['id']))
881
+ state = assume.get(identity)
882
+ if (state.retired if state is not None else bool(row['__retired'])) != scope.retired:
883
+ continue
884
+ if (latest or self._in_archive_scope(entity_type, row, scope)) and self._matches_tree(
885
+ entity_type, row, adapted
886
+ ):
887
+ selected.append(row)
888
+ if not scope.include_archived_projects and latest:
889
+ with assuming(assume):
890
+ for group in latest_groups(self._store, entity_type, selected):
891
+ if len({self._in_archive_scope(entity_type, row, scope) for row in group}) > 1:
892
+ raise MockgunError(
893
+ f'{entity_type} LATEST group mixes archived and active Projects'
894
+ )
895
+ selected = [row for row in selected if self._in_archive_scope(entity_type, row, scope)]
896
+ with assuming(assume):
897
+ return apply_presets(
898
+ self._store, entity_type, selected, presets, retired=scope.retired
899
+ )
900
+
901
+ def _state_counts(self, reads: _StateReads) -> dict[tuple[str, int], int]:
902
+ """Count reduced states without materializing unread link combinations."""
903
+ counts: dict[tuple[str, int], int] = {}
904
+ for identity, tokens in reads.items():
905
+ row = self._store.require(*identity)
906
+ taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
907
+ varies = bool(taint) and not taint.get('active')
908
+ if row['__retired'] and not varies:
909
+ counts[identity] = 1
910
+ else:
911
+ counts[identity] = 2 ** len(tokens - {None}) + int(varies and None in tokens)
912
+ return counts
913
+
914
+ def _states(self, reads: _StateReads) -> dict[tuple[str, int], tuple[RecordState, ...]]:
915
+ """Build reduced states from only the retirement and link bits read."""
916
+ dimensions: dict[tuple[str, int], tuple[RecordState, ...]] = {}
917
+ for identity, count in self._state_counts(reads).items():
918
+ if count <= 1:
919
+ continue
920
+ tokens = reads[identity]
921
+ links = [token for token in tokens if token is not None]
922
+ states = [
923
+ RecordState(
924
+ retired=False,
925
+ hidden=frozenset(
926
+ link for link, hide in zip(links, flags, strict=True) if hide
927
+ ),
928
+ )
929
+ for flags in product((False, True), repeat=len(links))
930
+ ]
931
+ row = self._store.require(*identity)
932
+ taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
933
+ if None in tokens and taint and not taint.get('active'):
934
+ states.append(RecordState(retired=True))
935
+ dimensions[identity] = tuple(states)
936
+ return dimensions
937
+
938
+ def _combinations(
939
+ self, reads: _StateReads, label: str
940
+ ) -> list[dict[tuple[str, int], RecordState]]:
941
+ """Check reduced cardinality before materializing any state combinations."""
942
+ count = prod(self._state_counts(reads).values())
943
+ if count > _STATE_LIMIT:
944
+ raise MockgunError(f'{label}: {count} combinations exceeds limit of {_STATE_LIMIT}')
945
+ dimensions = self._states(reads)
946
+ return [
947
+ dict(zip(dimensions, states, strict=True)) for states in product(*dimensions.values())
948
+ ]
949
+
950
+ def _symbolic_retired(self, row: Row) -> _Decision:
951
+ """Represent only an unsettled retirement, keeping known scope constant."""
952
+ taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
953
+ if taint and not taint.get('active'):
954
+ return self._fork(
955
+ (cast('str', row['type']), cast('int', row['id']), '', '', 0), off=False, on=True
956
+ )
957
+ return bool(row['__retired'])
958
+
959
+ def _symbolic_hidden(self, row: Row, field: str, link: _EntityLink) -> _Decision:
960
+ """Express hiding through the deciding record and the holder's scope."""
961
+ deciding = deciding_record(self._store, row, field, link)
962
+ if deciding is row:
963
+ outcome = self._fork(
964
+ (
965
+ cast('str', row['type']),
966
+ cast('int', row['id']),
967
+ field,
968
+ cast('str', link['type']),
969
+ cast('int', link['id']),
970
+ ),
971
+ off=False,
972
+ on=True,
973
+ )
974
+ elif deciding is not None:
975
+ outcome = self._symbolic_retired(deciding)
976
+ else:
977
+ active = {
978
+ (cast('str', row['type']), cast('int', row['id'])): RecordState(retired=False)
979
+ }
980
+ outcome = hidden(self._store, row, field, link, assume=active)
981
+ return self._choose(self._symbolic_retired(row), yes=False, no=outcome)
982
+
983
+ def _symbolic_archive(self, row: Row, scope: ReadScope) -> _Decision:
984
+ """Retain a project visibility bit only when its archive status matters."""
985
+ if scope.include_archived_projects or not self._schema.declares('Project', 'archived'):
986
+ return True
987
+ kind = cast('str', row['type'])
988
+ if kind == 'Project':
989
+ return row.get('archived') is not True
990
+ project = row.get('project')
991
+ if not self._schema.declares(kind, 'project') or not isinstance(project, dict):
992
+ return True
993
+ if project.get('type') != 'Project' or not isinstance(project.get('id'), int):
994
+ return True
995
+ target = self._store.row('Project', cast('int', project['id']))
996
+ if target is None or target.get('archived') is not True:
997
+ return True
998
+ return self._symbolic_hidden(row, 'project', project)
999
+
1000
+ def _symbolic_direct(self, row: Row, condition: dict[str, object]) -> _Decision:
1001
+ """Reduce a direct condition before consulting matching link visibility."""
1002
+ kind = cast('str', row['type'])
1003
+ path = cast('str', condition['path'])
1004
+ relation = cast('str', condition['relation'])
1005
+ values = cast('list[FieldValue]', condition['values'])
1006
+ field_type = self._schema.field_type(kind, path)
1007
+ actual = self._direct_field_value(kind, row, path, membership=True)
1008
+ if field_type not in {'entity', 'multi_entity'}:
1009
+ return self._matches_direct(kind, row, path, relation, values)
1010
+ links = actual if isinstance(actual, list) else [actual]
1011
+ result: _Decision = False
1012
+ hides: _Decision = False
1013
+ for link in links:
1014
+ if not isinstance(link, dict):
1015
+ continue
1016
+ visibility = self._symbolic_hidden(row, path, link)
1017
+ if relation == 'is' and values and values[0] is not None:
1018
+ if self._compare_value('entity', link, relation, values):
1019
+ result = self._any(result, self._choose(visibility, yes=False, no=True))
1020
+ else:
1021
+ hides = self._any(hides, visibility)
1022
+ if relation == 'is' and values and values[0] is not None:
1023
+ return result
1024
+ if relation.startswith('name_'):
1025
+ actual = self._name_relation_value(actual)
1026
+ result = self._compare_value(field_type, actual, relation, values)
1027
+ return self._choose(
1028
+ hides,
1029
+ yes=_Failure(f'hidden {kind} {row["id"]}.{path} link has unmodeled {relation}'),
1030
+ no=result,
1031
+ )
1032
+
1033
+ def _symbolic_field(self, row: Row, condition: dict[str, object]) -> _Decision:
1034
+ """Build one condition's graph, preserving terminal reads and failure leaves."""
1035
+ try:
1036
+ path = cast('str', condition['path'])
1037
+ if len(path.split('.', 2)) != _LINK_PATH_PARTS:
1038
+ return self._symbolic_direct(row, condition)
1039
+ field = path.split('.', 1)[0]
1040
+ actual = self._direct_field_value(
1041
+ cast('str', row['type']), row, field, membership=True
1042
+ )
1043
+ links = actual if isinstance(actual, list) else [actual]
1044
+ result: _Decision = False
1045
+ for link in links:
1046
+ result = self._members(result, self._symbolic_link(row, link, condition))
1047
+ except (MockgunError, ServerFaultError, TypeError, AttributeError, KeyError) as exc:
1048
+ return _Failure(str(exc))
1049
+ else:
1050
+ return result
1051
+
1052
+ def _symbolic_link(
1053
+ self, row: Row, link: FieldValue, condition: dict[str, object]
1054
+ ) -> _Decision:
1055
+ """Resolve one deep member and conditionally retain errors from its target."""
1056
+ path = cast('str', condition['path'])
1057
+ field, linked_type, rest = path.split('.', 2)
1058
+ relation = cast('str', condition['relation'])
1059
+ values = cast('list[FieldValue]', condition['values'])
1060
+ field_type = self._schema.field_type(cast('str', row['type']), path)
1061
+ if link is None:
1062
+ return self._compare_value(field_type, None, relation, values)
1063
+ if not isinstance(link, dict):
1064
+ return _Failure(f'linked field path {row["type"]}.{path} contains a non-entity value')
1065
+ visibility = self._symbolic_hidden(row, field, link)
1066
+ try:
1067
+ linked = self._linked_row(link)
1068
+ if link['type'] != linked_type:
1069
+ outcome: _Decision = (
1070
+ _Failure('a different linked type matches as null')
1071
+ if self._compare_value(field_type, None, relation, values)
1072
+ else False
1073
+ )
1074
+ else:
1075
+ outcome = self._symbolic_field(linked, {**condition, 'path': rest})
1076
+ except (MockgunError, ServerFaultError) as exc:
1077
+ outcome = _Failure(str(exc))
1078
+ hidden_outcome: _Decision = (
1079
+ False
1080
+ if relation == 'is' and values and values[0] is not None
1081
+ else _Failure(
1082
+ f'hidden {row["type"]} {row["id"]}.{field} link has unmodeled {relation}'
1083
+ )
1084
+ )
1085
+ return self._choose(visibility, yes=hidden_outcome, no=outcome)
1086
+
1087
+ def _symbolic_paths(self, paths: list[tuple[Row, str]], outcome: _Decision) -> _Decision:
1088
+ """Retain possible hidden-link failures for every reached preset dependency."""
1089
+ for holder, field in reversed(paths):
1090
+ value = holder.get(field)
1091
+ if isinstance(value, dict):
1092
+ outcome = self._choose(
1093
+ self._symbolic_hidden(holder, field, value),
1094
+ yes=_Failure(f'{holder["type"]} {holder["id"]}.{field}: hidden preset link'),
1095
+ no=outcome,
1096
+ )
1097
+ return outcome
1098
+
1099
+ def _symbolic_tree(self, row: Row, tree: dict[str, object]) -> _Decision:
1100
+ """Apply condition order and short circuits before discovering dimensions."""
1101
+ conjunction = tree['logical_operator'] == 'and'
1102
+ result: _Decision = conjunction
1103
+ for condition in cast('list[dict[str, object]]', tree['conditions']):
1104
+ if result is (not conjunction):
1105
+ break
1106
+ outcome = (
1107
+ self._symbolic_tree(row, condition)
1108
+ if 'logical_operator' in condition
1109
+ else self._symbolic_field(row, condition)
1110
+ )
1111
+ result = self._all(result, outcome) if conjunction else self._any(result, outcome)
1112
+ return result
1113
+
1114
+ @staticmethod
1115
+ def _validate_relation(
1116
+ entity_type: str,
1117
+ field_name: str,
1118
+ field_type: str,
1119
+ relation: str,
1120
+ values: Sequence[object],
1121
+ ) -> bool:
1122
+ """Validate vocabulary and known arity, returning evaluator availability."""
1123
+ arities: dict[str, dict[str, int | None]] = {
1124
+ 'checkbox': {'is': 1, 'is_not': 1},
1125
+ 'entity': {
1126
+ 'in': None,
1127
+ 'not_in': None,
1128
+ 'is': 1,
1129
+ 'is_not': 1,
1130
+ 'name_is': 1,
1131
+ 'name_contains': 1,
1132
+ 'name_ends_with': 1,
1133
+ 'name_not_contains': 1,
1134
+ 'name_starts_with': 1,
1135
+ 'type_is': 1,
1136
+ 'type_is_not': 1,
1137
+ },
1138
+ 'entity_type': {'is': 1},
1139
+ 'list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
1140
+ 'multi_entity': {
1141
+ 'in': None,
1142
+ 'is': 1,
1143
+ 'is_not': 1,
1144
+ 'name_contains': 1,
1145
+ 'name_is': 1,
1146
+ 'name_not_contains': 1,
1147
+ 'not_in': None,
1148
+ 'type_is': 1,
1149
+ 'type_is_not': 1,
1150
+ },
1151
+ 'status_list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
1152
+ 'text': {
1153
+ 'contains': 1,
1154
+ 'ends_with': 1,
1155
+ 'in': None,
1156
+ 'is': 1,
1157
+ 'is_not': 1,
1158
+ 'not_contains': 1,
1159
+ 'not_in': None,
1160
+ 'starts_with': 1,
1161
+ },
1162
+ }
1163
+ for numeric_type in _NUMERIC_TYPES:
1164
+ arities[numeric_type] = {
1165
+ 'between': 2,
1166
+ 'greater_than': 1,
1167
+ 'in': None,
1168
+ 'is': 1,
1169
+ 'is_not': 1,
1170
+ 'less_than': 1,
1171
+ 'not_in': None,
1172
+ }
1173
+ for null_type in ('currency', 'image', 'timecode'):
1174
+ arities[null_type] = {'is': 1, 'is_not': 1}
1175
+ if field_type in {'currency', 'timecode'} and relation == 'not_between':
1176
+ raise ServerFaultError(
1177
+ f'API read() {entity_type}.{field_name} {field_type} does not support not_between',
1178
+ error_code=103,
1179
+ )
1180
+ if field_type in _UNFILTERABLE_TYPES:
1181
+ raise ServerFaultError(
1182
+ f'API read() {entity_type}.{field_name} {field_type} '
1183
+ 'data type cannot be used in a filter',
1184
+ error_code=103,
1185
+ )
1186
+ valid = _VALID_RELATIONS.get(field_type)
1187
+ if valid is not None and relation not in valid:
1188
+ raise ServerFaultError(
1189
+ f'API read() {entity_type}.{field_name} {field_type} '
1190
+ f'does not support {relation}. Valid relations: {", ".join(valid)}',
1191
+ error_code=103,
1192
+ )
1193
+ if field_type == 'color':
1194
+ return (
1195
+ entity_type == 'Task'
1196
+ and field_name == 'color'
1197
+ and relation == 'is'
1198
+ and list(values) == ['pipeline_step']
1199
+ )
1200
+ field_relations = arities.get(field_type, {})
1201
+ expected = field_relations.get(relation)
1202
+ if expected is not None and len(values) != expected:
1203
+ if field_type == 'multi_entity' and relation == 'is':
1204
+ raise ServerFaultError(
1205
+ "API read() 'is' relation expects a single value for "
1206
+ f'{entity_type}.{field_name}',
1207
+ error_code=103,
1208
+ )
1209
+ raise ServerFaultError(f'{relation} requires exactly {expected} value(s)')
1210
+ return relation in field_relations
1211
+
1212
+ def _validate_tree(self, entity_type: str, tree: dict[str, object]) -> None:
1213
+ """Validate all filter relations before evaluating any database row."""
1214
+ conditions = cast('list[dict[str, object]]', tree['conditions'])
1215
+ for condition in conditions:
1216
+ if 'logical_operator' in condition:
1217
+ self._validate_tree(entity_type, condition)
1218
+ continue
1219
+ field_name = cast('str', condition['path'])
1220
+ field_type = self._filter_field_type(entity_type, field_name)
1221
+ relation = cast('str', condition['relation'])
1222
+ if field_type == 'url':
1223
+ for row in self._store.rows(entity_type, retired=self._scope.retired):
1224
+ self._guard_uploaded_file(row, field_name)
1225
+ if field_type in {'date', 'date_time'} and relation in _RELATIVE_RELATIONS:
1226
+ validate_relative(
1227
+ f'{entity_type}.{field_name}',
1228
+ field_type,
1229
+ relation,
1230
+ cast('list[FieldValue]', condition['values']),
1231
+ context=self._scope.context,
1232
+ )
1233
+ continue
1234
+ implemented = self._validate_relation(
1235
+ entity_type,
1236
+ field_name,
1237
+ field_type,
1238
+ cast('str', condition['relation']),
1239
+ cast('list[FieldValue]', condition['values']),
1240
+ )
1241
+ _validate_operands(
1242
+ entity_type,
1243
+ field_name,
1244
+ field_type,
1245
+ cast('str', condition['relation']),
1246
+ cast('list[FieldValue]', condition['values']),
1247
+ )
1248
+ if not implemented:
1249
+ raise MockgunError(
1250
+ f'mockgun does not implement {entity_type}.{field_name} '
1251
+ f'{field_type} relation {condition["relation"]}'
1252
+ )
1253
+ if field_type == 'date_time':
1254
+ try:
1255
+ condition['values'] = [
1256
+ parse_wire_datetime(value) if isinstance(value, str) else value
1257
+ for value in cast('list[object]', condition['values'])
1258
+ ]
1259
+ except (ValueError, OverflowError) as exc:
1260
+ raise MockgunError(
1261
+ f'mockgun cannot parse {entity_type}.{field_name} '
1262
+ f'{field_type} operand: {exc}'
1263
+ ) from exc
1264
+
1265
+ def _validate_selection(
1266
+ self, entity_type: str, tree: dict[str, object], scope: ReadScope
1267
+ ) -> tuple[dict[str, object], tuple[Preset, ...]]:
1268
+ """Validate filters and presets together before candidate selection."""
1269
+ preset_error: MockgunError | ServerFaultError | None = None
1270
+ try:
1271
+ presets = validate_presets(
1272
+ self._schema, entity_type, [] if scope.presets is None else scope.presets
1273
+ )
1274
+ except (MockgunError, ServerFaultError) as exc:
1275
+ preset_error = exc
1276
+ presets = ()
1277
+ try:
1278
+ adapted = _adapt_condition_tree(tree)
1279
+ self._validate_tree(entity_type, adapted)
1280
+ except (MockgunError, ServerFaultError) as exc:
1281
+ if preset_error is not None:
1282
+ raise MockgunError(
1283
+ f'mockgun cannot select {entity_type}: invalid filters and presets'
1284
+ ) from exc
1285
+ raise
1286
+ if preset_error is not None:
1287
+ raise preset_error
1288
+ return adapted, presets
1289
+
1290
+ def _visible_assumption(self) -> dict[tuple[str, int], RecordState]:
1291
+ """Supply one canonical visible state without generating state combinations."""
1292
+ return {
1293
+ (kind, cast('int', row['id'])): RecordState(retired=retired)
1294
+ for kind in self._schema.fields
1295
+ for retired in (False, True)
1296
+ for row in self._store.rows(kind, retired=retired)
1297
+ }
1298
+
1299
+ def _dominance_safe(
1300
+ self, candidate: _Candidate, group: list[_Candidate], presets: tuple[Preset, ...]
1301
+ ) -> bool:
1302
+ """Prove group guards unchanged using their pairwise witnesses and certain rows."""
1303
+ if self._errors(candidate.selected) is not True:
1304
+ return False
1305
+ if any(
1306
+ self._archive_guard(candidate, other) is not True
1307
+ for other in group
1308
+ if other is not candidate
1309
+ ):
1310
+ return False
1311
+ certain = [other.row for other in group if other.selected is True]
1312
+ peers = [
1313
+ certain,
1314
+ *[
1315
+ [*certain, other.row]
1316
+ for other in group
1317
+ if other is not candidate
1318
+ and other.selected is not True
1319
+ and other.selected is not False
1320
+ ],
1321
+ ]
1322
+ kind = cast('str', candidate.row['type'])
1323
+ assume = self._visible_assumption()
1324
+ for rows in peers:
1325
+ errors: list[bool] = []
1326
+ for comparison in (rows, [*rows, candidate.row]):
1327
+ try:
1328
+ with assuming(assume):
1329
+ apply_presets(self._store, kind, comparison, presets, retired=False)
1330
+ except MockgunError:
1331
+ errors.append(True)
1332
+ else:
1333
+ errors.append(False)
1334
+ if errors[0] != errors[1]:
1335
+ return False
1336
+ return True
1337
+
1338
+ def _dominance(
1339
+ self, entity_type: str, group: list[_Candidate], presets: tuple[Preset, ...]
1340
+ ) -> list[_Candidate]:
1341
+ """Drop strictly losing candidates before counting any state product."""
1342
+ mode = cast('str', next(preset for preset in presets if preset.name == 'LATEST').latest_by)
1343
+ certain = [candidate.row for candidate in group if candidate.selected is True]
1344
+ best = max(
1345
+ (latest_rank_key(self._store, entity_type, row, mode) for row in certain), default=None
1346
+ )
1347
+ retained: list[_Candidate] = []
1348
+ for candidate in group:
1349
+ if candidate.selected is not True and best is not None:
1350
+ try:
1351
+ loses = latest_rank_key(self._store, entity_type, candidate.row, mode) < best
1352
+ except MockgunError:
1353
+ loses = False
1354
+ if loses and self._dominance_safe(candidate, group, presets):
1355
+ continue
1356
+ retained.append(candidate)
1357
+ return retained
1358
+
1359
+ def _symbolic_external(self, entity_type: str, presets: tuple[Preset, ...]) -> _Decision:
1360
+ """Keep external preset reads independent of candidate and dominance rejection."""
1361
+ try:
1362
+ paths = preset_dependencies(self._store, presets)
1363
+ with assuming(self._visible_assumption()):
1364
+ apply_presets(
1365
+ self._store,
1366
+ entity_type,
1367
+ [],
1368
+ presets,
1369
+ retired=self._scope.retired,
1370
+ )
1371
+ except (MockgunError, ServerFaultError) as exc:
1372
+ return _Failure(str(exc))
1373
+ return self._symbolic_paths(paths, outcome=True)
1374
+
1375
+ def _symbolic_preset(
1376
+ self, row: Row, presets: tuple[Preset, ...], *, group_only: bool = False
1377
+ ) -> _Decision:
1378
+ """Keep preset failures conditional, without enumerating a candidate's states."""
1379
+ if not presets:
1380
+ return True
1381
+ kind = cast('str', row['type'])
1382
+ assume = self._visible_assumption()
1383
+ try:
1384
+ paths = self._preset_paths(row, presets, group_only=group_only)
1385
+ with assuming(assume):
1386
+ outcome: _Decision = bool(
1387
+ latest_groups(self._store, kind, [row])
1388
+ if group_only
1389
+ else apply_presets(
1390
+ self._store,
1391
+ kind,
1392
+ [row],
1393
+ presets,
1394
+ retired=self._scope.retired,
1395
+ )
1396
+ )
1397
+ except (MockgunError, ServerFaultError) as exc:
1398
+ return _Failure(str(exc))
1399
+ return self._symbolic_paths(paths, outcome)
1400
+
1401
+ def _candidate(
1402
+ self, row: Row, adapted: dict[str, object], presets: tuple[Preset, ...]
1403
+ ) -> _Candidate | None:
1404
+ """Reject known scope and constant candidacy before retaining any dimensions."""
1405
+ scope = self._scope
1406
+ admitted = self._choose(
1407
+ self._symbolic_retired(row), yes=scope.retired, no=not scope.retired
1408
+ )
1409
+ if admitted is False:
1410
+ return None
1411
+ latest = any(preset.name == 'LATEST' for preset in presets)
1412
+ archive = self._symbolic_archive(row, scope)
1413
+ if not latest:
1414
+ admitted = self._all(admitted, archive)
1415
+ if admitted is False:
1416
+ return None
1417
+ member = self._all(admitted, self._symbolic_tree(row, adapted))
1418
+ if member is False:
1419
+ return None
1420
+ if latest:
1421
+ member = self._all(member, self._symbolic_preset(row, presets, group_only=True))
1422
+ selected = self._all(member, archive)
1423
+ selected = self._all(selected, self._symbolic_preset(row, presets))
1424
+ if selected is False and (not latest or member is False):
1425
+ return None
1426
+ return _Candidate(row, member, archive, selected)
1427
+
1428
+ def _uncertainty_error(self, reads: _StateReads, detail: str = '') -> MockgunError:
1429
+ """Name deciding identities, fields and taint sources for a state-dependent answer."""
1430
+ reasons: list[str] = []
1431
+ for identity, tokens in reads.items():
1432
+ row = self._store.require(*identity)
1433
+ reason = self._store.tainted(row)
1434
+ links = [token for token in tokens if token is not None]
1435
+ fields = ','.join(token[0] for token in links) or 'retirement'
1436
+ if reason is None:
1437
+ reason = ', '.join(
1438
+ f'retired {kind} {entity_id} keep link' for _, kind, entity_id in links
1439
+ )
1440
+ reasons.append(f'{identity[0]} {identity[1]}.{fields}: {reason}')
1441
+ return MockgunError('; '.join([*reasons, detail] if detail else reasons))
1442
+
1443
+ def _select_unit(
1444
+ self,
1445
+ entity_type: str,
1446
+ rows: list[Row],
1447
+ reads: _StateReads,
1448
+ adapted: dict[str, object],
1449
+ presets: tuple[Preset, ...],
1450
+ ) -> list[Row]:
1451
+ """Count only reduced dimensions, then require identical outcomes in every state."""
1452
+ for identity, tokens in self._reads(self._external).items():
1453
+ reads.setdefault(identity, set()).update(tokens)
1454
+ answer: list[Row] | None = None
1455
+ for combination in self._combinations(
1456
+ reads, f'{entity_type} group {[row["id"] for row in rows]}'
1457
+ ):
1458
+ self._assume = {**self._visible_assumption(), **combination}
1459
+ try:
1460
+ selected = self._select_assumed(entity_type, rows, adapted, presets)
1461
+ except MockgunError as exc:
1462
+ if reads:
1463
+ raise self._uncertainty_error(reads, str(exc)) from exc
1464
+ raise
1465
+ if answer is not None and [row['id'] for row in selected] != [
1466
+ row['id'] for row in answer
1467
+ ]:
1468
+ raise self._uncertainty_error(reads)
1469
+ answer = selected
1470
+ return answer or []
1471
+
1472
+ @staticmethod
1473
+ def _stored_groups(entity_type: str, candidates: list[_Candidate]) -> list[list[_Candidate]]:
1474
+ """Partition possible candidates by stored preset group keys."""
1475
+ groups: dict[tuple[object, ...], list[_Candidate]] = {}
1476
+ for candidate in candidates:
1477
+ row = candidate.row
1478
+ entity = row.get('entity')
1479
+ identity = (entity.get('type'), entity.get('id')) if isinstance(entity, dict) else None
1480
+ task = row.get('task')
1481
+ task_key = (task.get('type'), task.get('id')) if isinstance(task, dict) else None
1482
+ key = (
1483
+ (identity, task_key, row.get('name'))
1484
+ if entity_type == 'PublishedFile'
1485
+ else (row.get('code'), identity)
1486
+ if entity_type == 'Cut'
1487
+ else (identity,)
1488
+ )
1489
+ groups.setdefault(key, []).append(candidate)
1490
+ return list(groups.values())
1491
+
1492
+ def _latest_units(
1493
+ self,
1494
+ entity_type: str,
1495
+ candidates: list[_Candidate],
1496
+ adapted: dict[str, object],
1497
+ presets: tuple[Preset, ...],
1498
+ ) -> list[Row]:
1499
+ """Apply all reductions before collecting each group's final state dimensions."""
1500
+ result: list[Row] = []
1501
+ for group in self._stored_groups(entity_type, candidates):
1502
+ retained = self._dominance(entity_type, group, presets)
1503
+ decisions = [candidate.selected for candidate in retained]
1504
+ decisions.extend(self._errors(candidate.member) for candidate in retained)
1505
+ decisions.extend(
1506
+ self._archive_guard(left, right) for left, right in combinations(retained, 2)
1507
+ )
1508
+ reads: _StateReads = {}
1509
+ for decision in decisions:
1510
+ for key, tokens in self._reads(decision).items():
1511
+ reads.setdefault(key, set()).update(tokens)
1512
+ result.extend(
1513
+ self._select_unit(
1514
+ entity_type, [candidate.row for candidate in retained], reads, adapted, presets
1515
+ )
1516
+ )
1517
+ return result
1518
+
1519
+ def _preset_rows(
1520
+ self,
1521
+ entity_type: str,
1522
+ adapted: dict[str, object],
1523
+ presets: tuple[Preset, ...],
1524
+ scope: ReadScope,
1525
+ ) -> list[Row]:
1526
+ """Reduce symbolic candidate outcomes, then perform bounded concrete evaluation."""
1527
+ if not self._has_uncertainty():
1528
+ return self._ordinary_rows(entity_type, adapted, presets, scope)
1529
+ source_rows = self._store.rows(entity_type, retired=scope.retired)
1530
+ for row in self._store.tainted_rows(entity_type):
1531
+ if row not in source_rows:
1532
+ source_rows.append(row)
1533
+ try:
1534
+ self._external = self._symbolic_external(entity_type, presets)
1535
+ candidates = [
1536
+ candidate
1537
+ for row in source_rows
1538
+ if (candidate := self._candidate(row, adapted, presets)) is not None
1539
+ ]
1540
+ if not candidates:
1541
+ return self._select_unit(entity_type, [], {}, adapted, presets)
1542
+ if not any(preset.name == 'LATEST' for preset in presets):
1543
+ return [
1544
+ selected
1545
+ for candidate in candidates
1546
+ for selected in self._select_unit(
1547
+ entity_type,
1548
+ [candidate.row],
1549
+ self._reads(candidate.selected),
1550
+ adapted,
1551
+ presets,
1552
+ )
1553
+ ]
1554
+ return self._latest_units(entity_type, candidates, adapted, presets)
1555
+ finally:
1556
+ self._assume = None
1557
+
1558
+ def select(
1559
+ self,
1560
+ entity_type: str,
1561
+ tree: dict[str, object],
1562
+ order: list[dict[str, str]],
1563
+ ) -> list[Row]:
1564
+ """Validate and order candidates by clauses, then ascending record ID.
1565
+
1566
+ Args:
1567
+ entity_type: Entity type to query.
1568
+ tree: Wire filter tree with `logical_operator` and `conditions`;
1569
+ leaf conditions contain `path`, `relation`, and `values`.
1570
+ order: Ordered mappings containing `field_name` and an `asc` or
1571
+ `desc` direction.
1572
+
1573
+ Returns:
1574
+ Live stored rows in the requested order, with ascending ID as the
1575
+ final tie breaker.
1576
+
1577
+ Raises:
1578
+ ServerFaultError: If selection has a modeled entity, filter, or
1579
+ preset fault.
1580
+ MockgunError: If selection, linked state, event state, or ordering
1581
+ is unsupported or unsettled.
1582
+ """
1583
+ if entity_type == 'EventLogEntry' and self._store.events_unknown is not None:
1584
+ raise MockgunError(f'EventLogEntry is unknown: {self._store.events_unknown}')
1585
+ adapted, presets = self._validate_selection(entity_type, tree, self._scope)
1586
+ clauses: list[tuple[dict[str, str], str]] = []
1587
+ for entry in order:
1588
+ path = entry['field_name']
1589
+ if entry['direction'] not in {'asc', 'desc'}:
1590
+ raise ValueError(f'unsupported ordering direction: {entry["direction"]}')
1591
+ if (
1592
+ '.' not in path
1593
+ and path not in {'id', 'type'}
1594
+ and not self._schema.declares(entity_type, path)
1595
+ ):
1596
+ continue
1597
+ try:
1598
+ field_type = self._order_field(entity_type, path)[2]
1599
+ except (MockgunError, ServerFaultError) as exc:
1600
+ raise MockgunError(f'mockgun cannot order {entity_type}.{path}: {exc}') from exc
1601
+ clauses.append((entry, field_type))
1602
+ rows = self._preset_rows(entity_type, adapted, presets, self._scope)
1603
+ rows.sort(key=lambda row: cast('int', row['id']))
1604
+ groups = [rows]
1605
+ for entry, field_type in clauses:
1606
+ if field_type == 'url':
1607
+ for row in rows:
1608
+ self._guard_uploaded_file(row, entry['field_name'])
1609
+ if len(rows) > 1 and field_type not in _ORDER_TYPES:
1610
+ raise MockgunError(
1611
+ f'mockgun cannot order {entity_type}.{entry["field_name"]} {field_type}'
1612
+ )
1613
+ groups = [
1614
+ part for group in groups for part in self._order_group(group, entity_type, entry)
1615
+ ]
1616
+ return [row for group in groups for row in group]
1617
+
1618
+
1619
+ def _adapt_condition_tree(tree: dict[str, object]) -> dict[str, object]:
1620
+ """Copy a source-form condition tree into the evaluator representation."""
1621
+ operator = tree.get('logical_operator')
1622
+ if not isinstance(operator, str) or operator not in {'and', 'or'}:
1623
+ raise ServerFaultError('condition-tree logical_operator must be "and" or "or"')
1624
+ source_conditions = tree.get('conditions')
1625
+ if not isinstance(source_conditions, Sequence) or isinstance(
1626
+ source_conditions,
1627
+ (str, bytes, bytearray),
1628
+ ):
1629
+ raise ServerFaultError('condition-tree conditions must be a sequence')
1630
+
1631
+ conditions: list[dict[str, object]] = []
1632
+ for source_condition in cast('Sequence[object]', source_conditions):
1633
+ if not isinstance(source_condition, dict):
1634
+ raise ServerFaultError('condition-tree nodes must be dictionaries')
1635
+ condition = cast('dict[str, object]', source_condition)
1636
+ if 'logical_operator' in condition or 'conditions' in condition:
1637
+ conditions.append(_adapt_condition_tree(condition))
1638
+ continue
1639
+ path = condition.get('path')
1640
+ if not isinstance(path, str):
1641
+ raise ServerFaultError('condition-tree leaf path must be a string')
1642
+ relation = condition.get('relation')
1643
+ if not isinstance(relation, str):
1644
+ raise ServerFaultError('condition-tree leaf relation must be a string')
1645
+ values = condition.get('values')
1646
+ if not isinstance(values, list):
1647
+ raise ServerFaultError('condition-tree leaf values must be a list')
1648
+ values = cast('list[object]', values)
1649
+ if path.startswith('$FROM$'):
1650
+ continue
1651
+ conditions.append(
1652
+ {
1653
+ 'path': path,
1654
+ 'relation': relation,
1655
+ 'values': copy.deepcopy(values),
1656
+ }
1657
+ )
1658
+ return {'logical_operator': operator, 'conditions': conditions}
1659
+
1660
+
1661
+ def _compare_entity_metadata(actual: _EntityLink | None, relation: str, expected: str) -> bool:
1662
+ """Compare nullable link types or case-insensitive modeled display names."""
1663
+ if actual is None:
1664
+ return relation in {'name_not_contains', 'type_is_not'}
1665
+ if relation == 'type_is':
1666
+ return actual['type'] == expected
1667
+ if relation == 'type_is_not':
1668
+ return actual['type'] != expected
1669
+ name = cast('str', actual['name']).lower()
1670
+ expected = expected.lower()
1671
+ if relation == 'name_is':
1672
+ return name == expected
1673
+ contains = expected in name
1674
+ return not contains if relation == 'name_not_contains' else contains
1675
+
1676
+
1677
+ def _compare_equality(actual: FieldValue, relation: str, values: list[FieldValue]) -> bool:
1678
+ """Evaluate a validated checkbox or null-only scalar relation."""
1679
+ return actual == values[0] if relation == 'is' else actual != values[0]
1680
+
1681
+
1682
+ def _compare_numeric(
1683
+ actual: _ComparableFieldValue | None,
1684
+ relation: str,
1685
+ values: list[_ComparableFieldValue | None],
1686
+ ) -> bool:
1687
+ """Evaluate a validated numeric, date, or datetime relation."""
1688
+ first = values[0] if values else None
1689
+ if relation == 'is':
1690
+ result = actual == first
1691
+ elif relation == 'is_not':
1692
+ result = actual != first
1693
+ elif relation == 'in':
1694
+ result = actual in values
1695
+ elif relation == 'not_in':
1696
+ result = actual not in values
1697
+ elif actual is None or first is None:
1698
+ result = False
1699
+ elif relation == 'less_than':
1700
+ result = actual < first
1701
+ elif relation == 'greater_than':
1702
+ result = actual > first
1703
+ else:
1704
+ second = values[1]
1705
+ if second is None:
1706
+ return False
1707
+ lower, upper = (second, first) if second < first else (first, second)
1708
+ result = lower <= actual <= upper
1709
+ return result
1710
+
1711
+
1712
+ def _compare_selection(actual: str | None, relation: str, values: list[str | None]) -> bool:
1713
+ """Evaluate a validated list or status-list relation."""
1714
+ actual = actual.lower() if isinstance(actual, str) else actual
1715
+ values = [value.lower() if isinstance(value, str) else value for value in values]
1716
+ first = values[0] if values else None
1717
+ if relation == 'is':
1718
+ result = actual == first
1719
+ elif relation == 'is_not':
1720
+ result = actual != first
1721
+ elif relation == 'in':
1722
+ result = actual in values
1723
+ else:
1724
+ result = actual not in values
1725
+ return result
1726
+
1727
+
1728
+ def _compare_text_fragment(actual: str | None, relation: str, expected: str | None) -> bool:
1729
+ """Evaluate a validated case-insensitive text fragment relation."""
1730
+ normalized_actual = (actual or '').lower()
1731
+ normalized_expected = (expected or '').lower()
1732
+ contains = normalized_expected in normalized_actual and (
1733
+ actual is not None or not isinstance(expected, str)
1734
+ )
1735
+ if relation == 'contains':
1736
+ result = contains
1737
+ elif relation == 'not_contains':
1738
+ result = not contains
1739
+ elif relation == 'starts_with':
1740
+ result = normalized_actual.startswith(normalized_expected)
1741
+ else:
1742
+ result = normalized_actual.endswith(normalized_expected)
1743
+ return result
1744
+
1745
+
1746
+ def _compare_text_membership(
1747
+ actual: str | None,
1748
+ relation: str,
1749
+ values: list[str | list[str | None] | None],
1750
+ ) -> bool:
1751
+ """Evaluate a validated case-insensitive text membership relation."""
1752
+ normalized_actual = actual.lower() if actual is not None else None
1753
+ normalized_values: list[str | list[str | None] | None] = []
1754
+ for value in values:
1755
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
1756
+ items = cast('Sequence[str | None]', value)
1757
+ normalized_values.append(
1758
+ [item.lower() if item is not None else None for item in items]
1759
+ )
1760
+ else:
1761
+ normalized_values.append(value.lower() if value is not None else None)
1762
+ first = normalized_values[0] if normalized_values else None
1763
+ if first == '' and relation in {'is', 'is_not'}:
1764
+ first = None
1765
+ if relation == 'is':
1766
+ result = normalized_actual == first
1767
+ elif relation == 'is_not':
1768
+ result = normalized_actual != first
1769
+ elif relation == 'in':
1770
+ result = normalized_actual in normalized_values
1771
+ else:
1772
+ result = normalized_actual not in normalized_values
1773
+ return result
1774
+
1775
+
1776
+ def _entity_identity(value: Mapping[str, FieldValue]) -> tuple[FieldValue, FieldValue]:
1777
+ """Return the source-observed identity pair for an entity value."""
1778
+ return value['type'], value['id']
1779
+
1780
+
1781
+ def _compare_entity_identity(
1782
+ actual: _EntityLink | None,
1783
+ relation: str,
1784
+ values: list[_EntityLink | None],
1785
+ ) -> bool:
1786
+ """Evaluate validated entity identity relations."""
1787
+ first = values[0] if values else None
1788
+ if relation == 'is':
1789
+ if actual is None or first is None:
1790
+ result = actual == first
1791
+ else:
1792
+ result = _entity_identity(actual) == _entity_identity(first)
1793
+ elif relation == 'is_not':
1794
+ if actual is None or first is None:
1795
+ result = actual != first
1796
+ else:
1797
+ result = _entity_identity(actual) != _entity_identity(first)
1798
+ else:
1799
+ actual_identity = _entity_identity(actual) if actual is not None else None
1800
+ contains = any(
1801
+ actual_identity == (_entity_identity(value) if value is not None else None)
1802
+ for value in values
1803
+ )
1804
+ result = not contains if relation == 'not_in' else contains
1805
+ return result
1806
+
1807
+
1808
+ def _compare_multi_entity(
1809
+ actual: list[_EntityLink] | None,
1810
+ relation: str,
1811
+ values: list[FieldValue],
1812
+ ) -> bool:
1813
+ """Match multi-entity members by identity or metadata, including complements."""
1814
+ if relation in {'is', 'is_not'} and values[0] is None:
1815
+ return not actual if relation == 'is' else bool(actual)
1816
+ positive = {
1817
+ 'is_not': 'is',
1818
+ 'not_in': 'in',
1819
+ 'name_not_contains': 'name_contains',
1820
+ 'type_is_not': 'type_is',
1821
+ }.get(relation, relation)
1822
+ if positive in {'is', 'in'}:
1823
+ matches = any(
1824
+ _compare_entity_identity(member, positive, cast('list[_EntityLink | None]', values))
1825
+ for member in actual or []
1826
+ )
1827
+ else:
1828
+ matches = any(
1829
+ _compare_entity_metadata(member, positive, cast('str', values[0]))
1830
+ for member in actual or []
1831
+ )
1832
+ return not matches if relation != positive else matches
1833
+
1834
+
1835
+ def _compare_scalar(
1836
+ field_type: str,
1837
+ actual: FieldValue,
1838
+ relation: str,
1839
+ values: list[FieldValue],
1840
+ ) -> bool:
1841
+ """Dispatch one non-list field value to its type-specific comparator."""
1842
+ if field_type in {'checkbox', 'color', 'currency', 'image', 'timecode'}:
1843
+ result = _compare_equality(actual, relation, values)
1844
+ elif field_type in _NUMERIC_TYPES:
1845
+ result = _compare_numeric(
1846
+ cast('_ComparableFieldValue | None', actual),
1847
+ relation,
1848
+ cast('list[_ComparableFieldValue | None]', values),
1849
+ )
1850
+ elif field_type in {'list', 'status_list'}:
1851
+ result = _compare_selection(
1852
+ cast('str | None', actual),
1853
+ relation,
1854
+ cast('list[str | None]', values),
1855
+ )
1856
+ elif field_type == 'entity_type':
1857
+ result = actual == values[0]
1858
+ elif field_type == 'text':
1859
+ if relation in {'contains', 'ends_with', 'not_contains', 'starts_with'}:
1860
+ result = _compare_text_fragment(
1861
+ cast('str | None', actual),
1862
+ relation,
1863
+ cast('str | None', values[0]),
1864
+ )
1865
+ else:
1866
+ result = _compare_text_membership(
1867
+ cast('str | None', actual),
1868
+ relation,
1869
+ cast('list[str | list[str | None] | None]', values),
1870
+ )
1871
+ elif field_type == 'entity':
1872
+ if relation in {'in', 'is', 'is_not', 'not_in'}:
1873
+ result = _compare_entity_identity(
1874
+ cast('_EntityLink | None', actual),
1875
+ relation,
1876
+ cast('list[_EntityLink | None]', values),
1877
+ )
1878
+ else:
1879
+ result = _compare_entity_metadata(
1880
+ cast('_EntityLink', actual),
1881
+ relation,
1882
+ cast('str', values[0]),
1883
+ )
1884
+ elif field_type == 'multi_entity':
1885
+ result = _compare_multi_entity(
1886
+ cast('list[_EntityLink] | None', actual),
1887
+ relation,
1888
+ values,
1889
+ )
1890
+ else:
1891
+ raise ServerFaultError(f'relation {relation} is unsupported for field type {field_type}')
1892
+ return result
1893
+
1894
+
1895
+ def _compare(
1896
+ field_type: str,
1897
+ actual: FieldValue,
1898
+ relation: str,
1899
+ values: list[FieldValue],
1900
+ ) -> bool:
1901
+ """Evaluate one source-supported field relation."""
1902
+ if isinstance(actual, list) and field_type != 'multi_entity':
1903
+ items = cast('list[FieldValue]', actual)
1904
+ return any(_compare(field_type, item, relation, values) for item in items)
1905
+ return _compare_scalar(field_type, actual, relation, values)
1906
+
1907
+
1908
+ def _entity_operand_fault(value: FieldValue) -> str | None:
1909
+ """Describe the observed string-ID entity hash fault."""
1910
+ if isinstance(value, dict) and isinstance(value.get('id'), str):
1911
+ return (
1912
+ "API read() invalid/missing entity hash integer 'id':\n"
1913
+ f'{{"type" => "{value.get("type")}", "id" => "{value["id"]}"}}'
1914
+ )
1915
+ return None
1916
+
1917
+
1918
+ def _format_datetimes(value: FieldValue) -> FieldValue:
1919
+ """Format datetime leaves while preserving nested link lists and null values."""
1920
+ if isinstance(value, datetime.datetime):
1921
+ return format_wire_datetime(value)
1922
+ if isinstance(value, list):
1923
+ return [_format_datetimes(member) for member in value]
1924
+ return value
1925
+
1926
+
1927
+ def _operand_fault(field_type: str, relation: str, value: object) -> str | None:
1928
+ """Describe only operand errors settled by the scalar characterizations."""
1929
+ wrong_type = (
1930
+ (field_type == 'number' and type(value) in {float, str})
1931
+ or (field_type == 'float' and type(value) is int)
1932
+ or (field_type == 'text' and type(value) in {int, float})
1933
+ or (
1934
+ field_type == 'date'
1935
+ and isinstance(value, str)
1936
+ and bool(re.search(r'\d{2}:\d{2}', value))
1937
+ )
1938
+ or (
1939
+ field_type == 'date_time'
1940
+ and isinstance(value, str)
1941
+ and bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}', value))
1942
+ )
1943
+ )
1944
+ if wrong_type:
1945
+ return f'{field_type} operand has the wrong type: {value!r}'
1946
+ if field_type == 'checkbox':
1947
+ prefix = 'expected [FalseClass, TrueClass] data type(s) but got '
1948
+ if isinstance(value, str):
1949
+ return f'{prefix}String:\n"{value}"'
1950
+ if value is None and relation == 'is':
1951
+ return f'{prefix}NilClass:\nnil'
1952
+ return None
1953
+
1954
+
1955
+ def _ordered_note_links(
1956
+ row: Row, field: str, stored: FieldValue, visible: FieldValue, context: RequestContext | None
1957
+ ) -> FieldValue:
1958
+ """Return Asset before Shot for script reads of a script-authored Note's two links."""
1959
+ creator = row.get('created_by')
1960
+ if (
1961
+ row['type'] == 'Note'
1962
+ and context is not None
1963
+ and context.session_kind == 'script'
1964
+ and isinstance(creator, dict)
1965
+ and creator.get('type') == 'ApiUser'
1966
+ and field == 'note_links'
1967
+ and isinstance(stored, list)
1968
+ and len(stored) == len(_NOTE_LINK_PAIR)
1969
+ and {cast('str', cast('_EntityLink', member)['type']) for member in stored}
1970
+ == _NOTE_LINK_PAIR
1971
+ and isinstance(visible, list)
1972
+ ):
1973
+ return sorted(visible, key=lambda member: cast('str', cast('_EntityLink', member)['type']))
1974
+ return visible
1975
+
1976
+
1977
+ def _validate_operands(
1978
+ entity_type: str,
1979
+ field_name: str,
1980
+ field_type: str,
1981
+ relation: str,
1982
+ values: list[FieldValue],
1983
+ ) -> None:
1984
+ """Validate settled scalar and entity operands independently of candidate rows."""
1985
+ qualified = f'{entity_type}.{field_name}'
1986
+ faults = [_operand_fault(field_type, relation, value) for value in values]
1987
+ entity_faults = (
1988
+ [_entity_operand_fault(value) for value in values]
1989
+ if (
1990
+ field_type in {'entity', 'multi_entity'}
1991
+ and relation in {'is', 'is_not', 'in', 'not_in'}
1992
+ )
1993
+ else []
1994
+ )
1995
+ unsettled = (
1996
+ field_type in {'duration', 'percent'}
1997
+ and any(value is not None and type(value) is not int for value in values)
1998
+ ) or (
1999
+ field_type in {'currency', 'image', 'timecode'}
2000
+ and any(value is not None for value in values)
2001
+ )
2002
+ has_null = (
2003
+ relation in {'in', 'not_in'}
2004
+ and relation in _VALID_RELATIONS.get(field_type, ())
2005
+ and None in values
2006
+ )
2007
+ if unsettled or (has_null and (any(faults) or any(entity_faults))):
2008
+ raise MockgunError(
2009
+ f'mockgun cannot evaluate {qualified} {field_type} {relation} {values!r}'
2010
+ )
2011
+ if has_null:
2012
+ raise ServerFaultError(
2013
+ f'API read() Read failed for entity type [{entity_type}]', error_code=104
2014
+ )
2015
+ for fault in entity_faults:
2016
+ if fault is not None:
2017
+ raise ServerFaultError(fault, error_code=103)
2018
+ for fault in faults:
2019
+ if fault is not None:
2020
+ raise ServerFaultError(f'API read() {qualified} {fault}', error_code=103)
2021
+ if not values and relation in {'in', 'not_in'}:
2022
+ if field_type == 'number':
2023
+ raise ServerFaultError(
2024
+ f'API read() {qualified} {relation} requires at least one value', error_code=103
2025
+ )
2026
+ if field_type in {'duration', 'entity', 'multi_entity', 'percent'} or (
2027
+ field_type in {'date', 'date_time', 'float'} and relation == 'not_in'
2028
+ ):
2029
+ raise MockgunError(
2030
+ f'mockgun cannot evaluate {qualified} {field_type} {relation} {values!r}'
2031
+ )
2032
+
2033
+
2034
+ def collation_key(value: str) -> str:
2035
+ """Return the ASCII letter-and-digit key used by modeled text order."""
2036
+ return ''.join(
2037
+ character
2038
+ for character in value.translate(_ASCII_LOWER)
2039
+ if character in _COLLATION_CHARACTERS
2040
+ )
2041
+
2042
+
2043
+ def check_collation(values: Collection[str], *, entity_type: str, field_name: str) -> None:
2044
+ """Reject unsettled comparisons within one preceding-key tie group.
2045
+
2046
+ Args:
2047
+ values: Distinct non-null text values or names from one tie group only.
2048
+ entity_type: Entity owning the ordered field.
2049
+ field_name: Ordered field or path, used in the error message.
2050
+
2051
+ Raises:
2052
+ MockgunError: If distinct ASCII-lowered values have equal keys, or an
2053
+ unobserved character decides their comparison. Equal lowered values
2054
+ tie.
2055
+ """
2056
+ lowered = {value.translate(_ASCII_LOWER) for value in values}
2057
+ if len({collation_key(value) for value in lowered}) != len(lowered):
2058
+ raise MockgunError(
2059
+ f'mockgun cannot order {entity_type}.{field_name}: equal collation keys'
2060
+ )
2061
+ stripped = [value.translate(_COLLATION_IGNORED) for value in lowered]
2062
+ if all(set(value) <= _COLLATION_CHARACTERS for value in stripped):
2063
+ return
2064
+ for left, right in combinations(stripped, 2):
2065
+ for first, second in zip(left, right, strict=False):
2066
+ if first != second:
2067
+ if first not in _COLLATION_CHARACTERS or second not in _COLLATION_CHARACTERS:
2068
+ raise MockgunError(
2069
+ f'mockgun cannot order {entity_type}.{field_name}: unsettled collation'
2070
+ )
2071
+ break
2072
+
2073
+
2074
+ def page(
2075
+ rows: list[Row], *, entities_per_page: int, current_page: int, max_page_size: int
2076
+ ) -> tuple[list[Row], dict[str, int]]:
2077
+ """Return the requested capped page and counts for all matching rows.
2078
+
2079
+ Args:
2080
+ rows: All matching rows in query order.
2081
+ entities_per_page: Requested positive page width.
2082
+ current_page: One-based page number.
2083
+ max_page_size: Site's positive maximum page width.
2084
+
2085
+ Returns:
2086
+ A new list retaining the selected live row references, followed by a
2087
+ paging dictionary containing `entity_count`, `current_page`,
2088
+ `entities_per_page`, and `page_count`. The effective width is the
2089
+ smaller of the requested width and site maximum.
2090
+ """
2091
+ width = min(entities_per_page, max_page_size)
2092
+ offset = (current_page - 1) * width
2093
+ count = len(rows)
2094
+ return rows[offset : offset + width], {
2095
+ 'entity_count': count,
2096
+ 'current_page': current_page,
2097
+ 'entities_per_page': width,
2098
+ 'page_count': (count + width - 1) // width,
2099
+ }
2100
+
2101
+
2102
+ def project_rows(
2103
+ store: Store,
2104
+ schema: SiteSchema,
2105
+ rows: list[Row],
2106
+ return_fields: list[str],
2107
+ *,
2108
+ context: RequestContext | None = None,
2109
+ ) -> list[dict[str, object]]:
2110
+ """Return detached selected fields plus identity for each row in order.
2111
+
2112
+ Args:
2113
+ store: Site records used to resolve projected links.
2114
+ schema: Field declarations for the requested projection.
2115
+ rows: Selected stored rows, in return order.
2116
+ return_fields: Fields to select alongside type and identity.
2117
+ context: Read actor for evidence bounded by session kind. When absent,
2118
+ preserve projection behavior outside observed read forms.
2119
+
2120
+ Raises:
2121
+ ServerFaultError: If resolving a declared field or linked entity
2122
+ encounters a modeled schema fault.
2123
+ MockgunError: If projected values or linked identity state are unknown
2124
+ or unsupported.
2125
+ OverflowError: If converting a projected datetime to UTC exceeds the
2126
+ supported datetime range.
2127
+ ValueError: If the platform cannot convert a naive datetime.
2128
+ OSError: If local timezone conversion fails.
2129
+ """
2130
+ return _Projection(store, schema, context).project(rows, return_fields)
2131
+
2132
+
2133
+ def select(
2134
+ store: Store,
2135
+ entity_type: str,
2136
+ tree: dict[str, object],
2137
+ order: list[dict[str, str]],
2138
+ *,
2139
+ scope: ReadScope,
2140
+ ) -> list[Row]:
2141
+ """Select matching rows with the current filter and stable ordering semantics.
2142
+
2143
+ Args:
2144
+ store: Site records and schema to query.
2145
+ entity_type: Entity type to query.
2146
+ tree: Wire filter tree with `logical_operator` and `conditions`;
2147
+ leaf conditions contain `path`, `relation`, and `values`.
2148
+ order: Ordered mappings containing `field_name` and an `asc` or `desc`
2149
+ direction.
2150
+ scope: Retirement, archive, preset, and request-context selection
2151
+ scope.
2152
+
2153
+ Returns:
2154
+ Live stored rows in the requested order, with ascending ID as the final
2155
+ tie breaker.
2156
+
2157
+ Raises:
2158
+ ServerFaultError: If selection has a modeled entity, filter, or preset
2159
+ fault.
2160
+ MockgunError: If selection, linked state, event state, or ordering is
2161
+ unsupported or unsettled.
2162
+ """
2163
+ return _Query(store, store.schema, scope).select(entity_type, tree, order)