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.
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/PKG-INFO +14 -14
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/pyproject.toml +1 -1
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_events.py +25 -4
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_handlers.py +1 -6
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_links.py +256 -98
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_query.py +79 -37
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_retirement.py +124 -29
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_store.py +153 -40
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_summaries.py +18 -17
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_threads.py +4 -4
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/.gitignore +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/LICENSE +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/README.md +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/__init__.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_client.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_driver.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_httpx2.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_async_transport.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_capabilities.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_client.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_config.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_connections.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_crud.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_driver.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_errors.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_execution.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_files.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_methods.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_protocol.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_sentinel.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_sg_timezone.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_transport.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/_version.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/__init__.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/__init__.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_activity.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_async_client.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_dates.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_errors.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_follows.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_new_client.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_presets.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_schema.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_schema_writes.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_search.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_server.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_sessions.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_site.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_site_data.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_storage.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_sync_client.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_thumbnails.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_transport.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_uploads.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/_wire.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/mockgun/mockgun.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/lib/sgtimezone.py +0 -0
- {sg_python_api-0.2.0 → sg_python_api-0.2.1}/src/shotgun_api3/py.typed +0 -0
- {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.
|
|
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.
|
|
49
|
-
through the switch, and the [compatibility reference](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
138
|
-
| Check what behaves differently from Autodesk's client | [Compatibility](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
139
|
-
| Tune pooling, TCP keepalive, or page prefetch, or observe requests | [Configuration extensions](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
140
|
-
| Confirm which client loads and run a smoke test | [Testing an installation](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
141
|
-
| Compare measured latency and transfer memory | [Benchmarks](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
142
|
-
| See changes by version | [Changelog](https://github.com/dactylo/sg-python-api/blob/v0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
'
|
|
55
|
+
'hide', 'always', touched=False, settled=True
|
|
49
56
|
),
|
|
50
57
|
('Attachment', 'attachment_links', 'Version'): _RetirementRule(
|
|
51
|
-
'
|
|
58
|
+
'hide', 'always', touched=False, settled=True
|
|
52
59
|
),
|
|
53
60
|
('Note', 'attachments', 'Attachment'): _RetirementRule(
|
|
54
|
-
'
|
|
61
|
+
'hide', 'always', touched=False, settled=True
|
|
55
62
|
),
|
|
56
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
139
|
+
'hide', 'always', touched=False, settled=True
|
|
126
140
|
),
|
|
127
141
|
('Sequence', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
128
|
-
|
|
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
|
-
|
|
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`, `
|
|
158
|
-
|
|
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
|
|
199
|
-
"""
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
214
|
-
|
|
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
|
|
315
|
-
"""Return a
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
343
|
-
return RetirementEdge(
|
|
344
|
-
|
|
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
|
|
360
|
-
`None` when no modeled uncertainty requires a deciding
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
552
|
+
raise MockgunError(
|
|
553
|
+
f'{holder["type"]} {holder["id"]}.{field_name}: {store.tainted(deciding)}'
|
|
554
|
+
)
|
|
422
555
|
if deciding is holder:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if
|
|
433
|
-
return
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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)
|