sg-python-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1666 @@
1
+ """In-memory implementation of the mockgun compatibility surface."""
2
+
3
+ import copy
4
+ import datetime
5
+ import pickle
6
+ import urllib.parse
7
+ from collections.abc import Callable, Collection, Mapping, Sequence
8
+ from pathlib import Path
9
+ from typing import Any, ClassVar, Never, Protocol, Self, TypeAlias, TypeVar, cast, overload
10
+
11
+ from shotgun_api3 import _crud, _protocol
12
+ from shotgun_api3._client import (
13
+ CONSTRUCTOR_SIGNATURE,
14
+ bind_constructor,
15
+ expose_constructor_signature,
16
+ )
17
+ from shotgun_api3._config import parse_server_params
18
+ from shotgun_api3._errors import Fault, ShotgunError
19
+
20
+ _DocumentationFunction = TypeVar('_DocumentationFunction', bound=Callable[..., object])
21
+
22
+ _FieldValue: TypeAlias = (
23
+ bool
24
+ | int
25
+ | float
26
+ | str
27
+ | datetime.date
28
+ | datetime.datetime
29
+ | dict[str, '_FieldValue']
30
+ | list['_FieldValue']
31
+ | None
32
+ )
33
+ _EntityLink: TypeAlias = dict[str, _FieldValue]
34
+ _Row: TypeAlias = dict[str, _FieldValue]
35
+ _Schema: TypeAlias = dict[str, _FieldValue]
36
+ _BatchResult: TypeAlias = bool | dict[str, object]
37
+
38
+ _DEFERRED_TRADITIONAL_FILTER_ERROR = 'deferred_traditional_filter_error'
39
+ _FIELD_PYTHON_TYPES: dict[str, type[object]] = {
40
+ 'checkbox': bool,
41
+ 'date': str,
42
+ 'date_time': datetime.datetime,
43
+ 'duration': int,
44
+ 'entity_type': str,
45
+ 'float': float,
46
+ 'list': str,
47
+ 'number': int,
48
+ 'percent': int,
49
+ 'serializable': dict,
50
+ 'status_list': str,
51
+ 'text': str,
52
+ 'url': dict,
53
+ }
54
+ _FIND_DOCSTRING = """Find entities matching the supplied in-memory filters.
55
+
56
+ Args:
57
+ entity_type: Schema entity type to query.
58
+ filters: Traditional filters or a logical condition tree.
59
+ fields: Additional result fields; `id` and `type` are always included.
60
+ order: Clauses containing `field_name` and `direction` (`asc` or `desc`).
61
+ Sorts are stable, with the last clause taking precedence. Without
62
+ clauses, results retain insertion order.
63
+ filter_operator: `all` or `any` for traditional filters; `None` uses
64
+ `all`. Ignored for a logical condition tree.
65
+ limit: Accepted for client compatibility and ignored.
66
+ retired_only: Whether to return retired rows instead of active
67
+ rows.
68
+ page: Accepted for client compatibility and ignored.
69
+ include_archived_projects: Accepted for client compatibility and ignored.
70
+ additional_filter_presets: Accepted for client compatibility and ignored.
71
+
72
+ Returns:
73
+ Detached matching entities in the requested order.
74
+
75
+ Raises:
76
+ ShotgunError: If the entity type, fields, or filters are invalid.
77
+ ValueError: If an order clause lacks `field_name` or has an unsupported
78
+ direction.
79
+ KeyError: If an order clause lacks `direction`.
80
+ TypeError: If arguments do not match the signature or ordered values
81
+ cannot be compared.
82
+ """
83
+ _FIND_ONE_DOCSTRING = """Return the first entity matching the supplied filters.
84
+
85
+ Args:
86
+ entity_type: Schema entity type to query.
87
+ filters: Traditional filters or a logical condition tree.
88
+ fields: Additional result fields; `id` and `type` are always included.
89
+ order: Clauses containing `field_name` and `direction` (`asc` or `desc`).
90
+ Sorts are stable, with the last clause taking precedence. Without
91
+ clauses, results retain insertion order.
92
+ filter_operator: `all` or `any` for traditional filters; `None` uses
93
+ `all`. Ignored for a logical condition tree.
94
+ retired_only: Whether to return retired rows instead of active
95
+ rows.
96
+ include_archived_projects: Accepted for client compatibility and ignored.
97
+ additional_filter_presets: Accepted for client compatibility and ignored.
98
+
99
+ Returns:
100
+ The first detached matching entity, or `None` when none match.
101
+
102
+ Raises:
103
+ ShotgunError: If the entity type, fields, or filters are invalid.
104
+ ValueError: If an order clause lacks `field_name` or has an unsupported
105
+ direction.
106
+ KeyError: If an order clause lacks `direction`.
107
+ TypeError: If arguments do not match the signature or ordered values
108
+ cannot be compared.
109
+ """
110
+ _MIN_DATETIME = datetime.datetime.fromisoformat('0001-01-01T00:00:00')
111
+ _MIN_TRADITIONAL_CONDITION_PARTS = 3
112
+ _TRADITIONAL_SEQUENCE_RELATIONS = frozenset({'between', 'in', 'not_between', 'not_in'})
113
+ _VALID_TRADITIONAL_FILTER_OPERATORS = frozenset({'all', 'any'})
114
+
115
+
116
+ class _ComparableFieldValue(Protocol):
117
+ """Describe values with ordered comparisons."""
118
+
119
+ def __gt__(self, other: Self, /) -> bool:
120
+ """Return whether this value sorts after another value."""
121
+ ...
122
+
123
+ def __le__(self, other: Self, /) -> bool:
124
+ """Return whether this value does not sort after another value."""
125
+ ...
126
+
127
+ def __lt__(self, other: Self, /) -> bool:
128
+ """Return whether this value sorts before another value."""
129
+ ...
130
+
131
+
132
+ class _MockConfig:
133
+ """Expose the minimal mutable configuration used by mockgun clients."""
134
+
135
+ def __init__(self, base_url: str) -> None:
136
+ """Parse a site URL into mock configuration defaults."""
137
+ self.scheme, _server, self.api_path = parse_server_params(base_url)
138
+ parsed = urllib.parse.urlsplit(base_url)
139
+ self.server = parsed.netloc
140
+ self.records_per_page = 500
141
+
142
+
143
+ class _SchemaReader(Protocol):
144
+ """Describe the schema export client surface."""
145
+
146
+ def schema_entity_read(self) -> Mapping[str, object]:
147
+ """Return entity schema metadata.
148
+
149
+ Returns:
150
+ Entity schema metadata keyed by entity type.
151
+ """
152
+ ...
153
+
154
+ def schema_read(self) -> Mapping[str, object]:
155
+ """Return field schema metadata.
156
+
157
+ Returns:
158
+ Field schema metadata keyed by entity type.
159
+ """
160
+ ...
161
+
162
+
163
+ class MockgunError(Exception):
164
+ """Report mockgun setup and initialization failures."""
165
+
166
+
167
+ def _adapt_condition_tree(tree: dict[str, object]) -> dict[str, object]:
168
+ """Copy a source-form condition tree into the evaluator representation."""
169
+ operator = tree.get('logical_operator')
170
+ if not isinstance(operator, str) or operator not in {'and', 'or'}:
171
+ raise ShotgunError('condition-tree logical_operator must be "and" or "or"')
172
+ source_conditions = tree.get('conditions')
173
+ if not isinstance(source_conditions, Sequence) or isinstance(
174
+ source_conditions,
175
+ (str, bytes, bytearray),
176
+ ):
177
+ raise ShotgunError('condition-tree conditions must be a sequence')
178
+
179
+ conditions: list[dict[str, object]] = []
180
+ for source_condition in cast('Sequence[object]', source_conditions):
181
+ if not isinstance(source_condition, dict):
182
+ raise ShotgunError('condition-tree nodes must be dictionaries')
183
+ condition = cast('dict[str, object]', source_condition)
184
+ if 'logical_operator' in condition or 'conditions' in condition:
185
+ conditions.append(_adapt_condition_tree(condition))
186
+ continue
187
+ path = condition.get('path')
188
+ if not isinstance(path, str):
189
+ raise ShotgunError('condition-tree leaf path must be a string')
190
+ relation = condition.get('relation')
191
+ if not isinstance(relation, str):
192
+ raise ShotgunError('condition-tree leaf relation must be a string')
193
+ values = condition.get('values')
194
+ if not isinstance(values, list):
195
+ raise ShotgunError('condition-tree leaf values must be a list')
196
+ values = cast('list[object]', values)
197
+ if path.startswith('$FROM$'):
198
+ continue
199
+ conditions.append(
200
+ {
201
+ 'path': path,
202
+ 'relation': relation,
203
+ 'values': copy.deepcopy(values),
204
+ }
205
+ )
206
+ return {'logical_operator': operator, 'conditions': conditions}
207
+
208
+
209
+ def _adapt_traditional_filter_tree(
210
+ filters: list[object] | tuple[object, ...] | dict[str, object],
211
+ tree: dict[str, object],
212
+ ) -> dict[str, object]:
213
+ """Restore mockgun operand semantics on a shared compiled filter tree."""
214
+ source_conditions = cast(
215
+ 'Sequence[object]',
216
+ filters.get('filters', []) if isinstance(filters, dict) else filters,
217
+ )
218
+ compiled_conditions = cast('list[dict[str, object]]', tree['conditions'])
219
+ for source_condition, compiled_condition in zip(
220
+ source_conditions,
221
+ compiled_conditions,
222
+ strict=True,
223
+ ):
224
+ if isinstance(source_condition, dict):
225
+ nested_filters = cast('dict[str, object]', source_condition)
226
+ _adapt_traditional_filter_tree(nested_filters, compiled_condition)
227
+ continue
228
+ condition = cast('Sequence[object]', source_condition)
229
+ if len(condition) < _MIN_TRADITIONAL_CONDITION_PARTS:
230
+ raise ShotgunError('Filters must be in the form [lval, operator, rval]')
231
+ if (
232
+ len(condition) == _MIN_TRADITIONAL_CONDITION_PARTS
233
+ and condition[1] not in _TRADITIONAL_SEQUENCE_RELATIONS
234
+ ):
235
+ compiled_condition['values'] = [copy.deepcopy(condition[2])]
236
+ return tree
237
+
238
+
239
+ def _compare_checkbox(actual: bool | None, relation: str, values: list[_FieldValue]) -> bool:
240
+ """Evaluate a validated checkbox relation."""
241
+ return actual == values[0] if relation == 'is' else actual != values[0]
242
+
243
+
244
+ def _compare_entity_metadata(actual: _EntityLink, relation: str, expected: str) -> bool:
245
+ """Evaluate validated entity type and display-name relations."""
246
+ if relation == 'type_is':
247
+ result = actual['type'] == expected
248
+ elif relation == 'type_is_not':
249
+ result = actual['type'] != expected
250
+ elif relation == 'name_contains':
251
+ result = expected in cast('str', actual['name'])
252
+ elif relation == 'name_not_contains':
253
+ result = expected not in cast('str', actual['name'])
254
+ elif relation == 'name_starts_with':
255
+ result = cast('str', actual['name']).startswith(expected)
256
+ else:
257
+ result = cast('str', actual['name']).endswith(expected)
258
+ return result
259
+
260
+
261
+ def _compare_multi_entity(
262
+ actual: list[_EntityLink] | None,
263
+ relation: str,
264
+ expected: _EntityLink | None,
265
+ ) -> bool:
266
+ """Evaluate validated multi-entity relations with source identity quirks."""
267
+ if expected is None:
268
+ result = not actual if relation == 'is' else bool(actual)
269
+ else:
270
+ contains_id = expected['id'] in (item['id'] for item in cast('list[_EntityLink]', actual))
271
+ result = contains_id if relation == 'is' else not contains_id
272
+ return result
273
+
274
+
275
+ def _compare_numeric(
276
+ actual: _ComparableFieldValue | None,
277
+ relation: str,
278
+ values: list[_ComparableFieldValue | None],
279
+ ) -> bool:
280
+ """Evaluate a validated numeric, date, or datetime relation."""
281
+ first = values[0] if values else None
282
+ if relation == 'is':
283
+ result = actual == first
284
+ elif relation == 'is_not':
285
+ result = actual != first
286
+ elif relation == 'less_than':
287
+ result = cast('_ComparableFieldValue', actual) < cast('_ComparableFieldValue', first)
288
+ elif relation == 'greater_than':
289
+ result = cast('_ComparableFieldValue', actual) > cast('_ComparableFieldValue', first)
290
+ elif relation == 'between':
291
+ result = (
292
+ cast('_ComparableFieldValue', values[0])
293
+ <= cast('_ComparableFieldValue', actual)
294
+ <= cast('_ComparableFieldValue', values[1])
295
+ )
296
+ elif relation == 'not_between':
297
+ result = cast('_ComparableFieldValue', actual) < cast(
298
+ '_ComparableFieldValue', values[0]
299
+ ) or cast('_ComparableFieldValue', actual) > cast('_ComparableFieldValue', values[1])
300
+ else:
301
+ result = actual in values
302
+ return result
303
+
304
+
305
+ def _compare_selection(actual: str | None, relation: str, values: list[str | None]) -> bool:
306
+ """Evaluate a validated list or status-list relation."""
307
+ first = values[0] if values else None
308
+ if relation == 'is':
309
+ result = actual == first
310
+ elif relation == 'is_not':
311
+ result = actual != first
312
+ elif relation == 'in':
313
+ result = actual in values
314
+ else:
315
+ result = actual not in values
316
+ return result
317
+
318
+
319
+ def _compare_text_fragment(actual: str | None, relation: str, expected: str | None) -> bool:
320
+ """Evaluate a validated case-insensitive text fragment relation."""
321
+ normalized_actual = (actual or '').lower()
322
+ normalized_expected = (expected or '').lower()
323
+ if relation == 'contains':
324
+ result = normalized_expected in normalized_actual
325
+ elif relation == 'not_contains':
326
+ result = normalized_expected not in normalized_actual
327
+ elif relation == 'starts_with':
328
+ result = normalized_actual.startswith(normalized_expected)
329
+ else:
330
+ result = normalized_actual.endswith(normalized_expected)
331
+ return result
332
+
333
+
334
+ def _compare_text_membership(
335
+ actual: str | None,
336
+ relation: str,
337
+ values: list[str | list[str | None] | None],
338
+ ) -> bool:
339
+ """Evaluate a validated case-insensitive text membership relation."""
340
+ normalized_actual = actual.lower() if actual is not None else None
341
+ normalized_values: list[str | list[str | None] | None] = []
342
+ for value in values:
343
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
344
+ items = cast('Sequence[str | None]', value)
345
+ normalized_values.append(
346
+ [item.lower() if item is not None else None for item in items]
347
+ )
348
+ else:
349
+ normalized_values.append(value.lower() if value is not None else None)
350
+ first = normalized_values[0] if normalized_values else None
351
+ if relation == 'is':
352
+ result = normalized_actual == first
353
+ elif relation == 'is_not':
354
+ result = normalized_actual != first
355
+ elif relation == 'in':
356
+ result = normalized_actual in normalized_values
357
+ else:
358
+ result = normalized_actual not in normalized_values
359
+ return result
360
+
361
+
362
+ def _deferred_traditional_filter_tree(message: str) -> dict[str, object]:
363
+ """Build an evaluator tree that raises only when its node is reached."""
364
+ return {
365
+ 'logical_operator': 'and',
366
+ 'conditions': [{_DEFERRED_TRADITIONAL_FILTER_ERROR: message}],
367
+ }
368
+
369
+
370
+ def _deferred_traditional_group_error(filters: dict[object, object]) -> str | None:
371
+ """Return a deferred error without invoking hooks on malformed group keys."""
372
+ keys: list[str] = []
373
+ has_filter_operator = False
374
+ has_filters = False
375
+ for key in filters:
376
+ if type(key) is not str:
377
+ return 'invalid traditional filter structure'
378
+ keys.append(key)
379
+ if key == 'filter_operator':
380
+ has_filter_operator = True
381
+ elif key == 'filters':
382
+ has_filters = True
383
+ if has_filter_operator and has_filters:
384
+ return None
385
+ found = ', '.join(keys)
386
+ return f"Bad filter operator, requires keys 'filter_operator' and 'filters', found {found}"
387
+
388
+
389
+ def _deferred_traditional_operator_error(operator: object) -> str | None:
390
+ """Return a deferred error without invoking hooks on an invalid operator."""
391
+ if operator is None:
392
+ return None
393
+ if type(operator) is not str:
394
+ return 'invalid traditional filter structure'
395
+ if operator in _VALID_TRADITIONAL_FILTER_OPERATORS:
396
+ return None
397
+ return f'Unknown filter_operator type: {operator}'
398
+
399
+
400
+ def _deferred_traditional_tuple_is_short(value: object) -> bool:
401
+ """Return whether a tuple condition lacks its right-hand operand."""
402
+ return (
403
+ isinstance(value, tuple)
404
+ and len(cast('tuple[object, ...]', value)) < _MIN_TRADITIONAL_CONDITION_PARTS
405
+ )
406
+
407
+
408
+ def _compile_deferred_traditional_condition(
409
+ source_condition: object,
410
+ ) -> list[dict[str, object]]:
411
+ """Compile one condition or return its deferred structural error."""
412
+ if _deferred_traditional_tuple_is_short(source_condition):
413
+ return [{_DEFERRED_TRADITIONAL_FILTER_ERROR: 'invalid traditional filter structure'}]
414
+ try:
415
+ condition_tree = _adapt_traditional_filter_tree(
416
+ [source_condition],
417
+ _protocol.translate_filters(
418
+ [source_condition],
419
+ optimize=False,
420
+ ),
421
+ )
422
+ except Fault:
423
+ message = 'invalid traditional filter structure'
424
+ if not isinstance(source_condition, (list, tuple, dict)):
425
+ message = (
426
+ 'Filters can only be lists or dictionaries, not '
427
+ f'{type(source_condition).__name__}.'
428
+ )
429
+ return [{_DEFERRED_TRADITIONAL_FILTER_ERROR: message}]
430
+ return cast('list[dict[str, object]]', condition_tree['conditions'])
431
+
432
+
433
+ def _compile_deferred_traditional_filter_tree(
434
+ filters: object,
435
+ filter_operator: str | None,
436
+ ) -> dict[str, object]:
437
+ """Compile valid nodes while preserving malformed nodes for evaluation."""
438
+ if isinstance(filters, dict):
439
+ group_error = _deferred_traditional_group_error(cast('dict[object, object]', filters))
440
+ if group_error is not None:
441
+ return _deferred_traditional_filter_tree(group_error)
442
+ typed_filters = cast('dict[str, object]', filters)
443
+ source_conditions: object = typed_filters.get('filters', [])
444
+ operator: object = typed_filters.get('filter_operator')
445
+ shell: list[object] | dict[str, object] = {
446
+ 'filter_operator': operator,
447
+ 'filters': [],
448
+ }
449
+ shell_operator = None
450
+ else:
451
+ source_conditions = filters
452
+ operator = filter_operator
453
+ shell = []
454
+ shell_operator = filter_operator
455
+ operator_error = _deferred_traditional_operator_error(operator)
456
+ if operator_error is not None:
457
+ return _deferred_traditional_filter_tree(operator_error)
458
+ try:
459
+ tree = _protocol.translate_filters(shell, shell_operator, optimize=False)
460
+ except Fault:
461
+ return _deferred_traditional_filter_tree('invalid traditional filter structure')
462
+ if not isinstance(source_conditions, (list, tuple)):
463
+ return _deferred_traditional_filter_tree('invalid traditional filter structure')
464
+
465
+ compiled_conditions = cast('list[dict[str, object]]', tree['conditions'])
466
+ for source_condition in cast('list[object] | tuple[object, ...]', source_conditions):
467
+ if isinstance(source_condition, dict):
468
+ nested_filters = cast('dict[str, object]', source_condition)
469
+ compiled_conditions.append(
470
+ _compile_deferred_traditional_filter_tree(nested_filters, None)
471
+ )
472
+ continue
473
+ compiled_conditions.extend(_compile_deferred_traditional_condition(source_condition))
474
+ return tree
475
+
476
+
477
+ def _deferred_traditional_tree_requires_fallback(
478
+ filters: object,
479
+ filter_operator: object,
480
+ ) -> bool:
481
+ """Return whether a filter tree contains a node requiring deferment."""
482
+ if isinstance(filters, dict):
483
+ if _deferred_traditional_group_error(cast('dict[object, object]', filters)) is not None:
484
+ return True
485
+ typed_filters = cast('dict[str, object]', filters)
486
+ operator = typed_filters.get('filter_operator')
487
+ source_conditions: object = typed_filters.get('filters', [])
488
+ else:
489
+ operator = filter_operator
490
+ source_conditions = filters
491
+ if _deferred_traditional_operator_error(operator) is not None:
492
+ return True
493
+ if not isinstance(source_conditions, (list, tuple)):
494
+ return False
495
+ return any(
496
+ _deferred_traditional_tuple_is_short(source_condition)
497
+ or (
498
+ isinstance(source_condition, dict)
499
+ and _deferred_traditional_tree_requires_fallback(
500
+ cast('dict[str, object]', source_condition),
501
+ None,
502
+ )
503
+ )
504
+ for source_condition in cast('list[object] | tuple[object, ...]', source_conditions)
505
+ )
506
+
507
+
508
+ def _compile_traditional_filter_tree(
509
+ filters: object,
510
+ filter_operator: str | None,
511
+ ) -> dict[str, object]:
512
+ """Compile a traditional tree, deferring only structural compiler faults."""
513
+ if not isinstance(filters, (list, tuple, dict)):
514
+ return _compile_deferred_traditional_filter_tree(filters, filter_operator)
515
+ filters = cast('list[object] | tuple[object, ...] | dict[str, object]', filters)
516
+ if _deferred_traditional_tree_requires_fallback(filters, filter_operator):
517
+ return _compile_deferred_traditional_filter_tree(filters, filter_operator)
518
+ try:
519
+ return _adapt_traditional_filter_tree(
520
+ filters,
521
+ _protocol.translate_filters(filters, filter_operator, optimize=False),
522
+ )
523
+ except Fault:
524
+ return _compile_deferred_traditional_filter_tree(filters, filter_operator)
525
+
526
+
527
+ def _entity_identity(value: Mapping[str, _FieldValue]) -> tuple[_FieldValue, _FieldValue]:
528
+ """Return the source-observed identity pair for an entity value."""
529
+ return value['type'], value['id']
530
+
531
+
532
+ def _compare_entity_identity(
533
+ actual: _EntityLink | None,
534
+ relation: str,
535
+ values: list[_EntityLink | None],
536
+ ) -> bool:
537
+ """Evaluate validated entity identity relations."""
538
+ first = values[0] if values else None
539
+ if relation == 'is':
540
+ if actual is None or first is None:
541
+ result = actual == first
542
+ else:
543
+ result = _entity_identity(actual) == _entity_identity(first)
544
+ elif relation == 'is_not':
545
+ if actual is None or first is None:
546
+ result = actual != first
547
+ else:
548
+ result = _entity_identity(actual) != _entity_identity(first)
549
+ else:
550
+ actual_identity = _entity_identity(cast('_EntityLink', actual))
551
+ result = all(
552
+ actual_identity == _entity_identity(cast('_EntityLink', value)) for value in values
553
+ )
554
+ return result
555
+
556
+
557
+ def _compare_scalar(
558
+ field_type: str,
559
+ actual: _FieldValue,
560
+ relation: str,
561
+ values: list[_FieldValue],
562
+ ) -> bool:
563
+ """Dispatch one non-list field value to its type-specific comparator."""
564
+ if field_type == 'checkbox':
565
+ result = _compare_checkbox(cast('bool | None', actual), relation, values)
566
+ elif field_type in {'date', 'date_time', 'float', 'number'}:
567
+ result = _compare_numeric(
568
+ cast('_ComparableFieldValue | None', actual),
569
+ relation,
570
+ cast('list[_ComparableFieldValue | None]', values),
571
+ )
572
+ elif field_type in {'list', 'status_list'}:
573
+ result = _compare_selection(
574
+ cast('str | None', actual),
575
+ relation,
576
+ cast('list[str | None]', values),
577
+ )
578
+ elif field_type == 'entity_type':
579
+ result = actual == values[0]
580
+ elif field_type == 'text':
581
+ if relation in {'contains', 'ends_with', 'not_contains', 'starts_with'}:
582
+ result = _compare_text_fragment(
583
+ cast('str | None', actual),
584
+ relation,
585
+ cast('str | None', values[0]),
586
+ )
587
+ else:
588
+ result = _compare_text_membership(
589
+ cast('str | None', actual),
590
+ relation,
591
+ cast('list[str | list[str | None] | None]', values),
592
+ )
593
+ elif field_type == 'entity':
594
+ if relation in {'in', 'is', 'is_not'}:
595
+ result = _compare_entity_identity(
596
+ cast('_EntityLink | None', actual),
597
+ relation,
598
+ cast('list[_EntityLink | None]', values),
599
+ )
600
+ else:
601
+ result = _compare_entity_metadata(
602
+ cast('_EntityLink', actual),
603
+ relation,
604
+ cast('str', values[0]),
605
+ )
606
+ elif field_type == 'multi_entity':
607
+ result = _compare_multi_entity(
608
+ cast('list[_EntityLink] | None', actual),
609
+ relation,
610
+ cast('_EntityLink | None', values[0]),
611
+ )
612
+ else:
613
+ raise ShotgunError(f'relation {relation} is unsupported for field type {field_type}')
614
+ return result
615
+
616
+
617
+ def _compare(
618
+ field_type: str,
619
+ actual: _FieldValue,
620
+ relation: str,
621
+ values: list[_FieldValue],
622
+ ) -> bool:
623
+ """Evaluate one source-supported field relation."""
624
+ if isinstance(actual, list) and field_type != 'multi_entity':
625
+ items = cast('list[_FieldValue]', actual)
626
+ return any(_compare(field_type, item, relation, values) for item in items)
627
+ return _compare_scalar(field_type, actual, relation, values)
628
+
629
+
630
+ def expose_find_documentation(function: _DocumentationFunction) -> _DocumentationFunction:
631
+ """Attach mockgun's public `find` documentation.
632
+
633
+ Args:
634
+ function: Sync or async mockgun implementation.
635
+
636
+ Returns:
637
+ The same function with mockgun's runtime documentation attached.
638
+ """
639
+ object.__setattr__(function, '__doc__', _FIND_DOCSTRING)
640
+ return function
641
+
642
+
643
+ def expose_find_one_documentation(function: _DocumentationFunction) -> _DocumentationFunction:
644
+ """Attach mockgun's public `find_one` documentation.
645
+
646
+ Args:
647
+ function: Sync or async mockgun implementation.
648
+
649
+ Returns:
650
+ The same function with mockgun's runtime documentation attached.
651
+ """
652
+ object.__setattr__(function, '__doc__', _FIND_ONE_DOCSTRING)
653
+ return function
654
+
655
+
656
+ class Shotgun:
657
+ """Provide an in-memory stand-in for `shotgun_api3.Shotgun`.
658
+
659
+ Each instance owns independent entity data and loaded schema objects.
660
+ Operations do not contact the configured site. Schema read methods expose
661
+ live schema values, so mutations to those values affect the instance.
662
+
663
+ Attributes:
664
+ base_url: Constructor-supplied site URL, preserved unchanged.
665
+ config: Mutable mock configuration.
666
+ finds: Number of find operations performed.
667
+ """
668
+
669
+ __signature__ = CONSTRUCTOR_SIGNATURE
670
+ _schema_entity_path: ClassVar[str | None] = None
671
+ _schema_path: ClassVar[str | None] = None
672
+
673
+ @overload
674
+ def __init__(self, base_url: Never) -> None: ...
675
+
676
+ @overload
677
+ def __init__(
678
+ self,
679
+ base_url: str,
680
+ script_name: str | None = None,
681
+ api_key: str | None = None,
682
+ convert_datetimes_to_utc: bool = True,
683
+ http_proxy: str | None = None,
684
+ connect: bool = True,
685
+ ca_certs: str | None = None,
686
+ login: str | None = None,
687
+ password: str | None = None,
688
+ sudo_as_login: str | None = None,
689
+ session_token: str | None = None,
690
+ auth_token: str | None = None,
691
+ ) -> None: ...
692
+
693
+ @expose_constructor_signature
694
+ def __init__(self, *args: object, **kwargs: object) -> None:
695
+ """Initialize an isolated client from the configured schema files.
696
+
697
+ Args:
698
+ *args: Positional values from the compatibility signature.
699
+ **kwargs: Keyword values from the compatibility signature.
700
+
701
+ Raises:
702
+ MockgunError: If schema paths are unset or files are missing.
703
+ TypeError: If arguments do not match the compatibility signature.
704
+ ValueError: If `base_url` has an unsupported or invalid origin.
705
+ """
706
+ options = bind_constructor(args, kwargs, 'Mockgun Shotgun')
707
+ self._initialize(options.base_url)
708
+
709
+ def _entity_display_value(self, value: _EntityLink) -> _EntityLink:
710
+ """Return a detached entity link enriched with its stored display name."""
711
+ result = copy.deepcopy(value)
712
+ linked_row = self._db[cast('str', result['type'])][cast('int', result['id'])]
713
+ if 'name' in linked_row:
714
+ result['name'] = linked_row['name']
715
+ elif 'code' in linked_row:
716
+ result['name'] = linked_row['code']
717
+ return result
718
+
719
+ @staticmethod
720
+ def _expand_local_paths(data: dict[str, Any]) -> dict[str, Any]:
721
+ """Copy data and add the file-link values synthesized by the service."""
722
+ expanded = copy.deepcopy(data)
723
+ for value in expanded.values():
724
+ if not isinstance(value, dict) or 'local_path' not in value:
725
+ continue
726
+ path_value = cast('dict[str, Any]', value)
727
+ path_value.setdefault(
728
+ 'local_storage',
729
+ {
730
+ 'id': 0,
731
+ 'name': 'auto_generated_by_mockgun',
732
+ 'type': 'LocalStorage',
733
+ },
734
+ )
735
+ path_value.setdefault('local_path_linux', path_value['local_path'])
736
+ path_value.setdefault('local_path_mac', path_value['local_path'])
737
+ path_value.setdefault('local_path_windows', path_value['local_path'])
738
+ return expanded
739
+
740
+ def _field_value(
741
+ self,
742
+ entity_type: str,
743
+ row: _Row,
744
+ field_name: str,
745
+ ) -> _FieldValue:
746
+ """Resolve a direct or recursively linked field without mutating links."""
747
+ try:
748
+ link_field, linked_type, subfield = field_name.split('.', 2)
749
+ except ValueError:
750
+ return row.get(field_name)
751
+
752
+ if link_field not in row:
753
+ return None
754
+ link_value = row[link_field]
755
+ if isinstance(link_value, list):
756
+ values: list[_FieldValue] = []
757
+ for item in cast('list[_FieldValue]', link_value):
758
+ if not isinstance(item, dict):
759
+ raise ShotgunError(
760
+ f'linked field path {entity_type}.{field_name} contains a non-entity value'
761
+ )
762
+ link = cast('_EntityLink', item)
763
+ if link['type'] != linked_type:
764
+ continue
765
+ linked_row = self._db[cast('str', link['type'])][cast('int', link['id'])]
766
+ values.append(self._field_value(linked_type, linked_row, subfield))
767
+ return values
768
+ if link_value is None:
769
+ return None
770
+ if not isinstance(link_value, dict):
771
+ raise ShotgunError(
772
+ f'linked field path {entity_type}.{field_name} contains a non-entity value'
773
+ )
774
+ if link_value['type'] != linked_type:
775
+ raise ShotgunError(
776
+ f'linked field path {entity_type}.{field_name} requests {linked_type}, '
777
+ f'but the stored link is {link_value!r}'
778
+ )
779
+ linked_row = self._db[cast('str', link_value['type'])][cast('int', link_value['id'])]
780
+ return self._field_value(linked_type, linked_row, subfield)
781
+
782
+ @staticmethod
783
+ def _normalize_field_value(field_type: str, value: _FieldValue) -> _FieldValue:
784
+ """Detach a field value and reduce entity links to identity keys."""
785
+ if value is None:
786
+ return None
787
+ if field_type == 'entity':
788
+ link = cast('_EntityLink', value)
789
+ return {'type': link['type'], 'id': link['id']}
790
+ if field_type == 'multi_entity':
791
+ links = cast('list[_EntityLink]', value)
792
+ return [{'type': item['type'], 'id': item['id']} for item in links]
793
+ return copy.deepcopy(value)
794
+
795
+ @staticmethod
796
+ def _require_dictionary(value: object, message: str) -> dict[str, Any]:
797
+ """Return a dynamically checked dictionary or raise `ShotgunError`."""
798
+ if not isinstance(value, dict):
799
+ raise ShotgunError(message)
800
+ return cast('dict[str, Any]', value)
801
+
802
+ @staticmethod
803
+ def _require_list(value: object, message: str) -> list[object]:
804
+ """Return a dynamically checked list or raise `ShotgunError`."""
805
+ if not isinstance(value, list):
806
+ raise ShotgunError(message)
807
+ return cast('list[object]', value)
808
+
809
+ @staticmethod
810
+ def _require_string_list(value: object, message: str) -> list[str]:
811
+ """Return a list of strings or raise `ShotgunError`."""
812
+ items = Shotgun._require_list(value, message)
813
+ if any(not isinstance(item, str) for item in items):
814
+ raise ShotgunError(message)
815
+ return cast('list[str]', items)
816
+
817
+ def _schema_field_type(self, entity_type: str, field_name: str) -> str:
818
+ """Return the data type of a field already validated against the schema."""
819
+ entity_schema = cast('_Schema', self._schema[entity_type])
820
+ field_schema = cast('_Schema', entity_schema[field_name])
821
+ data_type = cast('_Schema', field_schema['data_type'])
822
+ return cast('str', data_type['value'])
823
+
824
+ def _new_row(self, entity_type: str) -> _Row:
825
+ """Build an active row populated with independent schema defaults."""
826
+ row: _Row = {'__retired': False, 'type': entity_type}
827
+ entity_schema = cast('_Schema', self._schema[entity_type])
828
+ for field_name, field_schema_value in entity_schema.items():
829
+ field_schema = cast('_Schema', field_schema_value)
830
+ if self._schema_field_type(entity_type, field_name) == 'multi_entity':
831
+ row[field_name] = []
832
+ else:
833
+ properties = cast('_Schema', field_schema['properties'])
834
+ default_value = cast('_Schema', properties['default_value'])
835
+ row[field_name] = copy.deepcopy(default_value['value'])
836
+ return row
837
+
838
+ @staticmethod
839
+ def _update_multi_entity(
840
+ current: list[_EntityLink],
841
+ incoming: list[_EntityLink],
842
+ mode: object,
843
+ ) -> list[_EntityLink]:
844
+ """Apply source-compatible multi-entity replacement modes."""
845
+ normalized = [{'type': item['type'], 'id': item['id']} for item in incoming]
846
+ if mode == 'set':
847
+ return normalized
848
+ if mode == 'add':
849
+ result = copy.deepcopy(current)
850
+ for item in normalized:
851
+ if item not in result:
852
+ result.append(item)
853
+ return result
854
+ if mode == 'remove':
855
+ return [
856
+ copy.deepcopy(item)
857
+ for item in current
858
+ for removal in normalized
859
+ if item['id'] != removal['id'] or item['type'] != removal['type']
860
+ ]
861
+ return copy.deepcopy(current)
862
+
863
+ @staticmethod
864
+ def _validate_direct_fields(data: dict[str, Any]) -> None:
865
+ """Reject invalid names and fields controlled by mockgun."""
866
+ if any(
867
+ not isinstance(field, str) or not field for field in cast('Collection[object]', data)
868
+ ):
869
+ raise ShotgunError('field names must be nonempty strings')
870
+ if 'id' in data or 'type' in data:
871
+ raise ShotgunError("Can't set id or type on create or update")
872
+ internal_fields = sorted(field for field in data if field.startswith('_'))
873
+ if internal_fields:
874
+ raise ShotgunError(f'fields cannot be set directly: {", ".join(internal_fields)}')
875
+
876
+ @staticmethod
877
+ def _validate_entity_link(entity_type: str, field_name: str, value: object) -> None:
878
+ """Validate the structural requirements for a single link."""
879
+ if not isinstance(value, dict):
880
+ raise ShotgunError(
881
+ f'{entity_type}.{field_name} is of type entity, '
882
+ f'but data {value!r} is not a dictionary'
883
+ )
884
+ if 'type' not in value or 'id' not in value:
885
+ raise ShotgunError(
886
+ f'{entity_type}.{field_name} is of type entity, '
887
+ f"but data {value!r} does not contain 'type' and 'id'"
888
+ )
889
+
890
+ def _validate_entity_type(self, entity_type: str) -> None:
891
+ """Reject entity types absent from the loaded schema."""
892
+ if entity_type not in self._schema:
893
+ raise ShotgunError(f'{entity_type} is not a valid entity')
894
+
895
+ def _require_entity(self, entity_type: str, entity_id: int) -> _Row:
896
+ """Return an existing row or report its missing identity."""
897
+ self._validate_entity_type(entity_type)
898
+ row = self._db[entity_type].get(entity_id)
899
+ if row is None:
900
+ raise ShotgunError(f'No entity of type {entity_type} exists with id {entity_id}')
901
+ return row
902
+
903
+ def _validate_entity_fields(
904
+ self,
905
+ entity_type: str,
906
+ fields: Collection[str] | None,
907
+ ) -> None:
908
+ """Reject fields absent from the selected entity schema."""
909
+ self._validate_entity_type(entity_type)
910
+ if fields is None:
911
+ return
912
+ valid_fields = cast('_Schema', self._schema[entity_type])
913
+ for field_name in fields:
914
+ if field_name not in valid_fields and field_name not in {'id', 'type'}:
915
+ raise ShotgunError(f'{field_name} is not a valid field for entity {entity_type}')
916
+
917
+ def _field_type(self, entity_type: str, field_name: str) -> str:
918
+ """Return the schema type for a validated direct or linked field path."""
919
+ try:
920
+ link_field, linked_type, subfield = field_name.split('.', 2)
921
+ except ValueError:
922
+ self._validate_entity_fields(entity_type, [field_name])
923
+ else:
924
+ return self._validate_link_path(entity_type, link_field, linked_type, subfield)
925
+ if field_name == 'id':
926
+ return 'number'
927
+ if field_name == 'type':
928
+ return 'entity_type'
929
+ return self._schema_field_type(entity_type, field_name)
930
+
931
+ def _matches_tree(
932
+ self,
933
+ entity_type: str,
934
+ row: _Row,
935
+ tree: dict[str, object],
936
+ ) -> bool:
937
+ """Evaluate a compiled logical condition tree for one row."""
938
+ conditions = cast('list[dict[str, object]]', tree['conditions'])
939
+ for condition in conditions:
940
+ if _DEFERRED_TRADITIONAL_FILTER_ERROR in condition:
941
+ message = cast('str', condition[_DEFERRED_TRADITIONAL_FILTER_ERROR])
942
+ raise ShotgunError(message) from None
943
+ if 'logical_operator' in condition:
944
+ matches = self._matches_tree(entity_type, row, condition)
945
+ else:
946
+ field_name = cast('str', condition['path'])
947
+ relation = cast('str', condition['relation'])
948
+ values = cast('list[_FieldValue]', condition['values'])
949
+ field_type = self._field_type(entity_type, field_name)
950
+ actual = self._field_value(entity_type, row, field_name)
951
+ if field_type == 'entity' and actual is not None:
952
+ actual = self._entity_display_value(cast('_EntityLink', actual))
953
+ matches = _compare(
954
+ field_type,
955
+ actual,
956
+ relation,
957
+ values,
958
+ )
959
+ if tree['logical_operator'] == 'and' and not matches:
960
+ return False
961
+ if tree['logical_operator'] == 'or' and matches:
962
+ return True
963
+ return tree['logical_operator'] == 'and'
964
+
965
+ def _sort_value(
966
+ self,
967
+ entity_type: str,
968
+ row: _Row,
969
+ field_name: str,
970
+ ) -> _FieldValue:
971
+ """Return a source-compatible ordering value including `None` sentinels."""
972
+ value = self._field_value(entity_type, row, field_name)
973
+ if value is not None:
974
+ return value
975
+ field_type = self._field_type(entity_type, field_name)
976
+ if field_type in {'duration', 'number', 'percent'}:
977
+ return 0
978
+ if field_type == 'float':
979
+ return 0.0
980
+ if field_type in {'date', 'entity_type', 'list', 'status_list', 'text'}:
981
+ return ''
982
+ if field_type == 'date_time':
983
+ return _MIN_DATETIME
984
+ return None
985
+
986
+ def _validate_link_path(
987
+ self,
988
+ entity_type: str,
989
+ link_field: str,
990
+ linked_type: str,
991
+ subfield: str,
992
+ ) -> str:
993
+ """Validate one linked path level and return its resolved leaf type."""
994
+ self._validate_entity_fields(entity_type, [link_field])
995
+ field_type = self._schema_field_type(entity_type, link_field)
996
+ if field_type not in {'entity', 'multi_entity'}:
997
+ raise ShotgunError(
998
+ f'{link_field} is not a valid linked field for entity {entity_type}'
999
+ )
1000
+ self._validate_entity_type(linked_type)
1001
+ return self._field_type(linked_type, subfield)
1002
+
1003
+ @staticmethod
1004
+ def _validate_multi_entity_links(
1005
+ entity_type: str,
1006
+ field_name: str,
1007
+ value: object,
1008
+ valid_types: Collection[str],
1009
+ ) -> None:
1010
+ """Validate the structure and permitted types for a link list."""
1011
+ if not isinstance(value, list):
1012
+ raise ShotgunError(
1013
+ f'{entity_type}.{field_name} is of type multi_entity, '
1014
+ f'but data {value!r} is not a list'
1015
+ )
1016
+ items = cast('list[object]', value)
1017
+ if any(not isinstance(item, dict) for item in items):
1018
+ raise ShotgunError(
1019
+ f'{entity_type}.{field_name} is of type multi_entity, '
1020
+ f'but data {value!r} contains a non-dictionary'
1021
+ )
1022
+ links = cast('list[_EntityLink]', items)
1023
+ if any('type' not in item or 'id' not in item for item in links):
1024
+ raise ShotgunError(
1025
+ f'{entity_type}.{field_name} is of type multi_entity, '
1026
+ f"but an item in data {value!r} does not contain 'type' and 'id'"
1027
+ )
1028
+ if any(item['type'] not in valid_types for item in links):
1029
+ raise ShotgunError(
1030
+ f'{entity_type}.{field_name} is of type multi_entity, '
1031
+ f'but an item in data {value!r} has an invalid type '
1032
+ f'(expected one of {valid_types!r})'
1033
+ )
1034
+
1035
+ @staticmethod
1036
+ def _validate_relation(field_type: str, relation: str, values: Sequence[object]) -> None:
1037
+ """Validate a source-supported field/relation combination and its arity."""
1038
+ arities: dict[str, dict[str, int | None]] = {
1039
+ 'checkbox': {'is': 1, 'is_not': 1},
1040
+ 'date': {
1041
+ 'between': 2,
1042
+ 'greater_than': 1,
1043
+ 'in': None,
1044
+ 'is': 1,
1045
+ 'is_not': 1,
1046
+ 'less_than': 1,
1047
+ 'not_between': 2,
1048
+ },
1049
+ 'date_time': {
1050
+ 'between': 2,
1051
+ 'greater_than': 1,
1052
+ 'in': None,
1053
+ 'is': 1,
1054
+ 'is_not': 1,
1055
+ 'less_than': 1,
1056
+ 'not_between': 2,
1057
+ },
1058
+ 'entity': {
1059
+ 'in': None,
1060
+ 'is': 1,
1061
+ 'is_not': 1,
1062
+ 'name_contains': 1,
1063
+ 'name_ends_with': 1,
1064
+ 'name_not_contains': 1,
1065
+ 'name_starts_with': 1,
1066
+ 'type_is': 1,
1067
+ 'type_is_not': 1,
1068
+ },
1069
+ 'entity_type': {'is': 1},
1070
+ 'float': {
1071
+ 'between': 2,
1072
+ 'greater_than': 1,
1073
+ 'in': None,
1074
+ 'is': 1,
1075
+ 'is_not': 1,
1076
+ 'less_than': 1,
1077
+ 'not_between': 2,
1078
+ },
1079
+ 'list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
1080
+ 'multi_entity': {'is': 1, 'is_not': 1},
1081
+ 'number': {
1082
+ 'between': 2,
1083
+ 'greater_than': 1,
1084
+ 'in': None,
1085
+ 'is': 1,
1086
+ 'is_not': 1,
1087
+ 'less_than': 1,
1088
+ 'not_between': 2,
1089
+ },
1090
+ 'status_list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
1091
+ 'text': {
1092
+ 'contains': 1,
1093
+ 'ends_with': 1,
1094
+ 'in': None,
1095
+ 'is': 1,
1096
+ 'is_not': 1,
1097
+ 'not_contains': 1,
1098
+ 'not_in': None,
1099
+ 'starts_with': 1,
1100
+ },
1101
+ }
1102
+ field_relations = arities.get(field_type, {})
1103
+ if relation not in field_relations:
1104
+ raise ShotgunError(f'relation {relation} is unsupported for field type {field_type}')
1105
+ expected = field_relations[relation]
1106
+ if expected is not None and len(values) != expected:
1107
+ raise ShotgunError(f'{relation} requires exactly {expected} value(s)')
1108
+
1109
+ @staticmethod
1110
+ def _validate_scalar_value(
1111
+ entity_type: str,
1112
+ field_name: str,
1113
+ field_type: str,
1114
+ value: object,
1115
+ ) -> None:
1116
+ """Validate one non-relation field value."""
1117
+ python_type = _FIELD_PYTHON_TYPES.get(field_type)
1118
+ if python_type is None:
1119
+ raise ShotgunError(
1120
+ f'Field {entity_type}.{field_name}: handling for schema type '
1121
+ f'{field_type} is not implemented'
1122
+ )
1123
+ if not isinstance(value, python_type):
1124
+ raise ShotgunError(
1125
+ f'{entity_type}.{field_name} is of schema type {field_type}, '
1126
+ f'but data {value!r} is not a {python_type.__name__}'
1127
+ )
1128
+
1129
+ def _validate_entity_data(self, entity_type: str, data: dict[str, Any]) -> None:
1130
+ """Validate field values against the hand-loaded site schema."""
1131
+ self._validate_direct_fields(data)
1132
+ self._validate_entity_fields(entity_type, data.keys())
1133
+ for field_name, value in data.items():
1134
+ entity_schema = cast('_Schema', self._schema[entity_type])
1135
+ field_schema = cast('_Schema', entity_schema[field_name])
1136
+ field_type = self._schema_field_type(entity_type, field_name)
1137
+ if value is None and field_type != 'multi_entity':
1138
+ continue
1139
+ if field_type == 'entity':
1140
+ self._validate_entity_link(entity_type, field_name, value)
1141
+ continue
1142
+
1143
+ if field_type == 'multi_entity':
1144
+ properties = cast('_Schema', field_schema['properties'])
1145
+ valid_types = cast('_Schema', properties['valid_types'])
1146
+ self._validate_multi_entity_links(
1147
+ entity_type,
1148
+ field_name,
1149
+ value,
1150
+ cast('Collection[str]', valid_types['value']),
1151
+ )
1152
+ continue
1153
+
1154
+ self._validate_scalar_value(entity_type, field_name, field_type, value)
1155
+
1156
+ def _validate_tree(self, entity_type: str, tree: dict[str, object]) -> None:
1157
+ """Validate all filter relations before evaluating any database row."""
1158
+ conditions = cast('list[dict[str, object]]', tree['conditions'])
1159
+ for condition in conditions:
1160
+ if _DEFERRED_TRADITIONAL_FILTER_ERROR in condition:
1161
+ continue
1162
+ if 'logical_operator' in condition:
1163
+ self._validate_tree(entity_type, condition)
1164
+ continue
1165
+ field_type = self._field_type(entity_type, cast('str', condition['path']))
1166
+ self._validate_relation(
1167
+ field_type,
1168
+ cast('str', condition['relation']),
1169
+ cast('list[_FieldValue]', condition['values']),
1170
+ )
1171
+
1172
+ def _validated_order(
1173
+ self,
1174
+ entity_type: str,
1175
+ order: list[dict[str, str]] | None,
1176
+ ) -> list[dict[str, str]]:
1177
+ """Validate order clauses before any row evaluation."""
1178
+ if order is None:
1179
+ return []
1180
+ entries = self._require_list(order, 'find order must be a list')
1181
+ validated: list[dict[str, str]] = []
1182
+ for entry in entries:
1183
+ if not isinstance(entry, dict) or 'field_name' not in entry:
1184
+ raise ValueError('order clauses require field_name and direction keys')
1185
+ typed_entry = cast('dict[str, str]', entry)
1186
+ field_name = typed_entry['field_name']
1187
+ direction = typed_entry['direction']
1188
+ self._field_type(entity_type, field_name)
1189
+ if direction not in {'asc', 'desc'}:
1190
+ raise ValueError(f'unsupported ordering direction: {direction}')
1191
+ validated.append(typed_entry)
1192
+ return validated
1193
+
1194
+ def _find(self, options: _crud.FindOptions) -> list[dict[str, object]]:
1195
+ """Execute one bound in-memory find request."""
1196
+ self.finds += 1
1197
+ self._validate_entity_type(options.entity_type)
1198
+ fields = (
1199
+ []
1200
+ if options.fields is None
1201
+ else self._require_string_list(
1202
+ options.fields,
1203
+ 'find fields must be a list of strings',
1204
+ )
1205
+ )
1206
+ for field_name in fields:
1207
+ self._field_type(options.entity_type, field_name)
1208
+ order = self._validated_order(options.entity_type, options.order)
1209
+ filters = options.filters
1210
+ if (
1211
+ isinstance(filters, dict)
1212
+ and all(type(key) is str for key in cast('dict[object, object]', filters))
1213
+ and any(
1214
+ key in {'conditions', 'logical_operator'}
1215
+ for key in cast('dict[str, object]', filters)
1216
+ )
1217
+ ):
1218
+ tree = _adapt_condition_tree(cast('dict[str, object]', filters))
1219
+ else:
1220
+ tree = _compile_traditional_filter_tree(options.filters, options.filter_operator)
1221
+ source_rows = [
1222
+ row
1223
+ for row in self._db[options.entity_type].values()
1224
+ if bool(row['__retired']) is options.retired_only
1225
+ ]
1226
+ self._validate_tree(options.entity_type, tree)
1227
+ rows = [row for row in source_rows if self._matches_tree(options.entity_type, row, tree)]
1228
+ for order_entry in order:
1229
+ order_field = order_entry['field_name']
1230
+ rows = sorted(
1231
+ rows,
1232
+ key=lambda row, field=order_field: cast(
1233
+ '_ComparableFieldValue',
1234
+ self._sort_value(
1235
+ options.entity_type,
1236
+ row,
1237
+ field,
1238
+ ),
1239
+ ),
1240
+ reverse=order_entry['direction'] == 'desc',
1241
+ )
1242
+ result: list[dict[str, object]] = []
1243
+ for row in rows:
1244
+ item: dict[str, object] = {
1245
+ field_name: copy.deepcopy(self._field_value(options.entity_type, row, field_name))
1246
+ for field_name in fields
1247
+ }
1248
+ item['type'] = row['type']
1249
+ item['id'] = row['id']
1250
+ result.append(item)
1251
+ return result
1252
+
1253
+ def create(
1254
+ self,
1255
+ entity_type: str,
1256
+ data: dict[str, Any],
1257
+ return_fields: list[str] | None = None,
1258
+ ) -> dict[str, object]:
1259
+ """Create and return an entity in the in-memory database.
1260
+
1261
+ Args:
1262
+ entity_type: Schema entity type to create.
1263
+ data: Field values for the new entity.
1264
+ return_fields: Fields to return instead of the fields in `data`.
1265
+
1266
+ Returns:
1267
+ Created entity identity and the selected field values.
1268
+
1269
+ Raises:
1270
+ ShotgunError: If the entity, fields, or values are invalid.
1271
+ """
1272
+ validated_data = self._require_dictionary(
1273
+ data,
1274
+ 'create data must be a dictionary',
1275
+ )
1276
+ validated_return_fields = (
1277
+ None
1278
+ if return_fields is None
1279
+ else self._require_string_list(
1280
+ return_fields,
1281
+ 'create return_fields must be a list of strings',
1282
+ )
1283
+ )
1284
+
1285
+ created_data = self._expand_local_paths(validated_data)
1286
+ self._validate_entity_type(entity_type)
1287
+ self._validate_entity_data(entity_type, created_data)
1288
+ self._validate_entity_fields(entity_type, validated_return_fields)
1289
+
1290
+ table = self._db[entity_type]
1291
+ entity_id = max(table, default=0) + 1
1292
+ row = self._new_row(entity_type)
1293
+ for field_name, value in created_data.items():
1294
+ field_type = self._schema_field_type(entity_type, field_name)
1295
+ row[field_name] = self._normalize_field_value(field_type, value)
1296
+ row['id'] = entity_id
1297
+ table[entity_id] = row
1298
+
1299
+ fields = (
1300
+ validated_data.keys() if validated_return_fields is None else validated_return_fields
1301
+ )
1302
+ result: dict[str, object] = {
1303
+ field_name: copy.deepcopy(row[field_name]) for field_name in fields
1304
+ }
1305
+ result['type'] = entity_type
1306
+ result['id'] = entity_id
1307
+ return result
1308
+
1309
+ def _batch_create(self, index: int, request: dict[str, Any]) -> dict[str, object]:
1310
+ """Validate and dispatch one batch create request."""
1311
+ if 'data' not in request:
1312
+ raise ShotgunError(f"batch create request {index} is missing 'data'")
1313
+ return self.create(
1314
+ request['entity_type'],
1315
+ request['data'],
1316
+ request.get('return_fields'),
1317
+ )
1318
+
1319
+ def delete(self, entity_type: str, entity_id: int) -> bool:
1320
+ """Retire an active entity.
1321
+
1322
+ Args:
1323
+ entity_type: Schema entity type to retire.
1324
+ entity_id: ID of the existing entity.
1325
+
1326
+ Returns:
1327
+ `True` when the entity changed state, otherwise `False`.
1328
+
1329
+ Raises:
1330
+ ShotgunError: If the entity type or ID is unknown.
1331
+ """
1332
+ row = self._require_entity(entity_type, entity_id)
1333
+ if row['__retired']:
1334
+ return False
1335
+ row['__retired'] = True
1336
+ return True
1337
+
1338
+ def _batch_delete(self, index: int, request: dict[str, Any]) -> bool:
1339
+ """Validate and dispatch one batch delete request."""
1340
+ if 'entity_id' not in request:
1341
+ raise ShotgunError(f"batch delete request {index} is missing 'entity_id'")
1342
+ return self.delete(request['entity_type'], request['entity_id'])
1343
+
1344
+ @overload
1345
+ def find(self, entity_type: Never) -> list[dict[str, object]]: ...
1346
+
1347
+ @overload
1348
+ def find(
1349
+ self,
1350
+ entity_type: str,
1351
+ filters: list[Any] | tuple[Any, ...] | dict[str, Any],
1352
+ fields: list[str] | None = None,
1353
+ order: list[dict[str, str]] | None = None,
1354
+ filter_operator: str | None = None,
1355
+ limit: int = 0,
1356
+ retired_only: bool = False,
1357
+ page: int = 0,
1358
+ include_archived_projects: bool = True,
1359
+ additional_filter_presets: object = None,
1360
+ ) -> list[dict[str, object]]: ...
1361
+
1362
+ @expose_find_documentation
1363
+ @_crud.expose_find_signature
1364
+ def find(self, *args: object, **kwargs: object) -> list[dict[str, object]]:
1365
+ return self._find(_crud.bind_find(args, kwargs, 'Shotgun'))
1366
+
1367
+ @overload
1368
+ def find_one(self, entity_type: Never) -> dict[str, object] | None: ...
1369
+
1370
+ @overload
1371
+ def find_one(
1372
+ self,
1373
+ entity_type: str,
1374
+ filters: list[Any] | tuple[Any, ...] | dict[str, Any],
1375
+ fields: list[str] | None = None,
1376
+ order: list[dict[str, str]] | None = None,
1377
+ filter_operator: str | None = None,
1378
+ retired_only: bool = False,
1379
+ include_archived_projects: bool = True,
1380
+ additional_filter_presets: object = None,
1381
+ ) -> dict[str, object] | None: ...
1382
+
1383
+ @expose_find_one_documentation
1384
+ @_crud.expose_find_one_signature
1385
+ def find_one(self, *args: object, **kwargs: object) -> dict[str, object] | None:
1386
+ options = _crud.bind_find_one(args, kwargs, 'Shotgun')
1387
+ results = self.find(
1388
+ options.entity_type,
1389
+ options.filters,
1390
+ fields=options.fields,
1391
+ order=options.order,
1392
+ filter_operator=options.filter_operator,
1393
+ limit=1,
1394
+ retired_only=options.retired_only,
1395
+ include_archived_projects=options.include_archived_projects,
1396
+ additional_filter_presets=options.additional_filter_presets,
1397
+ )
1398
+ return results[0] if results else None
1399
+
1400
+ @classmethod
1401
+ def get_schema_paths(cls) -> tuple[str | None, str | None]:
1402
+ """Return the schema paths configured for this class.
1403
+
1404
+ Returns:
1405
+ Field-schema and entity-schema paths, in that order.
1406
+ """
1407
+ return cls._schema_path, cls._schema_entity_path
1408
+
1409
+ def _initialize(self, base_url: str) -> None:
1410
+ """Load schema state and seed the in-memory database."""
1411
+ schema_path, schema_entity_path = self.get_schema_paths()
1412
+ if schema_path is None or schema_entity_path is None:
1413
+ raise MockgunError(
1414
+ 'mockgun schema paths are not configured; call Shotgun.set_schema_paths() first'
1415
+ )
1416
+ if not Path(schema_path).is_file():
1417
+ raise MockgunError(f'mockgun schema file does not exist: {schema_path}')
1418
+ if not Path(schema_entity_path).is_file():
1419
+ raise MockgunError(f'mockgun schema_entity file does not exist: {schema_entity_path}')
1420
+
1421
+ with Path(schema_path).open('rb') as schema_file:
1422
+ self._schema = cast('_Schema', pickle.load(schema_file)) # noqa: S301
1423
+ with Path(schema_entity_path).open('rb') as schema_entity_file:
1424
+ self._schema_entity = cast(
1425
+ '_Schema',
1426
+ pickle.load(schema_entity_file), # noqa: S301
1427
+ )
1428
+
1429
+ self.base_url = base_url
1430
+ self.config = _MockConfig(base_url)
1431
+ self._db: dict[str, dict[int, _Row]] = {entity_type: {} for entity_type in self._schema}
1432
+
1433
+ event = self._new_row('EventLogEntry')
1434
+ event['description'] = 'Mockgun was born. Yay.'
1435
+ event['event_type'] = 'Hello_Mockgun_World'
1436
+ event['id'] = 1
1437
+ self._db['EventLogEntry'][1] = event
1438
+ self.finds = 0
1439
+
1440
+ def revive(self, entity_type: str, entity_id: int) -> bool:
1441
+ """Restore a retired entity to active state.
1442
+
1443
+ Args:
1444
+ entity_type: Schema entity type to restore.
1445
+ entity_id: ID of the existing entity.
1446
+
1447
+ Returns:
1448
+ `True` when the entity changed state, otherwise `False`.
1449
+
1450
+ Raises:
1451
+ ShotgunError: If the entity type or ID is unknown.
1452
+ """
1453
+ row = self._require_entity(entity_type, entity_id)
1454
+ if not row['__retired']:
1455
+ return False
1456
+ row['__retired'] = False
1457
+ return True
1458
+
1459
+ def schema_entity_read(
1460
+ self,
1461
+ project_entity: dict[str, Any] | None = None,
1462
+ ) -> dict[str, object]:
1463
+ """Return the loaded entity schema.
1464
+
1465
+ Args:
1466
+ project_entity: Accepted for public-client compatibility and
1467
+ ignored.
1468
+
1469
+ Returns:
1470
+ The live entity-schema dictionary. Mutations affect this client.
1471
+ """
1472
+ del project_entity
1473
+ return cast('dict[str, object]', self._schema_entity)
1474
+
1475
+ def schema_field_read(
1476
+ self,
1477
+ entity_type: str,
1478
+ field_name: str | None = None,
1479
+ project_entity: dict[str, Any] | None = None,
1480
+ ) -> dict[str, object]:
1481
+ """Return the loaded field schema for an entity or one field.
1482
+
1483
+ Args:
1484
+ entity_type: Entity type whose fields to return.
1485
+ field_name: Optional field name to select.
1486
+ project_entity: Accepted for public-client compatibility and
1487
+ ignored.
1488
+
1489
+ Returns:
1490
+ The live entity field-schema dictionary when no field is selected.
1491
+ Otherwise, a new dictionary containing the selected field and its
1492
+ shared schema value, or an empty dictionary for an unknown field.
1493
+ Mutating shared schema values affects this client.
1494
+
1495
+ Raises:
1496
+ KeyError: If `entity_type` is absent from the loaded schema.
1497
+ """
1498
+ del project_entity
1499
+ fields = cast('dict[str, object]', self._schema[entity_type])
1500
+ if field_name is None:
1501
+ return fields
1502
+ return {name: value for name, value in fields.items() if name == field_name}
1503
+
1504
+ def schema_read(
1505
+ self,
1506
+ project_entity: dict[str, Any] | None = None,
1507
+ ) -> dict[str, object]:
1508
+ """Return the loaded field schema.
1509
+
1510
+ Args:
1511
+ project_entity: Accepted for public-client compatibility and
1512
+ ignored.
1513
+
1514
+ Returns:
1515
+ The live field-schema dictionary. Mutations affect this client.
1516
+ """
1517
+ del project_entity
1518
+ return cast('dict[str, object]', self._schema)
1519
+
1520
+ @classmethod
1521
+ def set_schema_paths(cls, schema_path: str, schema_entity_path: str) -> None:
1522
+ """Configure the schema files loaded by future instances.
1523
+
1524
+ Args:
1525
+ schema_path: Path to the field-schema pickle.
1526
+ schema_entity_path: Path to the entity-schema pickle.
1527
+ """
1528
+ cls._schema_path = schema_path
1529
+ cls._schema_entity_path = schema_entity_path
1530
+
1531
+ def update(
1532
+ self,
1533
+ entity_type: str,
1534
+ entity_id: int,
1535
+ data: dict[str, Any],
1536
+ multi_entity_update_modes: dict[str, str] | None = None,
1537
+ ) -> list[dict[str, object]]:
1538
+ """Update an entity and return the official one-item result list.
1539
+
1540
+ Args:
1541
+ entity_type: Schema entity type to update.
1542
+ entity_id: ID of the existing entity.
1543
+ data: Replacement field values.
1544
+ multi_entity_update_modes: Per-field `set`, `add`, or `remove`
1545
+ modes.
1546
+
1547
+ Returns:
1548
+ One updated entity containing identity and submitted fields.
1549
+
1550
+ Raises:
1551
+ ShotgunError: If the entity, fields, or values are invalid.
1552
+ """
1553
+ validated_data = self._require_dictionary(
1554
+ data,
1555
+ 'update data must be a dictionary',
1556
+ )
1557
+ validated_modes = (
1558
+ None
1559
+ if multi_entity_update_modes is None
1560
+ else self._require_dictionary(
1561
+ multi_entity_update_modes,
1562
+ 'multi_entity_update_modes must be a dictionary',
1563
+ )
1564
+ )
1565
+
1566
+ self._validate_entity_type(entity_type)
1567
+ self._validate_entity_data(entity_type, validated_data)
1568
+ row = self._require_entity(entity_type, entity_id)
1569
+
1570
+ for field_name, value in validated_data.items():
1571
+ field_type = self._schema_field_type(entity_type, field_name)
1572
+ if field_type == 'multi_entity':
1573
+ mode = validated_modes.get(field_name, 'set') if validated_modes else 'set'
1574
+ row[field_name] = cast(
1575
+ '_FieldValue',
1576
+ self._update_multi_entity(
1577
+ cast('list[_EntityLink]', row[field_name]),
1578
+ cast('list[_EntityLink]', value),
1579
+ mode,
1580
+ ),
1581
+ )
1582
+ else:
1583
+ row[field_name] = self._normalize_field_value(field_type, value)
1584
+
1585
+ result: dict[str, object] = {
1586
+ field_name: copy.deepcopy(row[field_name]) for field_name in validated_data
1587
+ }
1588
+ result['type'] = entity_type
1589
+ result['id'] = entity_id
1590
+ return [result]
1591
+
1592
+ def _batch_update(self, index: int, request: dict[str, Any]) -> dict[str, object]:
1593
+ """Validate and dispatch one batch update request."""
1594
+ if 'entity_id' not in request:
1595
+ raise ShotgunError(f"batch update request {index} is missing 'entity_id'")
1596
+ if 'data' not in request:
1597
+ raise ShotgunError(f"batch update request {index} is missing 'data'")
1598
+ return self.update(
1599
+ request['entity_type'],
1600
+ request['entity_id'],
1601
+ request['data'],
1602
+ request.get('multi_entity_update_modes'),
1603
+ )[0]
1604
+
1605
+ def _batch_request(self, index: int, request: object) -> _BatchResult:
1606
+ """Validate shared batch keys and dispatch one supported request."""
1607
+ typed_request = self._require_dictionary(
1608
+ request, f'batch request {index} must be a dictionary'
1609
+ )
1610
+ if 'request_type' not in typed_request:
1611
+ raise ShotgunError(f"batch request {index} is missing 'request_type'")
1612
+ if 'entity_type' not in typed_request:
1613
+ raise ShotgunError(f"batch request {index} is missing 'entity_type'")
1614
+
1615
+ request_type = typed_request['request_type']
1616
+ if request_type == 'create':
1617
+ return self._batch_create(index, typed_request)
1618
+ if request_type == 'update':
1619
+ return self._batch_update(index, typed_request)
1620
+ if request_type == 'delete':
1621
+ return self._batch_delete(index, typed_request)
1622
+ raise ShotgunError(f'Invalid request type {request_type} in request {request!r}')
1623
+
1624
+ def batch(self, requests: list[dict[str, Any]]) -> list[object]:
1625
+ """Execute supported requests in order and return their result values.
1626
+
1627
+ Requests apply sequentially. If a later request fails, earlier
1628
+ mutations remain applied.
1629
+
1630
+ Args:
1631
+ requests: Create, update, and delete request dictionaries.
1632
+
1633
+ Returns:
1634
+ Per-request results in input order.
1635
+
1636
+ Raises:
1637
+ ShotgunError: If a request or delegated operation is invalid.
1638
+ """
1639
+ validated_requests = self._require_list(
1640
+ requests,
1641
+ 'batch requests must be a list',
1642
+ )
1643
+
1644
+ results: list[object] = []
1645
+ for index, request in enumerate(validated_requests):
1646
+ results.append(self._batch_request(index, request))
1647
+
1648
+ return results
1649
+
1650
+
1651
+ def generate_schema(
1652
+ sg: _SchemaReader,
1653
+ schema_file_path: str,
1654
+ schema_entity_file_path: str,
1655
+ ) -> None:
1656
+ """Write the schema dictionaries needed to initialize mockgun.
1657
+
1658
+ Args:
1659
+ sg: Client that supplies the schema dictionaries.
1660
+ schema_file_path: Destination for the field schema.
1661
+ schema_entity_file_path: Destination for the entity schema.
1662
+ """
1663
+ with Path(schema_file_path).open('wb') as schema_file:
1664
+ pickle.dump(sg.schema_read(), schema_file, protocol=2)
1665
+ with Path(schema_entity_file_path).open('wb') as schema_entity_file:
1666
+ pickle.dump(sg.schema_entity_read(), schema_entity_file, protocol=2)