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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.0.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 +1031 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +558 -0
  28. shotgun_api3/lib/mockgun/_links.py +440 -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 +2163 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +368 -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 +1446 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +964 -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.0.dist-info}/WHEEL +0 -0
  56. {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)