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