sg-python-api 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.0.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1031 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +558 -0
  28. shotgun_api3/lib/mockgun/_links.py +440 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2163 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +368 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1446 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +964 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
  56. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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
+ )