sg-python-api 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
"""Store fake-site records with the existing validation and normalization rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import datetime
|
|
7
|
+
import json
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import TYPE_CHECKING, TypeAlias, cast
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from ._errors import MockgunError, ServerFaultError
|
|
13
|
+
from ._wire import parse_wire_datetime
|
|
14
|
+
|
|
15
|
+
UPLOAD_LINK_REASON_PREFIX = 'POST /upload/api_link_file '
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Collection, Generator, Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
from ._schema import SiteSchema
|
|
21
|
+
from ._sessions import RequestContext
|
|
22
|
+
|
|
23
|
+
FieldValue: TypeAlias = (
|
|
24
|
+
bool
|
|
25
|
+
| int
|
|
26
|
+
| float
|
|
27
|
+
| str
|
|
28
|
+
| datetime.date
|
|
29
|
+
| datetime.datetime
|
|
30
|
+
| dict[str, 'FieldValue']
|
|
31
|
+
| list['FieldValue']
|
|
32
|
+
| None
|
|
33
|
+
)
|
|
34
|
+
Row: TypeAlias = dict[str, FieldValue]
|
|
35
|
+
_SideKey: TypeAlias = tuple[str | int, ...]
|
|
36
|
+
_EntityLink: TypeAlias = dict[str, FieldValue]
|
|
37
|
+
_Schema: TypeAlias = dict[str, FieldValue]
|
|
38
|
+
|
|
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
|
+
|
|
55
|
+
_PAIRED_FIELDS = {
|
|
56
|
+
('Shot', 'assets', 'Asset'): ('Asset', 'shots'),
|
|
57
|
+
('Asset', 'shots', 'Shot'): ('Shot', 'assets'),
|
|
58
|
+
('Shot', 'sg_sequence', 'Sequence'): ('Sequence', 'shots'),
|
|
59
|
+
('Sequence', 'shots', 'Shot'): ('Shot', 'sg_sequence'),
|
|
60
|
+
('Asset', 'parents', 'Asset'): ('Asset', 'assets'),
|
|
61
|
+
('Asset', 'assets', 'Asset'): ('Asset', 'parents'),
|
|
62
|
+
('Version', 'playlists', 'Playlist'): ('Playlist', 'versions'),
|
|
63
|
+
('Playlist', 'versions', 'Version'): ('Version', 'playlists'),
|
|
64
|
+
('PublishedFile', 'version', 'Version'): ('Version', 'published_files'),
|
|
65
|
+
('Task', 'entity', 'Shot'): ('Shot', 'tasks'),
|
|
66
|
+
('Note', 'note_links', 'Shot'): ('Shot', 'notes'),
|
|
67
|
+
('CutItem', 'cut', 'Cut'): ('Cut', 'cut_items'),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
CONNECTION_FIELDS = {
|
|
71
|
+
('Shot', 'assets', 'Asset'): ('AssetShotConnection', 'shot', 'asset'),
|
|
72
|
+
('Asset', 'shots', 'Shot'): ('AssetShotConnection', 'asset', 'shot'),
|
|
73
|
+
('Asset', 'parents', 'Asset'): ('AssetAssetConnection', 'asset', 'parent'),
|
|
74
|
+
('Asset', 'assets', 'Asset'): ('AssetAssetConnection', 'parent', 'asset'),
|
|
75
|
+
('Version', 'playlists', 'Playlist'): ('PlaylistVersionConnection', 'version', 'playlist'),
|
|
76
|
+
('Playlist', 'versions', 'Version'): ('PlaylistVersionConnection', 'playlist', 'version'),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_UNKNOWN_CREATE_FIELDS = {
|
|
80
|
+
'Task': ('color', 'due_date', 'content'),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Store:
|
|
85
|
+
"""Records and retirement state owned by one serialized fake server.
|
|
86
|
+
|
|
87
|
+
Rows returned by this internal interface are live store rows. The server
|
|
88
|
+
holds its request lock while using them; RPC handlers project detached
|
|
89
|
+
values.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
created_connections: Connection rows created by the latest create or
|
|
93
|
+
update, for immediate event construction under the request lock.
|
|
94
|
+
Reset at each create/update and on transactional restoration.
|
|
95
|
+
context: Latest request actor and time, or `None` before any request.
|
|
96
|
+
schema: Field declarations shared with query handlers.
|
|
97
|
+
events_unknown: First unsettled event write, or `None` while the log
|
|
98
|
+
remains readable.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, schema: SiteSchema) -> None:
|
|
102
|
+
"""Create empty entity tables and seed the declared startup event fields."""
|
|
103
|
+
self._actors: dict[tuple[str, int], str] = {}
|
|
104
|
+
self._candidate: Row | None = None
|
|
105
|
+
self._link_uuids: dict[tuple[str, int], str] = {}
|
|
106
|
+
self._undo: dict[tuple[str, int], Row | None] | None = None
|
|
107
|
+
self._side_tables: dict[str, dict[_SideKey, FieldValue]] = {
|
|
108
|
+
name: {}
|
|
109
|
+
for name in ('activity', 'activity_taint', 'follows', 'image_identity', 'last_access')
|
|
110
|
+
}
|
|
111
|
+
self._side_next_ids = dict.fromkeys(self._side_tables, 1)
|
|
112
|
+
self._side_undo: dict[tuple[str, _SideKey], FieldValue] | None = None
|
|
113
|
+
self.context: RequestContext | None = None
|
|
114
|
+
self.created_connections: list[Row] = []
|
|
115
|
+
self.schema = schema
|
|
116
|
+
self.events_unknown: str | None = None
|
|
117
|
+
self._schema = schema.fields
|
|
118
|
+
self._db: dict[str, dict[int, Row]] = {entity_type: {} for entity_type in self._schema}
|
|
119
|
+
if schema.declares('EventLogEntry'):
|
|
120
|
+
event = self._new_row('EventLogEntry')
|
|
121
|
+
for name, value in {
|
|
122
|
+
'description': 'Mockgun was born. Yay.',
|
|
123
|
+
'event_type': 'Hello_Mockgun_World',
|
|
124
|
+
}.items():
|
|
125
|
+
if schema.declares('EventLogEntry', name):
|
|
126
|
+
event[name] = value
|
|
127
|
+
event['id'] = 1
|
|
128
|
+
self._db['EventLogEntry'][1] = event
|
|
129
|
+
self._next_ids = {
|
|
130
|
+
entity_type: max(table, default=0) + 1 for entity_type, table in self._db.items()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _expand_local_paths(data: dict[str, object]) -> dict[str, object]:
|
|
135
|
+
"""Copy data and add the file-link values synthesized by the service."""
|
|
136
|
+
expanded = copy.deepcopy(data)
|
|
137
|
+
for value in expanded.values():
|
|
138
|
+
if not isinstance(value, dict) or 'local_path' not in value:
|
|
139
|
+
continue
|
|
140
|
+
path_value = cast('dict[str, object]', value)
|
|
141
|
+
path_value.setdefault(
|
|
142
|
+
'local_storage',
|
|
143
|
+
{
|
|
144
|
+
'id': 0,
|
|
145
|
+
'name': 'auto_generated_by_mockgun',
|
|
146
|
+
'type': 'LocalStorage',
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
path_value.setdefault('local_path_linux', path_value['local_path'])
|
|
150
|
+
path_value.setdefault('local_path_mac', path_value['local_path'])
|
|
151
|
+
path_value.setdefault('local_path_windows', path_value['local_path'])
|
|
152
|
+
return expanded
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _links(value: FieldValue, target_type: str) -> list[_EntityLink]:
|
|
156
|
+
"""Extract distinct relation identities for one observed target type."""
|
|
157
|
+
values = value if isinstance(value, list) else [value]
|
|
158
|
+
links: list[_EntityLink] = []
|
|
159
|
+
for item in values:
|
|
160
|
+
if isinstance(item, dict) and item.get('type') == target_type and item not in links:
|
|
161
|
+
links.append(item)
|
|
162
|
+
return links
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _normalize_field_value(field_type: str, value: FieldValue) -> FieldValue:
|
|
166
|
+
"""Detach a field value and reduce entity links to identity keys."""
|
|
167
|
+
if value is None:
|
|
168
|
+
return None
|
|
169
|
+
if field_type == 'text' and value == '':
|
|
170
|
+
return None
|
|
171
|
+
if field_type == 'entity':
|
|
172
|
+
link = cast('_EntityLink', value)
|
|
173
|
+
return {'type': link['type'], 'id': link['id']}
|
|
174
|
+
if field_type == 'multi_entity':
|
|
175
|
+
links = cast('list[_EntityLink]', value)
|
|
176
|
+
return [{'type': item['type'], 'id': item['id']} for item in links]
|
|
177
|
+
return copy.deepcopy(value)
|
|
178
|
+
|
|
179
|
+
def _remember_identity(self, entity_type: str, entity_id: int) -> None:
|
|
180
|
+
"""Save an identity's original row once before mutation or insertion."""
|
|
181
|
+
undo = self._undo
|
|
182
|
+
key = (entity_type, entity_id)
|
|
183
|
+
if undo is not None and key not in undo:
|
|
184
|
+
undo[key] = copy.deepcopy(self._db[entity_type].get(entity_id))
|
|
185
|
+
|
|
186
|
+
def _schema_field_type(self, entity_type: str, field_name: str) -> str:
|
|
187
|
+
"""Return the data type of a field already validated against the schema."""
|
|
188
|
+
entity_schema = cast('_Schema', self._schema[entity_type])
|
|
189
|
+
field_schema = cast('_Schema', entity_schema[field_name])
|
|
190
|
+
data_type = cast('_Schema', field_schema['data_type'])
|
|
191
|
+
return cast('str', data_type['value'])
|
|
192
|
+
|
|
193
|
+
def _new_row(self, entity_type: str) -> Row:
|
|
194
|
+
"""Build an active row populated with independent schema defaults."""
|
|
195
|
+
row: Row = {'__retired': False, 'type': entity_type}
|
|
196
|
+
entity_schema = cast('_Schema', self._schema[entity_type])
|
|
197
|
+
for field_name, field_schema_value in entity_schema.items():
|
|
198
|
+
field_schema = cast('_Schema', field_schema_value)
|
|
199
|
+
if self._schema_field_type(entity_type, field_name) == 'multi_entity':
|
|
200
|
+
row[field_name] = []
|
|
201
|
+
else:
|
|
202
|
+
properties = cast('_Schema', field_schema['properties'])
|
|
203
|
+
default_value = cast('_Schema', properties['default_value'])
|
|
204
|
+
row[field_name] = copy.deepcopy(default_value['value'])
|
|
205
|
+
return row
|
|
206
|
+
|
|
207
|
+
def _set_created_audit(
|
|
208
|
+
self, entity_type: str, data: dict[str, object], context: RequestContext
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Add declared creation fields while preserving supported supplied values."""
|
|
211
|
+
actor = context.actor
|
|
212
|
+
for name in ('created_at', 'updated_at'):
|
|
213
|
+
if self.schema.declares(entity_type, name):
|
|
214
|
+
if actor is None:
|
|
215
|
+
data[name] = context.now
|
|
216
|
+
else:
|
|
217
|
+
data.setdefault(name, context.now)
|
|
218
|
+
for name in ('created_by', 'updated_by'):
|
|
219
|
+
if not self.schema.declares(entity_type, name):
|
|
220
|
+
continue
|
|
221
|
+
link = (
|
|
222
|
+
{'type': actor.entity_type, 'id': actor.entity_id} if actor is not None else None
|
|
223
|
+
)
|
|
224
|
+
if actor is None or name == 'updated_by':
|
|
225
|
+
data[name] = link
|
|
226
|
+
else:
|
|
227
|
+
data.setdefault(name, link)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _update_multi_entity(
|
|
231
|
+
current: list[_EntityLink],
|
|
232
|
+
incoming: list[_EntityLink],
|
|
233
|
+
mode: object,
|
|
234
|
+
) -> list[_EntityLink]:
|
|
235
|
+
"""Apply multi-entity modes while retaining order and identity semantics."""
|
|
236
|
+
normalized = [{'type': item['type'], 'id': item['id']} for item in incoming]
|
|
237
|
+
if mode == 'set':
|
|
238
|
+
return normalized
|
|
239
|
+
if mode == 'add':
|
|
240
|
+
result = copy.deepcopy(current)
|
|
241
|
+
for item in normalized:
|
|
242
|
+
if item not in result:
|
|
243
|
+
result.append(item)
|
|
244
|
+
return result
|
|
245
|
+
if mode == 'remove':
|
|
246
|
+
return [copy.deepcopy(item) for item in current if item not in normalized]
|
|
247
|
+
return copy.deepcopy(current)
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _validate_direct_fields(data: dict[str, object]) -> None:
|
|
251
|
+
"""Reject invalid names and fields controlled by mockgun."""
|
|
252
|
+
if any(
|
|
253
|
+
not isinstance(field, str) or not field for field in cast('Collection[object]', data)
|
|
254
|
+
):
|
|
255
|
+
raise ServerFaultError('field names must be nonempty strings')
|
|
256
|
+
if 'id' in data or 'type' in data:
|
|
257
|
+
raise ServerFaultError("Can't set id or type on create or update")
|
|
258
|
+
internal_fields = sorted(field for field in data if field.startswith('_'))
|
|
259
|
+
if internal_fields:
|
|
260
|
+
raise ServerFaultError(f'fields cannot be set directly: {", ".join(internal_fields)}')
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _validate_entity_link(entity_type: str, field_name: str, value: object) -> None:
|
|
264
|
+
"""Validate the structural requirements for a single link."""
|
|
265
|
+
if not isinstance(value, dict):
|
|
266
|
+
raise ServerFaultError(
|
|
267
|
+
f'{entity_type}.{field_name} is of type entity, '
|
|
268
|
+
f'but data {value!r} is not a dictionary'
|
|
269
|
+
)
|
|
270
|
+
if 'type' not in value or 'id' not in value:
|
|
271
|
+
raise ServerFaultError(
|
|
272
|
+
f'{entity_type}.{field_name} is of type entity, '
|
|
273
|
+
f"but data {value!r} does not contain 'type' and 'id'"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _validate_entity_type(self, entity_type: str) -> None:
|
|
277
|
+
"""Reject entity types absent from the loaded schema."""
|
|
278
|
+
if entity_type not in self._schema:
|
|
279
|
+
raise ServerFaultError(f'{entity_type} is not a valid entity')
|
|
280
|
+
|
|
281
|
+
def _validate_entity_fields(
|
|
282
|
+
self,
|
|
283
|
+
entity_type: str,
|
|
284
|
+
fields: Collection[str] | None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Reject fields absent from the selected entity schema."""
|
|
287
|
+
self._validate_entity_type(entity_type)
|
|
288
|
+
if fields is None:
|
|
289
|
+
return
|
|
290
|
+
valid_fields = cast('_Schema', self._schema[entity_type])
|
|
291
|
+
for field_name in fields:
|
|
292
|
+
if field_name not in valid_fields and field_name not in {'id', 'type'}:
|
|
293
|
+
raise ServerFaultError(
|
|
294
|
+
f'{field_name} is not a valid field for entity {entity_type}',
|
|
295
|
+
error_code=103,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _parse_fields(self, entity_type: str, fields: dict[str, object]) -> dict[str, object]:
|
|
299
|
+
"""Convert declared datetime field values from their wire representation."""
|
|
300
|
+
if entity_type == 'Project' and 'last_accessed_by_current_user' in fields:
|
|
301
|
+
raise MockgunError(
|
|
302
|
+
'mockgun cannot create/update Project.last_accessed_by_current_user: '
|
|
303
|
+
'per-user field is not writable'
|
|
304
|
+
)
|
|
305
|
+
self._validate_entity_type(entity_type)
|
|
306
|
+
self._validate_direct_fields(fields)
|
|
307
|
+
self._validate_entity_fields(entity_type, fields.keys())
|
|
308
|
+
result = copy.deepcopy(fields)
|
|
309
|
+
for name, value in result.items():
|
|
310
|
+
if self._schema_field_type(entity_type, name) == 'date_time' and isinstance(
|
|
311
|
+
value, str
|
|
312
|
+
):
|
|
313
|
+
try:
|
|
314
|
+
result[name] = parse_wire_datetime(value)
|
|
315
|
+
except ValueError:
|
|
316
|
+
# Leave malformed strings for the existing scalar validation error.
|
|
317
|
+
continue
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _validate_multi_entity_links(
|
|
322
|
+
entity_type: str,
|
|
323
|
+
field_name: str,
|
|
324
|
+
value: object,
|
|
325
|
+
valid_types: Collection[str],
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Validate the structure and permitted types for a link list."""
|
|
328
|
+
if not isinstance(value, list):
|
|
329
|
+
raise ServerFaultError(
|
|
330
|
+
f'{entity_type}.{field_name} is of type multi_entity, '
|
|
331
|
+
f'but data {value!r} is not a list',
|
|
332
|
+
error_code=103 if value is None else None,
|
|
333
|
+
)
|
|
334
|
+
items = cast('list[object]', value)
|
|
335
|
+
if any(not isinstance(item, dict) for item in items):
|
|
336
|
+
raise ServerFaultError(
|
|
337
|
+
f'{entity_type}.{field_name} is of type multi_entity, '
|
|
338
|
+
f'but data {value!r} contains a non-dictionary'
|
|
339
|
+
)
|
|
340
|
+
links = cast('list[_EntityLink]', items)
|
|
341
|
+
if any('type' not in item or 'id' not in item for item in links):
|
|
342
|
+
raise ServerFaultError(
|
|
343
|
+
f'{entity_type}.{field_name} is of type multi_entity, '
|
|
344
|
+
f"but an item in data {value!r} does not contain 'type' and 'id'"
|
|
345
|
+
)
|
|
346
|
+
if any(item['type'] not in valid_types for item in links):
|
|
347
|
+
raise ServerFaultError(
|
|
348
|
+
f'{entity_type}.{field_name} is of type multi_entity, '
|
|
349
|
+
f'but an item in data {value!r} has an invalid type '
|
|
350
|
+
f'(expected one of {valid_types!r})'
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _validate_scalar_value(
|
|
355
|
+
entity_type: str,
|
|
356
|
+
field_name: str,
|
|
357
|
+
field_type: str,
|
|
358
|
+
value: object,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Validate one non-relation field value."""
|
|
361
|
+
python_type = _FIELD_PYTHON_TYPES.get(field_type)
|
|
362
|
+
if python_type is None:
|
|
363
|
+
raise MockgunError(
|
|
364
|
+
f'Field {entity_type}.{field_name}: handling for schema type '
|
|
365
|
+
f'{field_type} is not implemented'
|
|
366
|
+
)
|
|
367
|
+
if not isinstance(value, python_type):
|
|
368
|
+
raise ServerFaultError(
|
|
369
|
+
f'{entity_type}.{field_name} is of schema type {field_type}, '
|
|
370
|
+
f'but data {value!r} is not a {python_type.__name__}',
|
|
371
|
+
error_code=103 if field_type == 'checkbox' and value is None else None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _validate_valid_value(
|
|
376
|
+
field_path: str,
|
|
377
|
+
field_type: str,
|
|
378
|
+
value: object,
|
|
379
|
+
field_schema: _Schema,
|
|
380
|
+
operation: str,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Reject a status or list value outside its declared choices."""
|
|
383
|
+
if field_type not in {'status_list', 'list'}:
|
|
384
|
+
return
|
|
385
|
+
properties = cast('_Schema', field_schema['properties'])
|
|
386
|
+
valid_values = properties.get('valid_values')
|
|
387
|
+
if valid_values is None:
|
|
388
|
+
return
|
|
389
|
+
values = cast('list[str]', cast('_Schema', valid_values)['value'])
|
|
390
|
+
if value in values:
|
|
391
|
+
return
|
|
392
|
+
kind = 'status' if field_type == 'status_list' else 'list value'
|
|
393
|
+
label = 'statuses' if field_type == 'status_list' else 'list values'
|
|
394
|
+
raise ServerFaultError(
|
|
395
|
+
f'API {operation}() Update failed for [{field_path}]: '
|
|
396
|
+
f'{value} is not a valid {kind}. Valid {label}: {", ".join(values)}.',
|
|
397
|
+
error_code=104,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _validate_entity_data(
|
|
401
|
+
self, entity_type: str, data: dict[str, object], operation: str
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Validate field values against the hand-loaded site schema."""
|
|
404
|
+
self._validate_direct_fields(data)
|
|
405
|
+
self._validate_entity_fields(entity_type, data.keys())
|
|
406
|
+
for field_name, value in data.items():
|
|
407
|
+
entity_schema = cast('_Schema', self._schema[entity_type])
|
|
408
|
+
field_schema = cast('_Schema', entity_schema[field_name])
|
|
409
|
+
field_type = self._schema_field_type(entity_type, field_name)
|
|
410
|
+
if field_type == 'summary' and operation == 'update':
|
|
411
|
+
encoded = 'nil' if value is None else json.dumps(value)
|
|
412
|
+
raise ServerFaultError(
|
|
413
|
+
f'API update() {entity_type}.{field_name} is read only:\n'
|
|
414
|
+
f'{{"field_name" => "{field_name}", "value" => {encoded}}}',
|
|
415
|
+
error_code=103,
|
|
416
|
+
)
|
|
417
|
+
if (entity_type, field_name, value) == ('Version', 'code', ''):
|
|
418
|
+
if operation == 'create':
|
|
419
|
+
raise ServerFaultError(
|
|
420
|
+
'API create() Create failed for [Version]: '
|
|
421
|
+
'Cannot set identifier field to empty. (Version)',
|
|
422
|
+
error_code=104,
|
|
423
|
+
)
|
|
424
|
+
raise MockgunError('Version.code text update to empty is not modeled')
|
|
425
|
+
if value is None and field_type not in {'multi_entity', 'checkbox'}:
|
|
426
|
+
continue
|
|
427
|
+
if field_type == 'entity':
|
|
428
|
+
self._validate_entity_link(entity_type, field_name, value)
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
if field_type == 'multi_entity':
|
|
432
|
+
properties = cast('_Schema', field_schema['properties'])
|
|
433
|
+
valid_types = cast('_Schema', properties['valid_types'])
|
|
434
|
+
self._validate_multi_entity_links(
|
|
435
|
+
entity_type,
|
|
436
|
+
field_name,
|
|
437
|
+
value,
|
|
438
|
+
cast('Collection[str]', valid_types['value']),
|
|
439
|
+
)
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
self._validate_scalar_value(entity_type, field_name, field_type, value)
|
|
443
|
+
self._validate_valid_value(
|
|
444
|
+
f'{entity_type}.{field_name}', field_type, value, field_schema, operation
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def actor_key(self, entity_type: str, entity_id: int) -> str | None:
|
|
448
|
+
"""Return the credential key only for a site-created actor record."""
|
|
449
|
+
return self._actors.get((entity_type, entity_id))
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def check_known(row: Row, field_name: str, *, membership: bool = False) -> None:
|
|
453
|
+
"""Reject an unknown field value or an order-dependent read.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
row: Stored or detached record whose field is being read.
|
|
457
|
+
field_name: Direct field name.
|
|
458
|
+
membership: Whether only membership, rather than member order, is
|
|
459
|
+
read.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
MockgunError: If the requested value or order is unknown.
|
|
463
|
+
"""
|
|
464
|
+
for key in ('__unknown',) if membership else ('__unknown', '__unordered'):
|
|
465
|
+
markers = cast('dict[str, FieldValue]', row.get(key, {}))
|
|
466
|
+
if field_name in markers:
|
|
467
|
+
raise MockgunError(
|
|
468
|
+
f'{row["type"]}.{field_name} on record {row["id"]} is unknown: '
|
|
469
|
+
f'{markers[field_name]}'
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def followers(self, entity_type: str, entity_id: int) -> list[int]:
|
|
473
|
+
"""Return following HumanUser IDs in ascending order for one record."""
|
|
474
|
+
return sorted(
|
|
475
|
+
cast('int', key[0])
|
|
476
|
+
for key in self._side_tables['follows']
|
|
477
|
+
if key[1:] == (entity_type, entity_id)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def link_uuid(self, entity_type: str, entity_id: int) -> str:
|
|
481
|
+
"""Return a random canonical identity kept outside transactional snapshots."""
|
|
482
|
+
key = (entity_type, entity_id)
|
|
483
|
+
if key not in self._link_uuids:
|
|
484
|
+
self._link_uuids[key] = str(uuid4())
|
|
485
|
+
return self._link_uuids[key]
|
|
486
|
+
|
|
487
|
+
def next_side_id(self, table: str) -> int:
|
|
488
|
+
"""Consume the next ID for a declared side table without rollback."""
|
|
489
|
+
identity = self._side_next_ids[table]
|
|
490
|
+
self._side_next_ids[table] += 1
|
|
491
|
+
return identity
|
|
492
|
+
|
|
493
|
+
def preview(self, row: Row) -> Store:
|
|
494
|
+
"""Return a read view substituting one detached candidate row.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
row: Candidate to expose without changing tables or ID allocation.
|
|
498
|
+
"""
|
|
499
|
+
preview = copy.copy(self)
|
|
500
|
+
preview._candidate = row # noqa: SLF001
|
|
501
|
+
return preview
|
|
502
|
+
|
|
503
|
+
def record_actor(self, entity_type: str, entity_id: int, key: str) -> None:
|
|
504
|
+
"""Register a record created by the site for a script name or login."""
|
|
505
|
+
self._actors[(entity_type, entity_id)] = key
|
|
506
|
+
|
|
507
|
+
def remember(self, row: Row) -> None:
|
|
508
|
+
"""Journal a live row before mutation; ignore detached candidates."""
|
|
509
|
+
if self._undo is None:
|
|
510
|
+
return
|
|
511
|
+
entity_type = cast('str', row['type'])
|
|
512
|
+
entity_id = cast('int', row.get('id'))
|
|
513
|
+
if self._db[entity_type].get(entity_id) is row:
|
|
514
|
+
self._remember_identity(entity_type, entity_id)
|
|
515
|
+
|
|
516
|
+
def forget_unknown(self, row: Row, field_name: str) -> None:
|
|
517
|
+
"""Clear the value and order markers for a field made known by a write."""
|
|
518
|
+
self.remember(row)
|
|
519
|
+
for key in ('__unknown', '__unordered'):
|
|
520
|
+
markers = cast('dict[str, FieldValue]', row.get(key, {}))
|
|
521
|
+
markers.pop(field_name, None)
|
|
522
|
+
if not markers:
|
|
523
|
+
row.pop(key, None)
|
|
524
|
+
|
|
525
|
+
def _set_updated_audit(self, entity_type: str, row: Row, context: RequestContext) -> None:
|
|
526
|
+
"""Record the actor and time of a successful change when declared."""
|
|
527
|
+
self.remember(row)
|
|
528
|
+
for field_name in ('updated_at', 'updated_by'):
|
|
529
|
+
self.forget_unknown(row, field_name)
|
|
530
|
+
if self.schema.declares(entity_type, 'updated_at'):
|
|
531
|
+
row['updated_at'] = context.now
|
|
532
|
+
if self.schema.declares(entity_type, 'updated_by'):
|
|
533
|
+
actor = context.actor
|
|
534
|
+
row['updated_by'] = (
|
|
535
|
+
{'type': actor.entity_type, 'id': actor.entity_id} if actor is not None else None
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def drop_field(self, entity_type: str, field_name: str) -> None:
|
|
539
|
+
"""Remove a deleted schema field and its markers from every record."""
|
|
540
|
+
for row in self._db[entity_type].values():
|
|
541
|
+
self.remember(row)
|
|
542
|
+
row.pop(field_name, None)
|
|
543
|
+
self.forget_unknown(row, field_name)
|
|
544
|
+
|
|
545
|
+
def mark_unknown(
|
|
546
|
+
self, row: Row, field_name: str, reason: str, *, order_only: bool = False
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Record an unsettled value or member order alongside its stored row.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
row: Record to mark; snapshots include these markers.
|
|
552
|
+
field_name: Declared field whose value or order is unsettled.
|
|
553
|
+
reason: Evidence-specific explanation returned by read failures.
|
|
554
|
+
order_only: Mark only member order, retaining known membership.
|
|
555
|
+
"""
|
|
556
|
+
self.remember(row)
|
|
557
|
+
key = '__unordered' if order_only else '__unknown'
|
|
558
|
+
markers = cast('dict[str, FieldValue]', row.setdefault(key, {}))
|
|
559
|
+
markers[field_name] = reason
|
|
560
|
+
|
|
561
|
+
def _apply_create_computed(
|
|
562
|
+
self, row: Row, fields: Mapping[str, object], context: RequestContext
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Fill observed actor and Task values or retain unknown markers."""
|
|
565
|
+
actor = context.actor
|
|
566
|
+
if actor is None:
|
|
567
|
+
return
|
|
568
|
+
entity_type = cast('str', row['type'])
|
|
569
|
+
if (
|
|
570
|
+
entity_type in {'Version', 'Note', 'Reply'}
|
|
571
|
+
and 'user' not in fields
|
|
572
|
+
and self.schema.declares(entity_type, 'user')
|
|
573
|
+
):
|
|
574
|
+
row['user'] = {'type': actor.entity_type, 'id': actor.entity_id}
|
|
575
|
+
observed_task = entity_type == 'Task' and _observed_task_form(fields, context)
|
|
576
|
+
if observed_task:
|
|
577
|
+
if self.schema.declares(entity_type, 'color'):
|
|
578
|
+
row['color'] = 'pipeline_step'
|
|
579
|
+
if self.schema.declares(entity_type, 'due_date'):
|
|
580
|
+
row['due_date'] = None
|
|
581
|
+
for name in _UNKNOWN_CREATE_FIELDS.get(entity_type, ()):
|
|
582
|
+
if name in fields or not self.schema.declares(entity_type, name):
|
|
583
|
+
continue
|
|
584
|
+
if observed_task and name in {'color', 'due_date'}:
|
|
585
|
+
continue
|
|
586
|
+
self.mark_unknown(row, name, 'server-computed value on create is unobserved')
|
|
587
|
+
if entity_type == 'Note' and 'reply_content' in fields:
|
|
588
|
+
self.mark_unknown(row, 'reply_content', 'create does not retain the supplied value')
|
|
589
|
+
|
|
590
|
+
def _mirror_first_task_inverse(self, task: Row, version: Row) -> None:
|
|
591
|
+
"""Append the first observed Version assignment and unsettle Task audit."""
|
|
592
|
+
unknown = cast('dict[str, FieldValue]', task.get('__unknown', {}))
|
|
593
|
+
if 'sg_versions' not in unknown:
|
|
594
|
+
self.remember(task)
|
|
595
|
+
members = cast('list[FieldValue]', task['sg_versions'])
|
|
596
|
+
link: FieldValue = {'type': 'Version', 'id': version['id']}
|
|
597
|
+
if link not in members:
|
|
598
|
+
task['sg_versions'] = [*members, link]
|
|
599
|
+
for name in ('updated_at', 'updated_by'):
|
|
600
|
+
if self.schema.declares('Task', name):
|
|
601
|
+
self.mark_unknown(task, name, 'Version.sg_task inverse audit is unobserved')
|
|
602
|
+
|
|
603
|
+
def _update_certainty(
|
|
604
|
+
self,
|
|
605
|
+
row: Row,
|
|
606
|
+
stored: Row,
|
|
607
|
+
fields: dict[str, object],
|
|
608
|
+
modes: dict[str, str],
|
|
609
|
+
context: RequestContext,
|
|
610
|
+
) -> None:
|
|
611
|
+
"""Clear written markers and distinguish certain changes from unknown ones."""
|
|
612
|
+
entity_type = cast('str', row['type'])
|
|
613
|
+
changed = False
|
|
614
|
+
uncertain = False
|
|
615
|
+
for field_name in fields:
|
|
616
|
+
if field_name == 'cached_display_name':
|
|
617
|
+
continue
|
|
618
|
+
field_type = self._schema_field_type(entity_type, field_name)
|
|
619
|
+
unknown = field_name in cast('dict[str, FieldValue]', stored.get('__unknown', {}))
|
|
620
|
+
unordered = field_name in cast('dict[str, FieldValue]', stored.get('__unordered', {}))
|
|
621
|
+
if unknown:
|
|
622
|
+
uncertain = True
|
|
623
|
+
changed = changed or (
|
|
624
|
+
entity_type == 'Task' and field_name == 'content' and row[field_name] is None
|
|
625
|
+
)
|
|
626
|
+
if field_type != 'multi_entity' or modes.get(field_name, 'set') == 'set':
|
|
627
|
+
self.forget_unknown(row, field_name)
|
|
628
|
+
else:
|
|
629
|
+
changed = changed or row[field_name] != stored[field_name]
|
|
630
|
+
if unordered:
|
|
631
|
+
self.forget_unknown(row, field_name)
|
|
632
|
+
if changed:
|
|
633
|
+
self._set_updated_audit(entity_type, row, context)
|
|
634
|
+
elif uncertain:
|
|
635
|
+
for name in ('updated_at', 'updated_by'):
|
|
636
|
+
if self.schema.declares(entity_type, name):
|
|
637
|
+
self.mark_unknown(
|
|
638
|
+
row, name, 'whether the update changed an unknown value is unsettled'
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def add_field(self, entity_type: str, field_name: str) -> None:
|
|
642
|
+
"""Populate a new schema field on existing active and retired records."""
|
|
643
|
+
field_type = self._schema_field_type(entity_type, field_name)
|
|
644
|
+
for row in self._db[entity_type].values():
|
|
645
|
+
self.remember(row)
|
|
646
|
+
row[field_name] = [] if field_type == 'multi_entity' else None
|
|
647
|
+
if field_type == 'checkbox':
|
|
648
|
+
self.mark_unknown(
|
|
649
|
+
row,
|
|
650
|
+
field_name,
|
|
651
|
+
'value of a checkbox field created after this record is unsettled',
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def append_event(self, fields: Mapping[str, FieldValue], *, unknown: Collection[str]) -> Row:
|
|
655
|
+
"""Append declared event fields and mark all unsettled declared values unknown.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
fields: Known common event values; undeclared fields are ignored.
|
|
659
|
+
unknown: Fields whose supplied or default value is unsettled.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
The stored event row with the next EventLogEntry ID.
|
|
663
|
+
"""
|
|
664
|
+
row = self._new_row('EventLogEntry')
|
|
665
|
+
for name in self.schema.fields['EventLogEntry']:
|
|
666
|
+
if name in {'id', 'type'}:
|
|
667
|
+
continue
|
|
668
|
+
if name in fields:
|
|
669
|
+
row[name] = copy.deepcopy(fields[name])
|
|
670
|
+
if name in unknown or name not in fields:
|
|
671
|
+
self.mark_unknown(row, name, 'event field was not observed')
|
|
672
|
+
identity = self._next_ids['EventLogEntry']
|
|
673
|
+
self._next_ids['EventLogEntry'] += 1
|
|
674
|
+
row['id'] = identity
|
|
675
|
+
row['type'] = 'EventLogEntry'
|
|
676
|
+
self._remember_identity('EventLogEntry', identity)
|
|
677
|
+
self._db['EventLogEntry'][identity] = row
|
|
678
|
+
return row
|
|
679
|
+
|
|
680
|
+
def require(self, entity_type: str, entity_id: int) -> Row:
|
|
681
|
+
"""Return a live stored row, including retired records.
|
|
682
|
+
|
|
683
|
+
Raises:
|
|
684
|
+
ServerFaultError: If the entity type is undeclared or the ID is
|
|
685
|
+
missing. A missing ID uses error code 104.
|
|
686
|
+
"""
|
|
687
|
+
self._validate_entity_type(entity_type)
|
|
688
|
+
row = self._db[entity_type].get(entity_id)
|
|
689
|
+
if row is None:
|
|
690
|
+
raise ServerFaultError(
|
|
691
|
+
f'No entity of type {entity_type} exists with id {entity_id}', error_code=104
|
|
692
|
+
)
|
|
693
|
+
return row
|
|
694
|
+
|
|
695
|
+
def reserve_id(self, entity_type: str) -> int:
|
|
696
|
+
"""Consume the next ID of a declared entity type without creating a row.
|
|
697
|
+
|
|
698
|
+
Raises:
|
|
699
|
+
ServerFaultError: If the entity type is not declared.
|
|
700
|
+
"""
|
|
701
|
+
self._validate_entity_type(entity_type)
|
|
702
|
+
identity = self._next_ids[entity_type]
|
|
703
|
+
self._next_ids[entity_type] += 1
|
|
704
|
+
return identity
|
|
705
|
+
|
|
706
|
+
def restore(self, snapshot: object) -> None:
|
|
707
|
+
"""Restore table contents in place without rewinding ID allocation.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
snapshot: An opaque snapshot previously returned by this store.
|
|
711
|
+
"""
|
|
712
|
+
self.created_connections = []
|
|
713
|
+
tables, self.events_unknown, side_tables = cast(
|
|
714
|
+
'tuple[dict[str, dict[int, Row]], str | None, dict[str, dict[_SideKey, FieldValue]]]',
|
|
715
|
+
snapshot,
|
|
716
|
+
)
|
|
717
|
+
for entity_type, saved in tables.items():
|
|
718
|
+
table = self._db[entity_type]
|
|
719
|
+
table.clear()
|
|
720
|
+
table.update(copy.deepcopy(saved))
|
|
721
|
+
self._side_tables = copy.deepcopy(side_tables)
|
|
722
|
+
|
|
723
|
+
def row(self, entity_type: str, entity_id: int) -> Row | None:
|
|
724
|
+
"""Return a live row, including retired rows, or `None` for a missing ID.
|
|
725
|
+
|
|
726
|
+
Raises:
|
|
727
|
+
ServerFaultError: If the entity type is absent from the schema.
|
|
728
|
+
"""
|
|
729
|
+
self._validate_entity_type(entity_type)
|
|
730
|
+
candidate = self._candidate
|
|
731
|
+
if candidate is not None and (candidate['type'], candidate['id']) == (
|
|
732
|
+
entity_type,
|
|
733
|
+
entity_id,
|
|
734
|
+
):
|
|
735
|
+
return candidate
|
|
736
|
+
return self._db[entity_type].get(entity_id)
|
|
737
|
+
|
|
738
|
+
def rows(self, entity_type: str, *, retired: bool) -> list[Row]:
|
|
739
|
+
"""Return live rows of the requested retirement state in ascending ID order.
|
|
740
|
+
|
|
741
|
+
Raises:
|
|
742
|
+
ServerFaultError: If the entity type is absent from the schema.
|
|
743
|
+
"""
|
|
744
|
+
self._validate_entity_type(entity_type)
|
|
745
|
+
return [row for row in self._db[entity_type].values() if bool(row['__retired']) is retired]
|
|
746
|
+
|
|
747
|
+
def linking(self, entity_type: str, entity_id: int) -> list[tuple[Row, str]]:
|
|
748
|
+
"""Return active non-event holders and declared fields linking the identity.
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
Pairs of live stored holder rows and the names of their linking
|
|
752
|
+
fields.
|
|
753
|
+
"""
|
|
754
|
+
result: list[tuple[Row, str]] = []
|
|
755
|
+
for kind in self._db:
|
|
756
|
+
if kind == 'EventLogEntry':
|
|
757
|
+
continue
|
|
758
|
+
for row in self.rows(kind, retired=False):
|
|
759
|
+
for field in self.schema.fields[kind]:
|
|
760
|
+
if self._schema_field_type(kind, field) not in {'entity', 'multi_entity'}:
|
|
761
|
+
continue
|
|
762
|
+
if any(
|
|
763
|
+
link['id'] == entity_id
|
|
764
|
+
for link in self._links(row.get(field), entity_type)
|
|
765
|
+
):
|
|
766
|
+
result.append((row, field))
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
def side_get(self, table: str, key: _SideKey) -> FieldValue:
|
|
770
|
+
"""Return a detached side value, or `None` when the key is absent.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
table: One of the store's declared side tables.
|
|
774
|
+
key: Identity within that table.
|
|
775
|
+
"""
|
|
776
|
+
return copy.deepcopy(self._side_tables[table].get(key))
|
|
777
|
+
|
|
778
|
+
def side_items(self, table: str) -> list[tuple[_SideKey, FieldValue]]:
|
|
779
|
+
"""Return detached side-table entries in insertion order.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
table: One of the store's declared side tables.
|
|
783
|
+
"""
|
|
784
|
+
return copy.deepcopy(list(self._side_tables[table].items()))
|
|
785
|
+
|
|
786
|
+
def side_set(self, table: str, key: _SideKey, value: FieldValue) -> None:
|
|
787
|
+
"""Store a detached value, or delete its key when the value is `None`.
|
|
788
|
+
|
|
789
|
+
Mutations participate in the enclosing transaction, if any.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
table: One of the store's declared side tables.
|
|
793
|
+
key: Identity within that table.
|
|
794
|
+
value: Value to copy, or `None` to delete the entry.
|
|
795
|
+
"""
|
|
796
|
+
entries = self._side_tables[table]
|
|
797
|
+
undo = self._side_undo
|
|
798
|
+
identity = (table, key)
|
|
799
|
+
if undo is not None and identity not in undo:
|
|
800
|
+
undo[identity] = copy.deepcopy(entries.get(key))
|
|
801
|
+
if value is None:
|
|
802
|
+
entries.pop(key, None)
|
|
803
|
+
else:
|
|
804
|
+
entries[key] = copy.deepcopy(value)
|
|
805
|
+
|
|
806
|
+
def snapshot(self) -> object:
|
|
807
|
+
"""Return detached record and side tables, excluding all ID counters."""
|
|
808
|
+
return copy.deepcopy(self._db), self.events_unknown, copy.deepcopy(self._side_tables)
|
|
809
|
+
|
|
810
|
+
def taint_events(self, reason: str) -> None:
|
|
811
|
+
"""Preserve the first unsettled write when the site declares an event log."""
|
|
812
|
+
if self.schema.declares('EventLogEntry') and self.events_unknown is None:
|
|
813
|
+
self.events_unknown = reason
|
|
814
|
+
|
|
815
|
+
@contextmanager
|
|
816
|
+
def transaction(self) -> Generator[None, None, None]:
|
|
817
|
+
"""Roll back record and side-table writes, joining any enclosing journal.
|
|
818
|
+
|
|
819
|
+
All ID counters, link UUIDs and recorded actors remain outside
|
|
820
|
+
rollback.
|
|
821
|
+
Failures restore event certainty and clear transient connection rows.
|
|
822
|
+
"""
|
|
823
|
+
if self._undo is not None:
|
|
824
|
+
yield
|
|
825
|
+
return
|
|
826
|
+
undo: dict[tuple[str, int], Row | None] = {}
|
|
827
|
+
self._undo = undo
|
|
828
|
+
side_undo: dict[tuple[str, _SideKey], FieldValue] = {}
|
|
829
|
+
self._side_undo = side_undo
|
|
830
|
+
events_unknown = self.events_unknown
|
|
831
|
+
try:
|
|
832
|
+
yield
|
|
833
|
+
except BaseException:
|
|
834
|
+
reordered: set[str] = set()
|
|
835
|
+
for (entity_type, entity_id), original in reversed(undo.items()):
|
|
836
|
+
table = self._db[entity_type]
|
|
837
|
+
if original is None:
|
|
838
|
+
table.pop(entity_id, None)
|
|
839
|
+
else:
|
|
840
|
+
if entity_id not in table:
|
|
841
|
+
reordered.add(entity_type)
|
|
842
|
+
table[entity_id] = original
|
|
843
|
+
# IDs are inserted in ascending order. Reinserted deleted rows must
|
|
844
|
+
# regain that order; only failed deletions need this shallow rebuild.
|
|
845
|
+
for entity_type in reordered:
|
|
846
|
+
table = self._db[entity_type]
|
|
847
|
+
ordered = sorted(table.items())
|
|
848
|
+
table.clear()
|
|
849
|
+
table.update(ordered)
|
|
850
|
+
for (table_name, key), value in reversed(side_undo.items()):
|
|
851
|
+
entries = self._side_tables[table_name]
|
|
852
|
+
if value is None:
|
|
853
|
+
entries.pop(key, None)
|
|
854
|
+
else:
|
|
855
|
+
entries[key] = value
|
|
856
|
+
self.events_unknown = events_unknown
|
|
857
|
+
self.created_connections = []
|
|
858
|
+
raise
|
|
859
|
+
finally:
|
|
860
|
+
self._undo = None
|
|
861
|
+
self._side_undo = None
|
|
862
|
+
|
|
863
|
+
def taint(
|
|
864
|
+
self,
|
|
865
|
+
row: Row,
|
|
866
|
+
reason: str,
|
|
867
|
+
source: Row | None,
|
|
868
|
+
*,
|
|
869
|
+
link: tuple[str, Row] | None = None,
|
|
870
|
+
active: bool = False,
|
|
871
|
+
) -> None:
|
|
872
|
+
"""Mark unsettled lifecycle state, affected outgoing links and audit values.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
row: Live row whose state becomes unsettled.
|
|
876
|
+
reason: Explanation retained when the row is first tainted.
|
|
877
|
+
source: Row responsible for the uncertainty, or `None`.
|
|
878
|
+
link: Optional pair of outgoing field name and affected target row.
|
|
879
|
+
active: Whether the row is known to remain active despite its
|
|
880
|
+
unsettled effects.
|
|
881
|
+
"""
|
|
882
|
+
self.remember(row)
|
|
883
|
+
taint = cast(
|
|
884
|
+
'dict[str, FieldValue]',
|
|
885
|
+
row.setdefault(
|
|
886
|
+
'__taint',
|
|
887
|
+
{
|
|
888
|
+
'reason': reason,
|
|
889
|
+
'source': [source['type'], source['id']] if source is not None else None,
|
|
890
|
+
'links': [],
|
|
891
|
+
'active': active,
|
|
892
|
+
},
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
if link is not None:
|
|
896
|
+
field, target = link
|
|
897
|
+
member: FieldValue = [field, target['type'], target['id']]
|
|
898
|
+
members = cast('list[FieldValue]', taint['links'])
|
|
899
|
+
if member not in members:
|
|
900
|
+
members.append(member)
|
|
901
|
+
for field in ('updated_at', 'updated_by'):
|
|
902
|
+
if self.schema.declares(cast('str', row['type']), field):
|
|
903
|
+
self.mark_unknown(row, field, reason)
|
|
904
|
+
|
|
905
|
+
@staticmethod
|
|
906
|
+
def tainted(row: Row) -> str | None:
|
|
907
|
+
"""Return the stored lifecycle uncertainty reason, or `None` when untainted."""
|
|
908
|
+
taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
|
|
909
|
+
return cast('str', taint['reason']) if taint else None
|
|
910
|
+
|
|
911
|
+
def _connection_delta(
|
|
912
|
+
self, source: tuple[_EntityLink, str], target: _EntityLink, *, added: bool
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Mirror a supported pair delta into its declared connection table."""
|
|
915
|
+
writer, field = source
|
|
916
|
+
writer_type = cast('str', writer['type'])
|
|
917
|
+
target_type = cast('str', target['type'])
|
|
918
|
+
definition = CONNECTION_FIELDS.get((writer_type, field, target_type))
|
|
919
|
+
if definition is None:
|
|
920
|
+
return
|
|
921
|
+
connection_type, writer_field, target_field = definition
|
|
922
|
+
if not (
|
|
923
|
+
self.schema.declares(connection_type, writer_field)
|
|
924
|
+
and self.schema.declares(connection_type, target_field)
|
|
925
|
+
):
|
|
926
|
+
return
|
|
927
|
+
target_id = target['id']
|
|
928
|
+
if not isinstance(target_id, int) or self.row(target_type, target_id) is None:
|
|
929
|
+
return
|
|
930
|
+
endpoints = {writer_field: writer, target_field: target}
|
|
931
|
+
matches = [
|
|
932
|
+
row
|
|
933
|
+
for row in self._db[connection_type].values()
|
|
934
|
+
if all(row[name] == link for name, link in endpoints.items())
|
|
935
|
+
]
|
|
936
|
+
if added:
|
|
937
|
+
if matches:
|
|
938
|
+
return
|
|
939
|
+
row = self._new_row(connection_type)
|
|
940
|
+
row.update(copy.deepcopy(endpoints))
|
|
941
|
+
connection_id = self._next_ids[connection_type]
|
|
942
|
+
row['id'] = connection_id
|
|
943
|
+
self._next_ids[connection_type] += 1
|
|
944
|
+
self._remember_identity(connection_type, connection_id)
|
|
945
|
+
self._db[connection_type][connection_id] = row
|
|
946
|
+
self.created_connections.append(row)
|
|
947
|
+
else:
|
|
948
|
+
for row in matches:
|
|
949
|
+
reason = self.tainted(row)
|
|
950
|
+
if reason is not None:
|
|
951
|
+
raise MockgunError(
|
|
952
|
+
f'{connection_type} {row["id"]} {target_type} {target_id}: {reason}'
|
|
953
|
+
)
|
|
954
|
+
self.remember(row)
|
|
955
|
+
del self._db[connection_type][cast('int', row['id'])]
|
|
956
|
+
|
|
957
|
+
def _sequence_shots_provenance(self, before: Row, after: Row, data: dict[str, object]) -> None:
|
|
958
|
+
"""Retain the observed from-null boundary before inverse writes erase it."""
|
|
959
|
+
after.pop('__sequence_shots_from_null', None)
|
|
960
|
+
if (
|
|
961
|
+
after['type'] != 'Sequence'
|
|
962
|
+
or 'shots' not in data
|
|
963
|
+
or self.schema.field_type('Sequence', 'shots') != 'multi_entity'
|
|
964
|
+
):
|
|
965
|
+
return
|
|
966
|
+
old = cast('list[Row]', before.get('shots', []))
|
|
967
|
+
new = cast('list[Row]', after.get('shots', []))
|
|
968
|
+
added = [link for link in new if link not in old]
|
|
969
|
+
if (
|
|
970
|
+
len(added) != 1
|
|
971
|
+
or added[0]['type'] != 'Shot'
|
|
972
|
+
or not self.schema.declares('Shot', 'sg_sequence')
|
|
973
|
+
or self.schema.field_type('Shot', 'sg_sequence') != 'entity'
|
|
974
|
+
):
|
|
975
|
+
return
|
|
976
|
+
shot = self.row('Shot', cast('int', added[0]['id']))
|
|
977
|
+
if shot is None:
|
|
978
|
+
return
|
|
979
|
+
unknown = cast('dict[str, FieldValue]', shot.get('__unknown', {}))
|
|
980
|
+
after['__sequence_shots_from_null'] = (
|
|
981
|
+
shot.get('sg_sequence') is None
|
|
982
|
+
and 'sg_sequence' not in unknown
|
|
983
|
+
and not self.tainted(shot)
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
def check_writable(self, row: Row) -> None:
|
|
987
|
+
"""Reject a mutation of a record whose lifecycle effect is unsettled.
|
|
988
|
+
|
|
989
|
+
Raises:
|
|
990
|
+
MockgunError: If the record carries an unsettled lifecycle marker.
|
|
991
|
+
"""
|
|
992
|
+
reason = self.tainted(row)
|
|
993
|
+
if reason is not None:
|
|
994
|
+
raise MockgunError(f'{row["type"]} {row["id"]}: {reason}')
|
|
995
|
+
|
|
996
|
+
def _check_link_writes(self, entity_type: str, data: dict[str, object]) -> None:
|
|
997
|
+
"""Reject supplied links to records already tainted by an earlier write."""
|
|
998
|
+
for field_name, value in data.items():
|
|
999
|
+
if self._schema_field_type(entity_type, field_name) not in {'entity', 'multi_entity'}:
|
|
1000
|
+
continue
|
|
1001
|
+
links = cast('list[object]', value) if isinstance(value, list) else [value]
|
|
1002
|
+
for value_link in links:
|
|
1003
|
+
if isinstance(value_link, dict):
|
|
1004
|
+
link = cast('dict[str, object]', value_link)
|
|
1005
|
+
if not self.schema.declares(cast('str', link['type'])):
|
|
1006
|
+
continue
|
|
1007
|
+
target = self.row(cast('str', link['type']), cast('int', link['id']))
|
|
1008
|
+
if target is not None:
|
|
1009
|
+
self.check_writable(target)
|
|
1010
|
+
|
|
1011
|
+
def _mark_task_inverse(self, before: Row, after: Row) -> None:
|
|
1012
|
+
"""Mirror the observed first assignment and mark other inverses unsettled."""
|
|
1013
|
+
if after['type'] != 'Version' or before.get('sg_task') == after.get('sg_task'):
|
|
1014
|
+
return
|
|
1015
|
+
if (
|
|
1016
|
+
not self.schema.declares('Task', 'sg_versions')
|
|
1017
|
+
or self._schema_field_type('Task', 'sg_versions') != 'multi_entity'
|
|
1018
|
+
):
|
|
1019
|
+
return
|
|
1020
|
+
for link in (before.get('sg_task'), after.get('sg_task')):
|
|
1021
|
+
if isinstance(link, dict) and link.get('type') == 'Task':
|
|
1022
|
+
task = self.row('Task', cast('int', link['id']))
|
|
1023
|
+
if task is not None:
|
|
1024
|
+
self.check_writable(task)
|
|
1025
|
+
if before and before.get('sg_task') is None and link == after.get('sg_task'):
|
|
1026
|
+
self._mirror_first_task_inverse(task, after)
|
|
1027
|
+
continue
|
|
1028
|
+
self.mark_unknown(
|
|
1029
|
+
task, 'sg_versions', 'Version.sg_task inverse value is unobserved'
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
def _remove_previous_pair(
|
|
1033
|
+
self, previous: FieldValue, writer_type: str, field: str, target: _EntityLink
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Remove a reparented target from its previous owner's multi-entity field."""
|
|
1036
|
+
if not isinstance(previous, dict) or previous.get('type') != writer_type:
|
|
1037
|
+
return
|
|
1038
|
+
previous_id = previous.get('id')
|
|
1039
|
+
if not isinstance(previous_id, int):
|
|
1040
|
+
return
|
|
1041
|
+
previous_row = self._db[writer_type].get(previous_id)
|
|
1042
|
+
if previous_row is None or self._schema_field_type(writer_type, field) != 'multi_entity':
|
|
1043
|
+
return
|
|
1044
|
+
self.check_writable(previous_row)
|
|
1045
|
+
self.remember(previous_row)
|
|
1046
|
+
# W17: a Sequence losing a Shot through another Sequence keeps its audit.
|
|
1047
|
+
previous_row[field] = [
|
|
1048
|
+
item for item in cast('list[FieldValue]', previous_row[field]) if item != target
|
|
1049
|
+
]
|
|
1050
|
+
|
|
1051
|
+
def _mirror_pair(
|
|
1052
|
+
self,
|
|
1053
|
+
source: tuple[_EntityLink, str],
|
|
1054
|
+
target: _EntityLink,
|
|
1055
|
+
mirror: tuple[str, str],
|
|
1056
|
+
*,
|
|
1057
|
+
added: bool,
|
|
1058
|
+
context: RequestContext,
|
|
1059
|
+
) -> None:
|
|
1060
|
+
"""Apply one observed pair delta and its side-specific audit policy."""
|
|
1061
|
+
writer, field = source
|
|
1062
|
+
target_type, mirror_field = mirror
|
|
1063
|
+
target_id = target['id']
|
|
1064
|
+
if not isinstance(target_id, int):
|
|
1065
|
+
return
|
|
1066
|
+
target_row = self._db[target_type].get(target_id)
|
|
1067
|
+
if target_row is None:
|
|
1068
|
+
return
|
|
1069
|
+
self.check_writable(target_row)
|
|
1070
|
+
old_value = target_row[mirror_field]
|
|
1071
|
+
writer_type = cast('str', writer['type'])
|
|
1072
|
+
if self._schema_field_type(target_type, mirror_field) == 'multi_entity':
|
|
1073
|
+
members = cast('list[FieldValue]', old_value)
|
|
1074
|
+
value: FieldValue
|
|
1075
|
+
if added:
|
|
1076
|
+
value = [*members, writer] if writer not in members else members
|
|
1077
|
+
else:
|
|
1078
|
+
value = [item for item in members if item != writer]
|
|
1079
|
+
elif added:
|
|
1080
|
+
value = writer
|
|
1081
|
+
if old_value != writer:
|
|
1082
|
+
self._remove_previous_pair(old_value, writer_type, field, target)
|
|
1083
|
+
else:
|
|
1084
|
+
value = None if old_value == writer else old_value
|
|
1085
|
+
if value == old_value:
|
|
1086
|
+
return
|
|
1087
|
+
self.remember(target_row)
|
|
1088
|
+
target_row[mirror_field] = copy.deepcopy(value)
|
|
1089
|
+
preserve = (writer_type, field) == ('Note', 'note_links') or (
|
|
1090
|
+
(writer_type, field) == ('Sequence', 'shots') and not added
|
|
1091
|
+
)
|
|
1092
|
+
if not preserve:
|
|
1093
|
+
self._set_updated_audit(target_type, target_row, context)
|
|
1094
|
+
|
|
1095
|
+
def _apply_pairs(
|
|
1096
|
+
self,
|
|
1097
|
+
entity_type: str,
|
|
1098
|
+
before: Row,
|
|
1099
|
+
after: Row,
|
|
1100
|
+
fields: Collection[str],
|
|
1101
|
+
context: RequestContext,
|
|
1102
|
+
) -> None:
|
|
1103
|
+
"""Mirror changed identities only where both declared fields are relations."""
|
|
1104
|
+
writer: _EntityLink = {'type': entity_type, 'id': after['id']}
|
|
1105
|
+
for (source_type, field, target_type), mirror in _PAIRED_FIELDS.items():
|
|
1106
|
+
if source_type != entity_type or field not in fields:
|
|
1107
|
+
continue
|
|
1108
|
+
if not self.schema.declares(*mirror):
|
|
1109
|
+
continue
|
|
1110
|
+
if self._schema_field_type(entity_type, field) not in {'entity', 'multi_entity'}:
|
|
1111
|
+
continue
|
|
1112
|
+
if self._schema_field_type(*mirror) not in {'entity', 'multi_entity'}:
|
|
1113
|
+
continue
|
|
1114
|
+
old_links = self._links(before.get(field), target_type)
|
|
1115
|
+
new_links = self._links(after[field], target_type)
|
|
1116
|
+
for target in old_links:
|
|
1117
|
+
if target not in new_links:
|
|
1118
|
+
self._connection_delta((writer, field), target, added=False)
|
|
1119
|
+
self._mirror_pair(
|
|
1120
|
+
(writer, field), target, mirror, added=False, context=context
|
|
1121
|
+
)
|
|
1122
|
+
for target in new_links:
|
|
1123
|
+
if target not in old_links:
|
|
1124
|
+
self._mirror_pair((writer, field), target, mirror, added=True, context=context)
|
|
1125
|
+
self._connection_delta((writer, field), target, added=True)
|
|
1126
|
+
|
|
1127
|
+
@staticmethod
|
|
1128
|
+
def kept_fields(
|
|
1129
|
+
row: Row,
|
|
1130
|
+
fields: Mapping[str, object],
|
|
1131
|
+
modes: Mapping[str, str],
|
|
1132
|
+
kept: Mapping[str, Sequence[Mapping[str, FieldValue]]],
|
|
1133
|
+
) -> dict[str, object]:
|
|
1134
|
+
"""Apply visible-member modes and prepend the retained hidden members.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
row: Stored row supplying the current multi-entity members.
|
|
1138
|
+
fields: Incoming field values to copy and adjust.
|
|
1139
|
+
modes: Per-field `set`, `add`, or `remove` modes; omitted modes use
|
|
1140
|
+
`set`.
|
|
1141
|
+
kept: Hidden member dictionaries to retain, keyed by field name.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
A new field mapping whose adjusted multi-entity values contain
|
|
1145
|
+
copied hidden members followed by the updated visible members.
|
|
1146
|
+
Values for unadjusted fields retain their original references.
|
|
1147
|
+
"""
|
|
1148
|
+
result = dict(fields)
|
|
1149
|
+
for field, members in kept.items():
|
|
1150
|
+
visible = [
|
|
1151
|
+
item for item in cast('list[_EntityLink]', row[field]) if item not in members
|
|
1152
|
+
]
|
|
1153
|
+
result[field] = [
|
|
1154
|
+
*[dict(member) for member in members],
|
|
1155
|
+
*Store._update_multi_entity(
|
|
1156
|
+
visible, cast('list[_EntityLink]', fields[field]), modes.get(field, 'set')
|
|
1157
|
+
),
|
|
1158
|
+
]
|
|
1159
|
+
return result
|
|
1160
|
+
|
|
1161
|
+
def prepare_create(
|
|
1162
|
+
self,
|
|
1163
|
+
entity_type: str,
|
|
1164
|
+
fields: dict[str, object],
|
|
1165
|
+
*,
|
|
1166
|
+
context: RequestContext,
|
|
1167
|
+
) -> Row:
|
|
1168
|
+
"""Build a validated candidate without changing records or allocating an ID.
|
|
1169
|
+
|
|
1170
|
+
Args:
|
|
1171
|
+
entity_type: Schema entity type to create.
|
|
1172
|
+
fields: Field values for the new row.
|
|
1173
|
+
context: Request actor and time; a null actor marks bootstrap.
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
A detached candidate with the next available ID and schema
|
|
1177
|
+
defaults.
|
|
1178
|
+
|
|
1179
|
+
Raises:
|
|
1180
|
+
ServerFaultError: If entity, field, or value validation has a
|
|
1181
|
+
modeled server fault.
|
|
1182
|
+
MockgunError: If a field type, linked state, or computed result is
|
|
1183
|
+
unsupported or unsettled.
|
|
1184
|
+
OverflowError: If converting a timestamp field to UTC exceeds the
|
|
1185
|
+
supported datetime range.
|
|
1186
|
+
"""
|
|
1187
|
+
data = self._expand_local_paths(self._parse_fields(entity_type, fields))
|
|
1188
|
+
self._set_created_audit(entity_type, data, context)
|
|
1189
|
+
self._validate_entity_data(entity_type, data, 'create')
|
|
1190
|
+
self._check_link_writes(entity_type, data)
|
|
1191
|
+
row = self._new_row(entity_type)
|
|
1192
|
+
for field_name, value in data.items():
|
|
1193
|
+
if field_name == 'cached_display_name':
|
|
1194
|
+
continue
|
|
1195
|
+
field_type = self._schema_field_type(entity_type, field_name)
|
|
1196
|
+
row[field_name] = self._normalize_field_value(field_type, cast('FieldValue', value))
|
|
1197
|
+
row['id'] = self._next_ids[entity_type]
|
|
1198
|
+
self._apply_create_computed(row, fields, context)
|
|
1199
|
+
return row
|
|
1200
|
+
|
|
1201
|
+
def create(
|
|
1202
|
+
self,
|
|
1203
|
+
entity_type: str,
|
|
1204
|
+
fields: dict[str, object],
|
|
1205
|
+
*,
|
|
1206
|
+
context: RequestContext,
|
|
1207
|
+
) -> Row:
|
|
1208
|
+
"""Create a validated row with increasing per-entity IDs and schema defaults.
|
|
1209
|
+
|
|
1210
|
+
Use an enclosing `transaction()` when a failure must roll back the row
|
|
1211
|
+
and dependent updates.
|
|
1212
|
+
|
|
1213
|
+
Args:
|
|
1214
|
+
entity_type: Schema entity type to create.
|
|
1215
|
+
fields: Field values for the new row.
|
|
1216
|
+
context: Request actor and time; a null actor marks bootstrap.
|
|
1217
|
+
|
|
1218
|
+
Returns:
|
|
1219
|
+
The live stored row, including internal retirement state.
|
|
1220
|
+
|
|
1221
|
+
Raises:
|
|
1222
|
+
ServerFaultError: If entity, field, or value validation has a
|
|
1223
|
+
modeled server fault.
|
|
1224
|
+
MockgunError: If a field type, linked state, or dependent effect is
|
|
1225
|
+
unsupported or unsettled.
|
|
1226
|
+
OverflowError: If converting a timestamp field to UTC exceeds the
|
|
1227
|
+
supported datetime range.
|
|
1228
|
+
"""
|
|
1229
|
+
self.created_connections = []
|
|
1230
|
+
row = self.prepare_create(entity_type, fields, context=context)
|
|
1231
|
+
self._next_ids[entity_type] += 1
|
|
1232
|
+
self._remember_identity(entity_type, cast('int', row['id']))
|
|
1233
|
+
self._db[entity_type][cast('int', row['id'])] = row
|
|
1234
|
+
self._apply_pairs(entity_type, {}, row, fields.keys(), context)
|
|
1235
|
+
if context.actor is not None:
|
|
1236
|
+
self._mark_task_inverse({}, row)
|
|
1237
|
+
return row
|
|
1238
|
+
|
|
1239
|
+
def prepare_update(
|
|
1240
|
+
self,
|
|
1241
|
+
entity_type: str,
|
|
1242
|
+
entity_id: int,
|
|
1243
|
+
fields: dict[str, object],
|
|
1244
|
+
modes: dict[str, str],
|
|
1245
|
+
*,
|
|
1246
|
+
context: RequestContext,
|
|
1247
|
+
) -> Row:
|
|
1248
|
+
"""Build a validated replacement without changing the stored record.
|
|
1249
|
+
|
|
1250
|
+
Args:
|
|
1251
|
+
entity_type: Schema entity type to update.
|
|
1252
|
+
entity_id: Existing row ID.
|
|
1253
|
+
fields: Replacement field values from the wire.
|
|
1254
|
+
modes: Per-field multi-entity `set`, `add`, or `remove` modes.
|
|
1255
|
+
context: Request actor and time for updated audit fields.
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
A detached replacement row with validated field changes and audit
|
|
1259
|
+
values, ready to be stored.
|
|
1260
|
+
|
|
1261
|
+
Raises:
|
|
1262
|
+
ServerFaultError: If the record is absent or entity, field, or
|
|
1263
|
+
value validation has a modeled server fault.
|
|
1264
|
+
MockgunError: If a field type, linked state, or record lifecycle is
|
|
1265
|
+
unsupported or unsettled.
|
|
1266
|
+
OverflowError: If converting a timestamp field to UTC exceeds the
|
|
1267
|
+
supported datetime range.
|
|
1268
|
+
"""
|
|
1269
|
+
data = self._parse_fields(entity_type, fields)
|
|
1270
|
+
# C7 P4-17: clearing the image also clears the declared filmstrip.
|
|
1271
|
+
if (
|
|
1272
|
+
'image' in data
|
|
1273
|
+
and data['image'] is None
|
|
1274
|
+
and self.schema.declares(entity_type, 'filmstrip_image')
|
|
1275
|
+
):
|
|
1276
|
+
data['filmstrip_image'] = None
|
|
1277
|
+
for field_name in data:
|
|
1278
|
+
if field_name in {'created_at', 'created_by', 'updated_at', 'updated_by'}:
|
|
1279
|
+
raise ServerFaultError(
|
|
1280
|
+
f'API update() {entity_type}.{field_name} is editable on create only',
|
|
1281
|
+
error_code=103,
|
|
1282
|
+
)
|
|
1283
|
+
self._validate_entity_data(entity_type, data, 'update')
|
|
1284
|
+
self._check_link_writes(entity_type, data)
|
|
1285
|
+
stored = self.require(entity_type, entity_id)
|
|
1286
|
+
self.check_writable(stored)
|
|
1287
|
+
row = copy.deepcopy(stored)
|
|
1288
|
+
for field_name, value in data.items():
|
|
1289
|
+
if field_name == 'cached_display_name':
|
|
1290
|
+
continue
|
|
1291
|
+
field_type = self._schema_field_type(entity_type, field_name)
|
|
1292
|
+
if field_type == 'multi_entity':
|
|
1293
|
+
row[field_name] = cast(
|
|
1294
|
+
'FieldValue',
|
|
1295
|
+
self._update_multi_entity(
|
|
1296
|
+
cast('list[_EntityLink]', row[field_name]),
|
|
1297
|
+
cast('list[_EntityLink]', value),
|
|
1298
|
+
modes.get(field_name, 'set'),
|
|
1299
|
+
),
|
|
1300
|
+
)
|
|
1301
|
+
else:
|
|
1302
|
+
row[field_name] = self._normalize_field_value(
|
|
1303
|
+
field_type, cast('FieldValue', value)
|
|
1304
|
+
)
|
|
1305
|
+
self._update_certainty(row, stored, data, modes, context)
|
|
1306
|
+
self._sequence_shots_provenance(stored, row, data)
|
|
1307
|
+
return row
|
|
1308
|
+
|
|
1309
|
+
def retire(
|
|
1310
|
+
self, entity_type: str, entity_id: int, *, context: RequestContext | None = None
|
|
1311
|
+
) -> bool:
|
|
1312
|
+
"""Retire an active entity.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
entity_type: Schema entity type to retire.
|
|
1316
|
+
entity_id: ID of the existing entity.
|
|
1317
|
+
context: Request actor and time, when called through a server
|
|
1318
|
+
request.
|
|
1319
|
+
|
|
1320
|
+
Returns:
|
|
1321
|
+
`True` when the entity changed state, otherwise `False`.
|
|
1322
|
+
|
|
1323
|
+
Raises:
|
|
1324
|
+
ServerFaultError: If the entity type or ID is unknown.
|
|
1325
|
+
"""
|
|
1326
|
+
row = self.require(entity_type, entity_id)
|
|
1327
|
+
if row['__retired']:
|
|
1328
|
+
return False
|
|
1329
|
+
self.remember(row)
|
|
1330
|
+
row['__retired'] = True
|
|
1331
|
+
if context is not None:
|
|
1332
|
+
row['__retired_at'] = context.now
|
|
1333
|
+
self._set_updated_audit(entity_type, row, context)
|
|
1334
|
+
return True
|
|
1335
|
+
|
|
1336
|
+
def revive(
|
|
1337
|
+
self, entity_type: str, entity_id: int, *, context: RequestContext | None = None
|
|
1338
|
+
) -> bool:
|
|
1339
|
+
"""Restore a retired entity to active state.
|
|
1340
|
+
|
|
1341
|
+
Args:
|
|
1342
|
+
entity_type: Schema entity type to restore.
|
|
1343
|
+
entity_id: ID of the existing entity.
|
|
1344
|
+
context: Request actor and time, when called through a server
|
|
1345
|
+
request.
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
`True` when the entity changed state, otherwise `False`.
|
|
1349
|
+
|
|
1350
|
+
Raises:
|
|
1351
|
+
ServerFaultError: If the entity type or ID is unknown.
|
|
1352
|
+
"""
|
|
1353
|
+
row = self.require(entity_type, entity_id)
|
|
1354
|
+
if not row['__retired']:
|
|
1355
|
+
return False
|
|
1356
|
+
self.remember(row)
|
|
1357
|
+
row['__retired'] = False
|
|
1358
|
+
row.pop('__retired_at', None)
|
|
1359
|
+
if context is not None:
|
|
1360
|
+
self._set_updated_audit(entity_type, row, context)
|
|
1361
|
+
return True
|
|
1362
|
+
|
|
1363
|
+
def tainted_rows(self, entity_type: str) -> list[Row]:
|
|
1364
|
+
"""Return live tainted rows of one type from both retirement scopes.
|
|
1365
|
+
|
|
1366
|
+
Raises:
|
|
1367
|
+
ServerFaultError: If the entity type is absent from the schema.
|
|
1368
|
+
"""
|
|
1369
|
+
self._validate_entity_type(entity_type)
|
|
1370
|
+
return [
|
|
1371
|
+
row
|
|
1372
|
+
for row in self._db[entity_type].values()
|
|
1373
|
+
if row.get('__taint') and self.tainted(row)
|
|
1374
|
+
]
|
|
1375
|
+
|
|
1376
|
+
def update(
|
|
1377
|
+
self,
|
|
1378
|
+
entity_type: str,
|
|
1379
|
+
entity_id: int,
|
|
1380
|
+
fields: dict[str, object],
|
|
1381
|
+
modes: dict[str, str],
|
|
1382
|
+
*,
|
|
1383
|
+
context: RequestContext,
|
|
1384
|
+
) -> Row:
|
|
1385
|
+
"""Update validated fields and return the live stored row.
|
|
1386
|
+
|
|
1387
|
+
Use an enclosing `transaction()` when a failure must roll back the row
|
|
1388
|
+
and dependent updates.
|
|
1389
|
+
|
|
1390
|
+
Args:
|
|
1391
|
+
entity_type: Schema entity type to update.
|
|
1392
|
+
entity_id: Existing row ID.
|
|
1393
|
+
fields: Replacement field values from the wire.
|
|
1394
|
+
modes: Per-field multi-entity `set`, `add`, or `remove` modes.
|
|
1395
|
+
context: Request actor and time for updated audit fields.
|
|
1396
|
+
|
|
1397
|
+
Raises:
|
|
1398
|
+
ServerFaultError: If the record is absent or entity, field, or
|
|
1399
|
+
value validation has a modeled server fault.
|
|
1400
|
+
MockgunError: If a field type, linked state, record lifecycle, or
|
|
1401
|
+
dependent effect is unsupported or unsettled.
|
|
1402
|
+
OverflowError: If converting a timestamp field to UTC exceeds the
|
|
1403
|
+
supported datetime range.
|
|
1404
|
+
"""
|
|
1405
|
+
self.created_connections = []
|
|
1406
|
+
row = self.prepare_update(entity_type, entity_id, fields, modes, context=context)
|
|
1407
|
+
stored = self.require(entity_type, entity_id)
|
|
1408
|
+
if row != stored:
|
|
1409
|
+
before = copy.deepcopy(stored)
|
|
1410
|
+
self.remember(stored)
|
|
1411
|
+
stored.clear()
|
|
1412
|
+
stored.update(row)
|
|
1413
|
+
self._apply_pairs(entity_type, before, stored, fields.keys(), context)
|
|
1414
|
+
self._mark_task_inverse(before, stored)
|
|
1415
|
+
image_fields = {'image', 'filmstrip_image'} & fields.keys()
|
|
1416
|
+
if 'image' in image_fields and fields['image'] is None:
|
|
1417
|
+
image_fields.add('filmstrip_image')
|
|
1418
|
+
for field in image_fields:
|
|
1419
|
+
self.side_set('image_identity', (entity_type, entity_id, field), None)
|
|
1420
|
+
if image_fields:
|
|
1421
|
+
self.side_set(
|
|
1422
|
+
'activity_taint',
|
|
1423
|
+
(entity_type, entity_id),
|
|
1424
|
+
f'update {entity_type} {entity_id} image fields: activity is unsettled',
|
|
1425
|
+
)
|
|
1426
|
+
return stored
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _observed_task_form(fields: Mapping[str, object], context: RequestContext) -> bool:
|
|
1430
|
+
"""Identify the script Task create forms with observed computed values."""
|
|
1431
|
+
entity = fields.get('entity')
|
|
1432
|
+
content = fields.get('content')
|
|
1433
|
+
return (
|
|
1434
|
+
context.session_kind == 'script'
|
|
1435
|
+
and set(fields) <= {'project', 'content', 'entity'}
|
|
1436
|
+
and {'project', 'content'} <= set(fields)
|
|
1437
|
+
and isinstance(content, str)
|
|
1438
|
+
and content != ''
|
|
1439
|
+
and (
|
|
1440
|
+
entity is None
|
|
1441
|
+
or (
|
|
1442
|
+
isinstance(entity, dict)
|
|
1443
|
+
and cast('dict[str, object]', entity).get('type') == 'Shot'
|
|
1444
|
+
)
|
|
1445
|
+
)
|
|
1446
|
+
)
|