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,1031 @@
1
+ """Record settled event rows and reject reads of unsettled event metadata."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from typing import cast
7
+
8
+ from ._errors import MockgunError
9
+ from ._links import display_name
10
+ from ._retirement import RetirementEffects
11
+ from ._sessions import RequestContext
12
+ from ._store import FieldValue, Row, Store
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class _CreateForm:
17
+ """Describe one observed create form in request and event field order."""
18
+
19
+ kind: str
20
+ session: str
21
+ required: tuple[str, ...]
22
+ optional: tuple[str, ...]
23
+ computed: tuple[str, ...]
24
+ prefix: tuple[str, ...]
25
+
26
+
27
+ # C6 P6-03 B1-B3 and C7 Q1 E6-E17: exact observed create forms.
28
+ _CREATE_FORMS = (
29
+ _CreateForm(
30
+ 'Task',
31
+ 'script',
32
+ ('project', 'content'),
33
+ ('entity',),
34
+ ('color', 'sg_status_list'),
35
+ ('color', 'content', 'sg_status_list'),
36
+ ),
37
+ _CreateForm(
38
+ 'Note',
39
+ 'script',
40
+ ('project', 'subject', 'content'),
41
+ ('note_links',),
42
+ ('user', 'sg_status_list'),
43
+ ('user', 'sg_status_list'),
44
+ ),
45
+ _CreateForm(
46
+ 'Note',
47
+ 'sudo',
48
+ ('note_links', 'subject', 'project'),
49
+ (),
50
+ ('user', 'sg_status_list'),
51
+ ('user', 'sg_status_list'),
52
+ ),
53
+ _CreateForm('Reply', 'script', ('entity', 'content'), (), ('user',), ('user',)),
54
+ _CreateForm(
55
+ 'Sequence',
56
+ 'script',
57
+ ('project', 'code'),
58
+ ('description',),
59
+ ('sg_status_list',),
60
+ ('code', 'sg_status_list'),
61
+ ),
62
+ _CreateForm(
63
+ 'Cut', 'script', ('project', 'code'), (), ('sg_status_list',), ('code', 'sg_status_list')
64
+ ),
65
+ _CreateForm('CutItem', 'script', ('project', 'code', 'cut'), (), (), ('code',)),
66
+ _CreateForm(
67
+ 'CutItem', 'script', ('project', 'code', 'cut', 'shot', 'version'), (), (), ('code',)
68
+ ),
69
+ _CreateForm(
70
+ 'Playlist', 'script', ('project', 'code', 'description', 'versions'), (), (), ('code',)
71
+ ),
72
+ )
73
+ _CREATE_IGNORED = frozenset(
74
+ {
75
+ 'id',
76
+ 'type',
77
+ 'project',
78
+ 'created_at',
79
+ 'updated_at',
80
+ 'created_by',
81
+ 'updated_by',
82
+ 'cached_display_name',
83
+ }
84
+ )
85
+ _EVENT_KINDS = {
86
+ 'new_entity': 'New',
87
+ 'entity_retirement': 'Retirement',
88
+ 'entity_revival': 'Revival',
89
+ }
90
+ _INVERSE_FIELDS = {
91
+ ('Asset', 'parents'): 'assets',
92
+ ('Asset', 'shots'): 'assets',
93
+ ('CutItem', 'cut'): 'cut_items',
94
+ ('Playlist', 'versions'): 'playlists',
95
+ ('Reply', 'entity'): 'replies',
96
+ ('Sequence', 'shots'): 'sg_sequence',
97
+ ('Shot', 'assets'): 'shots',
98
+ ('Shot', 'sg_sequence'): 'shots',
99
+ ('Task', 'entity'): 'tasks',
100
+ ('Version', 'playlists'): 'versions',
101
+ ('Version', 'sg_task'): 'sg_versions',
102
+ }
103
+ _LIFECYCLE_TYPES = frozenset(
104
+ {
105
+ 'Shot',
106
+ 'Task',
107
+ 'Note',
108
+ 'Version',
109
+ 'Playlist',
110
+ 'Sequence',
111
+ 'Asset',
112
+ 'PublishedFile',
113
+ 'CutItem',
114
+ }
115
+ )
116
+ _LINK_TARGETS = {
117
+ ('Asset', 'parents'): 'Asset',
118
+ ('Asset', 'shots'): 'Shot',
119
+ ('CutItem', 'cut'): 'Cut',
120
+ ('CutItem', 'shot'): 'Shot',
121
+ ('CutItem', 'version'): 'Version',
122
+ ('Note', 'note_links'): 'Shot',
123
+ ('Playlist', 'versions'): 'Version',
124
+ ('Reply', 'entity'): 'Note',
125
+ ('Sequence', 'shots'): 'Shot',
126
+ ('Shot', 'assets'): 'Asset',
127
+ ('Shot', 'sg_sequence'): 'Sequence',
128
+ ('Task', 'entity'): 'Shot',
129
+ ('Version', 'playlists'): 'Playlist',
130
+ ('Version', 'sg_task'): 'Task',
131
+ }
132
+ # C6 P6-04 P1/P2: (added count, removed count), also for single links.
133
+ _LINK_UPDATE_FORMS = {
134
+ ('Asset', 'parents'): {(1, 0), (1, 1)},
135
+ ('Asset', 'shots'): {(1, 0)},
136
+ ('CutItem', 'cut'): {(1, 1)},
137
+ ('Note', 'note_links'): {(2, 0), (1, 2)},
138
+ ('Playlist', 'versions'): {(1, 0), (0, 2)},
139
+ ('Sequence', 'shots'): {(1, 0)},
140
+ ('Shot', 'sg_sequence'): {(1, 0), (1, 1)},
141
+ ('Task', 'entity'): {(1, 0), (1, 1)},
142
+ ('Version', 'playlists'): {(2, 0)},
143
+ ('Version', 'sg_task'): {(1, 0)},
144
+ }
145
+ _SCALAR_ENCODINGS = frozenset(
146
+ {'number', 'duration', 'checkbox', 'text', 'list', 'status_list', 'date', 'date_time', 'float'}
147
+ )
148
+ _WRITE_TYPES = frozenset(
149
+ {
150
+ 'Shot',
151
+ 'Asset',
152
+ 'Sequence',
153
+ 'Version',
154
+ 'Task',
155
+ 'Note',
156
+ 'Reply',
157
+ 'Playlist',
158
+ 'Cut',
159
+ 'CutItem',
160
+ 'PublishedFile',
161
+ }
162
+ )
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class _EventPlan:
167
+ """One ordered event independent of the log's availability."""
168
+
169
+ kind: str
170
+ row: Row
171
+ meta: Row
172
+ unknown: frozenset[str]
173
+ forward: int | None = None
174
+
175
+
176
+ def _check_element(store: Store, value: FieldValue) -> None:
177
+ """Check historical link names and statuses recursively within metadata."""
178
+ if isinstance(value, list):
179
+ for item in value:
180
+ _check_element(store, item)
181
+ elif isinstance(value, dict):
182
+ if {'id', 'type', 'name', 'valid', 'status', 'uuid'} <= value.keys():
183
+ kind = cast('str', value['type'])
184
+ identity = cast('int', value['id'])
185
+ target = store.row(kind, identity) if store.schema.declares(kind) else None
186
+ if target is None or target['__retired'] or store.tainted(target):
187
+ raise MockgunError(f'event meta link {kind} {identity} is no longer active')
188
+ store.check_known(target, 'sg_status_list')
189
+ if (
190
+ display_name(store, kind, identity) != value['name']
191
+ or target.get('sg_status_list') != value['status']
192
+ ):
193
+ raise MockgunError(f'event meta link {kind} {identity} name or status changed')
194
+ else:
195
+ for item in value.values():
196
+ _check_element(store, item)
197
+
198
+
199
+ def _context_plans(plans: list[_EventPlan], context: RequestContext) -> list[_EventPlan]:
200
+ """Keep sudo metadata uncertainty in the reusable plans themselves."""
201
+ if not context.sudo:
202
+ return plans
203
+ return [
204
+ _EventPlan(plan.kind, plan.row, plan.meta, plan.unknown | {'meta'}, plan.forward)
205
+ for plan in plans
206
+ ]
207
+
208
+
209
+ def _create_fields(store: Store, row: Row, supplied: Sequence[str]) -> list[str] | None:
210
+ """Resolve only observed populated create fields and their event order."""
211
+ kind = cast('str', row['type'])
212
+ # An omitted checkbox left False adds no row (C4 EV-04 with C5-04's readback).
213
+ populated = [
214
+ field
215
+ for field in store.schema.fields[kind]
216
+ if field not in _CREATE_IGNORED
217
+ and row.get(field) is not None
218
+ and row.get(field) != []
219
+ and not (
220
+ field not in supplied
221
+ and row.get(field) is False
222
+ and store.schema.field_type(kind, field) == 'checkbox'
223
+ )
224
+ ]
225
+ computed_user = kind == 'Version' and 'user' not in supplied
226
+ defaults = set(populated) - set(supplied) - ({'user'} if computed_user else set())
227
+ if (
228
+ row.get('__unknown')
229
+ or any(
230
+ store.schema.field_type(kind, field) not in {'status_list', 'list'}
231
+ for field in defaults
232
+ )
233
+ or any(
234
+ store.schema.field_type(kind, field) in {'entity', 'multi_entity'}
235
+ and not (computed_user and field == 'user')
236
+ for field in populated
237
+ )
238
+ ):
239
+ return None
240
+ if len(populated) <= 1:
241
+ return populated
242
+ if (
243
+ kind == 'Shot'
244
+ and set(populated) == {'code', 'sg_status_list', 'sg_shot_type'}
245
+ and defaults == {'sg_status_list', 'sg_shot_type'}
246
+ ):
247
+ return ['code', 'sg_status_list', 'sg_shot_type']
248
+ if (
249
+ kind == 'Asset'
250
+ and set(populated) == {'code', 'sg_status_list'}
251
+ and defaults == {'sg_status_list'}
252
+ ):
253
+ return ['code', 'sg_status_list']
254
+ if (
255
+ kind == 'Version'
256
+ and set(populated) == {'code', 'sg_status_list', 'user', 'description', 'sg_first_frame'}
257
+ and defaults == {'sg_status_list'}
258
+ and computed_user
259
+ ):
260
+ return [
261
+ 'code',
262
+ 'sg_status_list',
263
+ 'user',
264
+ *[field for field in supplied if field in {'description', 'sg_first_frame'}],
265
+ ]
266
+ return None
267
+
268
+
269
+ def _default_matches(store: Store, row: Row, field: str, context: RequestContext) -> bool:
270
+ """Require the precise schema default or observed computed value."""
271
+ if field == 'user':
272
+ actor = context.actor
273
+ return actor is not None and row[field] == {
274
+ 'type': actor.entity_type,
275
+ 'id': actor.entity_id,
276
+ }
277
+ if field == 'color':
278
+ return row[field] == 'pipeline_step'
279
+ entry = cast('dict[str, object]', store.schema.fields[cast('str', row['type'])][field])
280
+ properties = cast('dict[str, FieldValue]', entry.get('properties', {}))
281
+ default = cast('dict[str, FieldValue]', properties.get('default_value', {}))
282
+ return row[field] == default.get('value')
283
+
284
+
285
+ def _element(store: Store, link: Row) -> Row:
286
+ """Encode an Asset link with a stable site identity and observed status shape."""
287
+ kind, identity = cast('str', link['type']), cast('int', link['id'])
288
+ if kind != 'Asset' or not store.schema.declares(kind, 'sg_status_list'):
289
+ raise MockgunError('unobserved event link element')
290
+ target = store.require(kind, identity)
291
+ store.check_known(target, 'sg_status_list')
292
+ if target['__retired'] or store.tainted(target):
293
+ raise MockgunError('event link target is not known active')
294
+ return {
295
+ 'type': kind,
296
+ 'id': identity,
297
+ 'name': display_name(store, kind, identity),
298
+ 'valid': 'valid',
299
+ 'status': target.get('sg_status_list'),
300
+ 'uuid': store.link_uuid(kind, identity),
301
+ }
302
+
303
+
304
+ def _encode(value: FieldValue, field_type: str) -> FieldValue:
305
+ """Encode one observed scalar value, raising for unsettled float representations."""
306
+ if field_type not in _SCALAR_ENCODINGS:
307
+ raise MockgunError('unobserved scalar event encoding')
308
+ if value is None:
309
+ return None
310
+ if field_type in {'number', 'duration'}:
311
+ return int(cast('int', value))
312
+ if field_type == 'date_time':
313
+ return cast('datetime', value).astimezone(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')
314
+ if field_type == 'float':
315
+ value = cast('float', value)
316
+ if 'e' in repr(value).lower():
317
+ raise MockgunError('exponent float event meta is unsettled')
318
+ numerator, denominator = value.as_integer_ratio()
319
+ if (abs(numerator) * 1_000_000) % denominator * 2 == denominator:
320
+ raise MockgunError('tie float event meta is unsettled')
321
+ return repr(round(value, 6))
322
+ return value
323
+
324
+
325
+ def _change_meta(row: Row, field: str, field_type: str, old: FieldValue) -> tuple[Row, set[str]]:
326
+ """Build scalar change metadata while retaining unobserved encodings as unknown."""
327
+ meta: Row = {
328
+ 'type': 'attribute_change',
329
+ 'attribute_name': field,
330
+ 'entity_type': row['type'],
331
+ 'entity_id': row['id'],
332
+ 'field_data_type': field_type,
333
+ }
334
+ unknown: set[str] = set()
335
+ try:
336
+ meta['old_value'] = _encode(old, field_type)
337
+ meta['new_value'] = _encode(row.get(field), field_type)
338
+ except (MockgunError, OverflowError, ValueError):
339
+ unknown.add('meta')
340
+ return meta, unknown
341
+
342
+
343
+ def _known_inverse(store: Store, row: Row, field: str, links: list[Row]) -> bool:
344
+ """Require inverse targets and declarations before planning their rows."""
345
+ inverse = _INVERSE_FIELDS.get((cast('str', row['type']), field))
346
+ if inverse is None:
347
+ return True
348
+ return all(
349
+ store.schema.declares(cast('str', link['type']), inverse)
350
+ and store.row(cast('str', link['type']), cast('int', link['id'])) is not None
351
+ for link in links
352
+ )
353
+
354
+
355
+ def _link_meta(
356
+ store: Store, row: Row, field: str, delta: tuple[list[Row], list[Row]]
357
+ ) -> tuple[Row, set[str]]:
358
+ """Build one forward or inverse link delta, marking unsupported targets unknown."""
359
+ meta: Row = {
360
+ 'type': 'attribute_change',
361
+ 'attribute_name': field,
362
+ 'entity_type': row['type'],
363
+ 'entity_id': row['id'],
364
+ 'field_data_type': 'multi_entity',
365
+ }
366
+ try:
367
+ meta['added'] = [_element(store, link) for link in delta[0]]
368
+ meta['removed'] = [_element(store, link) for link in delta[1]]
369
+ except MockgunError:
370
+ return meta, {'meta'}
371
+ return meta, set()
372
+
373
+
374
+ def _members(value: FieldValue) -> list[Row]:
375
+ """Normalize single and multiple links for ordered delta calculations."""
376
+ if isinstance(value, dict):
377
+ return [value]
378
+ return cast('list[Row]', value or [])
379
+
380
+
381
+ def _new_plan(row: Row, *, connection: bool = False) -> _EventPlan:
382
+ """Build an entity or connection creation plan."""
383
+ return _EventPlan(
384
+ 'connection' if connection else 'new',
385
+ row,
386
+ {'type': 'new_entity', 'entity_type': row['type'], 'entity_id': row['id']},
387
+ frozenset({'created_at', 'project'} if connection else ()),
388
+ )
389
+
390
+
391
+ def _project(store: Store, row: Row) -> FieldValue:
392
+ """Resolve the write-time Project, following a Reply's Note when applicable."""
393
+ if row['type'] != 'Reply':
394
+ store.check_known(row, 'project')
395
+ return row.get('project')
396
+ store.check_known(row, 'entity')
397
+ link = row.get('entity')
398
+ if isinstance(link, dict) and link.get('type') == 'Note' and store.schema.declares('Note'):
399
+ note = store.row('Note', cast('int', link['id']))
400
+ if note is not None and not store.tainted(note):
401
+ store.check_known(note, 'project')
402
+ return note.get('project')
403
+ return None
404
+
405
+
406
+ def _append(
407
+ store: Store,
408
+ row: Row,
409
+ meta: Row,
410
+ *,
411
+ context: RequestContext,
412
+ unknown: set[str],
413
+ ) -> Row:
414
+ """Append one event with common fields and row-specific uncertainty."""
415
+ unknown = unknown | {'description'}
416
+ if context.sudo:
417
+ unknown.add('meta')
418
+ try:
419
+ project = _project(store, row)
420
+ except MockgunError:
421
+ project = None
422
+ if project is None:
423
+ unknown.add('project')
424
+ actor = context.actor
425
+ if actor is None:
426
+ raise MockgunError('cannot construct an event without an actor')
427
+ if meta['type'] == 'reading':
428
+ return store.append_event(
429
+ {
430
+ 'event_type': 'Shotgun_Reading_Change',
431
+ 'attribute_name': 'read_by_current_user',
432
+ 'user': {'type': actor.entity_type, 'id': actor.entity_id},
433
+ },
434
+ unknown=unknown,
435
+ )
436
+ kind = _EVENT_KINDS.get(cast('str', meta['type']), 'Change')
437
+ return store.append_event(
438
+ {
439
+ 'event_type': f'Shotgun_{row["type"]}_{kind}',
440
+ 'attribute_name': meta.get('attribute_name'),
441
+ 'meta': meta,
442
+ 'entity': {'type': row['type'], 'id': row['id']},
443
+ 'user': {'type': actor.entity_type, 'id': actor.entity_id},
444
+ 'project': project,
445
+ 'created_at': context.now,
446
+ 'session_uuid': context.session_uuid,
447
+ 'cached_display_name': None,
448
+ 'image': None,
449
+ 'filmstrip_image': None,
450
+ 'image_blur_hash': None,
451
+ 'image_source_entity': None,
452
+ },
453
+ unknown=unknown,
454
+ )
455
+
456
+
457
+ def _reading_plan(row: Row, *, follower: bool = False) -> _EventPlan:
458
+ """Represent a human Note read or the opaque followed-Reply read."""
459
+ unknown = {
460
+ 'entity',
461
+ 'project',
462
+ 'meta',
463
+ 'created_at',
464
+ 'session_uuid',
465
+ 'cached_display_name',
466
+ 'image',
467
+ 'filmstrip_image',
468
+ 'image_blur_hash',
469
+ 'image_source_entity',
470
+ }
471
+ if follower:
472
+ unknown.add('user')
473
+ return _EventPlan(
474
+ 'reading',
475
+ row,
476
+ {'type': 'reading', 'attribute_name': 'read_by_current_user'},
477
+ frozenset(unknown),
478
+ )
479
+
480
+
481
+ def _settled_link_update(
482
+ store: Store, before: Row, after: Row, field: str, context: RequestContext
483
+ ) -> bool:
484
+ """Recognize supported link changes, including single Shot asset and Note link changes."""
485
+ key = (cast('str', before['type']), field)
486
+ old, new = _members(before.get(field)), _members(after.get(field))
487
+ added, removed = (
488
+ [link for link in new if link not in old],
489
+ [link for link in old if link not in new],
490
+ )
491
+ if key in {('Shot', 'assets'), ('Note', 'note_links')} and len(added) + len(removed) == 1:
492
+ target = (added or removed)[0]
493
+ return key[0] == 'Note' or (
494
+ target['type'] == 'Asset' and store.row('Asset', cast('int', target['id'])) is not None
495
+ )
496
+ if not _known_inverse(store, after, field, [*added, *removed]):
497
+ return False
498
+ if context.session_kind != 'script' or (
499
+ len(added),
500
+ len(removed),
501
+ ) not in _LINK_UPDATE_FORMS.get(key, set()):
502
+ return False
503
+ if key == ('Note', 'note_links'):
504
+ return {cast('str', link['type']) for link in (removed or added)} == {'Shot', 'Asset'}
505
+ if key == ('Sequence', 'shots') and after.get('__sequence_shots_from_null') is not True:
506
+ return False
507
+ return all(link['type'] == _LINK_TARGETS[key] for link in [*added, *removed])
508
+
509
+
510
+ def _single_link_meta(store: Store, row: Row, field: str, old: FieldValue) -> tuple[Row, set[str]]:
511
+ """Encode a single link's old and new values using observed elements."""
512
+ meta: Row = {
513
+ 'type': 'attribute_change',
514
+ 'attribute_name': field,
515
+ 'entity_type': row['type'],
516
+ 'entity_id': row['id'],
517
+ 'field_data_type': 'entity',
518
+ }
519
+ try:
520
+ meta['old_value'] = _element(store, old) if isinstance(old, dict) else None
521
+ value = row.get(field)
522
+ meta['new_value'] = _element(store, value) if isinstance(value, dict) else None
523
+ except MockgunError:
524
+ return meta, {'meta'}
525
+ return meta, set()
526
+
527
+
528
+ def _field_plan(store: Store, row: Row, field: str, old: FieldValue) -> _EventPlan:
529
+ """Build a scalar or link change with the appropriate metadata encoding."""
530
+ field_type = store.schema.field_type(cast('str', row['type']), field)
531
+ if field_type == 'entity':
532
+ meta, unknown = _single_link_meta(store, row, field, old)
533
+ elif field_type == 'multi_entity':
534
+ before, after = _members(old), _members(row.get(field))
535
+ meta, unknown = _link_meta(
536
+ store,
537
+ row,
538
+ field,
539
+ (
540
+ [link for link in after if link not in before],
541
+ [link for link in before if link not in after],
542
+ ),
543
+ )
544
+ else:
545
+ meta, unknown = _change_meta(row, field, field_type, old)
546
+ return _EventPlan('change', row, meta, frozenset(unknown))
547
+
548
+
549
+ def _inverse_plans(
550
+ store: Store, row: Row, field: str, delta: tuple[list[Row], list[Row]], forward: int
551
+ ) -> list[_EventPlan]:
552
+ """Build removed targets before added targets, citing the forward plan."""
553
+ inverse = _INVERSE_FIELDS.get((cast('str', row['type']), field))
554
+ if inverse is None:
555
+ return []
556
+ writer: Row = {'type': row['type'], 'id': row['id']}
557
+ plans: list[_EventPlan] = []
558
+ added, removed = delta
559
+ for link in [*removed, *added]:
560
+ target = store.require(cast('str', link['type']), cast('int', link['id']))
561
+ if (row['type'], field) != ('Shot', 'assets') and store.schema.field_type(
562
+ cast('str', target['type']), inverse
563
+ ) == 'entity':
564
+ snapshot: Row = {**target, inverse: writer if link in added else None}
565
+ meta, unknown = _single_link_meta(
566
+ store, snapshot, inverse, None if link in added else writer
567
+ )
568
+ else:
569
+ meta, unknown = _link_meta(
570
+ store, target, inverse, ([writer], []) if link in added else ([], [writer])
571
+ )
572
+ meta['actual_attribute_changed'] = f'{row["type"]}.{field}'
573
+ if row['type'] == 'Shot' and field == 'assets':
574
+ unknown.add('project')
575
+ plans.append(_EventPlan('inverse', target, meta, frozenset(unknown), forward))
576
+ return plans
577
+
578
+
579
+ def _create_plans(
580
+ store: Store, row: Row, fields: list[str], context: RequestContext
581
+ ) -> list[_EventPlan]:
582
+ """Assemble new, connection and change rows in the observed order."""
583
+ plans: list[_EventPlan] = []
584
+ if (
585
+ row['type'] == 'Note'
586
+ and context.actor is not None
587
+ and context.actor.entity_type == 'HumanUser'
588
+ ):
589
+ plans.append(_reading_plan(row))
590
+ if row['type'] == 'Reply':
591
+ note = _members(row.get('entity'))
592
+ if note and store.followers('Note', cast('int', note[0]['id'])):
593
+ plans.append(_reading_plan(row, follower=True))
594
+ plans.append(_new_plan(row))
595
+ if row['type'] == 'Playlist':
596
+ plans.extend(
597
+ _new_plan(connection, connection=True) for connection in store.created_connections
598
+ )
599
+ for field in fields:
600
+ plan = _field_plan(store, row, field, None)
601
+ plan.meta['in_create'] = True
602
+ unknown = set(plan.unknown)
603
+ if store.schema.field_type(cast('str', row['type']), field) == 'checkbox' or (
604
+ row['type'] == 'CutItem' and field == 'code' and 'cut' in fields
605
+ ):
606
+ unknown.add('meta')
607
+ forward = len(plans)
608
+ plans.append(_EventPlan(plan.kind, plan.row, plan.meta, frozenset(unknown)))
609
+ if (cast('str', row['type']), field) in _INVERSE_FIELDS:
610
+ inverses = _inverse_plans(store, row, field, (_members(row.get(field)), []), forward)
611
+ for inverse in inverses:
612
+ inverse.meta['in_create'] = True
613
+ plans.extend(inverses)
614
+ return plans
615
+
616
+
617
+ def _plan_link_update(store: Store, before: Row, after: Row, field: str) -> list[_EventPlan]:
618
+ """Place created connections before the forward row and ordered inverses."""
619
+ old, new = _members(before.get(field)), _members(after.get(field))
620
+ plans = [_new_plan(connection, connection=True) for connection in store.created_connections]
621
+ forward = len(plans)
622
+ plans.append(_field_plan(store, after, field, before.get(field)))
623
+ plans.extend(
624
+ _inverse_plans(
625
+ store,
626
+ after,
627
+ field,
628
+ ([link for link in new if link not in old], [link for link in old if link not in new]),
629
+ forward,
630
+ )
631
+ )
632
+ return plans
633
+
634
+
635
+ def _plan_update(
636
+ store: Store, before: Row, after: Row, supplied: Mapping[str, str], *, context: RequestContext
637
+ ) -> list[_EventPlan] | None:
638
+ """Classify update events independently of event declaration or readability."""
639
+ # C7 P4-17 establishes clearing, but not its event rows, including no-ops.
640
+ image_fields = bool({'image', 'filmstrip_image'} & supplied.keys())
641
+ kind = cast('str', before['type'])
642
+ unknown = cast('dict[str, FieldValue]', before.get('__unknown', {}))
643
+ changed = [field for field in supplied if before.get(field) != after.get(field)]
644
+ uncertain = any(field in unknown for field in supplied)
645
+ if kind != 'EventLogEntry' and not changed and not uncertain and not image_fields:
646
+ return []
647
+ actor = context.actor
648
+ if (
649
+ kind not in _WRITE_TYPES
650
+ or image_fields
651
+ or actor is None
652
+ or context.session_kind == 'human'
653
+ or uncertain
654
+ ):
655
+ return None
656
+ if (kind == 'Version' and 'sg_task' in supplied and 'sg_task' not in changed) or (
657
+ kind == 'Task'
658
+ and (
659
+ 'due_date' in supplied
660
+ or any(
661
+ field in supplied and after.get(field) is not None
662
+ for field in ('start_date', 'duration')
663
+ )
664
+ )
665
+ ):
666
+ return None
667
+ if any(
668
+ store.schema.field_type(kind, field) in {'entity', 'multi_entity'} for field in changed
669
+ ):
670
+ if len(changed) != 1 or not _settled_link_update(
671
+ store, before, after, changed[0], context
672
+ ):
673
+ return None
674
+ return _context_plans(_plan_link_update(store, before, after, changed[0]), context)
675
+ return _context_plans(
676
+ [_field_plan(store, after, field, before.get(field)) for field in changed], context
677
+ )
678
+
679
+
680
+ def _unsettled(store: Store, reason: str) -> None:
681
+ """Keep the first unsettled API write as the event log's permanent read barrier."""
682
+ store.taint_events(reason)
683
+
684
+
685
+ def _valid_create_links(store: Store, row: Row, supplied: list[str]) -> bool:
686
+ """Restrict populated links to each recorded create form's target and count."""
687
+ kind = cast('str', row['type'])
688
+ for field in supplied:
689
+ if field == 'project':
690
+ continue
691
+ target = _LINK_TARGETS.get((kind, field))
692
+ if target is not None:
693
+ members = _members(row.get(field))
694
+ if (
695
+ len(members) != 1
696
+ or members[0]['type'] != target
697
+ or not _known_inverse(store, row, field, members)
698
+ ):
699
+ return False
700
+ if kind == 'Playlist' and not store.schema.declares('PlaylistVersionConnection'):
701
+ return False
702
+ if kind == 'Reply':
703
+ note = _members(row['entity'])[0]
704
+ followers = store.followers('Note', cast('int', note['id']))
705
+ return not followers or (len(followers) == 1 and supplied == ['entity', 'content'])
706
+ return True
707
+
708
+
709
+ def _observed_create_fields(
710
+ store: Store, row: Row, supplied: list[str], context: RequestContext
711
+ ) -> list[str] | None:
712
+ """Match exact observed create forms and leave other forms for fallback."""
713
+ actor = context.actor
714
+ if actor is None or row.get('__unknown'):
715
+ return None
716
+ session = context.session_kind
717
+ kind = cast('str', row['type'])
718
+ populated = {
719
+ field
720
+ for field in store.schema.fields[kind]
721
+ if field not in _CREATE_IGNORED and row.get(field) is not None and row.get(field) != []
722
+ }
723
+ for form in _CREATE_FORMS:
724
+ if (kind, session) != (form.kind, form.session):
725
+ continue
726
+ if not set(form.required) <= set(supplied) <= set(form.required) | set(form.optional):
727
+ continue
728
+ if session == 'sudo' and supplied != list(form.required):
729
+ continue
730
+ expected = (set(supplied) - {'project'}) | set(form.computed)
731
+ if populated != expected or not all(
732
+ _default_matches(store, row, field, context) for field in form.computed
733
+ ):
734
+ continue
735
+ if not _valid_create_links(store, row, supplied):
736
+ continue
737
+ return [
738
+ *form.prefix,
739
+ *[field for field in supplied if field != 'project' and field not in form.prefix],
740
+ ]
741
+ return None
742
+
743
+
744
+ def _plan_create(
745
+ store: Store, row: Row, supplied: Sequence[str], *, context: RequestContext
746
+ ) -> list[_EventPlan] | None:
747
+ """Classify create events without depending on the event log's state."""
748
+ actor = context.actor
749
+ if (
750
+ row['type'] not in _WRITE_TYPES
751
+ or actor is None
752
+ or (context.session_kind == 'human' and row['type'] != 'Note')
753
+ ):
754
+ return None
755
+ effective = [
756
+ field for field in supplied if row.get(field) is not None and row.get(field) != []
757
+ ]
758
+ fields = _observed_create_fields(store, row, effective, context)
759
+ if fields is None:
760
+ fields = _create_fields(store, row, effective)
761
+ if fields is None:
762
+ return None
763
+ plans = _create_plans(store, row, fields, context)
764
+ if any(
765
+ field in supplied and row.get(field) != context.now
766
+ for field in ('created_at', 'updated_at')
767
+ ):
768
+ plans = [
769
+ _EventPlan(plan.kind, plan.row, plan.meta, plan.unknown | {'created_at'}, plan.forward)
770
+ for plan in plans
771
+ ]
772
+ return _context_plans(plans, context)
773
+
774
+
775
+ def _write_plans(store: Store, plans: list[_EventPlan], context: RequestContext) -> None:
776
+ """Append every plan once, resolving inverse references to written IDs."""
777
+ written: list[Row] = []
778
+ for plan in plans:
779
+ meta = dict(plan.meta)
780
+ if plan.forward is not None:
781
+ meta['original_event_log_entry_id'] = written[plan.forward]['id']
782
+ written.append(_append(store, plan.row, meta, context=context, unknown=set(plan.unknown)))
783
+
784
+
785
+ def check_meta(store: Store, row: Row) -> None:
786
+ """Reject unknown metadata or historical links whose current identity state differs.
787
+
788
+ Args:
789
+ store: Site records used to resolve historical links.
790
+ row: Event row whose metadata is being read.
791
+
792
+ Raises:
793
+ MockgunError: If metadata is unknown or a historical link's current
794
+ identity, display state, or lifecycle cannot support the read.
795
+ """
796
+ store.check_known(row, 'meta')
797
+ _check_element(store, row.get('meta'))
798
+
799
+
800
+ def record_attachment_upload(
801
+ store: Store,
802
+ attachment: Row,
803
+ owner: Row,
804
+ *,
805
+ context: RequestContext,
806
+ display_name_changed: bool = True,
807
+ ) -> None:
808
+ """Write the observed rows for a plain script upload to a Note.
809
+
810
+ Args:
811
+ store: Store whose event log receives the rows.
812
+ attachment: Newly linked Attachment.
813
+ owner: Note receiving the plain upload.
814
+ context: Script actor and request instant captured by the HTTP route.
815
+ display_name_changed: Whether to include the display-name event; C8
816
+ omits it for observed text files retaining their filename.
817
+ """
818
+ if not store.schema.declares('EventLogEntry') or store.events_unknown is not None:
819
+ return
820
+ # C7 P4-07: the New row preceded the Project assignment; every Project
821
+ # value and each Change row's metadata remain unsettled.
822
+ new = _new_plan(attachment)
823
+ plans = [_EventPlan(new.kind, new.row, new.meta, frozenset({'project'}))]
824
+ for row, field in (
825
+ (attachment, 'attachment_links'),
826
+ (owner, 'attachments'),
827
+ (attachment, 'display_name'),
828
+ (attachment, 'project'),
829
+ ):
830
+ if field == 'display_name' and not display_name_changed:
831
+ continue
832
+ plans.append(
833
+ _EventPlan(
834
+ 'change',
835
+ row,
836
+ {
837
+ 'type': 'attribute_change',
838
+ 'attribute_name': field,
839
+ 'entity_type': row['type'],
840
+ 'entity_id': row['id'],
841
+ },
842
+ frozenset({'meta', 'project'}),
843
+ )
844
+ )
845
+ _write_plans(store, plans, context)
846
+
847
+
848
+ def record_create(
849
+ store: Store, row: Row, supplied: Sequence[str], *, context: RequestContext
850
+ ) -> bool:
851
+ """Classify a create and write its events when the log is readable.
852
+
853
+ Args:
854
+ store: Site records and event log.
855
+ row: Newly stored record including defaults and computed fields.
856
+ supplied: Field names in request order.
857
+ context: Request actor, time and session.
858
+
859
+ Returns:
860
+ Whether the write has a settled event plan, even without an event log.
861
+ """
862
+ plans = _plan_create(store, row, supplied, context=context)
863
+ if store.schema.declares('EventLogEntry') and store.events_unknown is None:
864
+ if plans is None:
865
+ _unsettled(
866
+ store,
867
+ f'{row["type"]} {row["id"]} create has unsettled event rows',
868
+ )
869
+ else:
870
+ _write_plans(store, plans, context)
871
+ return plans is not None
872
+
873
+
874
+ def record_lifecycle(
875
+ store: Store, effects: RetirementEffects, *, revival: bool, context: RequestContext
876
+ ) -> None:
877
+ """Write settled lifecycle rows using retained retirement timestamps.
878
+
879
+ Args:
880
+ store: Site records and event declarations.
881
+ effects: Completed lifecycle root, dependent changes and settlement
882
+ state.
883
+ revival: Whether the operation restored rather than retired the root.
884
+ context: Request actor, timestamp, session UUID and sudo flag.
885
+ """
886
+ if not store.schema.declares('EventLogEntry') or store.events_unknown is not None:
887
+ return
888
+ row = effects.root
889
+ kind = cast('str', row['type'])
890
+ if not effects.changed and kind != 'EventLogEntry':
891
+ return
892
+ actor = context.actor
893
+ if (
894
+ kind not in _LIFECYCLE_TYPES
895
+ or not effects.settled
896
+ or actor is None
897
+ or context.session_kind == 'human'
898
+ ):
899
+ operation = 'revive' if revival else 'delete'
900
+ _unsettled(store, f'{kind} {row["id"]} {operation} has unsettled event rows')
901
+ return
902
+ instant = effects.retired_at
903
+ stamp = (
904
+ instant.astimezone(UTC).strftime('%Y-%m-%d %H:%M:%S UTC') if instant is not None else None
905
+ )
906
+ _append(
907
+ store,
908
+ row,
909
+ {
910
+ 'type': 'attribute_change',
911
+ 'attribute_name': 'retirement_date',
912
+ 'entity_type': kind,
913
+ 'entity_id': row['id'],
914
+ 'old_value': stamp if revival else None,
915
+ 'new_value': None if revival else stamp,
916
+ },
917
+ context=context,
918
+ unknown={'meta'} if stamp is None else set(),
919
+ )
920
+ meta: Row = {
921
+ 'class_name': kind,
922
+ 'id': row['id'],
923
+ 'entity_type': kind,
924
+ 'entity_id': row['id'],
925
+ 'type': 'entity_revival' if revival else 'entity_retirement',
926
+ }
927
+ unknown: set[str] = set()
928
+ if not revival:
929
+ meta['retirement_date'] = stamp
930
+ if stamp is None:
931
+ unknown.add('meta')
932
+ try:
933
+ name = display_name(store, kind, cast('int', row['id']))
934
+ if name is None:
935
+ unknown.add('meta')
936
+ else:
937
+ meta['display_name'] = name
938
+ except MockgunError:
939
+ unknown.add('meta')
940
+ _append(store, row, meta, context=context, unknown=unknown)
941
+
942
+
943
+ def record_schema_write(
944
+ store: Store,
945
+ operation: str,
946
+ data_type: str,
947
+ properties: Sequence[Mapping[str, object]],
948
+ *,
949
+ context: RequestContext,
950
+ ) -> None:
951
+ """Append the observed text-field schema rows or taint the event log.
952
+
953
+ Args:
954
+ store: Site store receiving events or an event-log uncertainty marker.
955
+ operation: Schema operation: `create`, `update`, or `delete`.
956
+ data_type: Data type of the affected schema field.
957
+ properties: Wire property entries containing `property_name` and
958
+ `value`.
959
+ context: Validated request context with a resolved actor for an
960
+ event-producing operation.
961
+ """
962
+ if not store.schema.declares('EventLogEntry') or store.events_unknown is not None:
963
+ return
964
+ settled = (
965
+ data_type == 'text'
966
+ and context.session_kind == 'script'
967
+ and (
968
+ operation != 'update'
969
+ or [item.get('property_name') for item in properties] == ['description']
970
+ )
971
+ )
972
+ if not settled:
973
+ store.taint_events(f'schema_field_{operation} has unsettled event rows')
974
+ return
975
+ actor = context.actor
976
+ if actor is None:
977
+ raise MockgunError('schema event has no actor after session validation')
978
+ rows = {
979
+ 'create': [('Shotgun_DisplayColumn_New', None)],
980
+ 'update': [
981
+ ('Shotgun_DisplayColumn_Change', 'description'),
982
+ ('Shotgun_DisplayColumn_Change', 'properties'),
983
+ ],
984
+ 'delete': [
985
+ ('Shotgun_DisplayColumn_Change', 'retirement_date'),
986
+ ('Shotgun_DisplayColumn_Retirement', None),
987
+ ],
988
+ }[operation]
989
+ for event_type, attribute_name in rows:
990
+ store.append_event(
991
+ {
992
+ 'event_type': event_type,
993
+ 'attribute_name': attribute_name,
994
+ 'user': {'type': actor.entity_type, 'id': actor.entity_id},
995
+ 'project': None,
996
+ 'session_uuid': context.session_uuid,
997
+ 'cached_display_name': None,
998
+ 'image': None,
999
+ 'filmstrip_image': None,
1000
+ 'image_blur_hash': None,
1001
+ 'image_source_entity': None,
1002
+ },
1003
+ unknown={'entity', 'meta', 'created_at'},
1004
+ )
1005
+
1006
+
1007
+ def record_update(
1008
+ store: Store, before: Row, after: Row, supplied: Mapping[str, str], *, context: RequestContext
1009
+ ) -> bool:
1010
+ """Classify an update and write its events when the log is readable.
1011
+
1012
+ Args:
1013
+ store: Site records and event log.
1014
+ before: Detached record before the write.
1015
+ after: Record after the write.
1016
+ supplied: Fields mapped to their effective update modes.
1017
+ context: Request actor, time and session.
1018
+
1019
+ Returns:
1020
+ Whether the write has a settled event plan, even without an event log.
1021
+ """
1022
+ plans = _plan_update(store, before, after, supplied, context=context)
1023
+ if store.schema.declares('EventLogEntry') and store.events_unknown is None:
1024
+ if plans is None:
1025
+ _unsettled(
1026
+ store,
1027
+ f'{after["type"]} {after["id"]} update has unsettled event rows',
1028
+ )
1029
+ else:
1030
+ _write_plans(store, plans, context)
1031
+ return plans is not None