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
shotgun_api3/_version.py CHANGED
@@ -1,3 +1,8 @@
1
- """Package version shared by public exports and compatibility metadata."""
1
+ """Compatibility target reported by `__version__` and the package release version."""
2
2
 
3
- PACKAGE_VERSION = '0.1.2'
3
+ from importlib.metadata import distributions
4
+
5
+ COMPATIBILITY_VERSION = '3.10.5'
6
+ PACKAGE_VERSION = next(
7
+ (distribution.version for distribution in distributions(name='sg-python-api')), '0+unknown'
8
+ )
@@ -1,16 +1,29 @@
1
- """Provide in-memory sync and async Shotgun test doubles.
1
+ """Run real sync and async Shotgun clients against an in-memory fake site.
2
+
3
+ Site controls configure explicit preferences, a Monday-to-Sunday working week,
4
+ HumanUser subscriptions, and exact navigation/export responses or faults
5
+ through `set_preferences`, `set_work_week`, `set_subscriptions`,
6
+ `set_canned_response` and `set_canned_fault`. Inputs are detached under the
7
+ site lock. Unconfigured server-dependent values raise `MockgunError`.
2
8
 
3
9
  Configure the chosen client's `set_schema_paths()` with field-schema and
4
10
  entity-schema pickle files before constructing instances. Await
5
11
  `AsyncShotgun.set_schema_paths()` and `AsyncShotgun.get_schema_paths()`.
6
12
  `generate_schema()` can create the files from an existing client.
7
13
 
