sg-python-api 0.1.2__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
- sg_python_api-0.2.1.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 +1052 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +553 -0
- shotgun_api3/lib/mockgun/_links.py +598 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2205 -0
- shotgun_api3/lib/mockgun/_retirement.py +463 -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 +1559 -0
- shotgun_api3/lib/mockgun/_summaries.py +965 -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.1.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""Compute modeled display names and detached named entity links."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from ._errors import MockgunError
|
|
8
|
+
from ._store import CONNECTION_FIELDS, FieldValue, Row, Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class _RetirementRule:
|
|
13
|
+
"""Describe one observed retirement rule and its holder condition."""
|
|
14
|
+
|
|
15
|
+
effect: str
|
|
16
|
+
condition: str
|
|
17
|
+
touched: bool
|
|
18
|
+
settled: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DISPLAY_FIELDS = {
|
|
22
|
+
'Attachment': ('display_name',),
|
|
23
|
+
'Project': ('name',),
|
|
24
|
+
'HumanUser': ('name',),
|
|
25
|
+
'Department': ('name',),
|
|
26
|
+
'Shot': ('code',),
|
|
27
|
+
'Asset': ('code',),
|
|
28
|
+
'Sequence': ('code',),
|
|
29
|
+
'Version': ('code',),
|
|
30
|
+
'PublishedFile': ('code',),
|
|
31
|
+
'Cut': ('code',),
|
|
32
|
+
'Playlist': ('code',),
|
|
33
|
+
'CutItem': ('code',),
|
|
34
|
+
'Step': ('code',),
|
|
35
|
+
'PublishedFileType': ('code',),
|
|
36
|
+
'TaskTemplate': ('code',),
|
|
37
|
+
'Group': ('code',),
|
|
38
|
+
'CustomEntity03': ('code',),
|
|
39
|
+
'Task': ('content',),
|
|
40
|
+
'Ticket': ('title',),
|
|
41
|
+
'Reply': ('content',),
|
|
42
|
+
'Note': ('subject', 'content'),
|
|
43
|
+
'ApiUser': ('firstname', 'lastname'),
|
|
44
|
+
}
|
|
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
|
+
}
|
|
52
|
+
_RETIREMENT_EDGES = {
|
|
53
|
+
# 2026-11-10: Attachment and Note links hide; neither end is touched or logged.
|
|
54
|
+
('Attachment', 'attachment_links', 'Note'): _RetirementRule(
|
|
55
|
+
'hide', 'always', touched=False, settled=True
|
|
56
|
+
),
|
|
57
|
+
('Attachment', 'attachment_links', 'Version'): _RetirementRule(
|
|
58
|
+
'hide', 'always', touched=False, settled=True
|
|
59
|
+
),
|
|
60
|
+
('Note', 'attachments', 'Attachment'): _RetirementRule(
|
|
61
|
+
'hide', 'always', touched=False, settled=True
|
|
62
|
+
),
|
|
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.
|
|
66
|
+
('Version', 'entity', 'Shot'): _RetirementRule(
|
|
67
|
+
'cascade', 'always', touched=False, settled=True
|
|
68
|
+
),
|
|
69
|
+
('Task', 'entity', 'Asset'): _RetirementRule(
|
|
70
|
+
'cascade', 'always', touched=False, settled=False
|
|
71
|
+
),
|
|
72
|
+
('Task', 'entity', 'Sequence'): _RetirementRule(
|
|
73
|
+
'cascade', 'always', touched=False, settled=False
|
|
74
|
+
),
|
|
75
|
+
('Version', 'entity', 'Asset'): _RetirementRule(
|
|
76
|
+
'cascade', 'always', touched=False, settled=False
|
|
77
|
+
),
|
|
78
|
+
('Version', 'entity', 'Sequence'): _RetirementRule(
|
|
79
|
+
'cascade', 'always', touched=False, settled=False
|
|
80
|
+
),
|
|
81
|
+
('Note', 'note_links', 'Shot'): _RetirementRule(
|
|
82
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
83
|
+
),
|
|
84
|
+
('Note', 'note_links', 'Asset'): _RetirementRule(
|
|
85
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
86
|
+
),
|
|
87
|
+
('Note', 'note_links', 'Sequence'): _RetirementRule(
|
|
88
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
89
|
+
),
|
|
90
|
+
('Note', 'note_links', 'Version'): _RetirementRule(
|
|
91
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
92
|
+
),
|
|
93
|
+
('Note', 'tasks', 'Task'): _RetirementRule('cascade', 'tasks', touched=False, settled=False),
|
|
94
|
+
('Reply', 'entity', 'Note'): _RetirementRule(
|
|
95
|
+
'cascade', 'always', touched=False, settled=False
|
|
96
|
+
),
|
|
97
|
+
('Shot', 'project', 'Project'): _RetirementRule(
|
|
98
|
+
'cascade', 'project', touched=False, settled=False
|
|
99
|
+
),
|
|
100
|
+
('Asset', 'project', 'Project'): _RetirementRule(
|
|
101
|
+
'cascade', 'project', touched=False, settled=False
|
|
102
|
+
),
|
|
103
|
+
('Sequence', 'project', 'Project'): _RetirementRule(
|
|
104
|
+
'cascade', 'project', touched=False, settled=False
|
|
105
|
+
),
|
|
106
|
+
('Task', 'project', 'Project'): _RetirementRule(
|
|
107
|
+
'cascade', 'project', touched=False, settled=False
|
|
108
|
+
),
|
|
109
|
+
('Version', 'project', 'Project'): _RetirementRule(
|
|
110
|
+
'cascade', 'project', touched=False, settled=False
|
|
111
|
+
),
|
|
112
|
+
('Note', 'project', 'Project'): _RetirementRule(
|
|
113
|
+
'cascade', 'note_project', touched=False, settled=False
|
|
114
|
+
),
|
|
115
|
+
('PublishedFile', 'entity', 'Shot'): _RetirementRule(
|
|
116
|
+
'hide', 'always', touched=False, settled=True
|
|
117
|
+
),
|
|
118
|
+
('CutItem', 'shot', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
119
|
+
('PublishedFile', 'entity', 'Asset'): _RetirementRule(
|
|
120
|
+
'hide', 'always', touched=False, settled=True
|
|
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),
|
|
127
|
+
('Shot', 'sg_sequence', 'Sequence'): _RetirementRule(
|
|
128
|
+
'hide', 'always', touched=False, settled=True
|
|
129
|
+
),
|
|
130
|
+
('Cut', 'entity', 'Sequence'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
131
|
+
('PublishedFile', 'version', 'Version'): _RetirementRule(
|
|
132
|
+
'hide', 'always', touched=False, settled=True
|
|
133
|
+
),
|
|
134
|
+
('CutItem', 'version', 'Version'): _RetirementRule(
|
|
135
|
+
'hide', 'always', touched=False, settled=True
|
|
136
|
+
),
|
|
137
|
+
('Version', 'sg_task', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
138
|
+
('PublishedFile', 'task', 'Task'): _RetirementRule(
|
|
139
|
+
'hide', 'always', touched=False, settled=True
|
|
140
|
+
),
|
|
141
|
+
('Sequence', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
142
|
+
# 2026-11-10: retiring or reviving a Task alone adds no row for its Shot.
|
|
143
|
+
('Shot', 'tasks', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
144
|
+
('Shot', 'notes', 'Note'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
145
|
+
('Version', 'published_files', 'PublishedFile'): _RetirementRule(
|
|
146
|
+
'hide', 'always', touched=False, settled=False
|
|
147
|
+
),
|
|
148
|
+
('Cut', 'cut_items', 'CutItem'): _RetirementRule(
|
|
149
|
+
'hide', 'always', touched=False, settled=False
|
|
150
|
+
),
|
|
151
|
+
('Playlist', 'versions', 'Version'): _RetirementRule(
|
|
152
|
+
'hide', 'always', touched=True, settled=False
|
|
153
|
+
),
|
|
154
|
+
('Version', 'playlists', 'Playlist'): _RetirementRule(
|
|
155
|
+
'hide', 'always', touched=True, settled=False
|
|
156
|
+
),
|
|
157
|
+
('Asset', 'parents', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
158
|
+
('Asset', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
159
|
+
('Shot', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
160
|
+
('Asset', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
161
|
+
# 2026-11-10: a CutItem outlives its Cut with the link hidden and no rows.
|
|
162
|
+
('CutItem', 'cut', 'Cut'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
163
|
+
# 2026-11-10: a connection record retires with either end and revives with it.
|
|
164
|
+
('AssetShotConnection', 'shot', 'Shot'): _RetirementRule(
|
|
165
|
+
'cascade', 'always', touched=False, settled=False
|
|
166
|
+
),
|
|
167
|
+
('AssetShotConnection', 'asset', 'Asset'): _RetirementRule(
|
|
168
|
+
'cascade', 'always', touched=False, settled=False
|
|
169
|
+
),
|
|
170
|
+
('AssetAssetConnection', 'parent', 'Asset'): _RetirementRule(
|
|
171
|
+
'cascade', 'always', touched=False, settled=False
|
|
172
|
+
),
|
|
173
|
+
('AssetAssetConnection', 'asset', 'Asset'): _RetirementRule(
|
|
174
|
+
'cascade', 'always', touched=False, settled=False
|
|
175
|
+
),
|
|
176
|
+
('PlaylistVersionConnection', 'version', 'Version'): _RetirementRule(
|
|
177
|
+
'cascade', 'always', touched=False, settled=False
|
|
178
|
+
),
|
|
179
|
+
('PlaylistVersionConnection', 'playlist', 'Playlist'): _RetirementRule(
|
|
180
|
+
'cascade', 'always', touched=False, settled=False
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
_NOTE_COMBINED_LIMIT = 83
|
|
184
|
+
_NOTE_SUBJECT_LIMIT = 120
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class RetirementEdge:
|
|
189
|
+
"""Describe an immutable link retirement effect and its completeness.
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
effect: `hide`, `cascade`, or `taint`, describing the effect on the
|
|
193
|
+
holder when the target is retired.
|
|
194
|
+
touched: Whether hiding the link affects the holder's update audit
|
|
195
|
+
fields.
|
|
196
|
+
settled: Whether the edge's lifecycle-event effects are fully modeled.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
effect: str
|
|
200
|
+
touched: bool = False
|
|
201
|
+
settled: bool = False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class RecordState:
|
|
206
|
+
"""Describe an immutable possible retirement state and hidden outgoing links.
|
|
207
|
+
|
|
208
|
+
Attributes:
|
|
209
|
+
retired: Whether the record is retired in this assumed state.
|
|
210
|
+
hidden: Hidden outgoing links as `(field_name, target_type, target_id)`
|
|
211
|
+
tuples.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
retired: bool
|
|
215
|
+
hidden: frozenset[tuple[str, str, int]] = frozenset()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _display_values(store: Store, row: Row, fields: tuple[str, ...]) -> list[FieldValue]:
|
|
219
|
+
"""Read display inputs only after all their values are known."""
|
|
220
|
+
for field in fields:
|
|
221
|
+
store.check_known(row, field)
|
|
222
|
+
return [row.get(field) for field in fields]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _listed(holder: Row, field_name: str, link: Mapping[str, FieldValue]) -> bool:
|
|
226
|
+
"""Identify an outgoing link listed by the holder's taint."""
|
|
227
|
+
taint = cast('dict[str, FieldValue]', holder.get('__taint', {}))
|
|
228
|
+
return [field_name, link['type'], link['id']] in cast(
|
|
229
|
+
'list[FieldValue]', taint.get('links', [])
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
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')
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _unsettled(entity_type: str, fields: tuple[str, ...]) -> MockgunError:
|
|
253
|
+
"""Identify the unresolved display fields in a naming failure."""
|
|
254
|
+
paths = ', '.join(f'{entity_type}.{field}' for field in fields)
|
|
255
|
+
return MockgunError(f'{paths} text display name is not modeled for these values')
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _note_name(row: Row) -> tuple[str, bool]:
|
|
259
|
+
"""Return the observed whole or cut Note name, rejecting unsettled forms."""
|
|
260
|
+
subject = cast('str | None', row.get('subject'))
|
|
261
|
+
content = cast('str | None', row.get('content'))
|
|
262
|
+
error = _unsettled('Note', ('subject', 'content'))
|
|
263
|
+
if not (subject or content) or any('\r' in value for value in (subject, content) if value):
|
|
264
|
+
raise error
|
|
265
|
+
if subject and content:
|
|
266
|
+
name = f'{subject} - {content}'
|
|
267
|
+
if len(name) > _NOTE_COMBINED_LIMIT:
|
|
268
|
+
raise error
|
|
269
|
+
elif subject:
|
|
270
|
+
name = subject
|
|
271
|
+
if len(name) > _NOTE_SUBJECT_LIMIT:
|
|
272
|
+
raise error
|
|
273
|
+
else:
|
|
274
|
+
name = cast('str', content)
|
|
275
|
+
if len(name) > _DISPLAY_LIMIT:
|
|
276
|
+
prefix = name[:_DISPLAY_LIMIT]
|
|
277
|
+
if not all(
|
|
278
|
+
character.isascii() and (character.isalnum() or character == '-')
|
|
279
|
+
for character in prefix
|
|
280
|
+
):
|
|
281
|
+
raise error
|
|
282
|
+
return prefix, True
|
|
283
|
+
return name, False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _field_name(
|
|
287
|
+
store: Store, entity_type: str, entity_id: int, row: Row, fields: tuple[str, ...]
|
|
288
|
+
) -> tuple[str, bool]:
|
|
289
|
+
"""Name a record from its display fields in its type's observed form."""
|
|
290
|
+
values = _display_values(store, row, fields)
|
|
291
|
+
if entity_type == 'Note':
|
|
292
|
+
return _note_name(row)
|
|
293
|
+
if entity_type == 'Task' and row.get('content') is None:
|
|
294
|
+
return '-', True
|
|
295
|
+
if any(value is None for value in values):
|
|
296
|
+
raise _unsettled(entity_type, fields)
|
|
297
|
+
name = ' '.join(cast('list[str]', values))
|
|
298
|
+
# C9: a Ticket is named `#<id>: <title>`.
|
|
299
|
+
return (f'#{entity_id}: {name}' if entity_type == 'Ticket' else name), False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _display(store: Store, entity_type: str, entity_id: int) -> tuple[str | None, bool]:
|
|
303
|
+
"""Resolve naming precedence against the current stored or candidate row."""
|
|
304
|
+
if not store.schema.declares(entity_type):
|
|
305
|
+
return None, False
|
|
306
|
+
row = store.row(entity_type, entity_id)
|
|
307
|
+
if row is None:
|
|
308
|
+
return None, False
|
|
309
|
+
actor = store.actor_key(entity_type, entity_id)
|
|
310
|
+
if actor is not None and (
|
|
311
|
+
entity_type == 'ApiUser' or not store.schema.declares(entity_type, 'name')
|
|
312
|
+
):
|
|
313
|
+
name, derived = actor, False
|
|
314
|
+
else:
|
|
315
|
+
fields = _DISPLAY_FIELDS.get(entity_type)
|
|
316
|
+
if fields is None:
|
|
317
|
+
raise _unsettled(entity_type, ('cached_display_name',))
|
|
318
|
+
if not all(store.schema.declares(entity_type, field) for field in fields):
|
|
319
|
+
return None, False
|
|
320
|
+
name, derived = _field_name(store, entity_type, entity_id, row, fields)
|
|
321
|
+
if len(name) > _DISPLAY_LIMIT or (
|
|
322
|
+
not name.isascii() and len(name.encode('utf-8')) > _DISPLAY_LIMIT
|
|
323
|
+
):
|
|
324
|
+
raise _unsettled(entity_type, _DISPLAY_FIELDS[entity_type])
|
|
325
|
+
return name, derived
|
|
326
|
+
|
|
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
|
+
|
|
367
|
+
def derived_name(store: Store, entity_type: str, entity_id: int) -> bool:
|
|
368
|
+
"""Return whether a modeled name uses Task fallback or Note truncation.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
MockgunError: If the target's display name is unsettled.
|
|
372
|
+
"""
|
|
373
|
+
return _display(store, entity_type, entity_id)[1]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def display_name(store: Store, entity_type: str, entity_id: int) -> str | None:
|
|
377
|
+
"""Return the current display name, or `None` when its inputs are unmodeled.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
MockgunError: If the target's display name is unsettled.
|
|
381
|
+
"""
|
|
382
|
+
return _display(store, entity_type, entity_id)[0]
|
|
383
|
+
|
|
384
|
+
|
|
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())
|
|
388
|
+
|
|
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).
|
|
395
|
+
"""
|
|
396
|
+
member: FieldValue = [field_name, target['type'], target['id']]
|
|
397
|
+
return member not in cast('list[FieldValue]', holder.get('__retired_hidden', []))
|
|
398
|
+
|
|
399
|
+
|
|
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)
|
|
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:
|
|
473
|
+
return RetirementEdge('taint')
|
|
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')
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def deciding_record(
|
|
481
|
+
store: Store, holder: Row, field_name: str, link: Mapping[str, FieldValue]
|
|
482
|
+
) -> Row | None:
|
|
483
|
+
"""Return the record whose possible states decide an uncertain link.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
store: Site records used to resolve the link target.
|
|
487
|
+
holder: Stored record containing the outgoing link.
|
|
488
|
+
field_name: Holder field containing the link.
|
|
489
|
+
link: Target identity with `type` and `id`.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
The holder, target or connection row whose state decides link
|
|
493
|
+
visibility, or `None` when no modeled uncertainty requires a deciding
|
|
494
|
+
record.
|
|
495
|
+
"""
|
|
496
|
+
target = link_target(store, link)
|
|
497
|
+
if target is None or (target['type'], target['id']) == (holder['type'], holder['id']):
|
|
498
|
+
return None
|
|
499
|
+
if _listed(holder, field_name, link):
|
|
500
|
+
return holder
|
|
501
|
+
if _uncertain(target):
|
|
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
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def hidden(
|
|
511
|
+
store: Store,
|
|
512
|
+
holder: Row,
|
|
513
|
+
field_name: str,
|
|
514
|
+
link: Mapping[str, FieldValue],
|
|
515
|
+
*,
|
|
516
|
+
assume: Mapping[tuple[str, int], RecordState] | None = None,
|
|
517
|
+
) -> bool:
|
|
518
|
+
"""Resolve observed link hiding under optional record-state assumptions.
|
|
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
|
+
|
|
525
|
+
Args:
|
|
526
|
+
store: Site records used to resolve link targets.
|
|
527
|
+
holder: Stored record containing the outgoing link.
|
|
528
|
+
field_name: Holder field containing the link.
|
|
529
|
+
link: Target identity with `type` and `id`.
|
|
530
|
+
assume: Optional states keyed by `(entity_type, entity_id)` for records
|
|
531
|
+
whose visibility is uncertain.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Whether the link is hidden under the observed rules and supplied
|
|
535
|
+
assumptions.
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
MockgunError: If visibility depends on an uncertain record for which no
|
|
539
|
+
explicit state was supplied.
|
|
540
|
+
"""
|
|
541
|
+
target = link_target(store, link)
|
|
542
|
+
if target is None or (target['type'], target['id']) == (holder['type'], holder['id']):
|
|
543
|
+
return False
|
|
544
|
+
identity = (cast('str', holder['type']), cast('int', holder['id']))
|
|
545
|
+
holder_state = assume.get(identity) if assume is not None else None
|
|
546
|
+
retired = holder_state.retired if holder_state is not None else bool(holder['__retired'])
|
|
547
|
+
deciding = deciding_record(store, holder, field_name, link)
|
|
548
|
+
if deciding is not None:
|
|
549
|
+
key = (cast('str', deciding['type']), cast('int', deciding['id']))
|
|
550
|
+
state = assume.get(key) if assume is not None else None
|
|
551
|
+
if state is None:
|
|
552
|
+
raise MockgunError(
|
|
553
|
+
f'{holder["type"]} {holder["id"]}.{field_name}: {store.tainted(deciding)}'
|
|
554
|
+
)
|
|
555
|
+
if deciding is holder:
|
|
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)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Construct independent credential sessions against a shared fake site."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shotgun_api3._client import ConstructorOptions
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
from shotgun_api3._config import Config
|
|
15
|
+
|
|
16
|
+
from ._server import FakeServer
|
|
17
|
+
|
|
18
|
+
_CREDENTIAL_FIELDS = (
|
|
19
|
+
'script_name',
|
|
20
|
+
'api_key',
|
|
21
|
+
'login',
|
|
22
|
+
'password',
|
|
23
|
+
'sudo_as_login',
|
|
24
|
+
'session_token',
|
|
25
|
+
'auth_token',
|
|
26
|
+
)
|
|
27
|
+
_DEFAULT_SCRIPT = 'mockgun'
|
|
28
|
+
SHARED_SERVER: ContextVar[FakeServer | None] = ContextVar('mockgun_shared_server', default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def copy_client_settings(source: Config, target: Config) -> None:
|
|
32
|
+
"""Copy timeout, retry and user-agent settings into a newly constructed client.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
source: Original client's current configuration.
|
|
36
|
+
target: New client's configuration, updated atomically.
|
|
37
|
+
"""
|
|
38
|
+
target.update_fields(
|
|
39
|
+
timeout_secs=source.timeout_secs,
|
|
40
|
+
max_rpc_attempts=source.max_rpc_attempts,
|
|
41
|
+
rpc_attempt_interval=source.rpc_attempt_interval,
|
|
42
|
+
user_agents=source.user_agents,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_credentials(options: ConstructorOptions) -> ConstructorOptions:
|
|
47
|
+
"""Give a directly constructed client without credentials the default script's.
|
|
48
|
+
|
|
49
|
+
Earlier mockgun versions accepted a base URL alone, so tests written for
|
|
50
|
+
them keep working. A partial or combined credential form still fails as the
|
|
51
|
+
real client's validation decides.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
options: Bound constructor arguments of a client that owns a new site.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The same options, or a copy acting as the `mockgun` script when no
|
|
58
|
+
credential other than `sudo_as_login` was supplied.
|
|
59
|
+
"""
|
|
60
|
+
if any(
|
|
61
|
+
getattr(options, name) is not None
|
|
62
|
+
for name in _CREDENTIAL_FIELDS
|
|
63
|
+
if name != 'sudo_as_login'
|
|
64
|
+
):
|
|
65
|
+
return options
|
|
66
|
+
return dataclasses.replace(options, script_name=_DEFAULT_SCRIPT, api_key=_DEFAULT_SCRIPT)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def new_client_options(
|
|
70
|
+
options: ConstructorOptions, credentials: Mapping[str, object]
|
|
71
|
+
) -> ConstructorOptions:
|
|
72
|
+
"""Replace credentials while retaining original noncredential constructor values.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
options: Original bound constructor arguments.
|
|
76
|
+
credentials: New credentials; a lone `sudo_as_login` keeps the original
|
|
77
|
+
credentials and replaces only the impersonated login.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Bound constructor arguments for the new client.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
TypeError: If a key is not a credential argument.
|
|
84
|
+
"""
|
|
85
|
+
for name in credentials:
|
|
86
|
+
if name not in _CREDENTIAL_FIELDS:
|
|
87
|
+
raise TypeError(f"new_client() got an unexpected keyword argument '{name}'")
|
|
88
|
+
values = dataclasses.asdict(options)
|
|
89
|
+
if set(credentials) != {'sudo_as_login'}:
|
|
90
|
+
values.update(dict.fromkeys(_CREDENTIAL_FIELDS))
|
|
91
|
+
values.update(credentials)
|
|
92
|
+
return ConstructorOptions(**values)
|