sg-python-api 0.2.0__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.
@@ -5,7 +5,7 @@ from dataclasses import dataclass
5
5
  from typing import cast
6
6
 
7
7
  from ._errors import MockgunError
8
- from ._store import FieldValue, Row, Store
8
+ from ._store import CONNECTION_FIELDS, FieldValue, Row, Store
9
9
 
10
10
 
11
11
  @dataclass(frozen=True)
@@ -43,19 +43,28 @@ _DISPLAY_FIELDS = {
43
43
  'ApiUser': ('firstname', 'lastname'),
44
44
  }
45
45
  _DISPLAY_LIMIT = 255
46
+ # Fields the site keeps in NoteLink and NoteTask records, which mockgun does not
47
+ # store; a retired holder hides their members (2026-11-10 live comparison).
48
+ _IMPLICIT_CONNECTIONS = {
49
+ 'Note': frozenset({'note_links', 'tasks'}),
50
+ 'Shot': frozenset({'notes'}),
51
+ }
46
52
  _RETIREMENT_EDGES = {
53
+ # 2026-11-10: Attachment and Note links hide; neither end is touched or logged.
47
54
  ('Attachment', 'attachment_links', 'Note'): _RetirementRule(
48
- 'keep', 'always', touched=False, settled=False
55
+ 'hide', 'always', touched=False, settled=True
49
56
  ),
50
57
  ('Attachment', 'attachment_links', 'Version'): _RetirementRule(
51
- 'keep', 'always', touched=False, settled=False
58
+ 'hide', 'always', touched=False, settled=True
52
59
  ),
53
60
  ('Note', 'attachments', 'Attachment'): _RetirementRule(
54
- 'keep', 'always', touched=False, settled=False
61
+ 'hide', 'always', touched=False, settled=True
55
62
  ),
56
- ('Task', 'entity', 'Shot'): _RetirementRule('cascade', 'always', touched=False, settled=False),
63
+ # 2026-11-10: a live Task whose Shot is revived gains no event rows.
64
+ ('Task', 'entity', 'Shot'): _RetirementRule('cascade', 'always', touched=False, settled=True),
65
+ # 2026-11-10: a live Version whose Shot is revived gains no event rows.
57
66
  ('Version', 'entity', 'Shot'): _RetirementRule(
58
- 'cascade', 'always', touched=False, settled=False
67
+ 'cascade', 'always', touched=False, settled=True
59
68
  ),
60
69
  ('Task', 'entity', 'Asset'): _RetirementRule(
61
70
  'cascade', 'always', touched=False, settled=False
@@ -108,8 +117,13 @@ _RETIREMENT_EDGES = {
108
117
  ),
109
118
  ('CutItem', 'shot', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=True),
110
119
  ('PublishedFile', 'entity', 'Asset'): _RetirementRule(
111
- 'hide', 'always', touched=False, settled=False
120
+ 'hide', 'always', touched=False, settled=True
112
121
  ),
122
+ ('PublishedFile', 'entity', 'Sequence'): _RetirementRule(
123
+ 'hide', 'always', touched=False, settled=True
124
+ ),
125
+ ('TimeLog', 'entity', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=True),
126
+ ('TimeLog', 'entity', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
113
127
  ('Shot', 'sg_sequence', 'Sequence'): _RetirementRule(
114
128
  'hide', 'always', touched=False, settled=True
115
129
  ),
@@ -122,10 +136,11 @@ _RETIREMENT_EDGES = {
122
136
  ),
123
137
  ('Version', 'sg_task', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
124
138
  ('PublishedFile', 'task', 'Task'): _RetirementRule(
125
- 'hide', 'always', touched=False, settled=False
139
+ 'hide', 'always', touched=False, settled=True
126
140
  ),
127
141
  ('Sequence', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=False),
128
- ('Shot', 'tasks', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=False),
142
+ # 2026-11-10: retiring or reviving a Task alone adds no row for its Shot.
143
+ ('Shot', 'tasks', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
129
144
  ('Shot', 'notes', 'Note'): _RetirementRule('hide', 'always', touched=False, settled=False),
130
145
  ('Version', 'published_files', 'PublishedFile'): _RetirementRule(
131
146
  'hide', 'always', touched=False, settled=False
@@ -143,7 +158,27 @@ _RETIREMENT_EDGES = {
143
158
  ('Asset', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
144
159
  ('Shot', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
145
160
  ('Asset', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=True, settled=False),
146
- ('CutItem', 'cut', 'Cut'): _RetirementRule('keep', 'always', touched=False, settled=False),
161
+ # 2026-11-10: a CutItem outlives its Cut with the link hidden and no rows.
162
+ ('CutItem', 'cut', 'Cut'): _RetirementRule('hide', 'always', touched=False, settled=True),
163
+ # 2026-11-10: a connection record retires with either end and revives with it.
164
+ ('AssetShotConnection', 'shot', 'Shot'): _RetirementRule(
165
+ 'cascade', 'always', touched=False, settled=False
166
+ ),
167
+ ('AssetShotConnection', 'asset', 'Asset'): _RetirementRule(
168
+ 'cascade', 'always', touched=False, settled=False
169
+ ),
170
+ ('AssetAssetConnection', 'parent', 'Asset'): _RetirementRule(
171
+ 'cascade', 'always', touched=False, settled=False
172
+ ),
173
+ ('AssetAssetConnection', 'asset', 'Asset'): _RetirementRule(
174
+ 'cascade', 'always', touched=False, settled=False
175
+ ),
176
+ ('PlaylistVersionConnection', 'version', 'Version'): _RetirementRule(
177
+ 'cascade', 'always', touched=False, settled=False
178
+ ),
179
+ ('PlaylistVersionConnection', 'playlist', 'Playlist'): _RetirementRule(
180
+ 'cascade', 'always', touched=False, settled=False
181
+ ),
147
182
  }
148
183
  _NOTE_COMBINED_LIMIT = 83
149
184
  _NOTE_SUBJECT_LIMIT = 120
@@ -154,8 +189,8 @@ class RetirementEdge:
154
189
  """Describe an immutable link retirement effect and its completeness.
155
190
 
156
191
  Attributes:
157
- effect: `hide`, `keep`, `cascade`, or `taint`, describing the effect on
158
- the holder when the target is retired.
192
+ effect: `hide`, `cascade`, or `taint`, describing the effect on the
193
+ holder when the target is retired.
159
194
  touched: Whether hiding the link affects the holder's update audit
160
195
  fields.
161
196
  settled: Whether the edge's lifecycle-event effects are fully modeled.
@@ -195,26 +230,23 @@ def _listed(holder: Row, field_name: str, link: Mapping[str, FieldValue]) -> boo
195
230
  )
196
231
 
197
232
 
198
- def _note_edge(store: Store, holder: Row, target: Row) -> RetirementEdge:
199
- """Resolve the two observed Note.note_links shapes."""
200
- links = cast('list[dict[str, FieldValue]]', holder.get('note_links', []))
201
- if holder.get('tasks'):
202
- return RetirementEdge('taint')
203
- if len(links) == 1:
204
- return RetirementEdge('cascade')
205
- others = [
206
- link for link in links if (link['type'], link['id']) != (target['type'], target['id'])
207
- ]
208
- if (
209
- len(others) == 1
210
- and len(links) == len(others) + 1
211
- and target['type'] == 'Shot'
212
- and others[0]['type'] == 'Asset'
213
- ):
214
- other = store.row('Asset', cast('int', others[0]['id']))
215
- if other is not None and not other['__retired'] and not store.tainted(other):
216
- return RetirementEdge('hide')
217
- return RetirementEdge('taint')
233
+ def _retired_target(target: Row, assume: Mapping[tuple[str, int], RecordState] | None) -> bool:
234
+ """Return a target's assumed or settled retirement, rejecting an unsettled one."""
235
+ state = (
236
+ assume.get((cast('str', target['type']), cast('int', target['id']))) if assume else None
237
+ )
238
+ if state is not None:
239
+ return state.retired
240
+ taint = cast('dict[str, FieldValue]', target.get('__taint', {}))
241
+ if taint and not taint.get('active'):
242
+ raise MockgunError(f'{target["type"]} {target["id"]}: {taint["reason"]}')
243
+ return bool(target['__retired'])
244
+
245
+
246
+ def _uncertain(row: Row) -> bool:
247
+ """Return whether a record's own retirement state is unsettled."""
248
+ taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
249
+ return bool(taint) and not taint.get('active')
218
250
 
219
251
 
220
252
  def _unsettled(entity_type: str, fields: tuple[str, ...]) -> MockgunError:
@@ -293,6 +325,45 @@ def _display(store: Store, entity_type: str, entity_id: int) -> tuple[str | None
293
325
  return name, derived
294
326
 
295
327
 
328
+ def connection_backed(holder: Row, field_name: str, target: Row) -> bool:
329
+ """Return whether the site keeps a link's membership in a connection record.
330
+
331
+ A retired holder hides such a member while its connection record is
332
+ retired, even when the target is active.
333
+ """
334
+ kind = cast('str', holder['type'])
335
+ return (kind, field_name, cast('str', target['type'])) in CONNECTION_FIELDS or (
336
+ field_name in _IMPLICIT_CONNECTIONS.get(kind, frozenset())
337
+ )
338
+
339
+
340
+ def connection_record(store: Store, holder: Row, field_name: str, target: Row) -> Row | None:
341
+ """Return the stored connection record behind one member, retired or not.
342
+
343
+ Returns:
344
+ The live stored connection row, or `None` when the schema declares no
345
+ such record or none joins the two records.
346
+ """
347
+ definition = CONNECTION_FIELDS.get(
348
+ (cast('str', holder['type']), field_name, cast('str', target['type']))
349
+ )
350
+ if definition is None:
351
+ return None
352
+ connection_type, holder_side, target_side = definition
353
+ if not (
354
+ store.schema.declares(connection_type, holder_side)
355
+ and store.schema.declares(connection_type, target_side)
356
+ ):
357
+ return None
358
+ return store.connection_row(
359
+ connection_type,
360
+ {
361
+ holder_side: {'type': holder['type'], 'id': holder['id']},
362
+ target_side: {'type': target['type'], 'id': target['id']},
363
+ },
364
+ )
365
+
366
+
296
367
  def derived_name(store: Store, entity_type: str, entity_id: int) -> bool:
297
368
  """Return whether a modeled name uses Task fallback or Note truncation.
298
369
 
@@ -311,37 +382,99 @@ def display_name(store: Store, entity_type: str, entity_id: int) -> str | None:
311
382
  return _display(store, entity_type, entity_id)[0]
312
383
 
313
384
 
314
- def named_link(store: Store, link: Mapping[str, FieldValue]) -> dict[str, FieldValue]:
315
- """Return a detached identity with a name when the target is modeled.
385
+ def implicit_connection_fields(entity_type: str) -> frozenset[str]:
386
+ """Return a type's connection-backed fields whose records mockgun does not store."""
387
+ return _IMPLICIT_CONNECTIONS.get(entity_type, frozenset())
316
388
 
317
- Raises:
318
- MockgunError: If the target's display name is unsettled.
389
+
390
+ def implicit_link_hidden(holder: Row, field_name: str, target: Row) -> bool:
391
+ """Return whether a retired holder hides an active NoteLink or NoteTask member.
392
+
393
+ The internal record stays retired with the holder, unless the target
394
+ retired first and so revived it on its own revival (2026-11-10).
319
395
  """
320
- result = {'type': link['type'], 'id': link['id']}
321
- name = display_name(store, cast('str', link['type']), cast('int', link['id']))
322
- if name is not None:
323
- result['name'] = name
324
- return result
396
+ member: FieldValue = [field_name, target['type'], target['id']]
397
+ return member not in cast('list[FieldValue]', holder.get('__retired_hidden', []))
325
398
 
326
399
 
327
- def retirement_edge(store: Store, holder: Row, field_name: str, target: Row) -> RetirementEdge:
328
- """Resolve a retirement edge, including its observed holder conditions."""
329
- kind = cast('str', holder['type'])
330
- target_type = cast('str', target['type'])
331
- if kind == 'EventLogEntry' and field_name == 'entity':
332
- return RetirementEdge('hide')
333
- rule = _RETIREMENT_EDGES.get((kind, field_name, target_type))
334
- if rule is None or (target_type == 'Project' and target.get('archived')):
335
- return RetirementEdge('taint')
336
- if rule.condition == 'note_links':
337
- return _note_edge(store, holder, target)
338
- if rule.condition == 'tasks' and (
339
- holder.get('note_links') or len(cast('list[FieldValue]', holder.get('tasks', []))) != 1
400
+ def _retired_holder_hidden(
401
+ store: Store,
402
+ holder: Row,
403
+ field_name: str,
404
+ target: Row,
405
+ assume: Mapping[tuple[str, int], RecordState] | None,
406
+ ) -> bool:
407
+ """Resolve a retired holder's link from its target and any connection record."""
408
+ if _retired_target(target, assume):
409
+ return True
410
+ if not connection_backed(holder, field_name, target):
411
+ return False
412
+ record = connection_record(store, holder, field_name, target)
413
+ if record is not None:
414
+ return _retired_target(record, assume)
415
+ return implicit_link_hidden(holder, field_name, target)
416
+
417
+
418
+ def link_target(store: Store, link: Mapping[str, FieldValue]) -> Row | None:
419
+ """Return the stored record a link names, including a retired one.
420
+
421
+ Returns:
422
+ The live stored row, or `None` when the link is malformed, names an
423
+ undeclared entity type, or names a missing record.
424
+ """
425
+ kind, identity = link.get('type'), link.get('id')
426
+ if (
427
+ not isinstance(kind, str)
428
+ or not isinstance(identity, int)
429
+ or not store.schema.declares(kind)
340
430
  ):
431
+ return None
432
+ return store.row(kind, identity)
433
+
434
+
435
+ def _live(store: Store, link: Mapping[str, FieldValue]) -> bool:
436
+ """Return whether a link names an existing active record with settled state."""
437
+ row = link_target(store, link)
438
+ return row is not None and not row['__retired'] and not store.tainted(row)
439
+
440
+
441
+ def _chained_task(store: Store, holder: Row, target: Row) -> bool:
442
+ """Identify a Note linked to a Shot and to one live Task of that Shot."""
443
+ links = cast('list[dict[str, FieldValue]]', holder.get('note_links', []))
444
+ tasks = cast('list[dict[str, FieldValue]]', holder.get('tasks', []))
445
+ identity = {'type': target['type'], 'id': target['id']}
446
+ if target['type'] != 'Shot' or len(tasks) != 1 or links != [identity]:
447
+ return False
448
+ task = link_target(store, tasks[0])
449
+ return task is not None and task.get('entity') == identity and _live(store, tasks[0])
450
+
451
+
452
+ def _note_edge(store: Store, holder: Row, target: Row) -> RetirementEdge:
453
+ """Resolve the observed Note.note_links shapes, including the Shot-Task chain."""
454
+ links = cast('list[dict[str, FieldValue]]', holder.get('note_links', []))
455
+ if holder.get('tasks'):
456
+ # A Note created for a Task also links the Task's Shot; both retire with the Shot.
457
+ return RetirementEdge('cascade' if _chained_task(store, holder, target) else 'taint')
458
+ if len(links) == 1:
459
+ return RetirementEdge('cascade')
460
+ others = [
461
+ link for link in links if (link['type'], link['id']) != (target['type'], target['id'])
462
+ ]
463
+ # 2026-11-10: a two-link Note survives either link's retirement while the other is live.
464
+ if len(others) == 1 and len(links) == len(others) + 1 and _live(store, others[0]):
465
+ return RetirementEdge('hide')
466
+ return RetirementEdge('taint')
467
+
468
+
469
+ def _task_note_edge(store: Store, holder: Row, rule: _RetirementRule) -> RetirementEdge:
470
+ """Resolve a Note's single Task link by whether a live note_links target remains."""
471
+ links = cast('list[dict[str, FieldValue]]', holder.get('note_links', []))
472
+ if len(cast('list[FieldValue]', holder.get('tasks', []))) != 1:
341
473
  return RetirementEdge('taint')
342
- if rule.condition == 'note_project' and (holder.get('note_links') or holder.get('tasks')):
343
- return RetirementEdge('taint')
344
- return RetirementEdge(rule.effect, rule.touched, rule.settled)
474
+ if not links:
475
+ return RetirementEdge(rule.effect, rule.touched, rule.settled)
476
+ # 2026-11-10: a Note keeping a live note_links target survives its Task.
477
+ return RetirementEdge('hide' if all(_live(store, link) for link in links) else 'taint')
345
478
 
346
479
 
347
480
  def deciding_record(
@@ -356,29 +489,21 @@ def deciding_record(
356
489
  link: Target identity with `type` and `id`.
357
490
 
358
491
  Returns:
359
- The live holder or target row whose state decides link visibility, or
360
- `None` when no modeled uncertainty requires a deciding record.
492
+ The holder, target or connection row whose state decides link
493
+ visibility, or `None` when no modeled uncertainty requires a deciding
494
+ record.
361
495
  """
362
- if (
363
- not isinstance(link.get('type'), str)
364
- or not isinstance(link.get('id'), int)
365
- or not store.schema.declares(cast('str', link['type']))
366
- ):
496
+ target = link_target(store, link)
497
+ if target is None or (target['type'], target['id']) == (holder['type'], holder['id']):
367
498
  return None
368
499
  if _listed(holder, field_name, link):
369
500
  return holder
370
- target = store.row(cast('str', link['type']), cast('int', link['id']))
371
- if target is None:
372
- return None
373
- edge = retirement_edge(store, holder, field_name, target)
374
- if edge.effect == 'keep' and target['__retired']:
375
- return holder
376
- if (
377
- edge.effect == 'hide'
378
- and store.tainted(target)
379
- and not cast('dict[str, FieldValue]', target['__taint']).get('active')
380
- ):
501
+ if _uncertain(target):
381
502
  return target
503
+ if holder['__retired'] and connection_backed(holder, field_name, target):
504
+ record = connection_record(store, holder, field_name, target)
505
+ if record is not None and _uncertain(record):
506
+ return record
382
507
  return None
383
508
 
384
509
 
@@ -392,6 +517,11 @@ def hidden(
392
517
  ) -> bool:
393
518
  """Resolve observed link hiding under optional record-state assumptions.
394
519
 
520
+ A link is hidden while its target is retired, whether the holder is active
521
+ or retired. A retired holder also hides a connection-backed member while
522
+ the connection record is retired. A link to the holder itself stays
523
+ visible.
524
+
395
525
  Args:
396
526
  store: Site records used to resolve link targets.
397
527
  holder: Stored record containing the outgoing link.
@@ -402,39 +532,67 @@ def hidden(
402
532
 
403
533
  Returns:
404
534
  Whether the link is hidden under the observed rules and supplied
405
- assumptions. Retired holders retain their links.
535
+ assumptions.
406
536
 
407
537
  Raises:
408
538
  MockgunError: If visibility depends on an uncertain record for which no
409
539
  explicit state was supplied.
410
540
  """
541
+ target = link_target(store, link)
542
+ if target is None or (target['type'], target['id']) == (holder['type'], holder['id']):
543
+ return False
411
544
  identity = (cast('str', holder['type']), cast('int', holder['id']))
412
545
  holder_state = assume.get(identity) if assume is not None else None
413
- if holder_state.retired if holder_state is not None else holder['__retired']:
414
- return False
546
+ retired = holder_state.retired if holder_state is not None else bool(holder['__retired'])
415
547
  deciding = deciding_record(store, holder, field_name, link)
416
548
  if deciding is not None:
417
549
  key = (cast('str', deciding['type']), cast('int', deciding['id']))
418
550
  state = assume.get(key) if assume is not None else None
419
551
  if state is None:
420
- reason = store.tainted(deciding) or f'retired {link["type"]} {link["id"]} keep link'
421
- raise MockgunError(f'{holder["type"]} {holder["id"]}.{field_name}: {reason}')
552
+ raise MockgunError(
553
+ f'{holder["type"]} {holder["id"]}.{field_name}: {store.tainted(deciding)}'
554
+ )
422
555
  if deciding is holder:
423
- return (field_name, cast('str', link['type']), cast('int', link['id'])) in state.hidden
424
- return state.retired
425
- if (
426
- not isinstance(link.get('type'), str)
427
- or not isinstance(link.get('id'), int)
428
- or not store.schema.declares(cast('str', link['type']))
429
- ):
430
- return False
431
- target = store.row(cast('str', link['type']), cast('int', link['id']))
432
- if target is None:
433
- return False
434
- edge = retirement_edge(store, holder, field_name, target)
435
- hides = edge.effect == 'hide' or (holder['type'], field_name, target['type']) == (
436
- 'Version',
437
- 'entity',
438
- 'Shot',
439
- )
440
- return hides and bool(target['__retired'])
556
+ if not state.retired:
557
+ return (
558
+ field_name,
559
+ cast('str', link['type']),
560
+ cast('int', link['id']),
561
+ ) in state.hidden
562
+ retired = True
563
+ elif not retired:
564
+ return state.retired
565
+ if retired:
566
+ return _retired_holder_hidden(store, holder, field_name, target, assume)
567
+ return _retired_target(target, assume)
568
+
569
+
570
+ def named_link(store: Store, link: Mapping[str, FieldValue]) -> dict[str, FieldValue]:
571
+ """Return a detached identity with a name when the target is modeled.
572
+
573
+ Raises:
574
+ MockgunError: If the target's display name is unsettled.
575
+ """
576
+ result = {'type': link['type'], 'id': link['id']}
577
+ name = display_name(store, cast('str', link['type']), cast('int', link['id']))
578
+ if name is not None:
579
+ result['name'] = name
580
+ return result
581
+
582
+
583
+ def retirement_edge(store: Store, holder: Row, field_name: str, target: Row) -> RetirementEdge:
584
+ """Resolve a retirement edge, including its observed holder conditions."""
585
+ kind = cast('str', holder['type'])
586
+ target_type = cast('str', target['type'])
587
+ if kind == 'EventLogEntry' and field_name == 'entity':
588
+ return RetirementEdge('hide')
589
+ rule = _RETIREMENT_EDGES.get((kind, field_name, target_type))
590
+ if rule is None:
591
+ return RetirementEdge('taint')
592
+ if rule.condition == 'note_links':
593
+ return _note_edge(store, holder, target)
594
+ if rule.condition == 'tasks':
595
+ return _task_note_edge(store, holder, rule)
596
+ if rule.condition == 'note_project' and (holder.get('note_links') or holder.get('tasks')):
597
+ return RetirementEdge('taint')
598
+ return RetirementEdge(rule.effect, rule.touched, rule.settled)