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,368 @@
|
|
|
1
|
+
"""Apply observed lifecycle cascades and preserve unsettled retirement effects."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from ._links import RetirementEdge, hidden, retirement_edge
|
|
9
|
+
from ._sessions import RequestContext
|
|
10
|
+
from ._store import FieldValue, Row, Store
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RetirementEffects:
|
|
15
|
+
"""Describe a lifecycle change and the dependent rows it affected.
|
|
16
|
+
|
|
17
|
+
Field bindings are frozen, but contained rows remain live mutable store
|
|
18
|
+
rows.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
changed: Whether the root changed retirement state.
|
|
22
|
+
root: Root record after the operation.
|
|
23
|
+
cascaded: Dependent records retired or revived with the root.
|
|
24
|
+
tainted: Records tainted by retirement, or records whose source-owned
|
|
25
|
+
taints were cleared during revival.
|
|
26
|
+
touched: Records whose update audit fields were refreshed.
|
|
27
|
+
settled: Whether the operation's complete lifecycle-event delta is
|
|
28
|
+
modeled.
|
|
29
|
+
retired_at: Retirement instant for a retirement operation, the root's
|
|
30
|
+
prior retirement instant for revival, or `None` when unavailable or
|
|
31
|
+
no state change occurred.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
changed: bool
|
|
35
|
+
root: Row
|
|
36
|
+
cascaded: tuple[Row, ...]
|
|
37
|
+
tainted: tuple[Row, ...]
|
|
38
|
+
touched: tuple[Row, ...]
|
|
39
|
+
settled: bool
|
|
40
|
+
retired_at: datetime | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cascade_allowed(
|
|
44
|
+
root: Row, target: Row, entry: tuple[Row, list[tuple[str, RetirementEdge]]]
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Allow direct cascades and only the observed Shot-Task-Note chain."""
|
|
47
|
+
holder, edges = entry
|
|
48
|
+
if len(edges) != 1 or edges[0][1].effect != 'cascade' or target.get('__taint'):
|
|
49
|
+
return False
|
|
50
|
+
return target is root or (
|
|
51
|
+
target['type'] == 'Task'
|
|
52
|
+
and root['type'] == 'Shot'
|
|
53
|
+
and target.get('entity') == {'type': root['type'], 'id': root['id']}
|
|
54
|
+
and holder['type'] == 'Note'
|
|
55
|
+
and edges[0][0] == 'tasks'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clear_source(store: Store, root: Row) -> list[Row]:
|
|
60
|
+
"""Clear source-owned lifecycle taints while preserving unknown audits."""
|
|
61
|
+
cleared: list[Row] = []
|
|
62
|
+
for kind in store.schema.fields:
|
|
63
|
+
for row in store.tainted_rows(kind):
|
|
64
|
+
taint = cast('dict[str, FieldValue]', row['__taint'])
|
|
65
|
+
if taint['source'] == [root['type'], root['id']]:
|
|
66
|
+
store.remember(row)
|
|
67
|
+
row.pop('__taint')
|
|
68
|
+
cleared.append(row)
|
|
69
|
+
return cleared
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _identity(row: Row) -> tuple[str, int]:
|
|
73
|
+
"""Return a stored record's identity."""
|
|
74
|
+
return cast('str', row['type']), cast('int', row['id'])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _holder_groups(
|
|
78
|
+
store: Store, target: Row
|
|
79
|
+
) -> list[tuple[Row, list[tuple[str, RetirementEdge]]]]:
|
|
80
|
+
"""Group incoming fields before deciding whether their rules agree."""
|
|
81
|
+
groups: dict[tuple[str, int], tuple[Row, list[tuple[str, RetirementEdge]]]] = {}
|
|
82
|
+
for holder, field in store.linking(*_identity(target)):
|
|
83
|
+
groups.setdefault(_identity(holder), (holder, []))[1].append(
|
|
84
|
+
(field, retirement_edge(store, holder, field, target))
|
|
85
|
+
)
|
|
86
|
+
return list(groups.values())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _revived_alone(store: Store, roots: tuple[Row, Row | None], children: list[Row]) -> None:
|
|
90
|
+
"""Taint the unobserved standalone revival while preserving the Version exception."""
|
|
91
|
+
root, source = roots
|
|
92
|
+
if source is None or not source['__retired']:
|
|
93
|
+
return
|
|
94
|
+
if (
|
|
95
|
+
root['type'] == 'Version'
|
|
96
|
+
and source['type'] == 'Shot'
|
|
97
|
+
and root.get('entity') == {'type': 'Shot', 'id': source['id']}
|
|
98
|
+
):
|
|
99
|
+
return
|
|
100
|
+
reason = f'unsettled revival while {source["type"]} {source["id"]} remains retired'
|
|
101
|
+
for holder, field in store.linking(*_identity(source)):
|
|
102
|
+
if holder is root:
|
|
103
|
+
store.taint(root, reason, source, link=(field, source), active=True)
|
|
104
|
+
for row in children:
|
|
105
|
+
store.taint(row, reason, source)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _touch(store: Store, row: Row, context: RequestContext) -> None:
|
|
109
|
+
"""Set the observed actor and operation time on a lifecycle effect."""
|
|
110
|
+
kind = cast('str', row['type'])
|
|
111
|
+
store.remember(row)
|
|
112
|
+
if store.schema.declares(kind, 'updated_by'):
|
|
113
|
+
actor = context.actor
|
|
114
|
+
row['updated_by'] = {'type': actor.entity_type, 'id': actor.entity_id} if actor else None
|
|
115
|
+
store.forget_unknown(row, 'updated_by')
|
|
116
|
+
if store.schema.declares(kind, 'updated_at'):
|
|
117
|
+
row['updated_at'] = context.now
|
|
118
|
+
store.forget_unknown(row, 'updated_at')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _holder_effect(
|
|
122
|
+
store: Store,
|
|
123
|
+
roots: tuple[Row, Row],
|
|
124
|
+
entry: tuple[Row, list[tuple[str, RetirementEdge]]],
|
|
125
|
+
context: RequestContext,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Apply one holder's effect and identify the next scan or touch category."""
|
|
128
|
+
root, target = roots
|
|
129
|
+
holder, edges = entry
|
|
130
|
+
if _cascade_allowed(root, target, entry):
|
|
131
|
+
store.retire(*_identity(holder))
|
|
132
|
+
store.remember(holder)
|
|
133
|
+
holder['__retired_at'] = context.now
|
|
134
|
+
holder['__cascaded_by'] = [target['type'], target['id']]
|
|
135
|
+
_touch(store, holder, context)
|
|
136
|
+
return 'cascade'
|
|
137
|
+
reason = f'unsettled retirement effect from {root["type"]} {root["id"]}'
|
|
138
|
+
effect = edges[0][1].effect if len(edges) == 1 else 'taint'
|
|
139
|
+
if effect == 'hide':
|
|
140
|
+
if edges[0][1].touched:
|
|
141
|
+
if store.tainted(target):
|
|
142
|
+
for field in ('updated_at', 'updated_by'):
|
|
143
|
+
if store.schema.declares(cast('str', holder['type']), field):
|
|
144
|
+
store.mark_unknown(holder, field, reason)
|
|
145
|
+
else:
|
|
146
|
+
_touch(store, holder, context)
|
|
147
|
+
return 'touch'
|
|
148
|
+
return 'hide'
|
|
149
|
+
if effect == 'keep' and not store.tainted(target):
|
|
150
|
+
return 'keep'
|
|
151
|
+
for field, _ in edges:
|
|
152
|
+
store.taint(holder, reason, root, link=(field, target))
|
|
153
|
+
return 'taint'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _restore_children(store: Store, parent: Row, context: RequestContext) -> list[Row]:
|
|
157
|
+
"""Revive only still-retired children owned by this cascade marker."""
|
|
158
|
+
children: list[Row] = []
|
|
159
|
+
for kind in store.schema.fields:
|
|
160
|
+
for row in store.rows(kind, retired=True):
|
|
161
|
+
if row.get('__cascaded_by') == [parent['type'], parent['id']]:
|
|
162
|
+
store.check_writable(row)
|
|
163
|
+
store.revive(*_identity(row))
|
|
164
|
+
store.remember(row)
|
|
165
|
+
row.pop('__cascaded_by', None)
|
|
166
|
+
_touch(store, row, context)
|
|
167
|
+
children.append(row)
|
|
168
|
+
return children
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _restore_touches(store: Store, parent: Row, context: RequestContext) -> list[Row]:
|
|
172
|
+
"""Repeat the audit touches recorded by the retirement operation."""
|
|
173
|
+
holders: list[Row] = []
|
|
174
|
+
store.remember(parent)
|
|
175
|
+
for link in cast('list[list[FieldValue]]', parent.pop('__retirement_touched', [])):
|
|
176
|
+
holder = store.require(cast('str', link[0]), cast('int', link[1]))
|
|
177
|
+
store.check_writable(holder)
|
|
178
|
+
_touch(store, holder, context)
|
|
179
|
+
holders.append(holder)
|
|
180
|
+
return holders
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _revive(store: Store, root: Row, context: RequestContext) -> RetirementEffects:
|
|
184
|
+
"""Restore marked children and touches, then resolve source-owned taints."""
|
|
185
|
+
store.remember(root)
|
|
186
|
+
source_link = root.pop('__cascaded_by', None)
|
|
187
|
+
source = (
|
|
188
|
+
store.row(cast('str', source_link[0]), cast('int', source_link[1]))
|
|
189
|
+
if isinstance(source_link, list)
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
retired_at = cast('datetime | None', root.get('__retired_at'))
|
|
193
|
+
store.revive(*_identity(root), context=context)
|
|
194
|
+
cascaded: list[Row] = []
|
|
195
|
+
touched: list[Row] = []
|
|
196
|
+
queue = [root]
|
|
197
|
+
settled = True
|
|
198
|
+
while queue:
|
|
199
|
+
parent = queue.pop(0)
|
|
200
|
+
for holder, field in store.linking(*_identity(parent)):
|
|
201
|
+
taint = cast('dict[str, FieldValue]', holder.get('__taint', {}))
|
|
202
|
+
if taint and taint.get('source') != [root['type'], root['id']]:
|
|
203
|
+
store.check_writable(holder)
|
|
204
|
+
if not taint:
|
|
205
|
+
settled = settled and retirement_edge(store, holder, field, parent).settled
|
|
206
|
+
children = _restore_children(store, parent, context)
|
|
207
|
+
cascaded.extend(children)
|
|
208
|
+
queue.extend(children)
|
|
209
|
+
touched.extend(_restore_touches(store, parent, context))
|
|
210
|
+
cleared = _clear_source(store, root)
|
|
211
|
+
_revived_alone(store, (root, source), cascaded)
|
|
212
|
+
return RetirementEffects(
|
|
213
|
+
changed=True,
|
|
214
|
+
root=root,
|
|
215
|
+
cascaded=tuple(cascaded),
|
|
216
|
+
tainted=tuple(cleared),
|
|
217
|
+
touched=tuple(touched),
|
|
218
|
+
settled=settled and not (cascaded or touched or cleared or store.tainted(root)),
|
|
219
|
+
retired_at=retired_at,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _scan(store: Store, root: Row, context: RequestContext) -> RetirementEffects:
|
|
224
|
+
"""Scan each lifecycle wave against the state left by its predecessor."""
|
|
225
|
+
cascaded: list[Row] = []
|
|
226
|
+
tainted: list[Row] = []
|
|
227
|
+
touched: list[Row] = []
|
|
228
|
+
buckets = {'cascade': cascaded, 'taint': tainted, 'touch': touched}
|
|
229
|
+
initial_taints = {
|
|
230
|
+
_identity(row) for kind in store.schema.fields for row in store.tainted_rows(kind)
|
|
231
|
+
}
|
|
232
|
+
queue = [root]
|
|
233
|
+
visited: set[tuple[str, int]] = set()
|
|
234
|
+
settled = True
|
|
235
|
+
while queue:
|
|
236
|
+
target = queue.pop(0)
|
|
237
|
+
key = _identity(target)
|
|
238
|
+
if key in visited:
|
|
239
|
+
continue
|
|
240
|
+
visited.add(key)
|
|
241
|
+
for entry in _holder_groups(store, target):
|
|
242
|
+
holder, edges = entry
|
|
243
|
+
if holder is root or holder in cascaded:
|
|
244
|
+
continue
|
|
245
|
+
if _identity(holder) in initial_taints:
|
|
246
|
+
store.check_writable(holder)
|
|
247
|
+
settled = settled and all(edge.settled for _, edge in edges)
|
|
248
|
+
effect = _holder_effect(store, (root, target), entry, context)
|
|
249
|
+
bucket = buckets.get(effect)
|
|
250
|
+
if bucket is not None and holder not in bucket:
|
|
251
|
+
bucket.append(holder)
|
|
252
|
+
if effect != 'touch':
|
|
253
|
+
queue.append(holder)
|
|
254
|
+
store.remember(root)
|
|
255
|
+
root['__retirement_touched'] = [[row['type'], row['id']] for row in touched]
|
|
256
|
+
return RetirementEffects(
|
|
257
|
+
changed=True,
|
|
258
|
+
root=root,
|
|
259
|
+
cascaded=tuple(cascaded),
|
|
260
|
+
tainted=tuple(tainted),
|
|
261
|
+
touched=tuple(touched),
|
|
262
|
+
settled=settled and not (cascaded or tainted or touched),
|
|
263
|
+
retired_at=context.now,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def kept_members(
|
|
268
|
+
store: Store,
|
|
269
|
+
entity_type: str,
|
|
270
|
+
entity_id: int,
|
|
271
|
+
fields: Mapping[str, object],
|
|
272
|
+
) -> dict[str, list[dict[str, FieldValue]]]:
|
|
273
|
+
"""Keep hidden Asset.shots members when an edit names visible members only.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
store: Site records used to inspect existing shot links.
|
|
277
|
+
entity_type: Entity type being updated.
|
|
278
|
+
entity_id: Existing entity identifier.
|
|
279
|
+
fields: Incoming field values. Naming any hidden shot explicitly
|
|
280
|
+
disables automatic preservation.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
A `shots` entry containing preserved stored link dictionaries, or an
|
|
284
|
+
empty mapping when no preservation applies.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
ServerFaultError: If the Asset record cannot be resolved.
|
|
288
|
+
MockgunError: If an existing shot link's visibility is unsettled.
|
|
289
|
+
"""
|
|
290
|
+
if entity_type != 'Asset' or 'shots' not in fields:
|
|
291
|
+
return {}
|
|
292
|
+
row = store.require(entity_type, entity_id)
|
|
293
|
+
if row['__retired']:
|
|
294
|
+
return {}
|
|
295
|
+
members = cast('list[dict[str, FieldValue]]', row.get('shots', []))
|
|
296
|
+
kept = [link for link in members if hidden(store, row, 'shots', link)]
|
|
297
|
+
incoming = fields['shots']
|
|
298
|
+
if not isinstance(incoming, list):
|
|
299
|
+
return {}
|
|
300
|
+
identities = {
|
|
301
|
+
(cast('dict[str, object]', item).get('type'), cast('dict[str, object]', item).get('id'))
|
|
302
|
+
for item in cast('list[object]', incoming)
|
|
303
|
+
if isinstance(item, dict)
|
|
304
|
+
}
|
|
305
|
+
if any((link['type'], link['id']) in identities for link in kept):
|
|
306
|
+
return {}
|
|
307
|
+
return {'shots': kept} if kept else {}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def retire(
|
|
311
|
+
store: Store, entity_type: str, entity_id: int, *, context: RequestContext
|
|
312
|
+
) -> RetirementEffects:
|
|
313
|
+
"""Retire a root and apply its observed dependent effects atomically.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
store: Site records and transaction journal.
|
|
317
|
+
entity_type: Root entity type.
|
|
318
|
+
entity_id: Root entity identifier.
|
|
319
|
+
context: Request actor and timestamp used for lifecycle audit fields.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Effects containing live stored rows. `changed` is `False` when the root
|
|
323
|
+
was already retired.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ServerFaultError: If a required entity type or record is missing.
|
|
327
|
+
MockgunError: If the root or a dependent record has an unsettled
|
|
328
|
+
lifecycle state that prevents the operation.
|
|
329
|
+
"""
|
|
330
|
+
root = store.require(entity_type, entity_id)
|
|
331
|
+
store.check_writable(root)
|
|
332
|
+
if root['__retired']:
|
|
333
|
+
return RetirementEffects(
|
|
334
|
+
changed=False, root=root, cascaded=(), tainted=(), touched=(), settled=True
|
|
335
|
+
)
|
|
336
|
+
with store.transaction():
|
|
337
|
+
store.retire(entity_type, entity_id, context=context)
|
|
338
|
+
return _scan(store, root, context)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def revive(
|
|
342
|
+
store: Store, entity_type: str, entity_id: int, *, context: RequestContext
|
|
343
|
+
) -> RetirementEffects:
|
|
344
|
+
"""Restore a root and its recorded dependent effects atomically.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
store: Site records and transaction journal.
|
|
348
|
+
entity_type: Root entity type.
|
|
349
|
+
entity_id: Root entity identifier.
|
|
350
|
+
context: Request actor and timestamp used for lifecycle audit fields.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Effects containing live stored rows. `changed` is `False` when the root
|
|
354
|
+
was already active.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ServerFaultError: If a required entity type or record is missing.
|
|
358
|
+
MockgunError: If the root or a dependent record has an unsettled
|
|
359
|
+
lifecycle state that prevents restoration.
|
|
360
|
+
"""
|
|
361
|
+
root = store.require(entity_type, entity_id)
|
|
362
|
+
store.check_writable(root)
|
|
363
|
+
if not root['__retired']:
|
|
364
|
+
return RetirementEffects(
|
|
365
|
+
changed=False, root=root, cascaded=(), tainted=(), touched=(), settled=True
|
|
366
|
+
)
|
|
367
|
+
with store.transaction():
|
|
368
|
+
return _revive(store, root, context)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Load schema snapshots and resolve declared field paths."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol, cast
|
|
9
|
+
|
|
10
|
+
from ._errors import MockgunError, ServerFaultError
|
|
11
|
+
|
|
12
|
+
_LINK_PATH_PARTS = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SchemaReader(Protocol):
|
|
16
|
+
"""Describe the schema export client surface."""
|
|
17
|
+
|
|
18
|
+
def schema_entity_read(self) -> Mapping[str, object]:
|
|
19
|
+
"""Return entity schema metadata.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Entity schema metadata keyed by entity type.
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def schema_read(self) -> Mapping[str, object]:
|
|
27
|
+
"""Return field schema metadata.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Field schema metadata keyed by entity type.
|
|
31
|
+
"""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SiteSchema:
|
|
37
|
+
"""Hold schema metadata and site-local field changes.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
fields: Field metadata keyed by entity type, then field name.
|
|
41
|
+
entities: Entity metadata keyed by entity type.
|
|
42
|
+
site_created: `(entity_type, field_name)` identities created on this
|
|
43
|
+
fake site.
|
|
44
|
+
retired_fields: Deleted field identities retained for subsequent
|
|
45
|
+
field-creation validation.
|
|
46
|
+
visibility: Per-field Project visibility overrides, keyed by
|
|
47
|
+
`(entity_type, field_name)` and then Project ID.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
fields: dict[str, dict[str, object]]
|
|
51
|
+
entities: dict[str, dict[str, object]]
|
|
52
|
+
site_created: set[tuple[str, str]] = field(default_factory=set[tuple[str, str]])
|
|
53
|
+
retired_fields: set[tuple[str, str]] = field(default_factory=set[tuple[str, str]])
|
|
54
|
+
visibility: dict[tuple[str, str], dict[int, bool]] = field(
|
|
55
|
+
default_factory=dict[tuple[str, str], dict[int, bool]]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def declares(self, entity_type: str, field_name: str | None = None) -> bool:
|
|
59
|
+
"""Return whether an entity or field exists in the loaded field schema."""
|
|
60
|
+
return entity_type in self.fields and (
|
|
61
|
+
field_name is None or field_name in self.fields[entity_type]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def field_type(self, entity_type: str, path: str) -> str:
|
|
65
|
+
"""Resolve a direct or deep-linked field to its declared data type.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ServerFaultError: If the entity, field, or linked path is invalid.
|
|
69
|
+
"""
|
|
70
|
+
if entity_type not in self.fields:
|
|
71
|
+
raise ServerFaultError(f'{entity_type} is not a valid entity')
|
|
72
|
+
parts = path.split('.', 2)
|
|
73
|
+
field = parts[0] if len(parts) == _LINK_PATH_PARTS else path
|
|
74
|
+
if field not in self.fields[entity_type] and field not in {'id', 'type'}:
|
|
75
|
+
raise ServerFaultError(f'{field} is not a valid field for entity {entity_type}')
|
|
76
|
+
if field == 'id':
|
|
77
|
+
field_type = 'number'
|
|
78
|
+
elif field == 'type':
|
|
79
|
+
field_type = 'entity_type'
|
|
80
|
+
else:
|
|
81
|
+
metadata = cast('dict[str, object]', self.fields[entity_type][field])
|
|
82
|
+
field_type = cast('str', cast('dict[str, object]', metadata['data_type'])['value'])
|
|
83
|
+
if len(parts) == _LINK_PATH_PARTS:
|
|
84
|
+
if field_type not in {'entity', 'multi_entity'}:
|
|
85
|
+
raise ServerFaultError(
|
|
86
|
+
f'{field} is not a valid linked field for entity {entity_type}'
|
|
87
|
+
)
|
|
88
|
+
return self.field_type(parts[1], parts[2])
|
|
89
|
+
return field_type
|
|
90
|
+
|
|
91
|
+
def scoped(
|
|
92
|
+
self, project_id: int | None, entity_type: str | None = None
|
|
93
|
+
) -> dict[str, dict[str, object]]:
|
|
94
|
+
"""Return detached field metadata with one Project's visibility settings."""
|
|
95
|
+
fields = (
|
|
96
|
+
deepcopy(self.fields)
|
|
97
|
+
if entity_type is None
|
|
98
|
+
else {entity_type: deepcopy(self.fields[entity_type])}
|
|
99
|
+
)
|
|
100
|
+
if project_id is None:
|
|
101
|
+
return fields
|
|
102
|
+
for (kind, field_name), projects in self.visibility.items():
|
|
103
|
+
if project_id in projects and field_name in fields.get(kind, {}):
|
|
104
|
+
entry = cast('dict[str, object]', fields[kind][field_name])
|
|
105
|
+
visible = cast(
|
|
106
|
+
'dict[str, object]',
|
|
107
|
+
entry.setdefault('visible', {'value': projects[project_id], 'editable': True}),
|
|
108
|
+
)
|
|
109
|
+
visible['value'] = projects[project_id]
|
|
110
|
+
return fields
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_schema(
|
|
114
|
+
sg: _SchemaReader,
|
|
115
|
+
schema_file_path: str,
|
|
116
|
+
schema_entity_file_path: str,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Write the schema dictionaries needed to initialize mockgun.
|
|
119
|
+
|
|
120
|
+
Write the field schema first and the entity schema second, using pickle
|
|
121
|
+
protocol 2. Existing destination files are replaced. A failure can leave
|
|
122
|
+
incomplete output; the two files are not published atomically.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
sg: Client that supplies the schema dictionaries.
|
|
126
|
+
schema_file_path: Destination for the field schema.
|
|
127
|
+
schema_entity_file_path: Destination for the entity schema.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
OSError: If a destination cannot be opened or written.
|
|
131
|
+
ShotgunError: If a supplied Shotgun client's schema request fails.
|
|
132
|
+
ProtocolError: If a supplied Shotgun client's schema request receives
|
|
133
|
+
an HTTP error.
|
|
134
|
+
ValueError: If a supplied Shotgun client has invalid configuration.
|
|
135
|
+
"""
|
|
136
|
+
with Path(schema_file_path).open('wb') as schema_file:
|
|
137
|
+
pickle.dump(sg.schema_read(), schema_file, protocol=2)
|
|
138
|
+
with Path(schema_entity_file_path).open('wb') as schema_entity_file:
|
|
139
|
+
pickle.dump(sg.schema_entity_read(), schema_entity_file, protocol=2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_schema(schema_path: str | None, schema_entity_path: str | None) -> SiteSchema:
|
|
143
|
+
"""Load the configured trusted field and entity schema pickles.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
schema_path: Field-schema pickle path, or `None` when unconfigured.
|
|
147
|
+
schema_entity_path: Entity-schema pickle path, or `None` when
|
|
148
|
+
unconfigured.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Schema metadata loaded from the two files.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
MockgunError: If paths are unconfigured or either file is missing.
|
|
155
|
+
OSError: If a schema file cannot be opened or read.
|
|
156
|
+
EOFError: If a schema pickle is empty or truncated.
|
|
157
|
+
pickle.UnpicklingError: If a schema file cannot be decoded as a pickle.
|
|
158
|
+
ValueError: If a pickle requires an unsupported protocol.
|
|
159
|
+
"""
|
|
160
|
+
if schema_path is None or schema_entity_path is None:
|
|
161
|
+
raise MockgunError(
|
|
162
|
+
'mockgun schema paths are not configured; call Shotgun.set_schema_paths() first'
|
|
163
|
+
)
|
|
164
|
+
if not Path(schema_path).is_file():
|
|
165
|
+
raise MockgunError(f'mockgun schema file does not exist: {schema_path}')
|
|
166
|
+
if not Path(schema_entity_path).is_file():
|
|
167
|
+
raise MockgunError(f'mockgun schema_entity file does not exist: {schema_entity_path}')
|
|
168
|
+
with Path(schema_path).open('rb') as schema_file:
|
|
169
|
+
fields = cast('dict[str, dict[str, object]]', pickle.load(schema_file)) # noqa: S301
|
|
170
|
+
with Path(schema_entity_path).open('rb') as schema_entity_file:
|
|
171
|
+
entities = cast('dict[str, dict[str, object]]', pickle.load(schema_entity_file)) # noqa: S301
|
|
172
|
+
return SiteSchema(fields, entities)
|