sg-python-api 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,1666 +0,0 @@
|
|
|
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)
|