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

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