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,440 @@
|
|
|
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 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
|
+
_RETIREMENT_EDGES = {
|
|
47
|
+
('Attachment', 'attachment_links', 'Note'): _RetirementRule(
|
|
48
|
+
'keep', 'always', touched=False, settled=False
|
|
49
|
+
),
|
|
50
|
+
('Attachment', 'attachment_links', 'Version'): _RetirementRule(
|
|
51
|
+
'keep', 'always', touched=False, settled=False
|
|
52
|
+
),
|
|
53
|
+
('Note', 'attachments', 'Attachment'): _RetirementRule(
|
|
54
|
+
'keep', 'always', touched=False, settled=False
|
|
55
|
+
),
|
|
56
|
+
('Task', 'entity', 'Shot'): _RetirementRule('cascade', 'always', touched=False, settled=False),
|
|
57
|
+
('Version', 'entity', 'Shot'): _RetirementRule(
|
|
58
|
+
'cascade', 'always', touched=False, settled=False
|
|
59
|
+
),
|
|
60
|
+
('Task', 'entity', 'Asset'): _RetirementRule(
|
|
61
|
+
'cascade', 'always', touched=False, settled=False
|
|
62
|
+
),
|
|
63
|
+
('Task', 'entity', 'Sequence'): _RetirementRule(
|
|
64
|
+
'cascade', 'always', touched=False, settled=False
|
|
65
|
+
),
|
|
66
|
+
('Version', 'entity', 'Asset'): _RetirementRule(
|
|
67
|
+
'cascade', 'always', touched=False, settled=False
|
|
68
|
+
),
|
|
69
|
+
('Version', 'entity', 'Sequence'): _RetirementRule(
|
|
70
|
+
'cascade', 'always', touched=False, settled=False
|
|
71
|
+
),
|
|
72
|
+
('Note', 'note_links', 'Shot'): _RetirementRule(
|
|
73
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
74
|
+
),
|
|
75
|
+
('Note', 'note_links', 'Asset'): _RetirementRule(
|
|
76
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
77
|
+
),
|
|
78
|
+
('Note', 'note_links', 'Sequence'): _RetirementRule(
|
|
79
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
80
|
+
),
|
|
81
|
+
('Note', 'note_links', 'Version'): _RetirementRule(
|
|
82
|
+
'cascade', 'note_links', touched=False, settled=False
|
|
83
|
+
),
|
|
84
|
+
('Note', 'tasks', 'Task'): _RetirementRule('cascade', 'tasks', touched=False, settled=False),
|
|
85
|
+
('Reply', 'entity', 'Note'): _RetirementRule(
|
|
86
|
+
'cascade', 'always', touched=False, settled=False
|
|
87
|
+
),
|
|
88
|
+
('Shot', 'project', 'Project'): _RetirementRule(
|
|
89
|
+
'cascade', 'project', touched=False, settled=False
|
|
90
|
+
),
|
|
91
|
+
('Asset', 'project', 'Project'): _RetirementRule(
|
|
92
|
+
'cascade', 'project', touched=False, settled=False
|
|
93
|
+
),
|
|
94
|
+
('Sequence', 'project', 'Project'): _RetirementRule(
|
|
95
|
+
'cascade', 'project', touched=False, settled=False
|
|
96
|
+
),
|
|
97
|
+
('Task', 'project', 'Project'): _RetirementRule(
|
|
98
|
+
'cascade', 'project', touched=False, settled=False
|
|
99
|
+
),
|
|
100
|
+
('Version', 'project', 'Project'): _RetirementRule(
|
|
101
|
+
'cascade', 'project', touched=False, settled=False
|
|
102
|
+
),
|
|
103
|
+
('Note', 'project', 'Project'): _RetirementRule(
|
|
104
|
+
'cascade', 'note_project', touched=False, settled=False
|
|
105
|
+
),
|
|
106
|
+
('PublishedFile', 'entity', 'Shot'): _RetirementRule(
|
|
107
|
+
'hide', 'always', touched=False, settled=True
|
|
108
|
+
),
|
|
109
|
+
('CutItem', 'shot', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
110
|
+
('PublishedFile', 'entity', 'Asset'): _RetirementRule(
|
|
111
|
+
'hide', 'always', touched=False, settled=False
|
|
112
|
+
),
|
|
113
|
+
('Shot', 'sg_sequence', 'Sequence'): _RetirementRule(
|
|
114
|
+
'hide', 'always', touched=False, settled=True
|
|
115
|
+
),
|
|
116
|
+
('Cut', 'entity', 'Sequence'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
117
|
+
('PublishedFile', 'version', 'Version'): _RetirementRule(
|
|
118
|
+
'hide', 'always', touched=False, settled=True
|
|
119
|
+
),
|
|
120
|
+
('CutItem', 'version', 'Version'): _RetirementRule(
|
|
121
|
+
'hide', 'always', touched=False, settled=True
|
|
122
|
+
),
|
|
123
|
+
('Version', 'sg_task', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=True),
|
|
124
|
+
('PublishedFile', 'task', 'Task'): _RetirementRule(
|
|
125
|
+
'hide', 'always', touched=False, settled=False
|
|
126
|
+
),
|
|
127
|
+
('Sequence', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
128
|
+
('Shot', 'tasks', 'Task'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
129
|
+
('Shot', 'notes', 'Note'): _RetirementRule('hide', 'always', touched=False, settled=False),
|
|
130
|
+
('Version', 'published_files', 'PublishedFile'): _RetirementRule(
|
|
131
|
+
'hide', 'always', touched=False, settled=False
|
|
132
|
+
),
|
|
133
|
+
('Cut', 'cut_items', 'CutItem'): _RetirementRule(
|
|
134
|
+
'hide', 'always', touched=False, settled=False
|
|
135
|
+
),
|
|
136
|
+
('Playlist', 'versions', 'Version'): _RetirementRule(
|
|
137
|
+
'hide', 'always', touched=True, settled=False
|
|
138
|
+
),
|
|
139
|
+
('Version', 'playlists', 'Playlist'): _RetirementRule(
|
|
140
|
+
'hide', 'always', touched=True, settled=False
|
|
141
|
+
),
|
|
142
|
+
('Asset', 'parents', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
143
|
+
('Asset', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
144
|
+
('Shot', 'assets', 'Asset'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
145
|
+
('Asset', 'shots', 'Shot'): _RetirementRule('hide', 'always', touched=True, settled=False),
|
|
146
|
+
('CutItem', 'cut', 'Cut'): _RetirementRule('keep', 'always', touched=False, settled=False),
|
|
147
|
+
}
|
|
148
|
+
_NOTE_COMBINED_LIMIT = 83
|
|
149
|
+
_NOTE_SUBJECT_LIMIT = 120
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class RetirementEdge:
|
|
154
|
+
"""Describe an immutable link retirement effect and its completeness.
|
|
155
|
+
|
|
156
|
+
Attributes:
|
|
157
|
+
effect: `hide`, `keep`, `cascade`, or `taint`, describing the effect on
|
|
158
|
+
the holder when the target is retired.
|
|
159
|
+
touched: Whether hiding the link affects the holder's update audit
|
|
160
|
+
fields.
|
|
161
|
+
settled: Whether the edge's lifecycle-event effects are fully modeled.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
effect: str
|
|
165
|
+
touched: bool = False
|
|
166
|
+
settled: bool = False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class RecordState:
|
|
171
|
+
"""Describe an immutable possible retirement state and hidden outgoing links.
|
|
172
|
+
|
|
173
|
+
Attributes:
|
|
174
|
+
retired: Whether the record is retired in this assumed state.
|
|
175
|
+
hidden: Hidden outgoing links as `(field_name, target_type, target_id)`
|
|
176
|
+
tuples.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
retired: bool
|
|
180
|
+
hidden: frozenset[tuple[str, str, int]] = frozenset()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _display_values(store: Store, row: Row, fields: tuple[str, ...]) -> list[FieldValue]:
|
|
184
|
+
"""Read display inputs only after all their values are known."""
|
|
185
|
+
for field in fields:
|
|
186
|
+
store.check_known(row, field)
|
|
187
|
+
return [row.get(field) for field in fields]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _listed(holder: Row, field_name: str, link: Mapping[str, FieldValue]) -> bool:
|
|
191
|
+
"""Identify an outgoing link listed by the holder's taint."""
|
|
192
|
+
taint = cast('dict[str, FieldValue]', holder.get('__taint', {}))
|
|
193
|
+
return [field_name, link['type'], link['id']] in cast(
|
|
194
|
+
'list[FieldValue]', taint.get('links', [])
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _note_edge(store: Store, holder: Row, target: Row) -> RetirementEdge:
|
|
199
|
+
"""Resolve the two observed Note.note_links shapes."""
|
|
200
|
+
links = cast('list[dict[str, FieldValue]]', holder.get('note_links', []))
|
|
201
|
+
if holder.get('tasks'):
|
|
202
|
+
return RetirementEdge('taint')
|
|
203
|
+
if len(links) == 1:
|
|
204
|
+
return RetirementEdge('cascade')
|
|
205
|
+
others = [
|
|
206
|
+
link for link in links if (link['type'], link['id']) != (target['type'], target['id'])
|
|
207
|
+
]
|
|
208
|
+
if (
|
|
209
|
+
len(others) == 1
|
|
210
|
+
and len(links) == len(others) + 1
|
|
211
|
+
and target['type'] == 'Shot'
|
|
212
|
+
and others[0]['type'] == 'Asset'
|
|
213
|
+
):
|
|
214
|
+
other = store.row('Asset', cast('int', others[0]['id']))
|
|
215
|
+
if other is not None and not other['__retired'] and not store.tainted(other):
|
|
216
|
+
return RetirementEdge('hide')
|
|
217
|
+
return RetirementEdge('taint')
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _unsettled(entity_type: str, fields: tuple[str, ...]) -> MockgunError:
|
|
221
|
+
"""Identify the unresolved display fields in a naming failure."""
|
|
222
|
+
paths = ', '.join(f'{entity_type}.{field}' for field in fields)
|
|
223
|
+
return MockgunError(f'{paths} text display name is not modeled for these values')
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _note_name(row: Row) -> tuple[str, bool]:
|
|
227
|
+
"""Return the observed whole or cut Note name, rejecting unsettled forms."""
|
|
228
|
+
subject = cast('str | None', row.get('subject'))
|
|
229
|
+
content = cast('str | None', row.get('content'))
|
|
230
|
+
error = _unsettled('Note', ('subject', 'content'))
|
|
231
|
+
if not (subject or content) or any('\r' in value for value in (subject, content) if value):
|
|
232
|
+
raise error
|
|
233
|
+
if subject and content:
|
|
234
|
+
name = f'{subject} - {content}'
|
|
235
|
+
if len(name) > _NOTE_COMBINED_LIMIT:
|
|
236
|
+
raise error
|
|
237
|
+
elif subject:
|
|
238
|
+
name = subject
|
|
239
|
+
if len(name) > _NOTE_SUBJECT_LIMIT:
|
|
240
|
+
raise error
|
|
241
|
+
else:
|
|
242
|
+
name = cast('str', content)
|
|
243
|
+
if len(name) > _DISPLAY_LIMIT:
|
|
244
|
+
prefix = name[:_DISPLAY_LIMIT]
|
|
245
|
+
if not all(
|
|
246
|
+
character.isascii() and (character.isalnum() or character == '-')
|
|
247
|
+
for character in prefix
|
|
248
|
+
):
|
|
249
|
+
raise error
|
|
250
|
+
return prefix, True
|
|
251
|
+
return name, False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _field_name(
|
|
255
|
+
store: Store, entity_type: str, entity_id: int, row: Row, fields: tuple[str, ...]
|
|
256
|
+
) -> tuple[str, bool]:
|
|
257
|
+
"""Name a record from its display fields in its type's observed form."""
|
|
258
|
+
values = _display_values(store, row, fields)
|
|
259
|
+
if entity_type == 'Note':
|
|
260
|
+
return _note_name(row)
|
|
261
|
+
if entity_type == 'Task' and row.get('content') is None:
|
|
262
|
+
return '-', True
|
|
263
|
+
if any(value is None for value in values):
|
|
264
|
+
raise _unsettled(entity_type, fields)
|
|
265
|
+
name = ' '.join(cast('list[str]', values))
|
|
266
|
+
# C9: a Ticket is named `#<id>: <title>`.
|
|
267
|
+
return (f'#{entity_id}: {name}' if entity_type == 'Ticket' else name), False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _display(store: Store, entity_type: str, entity_id: int) -> tuple[str | None, bool]:
|
|
271
|
+
"""Resolve naming precedence against the current stored or candidate row."""
|
|
272
|
+
if not store.schema.declares(entity_type):
|
|
273
|
+
return None, False
|
|
274
|
+
row = store.row(entity_type, entity_id)
|
|
275
|
+
if row is None:
|
|
276
|
+
return None, False
|
|
277
|
+
actor = store.actor_key(entity_type, entity_id)
|
|
278
|
+
if actor is not None and (
|
|
279
|
+
entity_type == 'ApiUser' or not store.schema.declares(entity_type, 'name')
|
|
280
|
+
):
|
|
281
|
+
name, derived = actor, False
|
|
282
|
+
else:
|
|
283
|
+
fields = _DISPLAY_FIELDS.get(entity_type)
|
|
284
|
+
if fields is None:
|
|
285
|
+
raise _unsettled(entity_type, ('cached_display_name',))
|
|
286
|
+
if not all(store.schema.declares(entity_type, field) for field in fields):
|
|
287
|
+
return None, False
|
|
288
|
+
name, derived = _field_name(store, entity_type, entity_id, row, fields)
|
|
289
|
+
if len(name) > _DISPLAY_LIMIT or (
|
|
290
|
+
not name.isascii() and len(name.encode('utf-8')) > _DISPLAY_LIMIT
|
|
291
|
+
):
|
|
292
|
+
raise _unsettled(entity_type, _DISPLAY_FIELDS[entity_type])
|
|
293
|
+
return name, derived
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def derived_name(store: Store, entity_type: str, entity_id: int) -> bool:
|
|
297
|
+
"""Return whether a modeled name uses Task fallback or Note truncation.
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
MockgunError: If the target's display name is unsettled.
|
|
301
|
+
"""
|
|
302
|
+
return _display(store, entity_type, entity_id)[1]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def display_name(store: Store, entity_type: str, entity_id: int) -> str | None:
|
|
306
|
+
"""Return the current display name, or `None` when its inputs are unmodeled.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
MockgunError: If the target's display name is unsettled.
|
|
310
|
+
"""
|
|
311
|
+
return _display(store, entity_type, entity_id)[0]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def named_link(store: Store, link: Mapping[str, FieldValue]) -> dict[str, FieldValue]:
|
|
315
|
+
"""Return a detached identity with a name when the target is modeled.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
MockgunError: If the target's display name is unsettled.
|
|
319
|
+
"""
|
|
320
|
+
result = {'type': link['type'], 'id': link['id']}
|
|
321
|
+
name = display_name(store, cast('str', link['type']), cast('int', link['id']))
|
|
322
|
+
if name is not None:
|
|
323
|
+
result['name'] = name
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def retirement_edge(store: Store, holder: Row, field_name: str, target: Row) -> RetirementEdge:
|
|
328
|
+
"""Resolve a retirement edge, including its observed holder conditions."""
|
|
329
|
+
kind = cast('str', holder['type'])
|
|
330
|
+
target_type = cast('str', target['type'])
|
|
331
|
+
if kind == 'EventLogEntry' and field_name == 'entity':
|
|
332
|
+
return RetirementEdge('hide')
|
|
333
|
+
rule = _RETIREMENT_EDGES.get((kind, field_name, target_type))
|
|
334
|
+
if rule is None or (target_type == 'Project' and target.get('archived')):
|
|
335
|
+
return RetirementEdge('taint')
|
|
336
|
+
if rule.condition == 'note_links':
|
|
337
|
+
return _note_edge(store, holder, target)
|
|
338
|
+
if rule.condition == 'tasks' and (
|
|
339
|
+
holder.get('note_links') or len(cast('list[FieldValue]', holder.get('tasks', []))) != 1
|
|
340
|
+
):
|
|
341
|
+
return RetirementEdge('taint')
|
|
342
|
+
if rule.condition == 'note_project' and (holder.get('note_links') or holder.get('tasks')):
|
|
343
|
+
return RetirementEdge('taint')
|
|
344
|
+
return RetirementEdge(rule.effect, rule.touched, rule.settled)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def deciding_record(
|
|
348
|
+
store: Store, holder: Row, field_name: str, link: Mapping[str, FieldValue]
|
|
349
|
+
) -> Row | None:
|
|
350
|
+
"""Return the record whose possible states decide an uncertain link.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
store: Site records used to resolve the link target.
|
|
354
|
+
holder: Stored record containing the outgoing link.
|
|
355
|
+
field_name: Holder field containing the link.
|
|
356
|
+
link: Target identity with `type` and `id`.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
The live holder or target row whose state decides link visibility, or
|
|
360
|
+
`None` when no modeled uncertainty requires a deciding record.
|
|
361
|
+
"""
|
|
362
|
+
if (
|
|
363
|
+
not isinstance(link.get('type'), str)
|
|
364
|
+
or not isinstance(link.get('id'), int)
|
|
365
|
+
or not store.schema.declares(cast('str', link['type']))
|
|
366
|
+
):
|
|
367
|
+
return None
|
|
368
|
+
if _listed(holder, field_name, link):
|
|
369
|
+
return holder
|
|
370
|
+
target = store.row(cast('str', link['type']), cast('int', link['id']))
|
|
371
|
+
if target is None:
|
|
372
|
+
return None
|
|
373
|
+
edge = retirement_edge(store, holder, field_name, target)
|
|
374
|
+
if edge.effect == 'keep' and target['__retired']:
|
|
375
|
+
return holder
|
|
376
|
+
if (
|
|
377
|
+
edge.effect == 'hide'
|
|
378
|
+
and store.tainted(target)
|
|
379
|
+
and not cast('dict[str, FieldValue]', target['__taint']).get('active')
|
|
380
|
+
):
|
|
381
|
+
return target
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def hidden(
|
|
386
|
+
store: Store,
|
|
387
|
+
holder: Row,
|
|
388
|
+
field_name: str,
|
|
389
|
+
link: Mapping[str, FieldValue],
|
|
390
|
+
*,
|
|
391
|
+
assume: Mapping[tuple[str, int], RecordState] | None = None,
|
|
392
|
+
) -> bool:
|
|
393
|
+
"""Resolve observed link hiding under optional record-state assumptions.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
store: Site records used to resolve link targets.
|
|
397
|
+
holder: Stored record containing the outgoing link.
|
|
398
|
+
field_name: Holder field containing the link.
|
|
399
|
+
link: Target identity with `type` and `id`.
|
|
400
|
+
assume: Optional states keyed by `(entity_type, entity_id)` for records
|
|
401
|
+
whose visibility is uncertain.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Whether the link is hidden under the observed rules and supplied
|
|
405
|
+
assumptions. Retired holders retain their links.
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
MockgunError: If visibility depends on an uncertain record for which no
|
|
409
|
+
explicit state was supplied.
|
|
410
|
+
"""
|
|
411
|
+
identity = (cast('str', holder['type']), cast('int', holder['id']))
|
|
412
|
+
holder_state = assume.get(identity) if assume is not None else None
|
|
413
|
+
if holder_state.retired if holder_state is not None else holder['__retired']:
|
|
414
|
+
return False
|
|
415
|
+
deciding = deciding_record(store, holder, field_name, link)
|
|
416
|
+
if deciding is not None:
|
|
417
|
+
key = (cast('str', deciding['type']), cast('int', deciding['id']))
|
|
418
|
+
state = assume.get(key) if assume is not None else None
|
|
419
|
+
if state is None:
|
|
420
|
+
reason = store.tainted(deciding) or f'retired {link["type"]} {link["id"]} keep link'
|
|
421
|
+
raise MockgunError(f'{holder["type"]} {holder["id"]}.{field_name}: {reason}')
|
|
422
|
+
if deciding is holder:
|
|
423
|
+
return (field_name, cast('str', link['type']), cast('int', link['id'])) in state.hidden
|
|
424
|
+
return state.retired
|
|
425
|
+
if (
|
|
426
|
+
not isinstance(link.get('type'), str)
|
|
427
|
+
or not isinstance(link.get('id'), int)
|
|
428
|
+
or not store.schema.declares(cast('str', link['type']))
|
|
429
|
+
):
|
|
430
|
+
return False
|
|
431
|
+
target = store.row(cast('str', link['type']), cast('int', link['id']))
|
|
432
|
+
if target is None:
|
|
433
|
+
return False
|
|
434
|
+
edge = retirement_edge(store, holder, field_name, target)
|
|
435
|
+
hides = edge.effect == 'hide' or (holder['type'], field_name, target['type']) == (
|
|
436
|
+
'Version',
|
|
437
|
+
'entity',
|
|
438
|
+
'Shot',
|
|
439
|
+
)
|
|
440
|
+
return hides and bool(target['__retired'])
|
|
@@ -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)
|