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