sg-python-api 0.2.0__tar.gz → 0.2.1__tar.gz

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 (59) hide show
  1. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/PKG-INFO +14 -14
  2. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/pyproject.toml +1 -1
  3. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_events.py +25 -4
  4. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_handlers.py +1 -6
  5. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_links.py +256 -98
  6. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_query.py +79 -37
  7. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_retirement.py +124 -29
  8. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_store.py +153 -40
  9. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_summaries.py +18 -17
  10. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_threads.py +4 -4
  11. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/.gitignore +0 -0
  12. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/LICENSE +0 -0
  13. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/README.md +0 -0
  14. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/__init__.py +0 -0
  15. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_client.py +0 -0
  16. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_driver.py +0 -0
  17. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_httpx2.py +0 -0
  18. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_transport.py +0 -0
  19. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_capabilities.py +0 -0
  20. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_client.py +0 -0
  21. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_config.py +0 -0
  22. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_connections.py +0 -0
  23. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_crud.py +0 -0
  24. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_driver.py +0 -0
  25. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_errors.py +0 -0
  26. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_execution.py +0 -0
  27. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_files.py +0 -0
  28. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_methods.py +0 -0
  29. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_protocol.py +0 -0
  30. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_sentinel.py +0 -0
  31. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_sg_timezone.py +0 -0
  32. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_transport.py +0 -0
  33. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_version.py +0 -0
  34. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/__init__.py +0 -0
  35. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/__init__.py +0 -0
  36. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_activity.py +0 -0
  37. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_async_client.py +0 -0
  38. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_dates.py +0 -0
  39. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_errors.py +0 -0
  40. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_follows.py +0 -0
  41. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_new_client.py +0 -0
  42. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_presets.py +0 -0
  43. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_schema.py +0 -0
  44. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_schema_writes.py +0 -0
  45. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_search.py +0 -0
  46. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_server.py +0 -0
  47. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_sessions.py +0 -0
  48. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_site.py +0 -0
  49. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_site_data.py +0 -0
  50. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_storage.py +0 -0
  51. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_sync_client.py +0 -0
  52. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_thumbnails.py +0 -0
  53. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_transport.py +0 -0
  54. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_uploads.py +0 -0
  55. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_wire.py +0 -0
  56. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/mockgun.py +0 -0
  57. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/sgtimezone.py +0 -0
  58. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/py.typed +0 -0
  59. {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/shotgun.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sg-python-api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Python client for Autodesk Flow Production Tracking
5
5
  Project-URL: Documentation, https://github.com/dactylo/sg-python-api/tree/main/docs
6
6
  Project-URL: Homepage, https://github.com/dactylo/sg-python-api
@@ -45,8 +45,8 @@ This documentation uses SG for Flow Production Tracking, formerly named Shotgun
45
45
 
46
46
  sg-python-api is an independent implementation of the documented API of Autodesk's Python client
47
47
  `3.10.5`. Some behavior differs on purpose, so validate your application's workflows against a test
48
- site before replacing its production client. The [migration guide](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/migration-guide.md) walks
49
- through the switch, and the [compatibility reference](https://github.com/dactylo/sg-python-api/blob/v0.2.0/COMPATIBILITY.md) lists the differences.
48
+ site before replacing its production client. The [migration guide](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/migration-guide.md) walks
49
+ through the switch, and the [compatibility reference](https://github.com/dactylo/sg-python-api/blob/v0.2.1/COMPATIBILITY.md) lists the differences.
50
50
 
51
51
  ## Installation
52
52
 
@@ -55,7 +55,7 @@ requests to a remote site require HTTPS.
55
55
 
56
56
  `sg-python-api` and Autodesk's `shotgun-api3` both install the `shotgun_api3` package, so install
57
57
  exactly one of them in a Python environment. If the official client is already installed, follow
58
- the [migration steps](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/migration-guide.md#install-the-replacement) to replace it.
58
+ the [migration steps](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/migration-guide.md#install-the-replacement) to replace it.
59
59
 
60
60
  From a checkout, install into the Python environment that runs your application:
61
61
 
@@ -127,23 +127,23 @@ asyncio.run(main())
127
127
 
128
128
  The output matches the synchronous example. The `async with` block closes the client even when an
129
129
  operation fails. Share an `AsyncShotgun` only among tasks on one event loop. See the
130
- [async reference](https://github.com/dactylo/sg-python-api/blob/v0.2.0/COMPATIBILITY.md#async-extension-surfaces) for lifecycle and cancellation
130
+ [async reference](https://github.com/dactylo/sg-python-api/blob/v0.2.1/COMPATIBILITY.md#async-extension-surfaces) for lifecycle and cancellation
131
131
  details.
132
132
 
133
133
  ## Documentation
134
134
 
135
135
  | If you want to... | Read |
136
136
  | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
137
- | Replace Autodesk's client in an existing application | [Migration guide](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/migration-guide.md) |
138
- | Check what behaves differently from Autodesk's client | [Compatibility](https://github.com/dactylo/sg-python-api/blob/v0.2.0/COMPATIBILITY.md) |
139
- | Tune pooling, TCP keepalive, or page prefetch, or observe requests | [Configuration extensions](https://github.com/dactylo/sg-python-api/blob/v0.2.0/COMPATIBILITY.md#configuration-and-observer-extensions) |
140
- | Confirm which client loads and run a smoke test | [Testing an installation](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/testing.md) |
141
- | Compare measured latency and transfer memory | [Benchmarks](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/benchmarks.md) |
142
- | See changes by version | [Changelog](https://github.com/dactylo/sg-python-api/blob/v0.2.0/CHANGELOG.md) |
137
+ | Replace Autodesk's client in an existing application | [Migration guide](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/migration-guide.md) |
138
+ | Check what behaves differently from Autodesk's client | [Compatibility](https://github.com/dactylo/sg-python-api/blob/v0.2.1/COMPATIBILITY.md) |
139
+ | Tune pooling, TCP keepalive, or page prefetch, or observe requests | [Configuration extensions](https://github.com/dactylo/sg-python-api/blob/v0.2.1/COMPATIBILITY.md#configuration-and-observer-extensions) |
140
+ | Confirm which client loads and run a smoke test | [Testing an installation](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/testing.md) |
141
+ | Compare measured latency and transfer memory | [Benchmarks](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/benchmarks.md) |
142
+ | See changes by version | [Changelog](https://github.com/dactylo/sg-python-api/blob/v0.2.1/CHANGELOG.md) |
143
143
 
144
144
  ## Development
145
145
 
146
- See the [development guide](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/development.md) for the required tools, CI, and dependency locks.
146
+ See the [development guide](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/development.md) for the required tools, CI, and dependency locks.
147
147
  With those tools installed, run:
148
148
 
149
149
  ```sh
@@ -151,11 +151,11 @@ make setup
151
151
  make check TEST_FLAGS="-m 'not live'"
152
152
  ```
153
153
 
154
- See [live-site tests](https://github.com/dactylo/sg-python-api/blob/v0.2.0/docs/development.md#live-site-tests) to configure tests that create and
154
+ See [live-site tests](https://github.com/dactylo/sg-python-api/blob/v0.2.1/docs/development.md#live-site-tests) to configure tests that create and
155
155
  retire records on a disposable site.
156
156
 
157
157
  ## License
158
158
 
159
- sg-python-api is available under the [MIT License](https://github.com/dactylo/sg-python-api/blob/v0.2.0/LICENSE). It is an independent project and is
159
+ sg-python-api is available under the [MIT License](https://github.com/dactylo/sg-python-api/blob/v0.2.1/LICENSE). It is an independent project and is
160
160
  not affiliated with or endorsed by Autodesk. Flow Production Tracking is a product of Autodesk,
161
161
  Inc.
@@ -10,7 +10,7 @@ requires = [ "editables~=0.3", "hatch-fancy-pypi-readme>=25.1", "hatchling>=1.27
10
10
  # ==========================================================
11
11
  [project]
12
12
  name = "sg-python-api"
13
- version = "0.2.0"
13
+ version = "0.2.1"
14
14
  description = "Python client for Autodesk Flow Production Tracking"
15
15
  keywords = [ "api", "flow-production-tracking", "shotgrid", "shotgun", "shotgun-api3" ]
16
16
  license = "MIT"
@@ -111,6 +111,9 @@ _LIFECYCLE_TYPES = frozenset(
111
111
  'Asset',
112
112
  'PublishedFile',
113
113
  'CutItem',
114
+ # 2026-11-10: a lone Cut or Attachment logs only its own lifecycle rows.
115
+ 'Cut',
116
+ 'Attachment',
114
117
  }
115
118
  )
116
119
  _LINK_TARGETS = {
@@ -145,6 +148,10 @@ _LINK_UPDATE_FORMS = {
145
148
  _SCALAR_ENCODINGS = frozenset(
146
149
  {'number', 'duration', 'checkbox', 'text', 'list', 'status_list', 'date', 'date_time', 'float'}
147
150
  )
151
+ # 2026-11-10 trial capture: no create logs a default it fills into a field of these data types,
152
+ # across 459 Task, 240 Note, 83 Reply and 36 Playlist creates; status list and list defaults are
153
+ # always logged.
154
+ _UNLOGGED_DEFAULT_TYPES = frozenset({'duration', 'number', 'percent', 'text'})
148
155
  _WRITE_TYPES = frozenset(
149
156
  {
150
157
  'Shot',
@@ -216,6 +223,10 @@ def _create_fields(store: Store, row: Row, supplied: Sequence[str]) -> list[str]
216
223
  if field not in _CREATE_IGNORED
217
224
  and row.get(field) is not None
218
225
  and row.get(field) != []
226
+ and not (
227
+ field not in supplied
228
+ and store.schema.field_type(kind, field) in _UNLOGGED_DEFAULT_TYPES
229
+ )
219
230
  and not (
220
231
  field not in supplied
221
232
  and row.get(field) is False
@@ -718,7 +729,13 @@ def _observed_create_fields(
718
729
  populated = {
719
730
  field
720
731
  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) != []
732
+ if field not in _CREATE_IGNORED
733
+ and row.get(field) is not None
734
+ and row.get(field) != []
735
+ and not (
736
+ field not in supplied
737
+ and store.schema.field_type(kind, field) in _UNLOGGED_DEFAULT_TYPES
738
+ )
722
739
  }
723
740
  for form in _CREATE_FORMS:
724
741
  if (kind, session) != (form.kind, form.session):
@@ -862,10 +879,14 @@ def record_create(
862
879
  plans = _plan_create(store, row, supplied, context=context)
863
880
  if store.schema.declares('EventLogEntry') and store.events_unknown is None:
864
881
  if plans is None:
865
- _unsettled(
866
- store,
867
- f'{row["type"]} {row["id"]} create has unsettled event rows',
882
+ # 2026-11-10: a new Project also logs hundreds of rows for the pages,
883
+ # page settings and field preferences the site copies from a template.
884
+ detail = (
885
+ ' for the pages and preferences the site creates with it'
886
+ if row['type'] == 'Project'
887
+ else ''
868
888
  )
889
+ _unsettled(store, f'{row["type"]} {row["id"]} create has unsettled event rows{detail}')
869
890
  else:
870
891
  _write_plans(store, plans, context)
871
892
  return plans is not None
@@ -27,7 +27,6 @@ from ._wire import RpcCall
27
27
 
28
28
  if TYPE_CHECKING:
29
29
  from ._server import FakeServer
30
- from ._store import FieldValue
31
30
 
32
31
  _Handler: TypeAlias = Callable[['FakeServer', RpcCall, RequestContext], object]
33
32
  HANDLERS: dict[str, _Handler] = {}
@@ -385,6 +384,7 @@ def _update(server: 'FakeServer', call: RpcCall, context: RequestContext) -> obj
385
384
  entity_type = cast('str', params['type'])
386
385
  entity_id = cast('int', params['id'])
387
386
  _check_event_write(server, entity_type)
387
+ server.store.check_link_targets(entity_type, fields, 'update')
388
388
  kept = kept_members(server.store, entity_type, entity_id, fields)
389
389
  effective = server.store.kept_fields(
390
390
  server.store.require(entity_type, entity_id), fields, modes, kept
@@ -401,11 +401,6 @@ def _update(server: 'FakeServer', call: RpcCall, context: RequestContext) -> obj
401
401
  row = server.store.update(
402
402
  entity_type, entity_id, effective, effective_modes, context=context
403
403
  )
404
- for field in kept:
405
- if len(cast('list[FieldValue]', row[field])) > 1:
406
- server.store.mark_unknown(
407
- row, field, 'retained hidden members have unsettled order', order_only=True
408
- )
409
404
  settled = record_update(
410
405
  server.store,
411
406
  before,
@@ -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)