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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.1.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1052 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +553 -0
  28. shotgun_api3/lib/mockgun/_links.py +598 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2205 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +463 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1559 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +965 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
  56. {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)