8
- Both clients operate without network connections. `AsyncShotgun` exposes
9
- awaitable methods over a synchronous in-memory delegate and does not require
10
- the real client's optional async transport.
14
+ Constructor credentials are validated by the real client; a client constructed
15
+ without any credentials acts as the `mockgun` script. Both clients operate
16
+ without network connections, and `AsyncShotgun` needs no optional async
17
+ transport extra. Use each client's `site` facade to control its clock,
18
+ readiness metadata, and maximum page size. Call `new_client()` with new
19
+ credentials to share the same site's schema and records. It is a plain method
20
+ on both clients; directly constructed clients have independent sites.
11
21
  """
12
22
 
13
- from ._async import AsyncShotgun
14
- from ._mockgun import MockgunError, Shotgun, generate_schema
23
+ from ._async_client import AsyncShotgun
24
+ from ._errors import MockgunError
25
+ from ._schema import generate_schema
26
+ from ._site import Site
27
+ from ._sync_client import Shotgun
15
28
 
16
- __all__ = ['AsyncShotgun', 'MockgunError', 'Shotgun', 'generate_schema']
29
+ __all__ = ['AsyncShotgun', 'MockgunError', 'Shotgun', 'Site', 'generate_schema']
@@ -0,0 +1,510 @@
1
+ """Record and render the observed activity stream forms."""
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from copy import deepcopy
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from ._errors import MockgunError
8
+ from ._links import display_name
9
+ from ._query import project_rows
10
+ from ._retirement import RetirementEffects
11
+ from ._sessions import RequestContext
12
+ from ._store import FieldValue, Row, Store
13
+ from ._wire import format_wire_datetime
14
+
15
+ if TYPE_CHECKING:
16
+ from datetime import datetime
17
+
18
+ _READABLE_TYPES = frozenset({'Asset', 'Note', 'Project', 'Shot', 'Task'})
19
+ _ACTOR_PARTS = 2
20
+ _AUDIT_LINKS = frozenset({'created_by', 'updated_by', 'user'})
21
+ _SILENT_FIELDS = {
22
+ 'Shot': frozenset(
23
+ {'description', 'code', 'sg_cut_in', 'sg_shot_type', 'sg_sequence', 'assets'}
24
+ ),
25
+ 'Task': frozenset({'start_date', 'est_in_mins', 'sg_status_list'}),
26
+ }
27
+
28
+
29
+ def _error(row: Row, reason: str) -> MockgunError:
30
+ """Name the stream record and the unobserved answer."""
31
+ return MockgunError(f'mockgun cannot activity_stream {row["type"]} {row["id"]}: {reason}')
32
+
33
+
34
+ def _earliest(
35
+ row: Row, entries: list[Row], kept: list[Row], shown: list[Row], minimum: int | None
36
+ ) -> int:
37
+ """Resolve only the observed lower-bound and limit header cases."""
38
+ if not shown:
39
+ return 0
40
+ lowest = cast('int', shown[-1]['id'])
41
+ if minimum is not None and any(cast('int', entry['id']) <= minimum for entry in entries):
42
+ if lowest != minimum + 1:
43
+ raise _error(row, 'min_id cut the scan with an unsettled earliest_update_id')
44
+ return lowest
45
+ return lowest if len(shown) < len(kept) else 0
46
+
47
+
48
+ def _human_status(store: Store, entry: Row, identity: int) -> FieldValue:
49
+ """Require O2 S1's known, unchanged creator status and null image."""
50
+ label = f'HumanUser {identity}'
51
+ if not store.schema.declares('HumanUser'):
52
+ raise MockgunError(f'{label} creator record is missing')
53
+ human = store.row('HumanUser', identity)
54
+ if human is None:
55
+ raise MockgunError(f'{label} creator record is missing')
56
+ store.check_known(human, 'image')
57
+ if human.get('image') is not None:
58
+ raise MockgunError(f'{label} image is unsettled')
59
+ if not store.schema.declares('HumanUser', 'sg_status_list'):
60
+ raise MockgunError(f'{label} sg_status_list is undeclared')
61
+ store.check_known(human, 'sg_status_list')
62
+ if 'actor_status' not in entry:
63
+ raise MockgunError(f'{label} sg_status_list at the write is unknown')
64
+ status = human.get('sg_status_list')
65
+ if status != entry['actor_status']:
66
+ raise MockgunError(f'{label} sg_status_list changed since the write')
67
+ return status
68
+
69
+
70
+ def _creator(store: Store, entry: Row, stream: Row) -> dict[str, object]:
71
+ """Render ApiUser creators and HumanUser creators with unchanged status and null image."""
72
+ actor = cast('list[FieldValue]', entry['actor'])
73
+ if len(actor) != _ACTOR_PARTS or actor[0] not in {'ApiUser', 'HumanUser'}:
74
+ raise _error(stream, f'entry {entry["id"]} absent created_by is unsettled')
75
+ kind, identity = cast('str', actor[0]), cast('int', actor[1])
76
+ try:
77
+ status = _human_status(store, entry, identity) if kind == 'HumanUser' else None
78
+ name = display_name(store, kind, identity)
79
+ except MockgunError as exc:
80
+ raise _error(stream, f'entry {entry["id"]} {exc}') from exc
81
+ if name is None:
82
+ raise _error(stream, f'entry {entry["id"]} {kind} {identity} display name is unmodeled')
83
+ return {'type': kind, 'id': identity, 'name': name, 'status': status, 'image': None}
84
+
85
+
86
+ def _entry_row(
87
+ store: Store, entry: Row, stream: Row, fields: dict[str, list[str]]
88
+ ) -> dict[str, object]:
89
+ """Render an entry only when its actor, source write and current primary are settled."""
90
+ if not entry['settled']:
91
+ raise _error(stream, f'entry {entry["id"]} has unsettled source event rows')
92
+ creator = _creator(store, entry, stream)
93
+ primary = cast('list[FieldValue]', entry['primary'])
94
+ kind, identity = cast('str', primary[0]), cast('int', primary[1])
95
+ row = store.require(kind, identity)
96
+ if row['__retired']:
97
+ raise _error(stream, f'entry {entry["id"]} primary {kind} {identity} is retired')
98
+ if stream['type'] == 'Project':
99
+ for link in cast('list[list[FieldValue]]', entry['streams']):
100
+ linked = store.require(cast('str', link[0]), cast('int', link[1]))
101
+ if linked['__retired']:
102
+ raise _error(stream, f'entry {entry["id"]} stream record {link} is retired')
103
+ if not store.schema.declares(kind, 'sg_status_list'):
104
+ raise _error(stream, f'{kind}.sg_status_list is undeclared')
105
+ try:
106
+ store.check_known(row, 'sg_status_list')
107
+ name = display_name(store, kind, identity)
108
+ selected = project_rows(store, store.schema, [row], fields.get(kind, []))[0]
109
+ except MockgunError as exc:
110
+ raise _error(stream, str(exc)) from exc
111
+ if name is None:
112
+ raise _error(stream, f'entry {entry["id"]} display name is unmodeled')
113
+ primary_entity: dict[str, object] = {
114
+ 'type': kind,
115
+ 'id': identity,
116
+ 'name': name,
117
+ 'status': row.get('sg_status_list'),
118
+ }
119
+ primary_entity.update({field: selected[field] for field in fields.get(kind, [])})
120
+ return {
121
+ 'id': entry['id'],
122
+ 'update_type': entry['update_type'],
123
+ 'meta': deepcopy(entry['meta']),
124
+ 'created_at': format_wire_datetime(cast('datetime', entry['created_at'])),
125
+ 'read': False,
126
+ 'primary_entity': primary_entity,
127
+ 'created_by': creator,
128
+ }
129
+
130
+
131
+ def _kept(candidates: list[Row]) -> list[Row]:
132
+ """Keep only the newest bounded candidate for each Note."""
133
+ result: list[Row] = []
134
+ seen: set[int] = set()
135
+ for entry in candidates:
136
+ note = cast('int | None', entry['note'])
137
+ if note is not None and note in seen:
138
+ continue
139
+ if note is not None:
140
+ seen.add(note)
141
+ result.append(entry)
142
+ return result
143
+
144
+
145
+ def _kept_actor_status(store: Store, context: RequestContext) -> Row:
146
+ """Keep only known HumanUser status in the journaled entry without blocking writes."""
147
+ actor = context.actor
148
+ if (
149
+ actor is None
150
+ or actor.entity_type != 'HumanUser'
151
+ or not store.schema.declares('HumanUser', 'sg_status_list')
152
+ ):
153
+ return {}
154
+ human = store.row('HumanUser', actor.entity_id)
155
+ if human is None:
156
+ return {}
157
+ try:
158
+ store.check_known(human, 'sg_status_list')
159
+ except MockgunError:
160
+ return {}
161
+ return {'actor_status': human.get('sg_status_list')}
162
+
163
+
164
+ def _key(row: Row) -> tuple[str, int]:
165
+ """Return a record's stream identity."""
166
+ return cast('str', row['type']), cast('int', row['id'])
167
+
168
+
169
+ def _selections(store: Store, raw: object) -> dict[str, list[str]]:
170
+ """Validate direct declared fields for each requested primary type."""
171
+ if not isinstance(raw, dict):
172
+ raise MockgunError('mockgun cannot activity_stream parameter entity_fields')
173
+ result: dict[str, list[str]] = {}
174
+ for kind, names in cast('dict[object, object]', raw).items():
175
+ if (
176
+ not isinstance(kind, str)
177
+ or not store.schema.declares(kind)
178
+ or not isinstance(names, list)
179
+ ):
180
+ raise MockgunError(f'mockgun cannot activity_stream entity_fields {kind}')
181
+ selected: list[str] = []
182
+ for field in cast('list[object]', names):
183
+ if (
184
+ not isinstance(field, str)
185
+ or '.' in field
186
+ or not store.schema.declares(kind, field)
187
+ ):
188
+ raise MockgunError(
189
+ f'mockgun cannot activity_stream {kind}.{field}: undeclared or deep field'
190
+ )
191
+ selected.append(field)
192
+ result[kind] = selected
193
+ return result
194
+
195
+
196
+ def _parameters(store: Store, params: Mapping[str, object]) -> dict[str, list[str]]:
197
+ """Require the exact client parameter shape and direct declared selections."""
198
+ required = {'type', 'id', 'max_id', 'min_id', 'limit', 'entity_fields'}
199
+ mismatch = required.symmetric_difference(params)
200
+ if mismatch:
201
+ raise MockgunError(f'mockgun cannot activity_stream parameter {min(mismatch)}')
202
+ for name in ('id', 'min_id', 'max_id', 'limit'):
203
+ value = params[name]
204
+ if value is None and name != 'id':
205
+ continue
206
+ if (
207
+ not isinstance(value, int)
208
+ or isinstance(value, bool)
209
+ or (name == 'limit' and value < 1)
210
+ ):
211
+ raise MockgunError(f'mockgun cannot activity_stream parameter {name}')
212
+ if not isinstance(params['type'], str):
213
+ raise MockgunError('mockgun cannot activity_stream parameter type')
214
+ return _selections(store, params['entity_fields'])
215
+
216
+
217
+ def _target(store: Store, link: dict[str, FieldValue]) -> Row | None:
218
+ """Resolve only declared targets so activity hooks preserve partial-schema writes."""
219
+ kind = cast('str', link['type'])
220
+ if not store.schema.declares(kind):
221
+ return None
222
+ return store.row(kind, cast('int', link['id']))
223
+
224
+
225
+ def _linked(store: Store, row: Row) -> list[Row]:
226
+ """Collect direct entity links except the three excluded actor fields."""
227
+ result: list[Row] = []
228
+ kind = cast('str', row['type'])
229
+ for field in store.schema.fields[kind]:
230
+ if field in _AUDIT_LINKS:
231
+ continue
232
+ data_type = store.schema.field_type(kind, field)
233
+ value = row.get(field)
234
+ values = value if data_type == 'multi_entity' and isinstance(value, list) else [value]
235
+ if data_type not in {'entity', 'multi_entity'}:
236
+ continue
237
+ for link in values:
238
+ if isinstance(link, dict):
239
+ target = _target(store, link)
240
+ if target is not None:
241
+ result.append(target)
242
+ return result
243
+
244
+
245
+ def _primary(store: Store, row: Row) -> Row:
246
+ """Map a Reply write onto its Note stream record."""
247
+ parent = row.get('entity')
248
+ if row['type'] == 'Reply' and isinstance(parent, dict) and parent.get('type') == 'Note':
249
+ note = _target(store, parent)
250
+ if note is not None:
251
+ return note
252
+ return row
253
+
254
+
255
+ def _with_projects(store: Store, rows: list[Row]) -> dict[tuple[str, int], Row]:
256
+ """Include each record's current Project without traversing other links."""
257
+ result = {_key(row): row for row in rows}
258
+ for row in rows:
259
+ project = row.get('project')
260
+ if isinstance(project, dict) and project.get('type') == 'Project':
261
+ target = _target(store, project)
262
+ if target is not None:
263
+ result[_key(target)] = target
264
+ return result
265
+
266
+
267
+ def _affected(store: Store, row: Row) -> dict[tuple[str, int], Row]:
268
+ """Collect a write's primary, direct links, and their current Projects."""
269
+ primary = _primary(store, row)
270
+ rows = [primary, *_linked(store, row)]
271
+ if primary is not row:
272
+ rows.extend(_linked(store, primary))
273
+ return {
274
+ key: target
275
+ for key, target in _with_projects(store, rows).items()
276
+ if key[0] in _READABLE_TYPES
277
+ }
278
+
279
+
280
+ def _streams(store: Store, row: Row) -> list[list[FieldValue]]:
281
+ """Capture the observed create audience and its Projects at write time."""
282
+ rows = [row]
283
+ if row['type'] == 'Task':
284
+ links = [row.get('entity')]
285
+ elif row['type'] == 'Note':
286
+ links = cast('list[FieldValue]', row.get('note_links', []))
287
+ else:
288
+ links = []
289
+ for link in links:
290
+ if isinstance(link, dict) and link.get('type') in {'Shot', 'Asset'}:
291
+ target = _target(store, link)
292
+ if target is not None:
293
+ rows.append(target)
294
+ return [[kind, identity] for kind, identity in _with_projects(store, rows)]
295
+
296
+
297
+ def _write_entry(
298
+ store: Store, row: Row, entry: Row, context: RequestContext, *, before: Row | None = None
299
+ ) -> None:
300
+ """Journal an entry and taint linked readable records outside its observed audience."""
301
+ primary = _primary(store, row)
302
+ streams = _streams(store, primary)
303
+ actor = context.actor
304
+ identity = store.next_side_id('activity')
305
+ store.side_set(
306
+ 'activity',
307
+ (identity,),
308
+ {
309
+ 'id': identity,
310
+ **entry,
311
+ **_kept_actor_status(store, context),
312
+ 'created_at': context.now,
313
+ 'actor': [actor.entity_type, actor.entity_id] if actor else [],
314
+ 'primary': [primary['type'], primary['id']],
315
+ 'streams': cast('list[FieldValue]', streams),
316
+ 'note': primary['id'] if primary['type'] == 'Note' else None,
317
+ },
318
+ )
319
+ included = {tuple(link) for link in streams}
320
+ affected = _affected(store, row)
321
+ if before is not None:
322
+ affected.update(_affected(store, before))
323
+ for key in affected:
324
+ if key not in included:
325
+ store.side_set(
326
+ 'activity_taint',
327
+ key,
328
+ f'{row["type"]} {row["id"]} {entry["update_type"]}: linked activity is unsettled',
329
+ )
330
+
331
+
332
+ def read_stream(
333
+ store: Store, params: Mapping[str, object], *, context: RequestContext
334
+ ) -> dict[str, object]:
335
+ """Read the bounded activity view for a declared readable record.
336
+
337
+ Args:
338
+ store: Site records and journaled activity history.
339
+ params: Exact client wire parameters, including bounds and field
340
+ selections.
341
+ context: Request context captured for this RPC.
342
+
343
+ Returns:
344
+ An unwrapped stream envelope with newest-first updates and observed
345
+ headers.
346
+
347
+ Raises:
348
+ MockgunError: If the record, view, source entry or rendering is
349
+ unsettled.
350
+ """
351
+ del context
352
+ fields = _parameters(store, params)
353
+ kind, identity = cast('str', params['type']), cast('int', params['id'])
354
+ if not store.schema.declares(kind) or kind not in _READABLE_TYPES:
355
+ raise MockgunError(f'mockgun cannot activity_stream {kind} {identity}: unreadable type')
356
+ row = store.row(kind, identity)
357
+ if row is None:
358
+ raise MockgunError(f'mockgun cannot activity_stream {kind} {identity}: missing record')
359
+ taint = store.side_get('activity_taint', (kind, identity))
360
+ if taint is not None:
361
+ raise _error(row, str(taint))
362
+ entries = [
363
+ cast('Row', value)
364
+ for _, value in store.side_items('activity')
365
+ if [kind, identity] in cast('list[list[FieldValue]]', cast('Row', value)['streams'])
366
+ ]
367
+ minimum, maximum = cast('int | None', params['min_id']), cast('int | None', params['max_id'])
368
+ candidates = [
369
+ entry
370
+ for entry in entries
371
+ if (minimum is None or cast('int', entry['id']) > minimum)
372
+ and (maximum is None or cast('int', entry['id']) < maximum)
373
+ ]
374
+ candidates.sort(key=lambda entry: cast('int', entry['id']), reverse=True)
375
+ if not candidates:
376
+ raise _error(row, 'no candidates: activity headers are unsettled')
377
+ kept = _kept(candidates)
378
+ shown = [] if row['__retired'] else kept[: cast('int | None', params['limit'])]
379
+ return {
380
+ 'entity_type': kind,
381
+ 'entity_id': identity,
382
+ 'latest_update_id': candidates[0]['id'],
383
+ 'earliest_update_id': _earliest(row, entries, kept, shown, minimum),
384
+ 'updates': [_entry_row(store, entry, row, fields) for entry in shown],
385
+ }
386
+
387
+
388
+ def taint_activity(store: Store, records: Iterable[Row], reason: str) -> None:
389
+ """Mark every affected readable stream as unsettled.
390
+
391
+ Args:
392
+ store: Site records and journaled side tables.
393
+ records: Written records, including before and after snapshots when
394
+ applicable.
395
+ reason: Diagnostic naming the write whose activity is unobserved.
396
+ """
397
+ for row in records:
398
+ for key in _affected(store, row):
399
+ store.side_set('activity_taint', key, reason)
400
+
401
+
402
+ def record_create_activity(
403
+ store: Store, row: Row, *, settled: bool, context: RequestContext
404
+ ) -> None:
405
+ """Record observed entity or Reply creates and taint every other create.
406
+
407
+ Args:
408
+ store: Site records and journaled side tables.
409
+ row: Newly created record.
410
+ settled: Whether this operation's event planner returned a complete
411
+ plan.
412
+ context: Actor and instant captured for the write.
413
+ """
414
+ kind = row['type']
415
+ if kind not in {'Asset', 'Shot', 'Task', 'Note'} and not (
416
+ kind == 'Reply' and _primary(store, row)['type'] == 'Note'
417
+ ):
418
+ taint_activity(store, [row], f'create {kind} {row["id"]}: activity is unsettled')
419
+ return
420
+ meta: Row = {'type': 'new_entity', 'entity_type': kind, 'entity_id': row['id']}
421
+ if kind == 'Reply':
422
+ meta['content'] = row.get('content')
423
+ elif context.sudo and context.actual is not None:
424
+ actual = context.actual
425
+ meta['sudo_actual_user'] = {
426
+ 'type': actual.entity_type,
427
+ 'id': actual.entity_id,
428
+ 'name': display_name(store, actual.entity_type, actual.entity_id),
429
+ 'valid': 'valid',
430
+ 'uuid': store.link_uuid(actual.entity_type, actual.entity_id),
431
+ }
432
+ _write_entry(
433
+ store,
434
+ row,
435
+ {
436
+ 'update_type': 'create_reply' if kind == 'Reply' else 'create',
437
+ 'meta': meta,
438
+ 'settled': settled,
439
+ },
440
+ context,
441
+ )
442
+
443
+
444
+ def record_lifecycle_activity(
445
+ store: Store, effects: RetirementEffects, *, context: RequestContext
446
+ ) -> None:
447
+ """Keep Shot lifecycles silent and taint other lifecycle audiences.
448
+
449
+ Args:
450
+ store: Site records and journaled side tables.
451
+ effects: Root, cascaded, touched and tainted rows returned by the
452
+ lifecycle.
453
+ context: Request context captured for the write.
454
+ """
455
+ del context
456
+ if not effects.changed or effects.root['type'] == 'Shot':
457
+ return
458
+ reason = (
459
+ f'retirement or revival {effects.root["type"]} {effects.root["id"]}: activity is unsettled'
460
+ )
461
+ taint_activity(
462
+ store, [effects.root, *effects.cascaded, *effects.touched, *effects.tainted], reason
463
+ )
464
+
465
+
466
+ def record_update_activity(
467
+ store: Store, before: Row, after: Row, *, settled: bool, context: RequestContext
468
+ ) -> None:
469
+ """Record Shot and Asset status changes while guarding unobserved changed fields.
470
+
471
+ Args:
472
+ store: Site records and journaled side tables.
473
+ before: Detached record before this operation.
474
+ after: Updated record including side effects.
475
+ settled: Whether this operation's event planner returned a complete
476
+ plan.
477
+ context: Actor and instant captured for the write.
478
+ """
479
+ kind = cast('str', after['type'])
480
+ changed = {
481
+ field for field in store.schema.fields[kind] if before.get(field) != after.get(field)
482
+ } - {'updated_at', 'updated_by'}
483
+ status = kind in {'Shot', 'Asset'} and 'sg_status_list' in changed
484
+ if status:
485
+ meta: Row = {
486
+ 'type': 'attribute_change',
487
+ 'attribute_name': 'sg_status_list',
488
+ 'entity_type': kind,
489
+ 'entity_id': after['id'],
490
+ 'field_data_type': 'status_list',
491
+ 'old_value': before.get('sg_status_list'),
492
+ 'new_value': after.get('sg_status_list'),
493
+ }
494
+ _write_entry(
495
+ store,
496
+ after,
497
+ {'update_type': 'update', 'meta': meta, 'settled': settled},
498
+ context,
499
+ before=before,
500
+ )
501
+ other = (
502
+ changed - _SILENT_FIELDS.get(kind, frozenset()) - ({'sg_status_list'} if status else set())
503
+ )
504
+ if other:
505
+ taint_activity(
506
+ store,
507
+ [before, after],
508
+ f'update {kind} {after["id"]} fields {", ".join(sorted(other))}: '
509
+ 'activity is unsettled',
510
+ )