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,378 @@
1
+ """Match current display names and construct bounded search results."""
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from itertools import groupby
6
+ from typing import cast
7
+
8
+ from ._errors import MockgunError, ServerFaultError
9
+ from ._links import derived_name, display_name, hidden
10
+ from ._query import ReadScope, check_collation, collation_key, project_rows, select
11
+ from ._schema import SiteSchema
12
+ from ._sessions import RequestContext
13
+ from ._store import Row, Store
14
+
15
+ _MIN_TEXT_LENGTH = 3
16
+ _SEARCHABLE_TYPES = {
17
+ 'Shot',
18
+ 'Asset',
19
+ 'Version',
20
+ 'Task',
21
+ 'Note',
22
+ 'Sequence',
23
+ 'Playlist',
24
+ 'Cut',
25
+ 'CutItem',
26
+ 'PublishedFile',
27
+ }
28
+ _UNSEARCHED_FIELDS = {
29
+ 'Shot': {'description'},
30
+ 'Asset': {'description'},
31
+ 'Task': {'sg_description'},
32
+ 'Version': {
33
+ 'client_code',
34
+ 'sg_department',
35
+ 'sg_path_to_movie',
36
+ 'sg_translation_type',
37
+ 'frame_range',
38
+ 'sg_path_to_frames',
39
+ 'description',
40
+ 'sg_path_to_geometry',
41
+ },
42
+ }
43
+
44
+
45
+ def _natural_key(name: str) -> list[str | int]:
46
+ """Split ASCII names into lowercased text runs and numeric digit runs."""
47
+ return [int(part) if part.isdecimal() else part.lower() for part in re.split(r'(\d+)', name)]
48
+
49
+
50
+ def _select_candidates(
51
+ store: Store,
52
+ schema: SiteSchema,
53
+ entity_types: dict[object, object],
54
+ scope: ReadScope,
55
+ *,
56
+ short_text: bool,
57
+ ) -> list[Row]:
58
+ """Validate each filter and collect its selected rows in request order."""
59
+ candidates: list[Row] = []
60
+ for entity_type, tree in entity_types.items():
61
+ if (
62
+ not isinstance(entity_type, str)
63
+ or not schema.declares(entity_type)
64
+ or not isinstance(tree, dict)
65
+ ):
66
+ raise MockgunError('mockgun cannot query_display_name_cache this request yet')
67
+ try:
68
+ rows = select(
69
+ store,
70
+ entity_type,
71
+ cast('dict[str, object]', tree),
72
+ [],
73
+ scope=scope,
74
+ )
75
+ except ServerFaultError as error:
76
+ if short_text:
77
+ raise MockgunError(
78
+ f'mockgun cannot query_display_name_cache short text and filter: {error}'
79
+ ) from error
80
+ raise
81
+ candidates.extend(rows)
82
+ return candidates
83
+
84
+
85
+ def _unsupported(row: Mapping[str, object], terms: list[str], reason: str) -> MockgunError:
86
+ """Identify the search candidate and terms whose behavior remains unsettled."""
87
+ return MockgunError(
88
+ f'mockgun cannot query_display_name_cache {row["type"]} {row["id"]} '
89
+ f'terms {terms!r}: {reason}'
90
+ )
91
+
92
+
93
+ def _candidate_name(store: Store, row: Row, terms: list[str]) -> str:
94
+ """Read a modeled, non-derived ASCII display name."""
95
+ kind, identity = cast('str', row['type']), cast('int', row['id'])
96
+ name = display_name(store, kind, identity)
97
+ if name is None or not name.isascii() or derived_name(store, kind, identity):
98
+ raise _unsupported(row, terms, 'unmodeled, non-ASCII or derived display name')
99
+ return name
100
+
101
+
102
+ def _match_links(store: Store, schema: SiteSchema, row: Row, terms: list[str]) -> list[str]:
103
+ """Return observed link labels, rejecting other link shapes."""
104
+ kind = cast('str', row['type'])
105
+ if kind in {'Task', 'Version'}:
106
+ raw_link = row.get('entity')
107
+ if isinstance(raw_link, dict) and hidden(store, row, 'entity', raw_link):
108
+ raise _unsupported(row, terms, 'hidden entity')
109
+ values = project_rows(
110
+ store, schema, [row], ['entity'] if schema.declares(kind, 'entity') else []
111
+ )[0]
112
+ link = cast('dict[str, object] | None', values.get('entity'))
113
+ if not isinstance(link, dict) or link.get('type') != 'Shot':
114
+ raise _unsupported(row, terms, 'entity is not a visible Shot')
115
+ metadata = cast('dict[str, object]', schema.entities.get('Shot', {}).get('name', {}))
116
+ if metadata.get('value') != 'Shot':
117
+ raise _unsupported(row, terms, 'Shot display label')
118
+ shot_id = cast('int', link['id'])
119
+ shot_name = display_name(store, 'Shot', shot_id)
120
+ if shot_name is None or derived_name(store, 'Shot', shot_id):
121
+ raise _unsupported(row, terms, 'Shot display name')
122
+ return ['Shot', shot_name]
123
+ if kind in {'Asset', 'Playlist'} and any(
124
+ row.get(field)
125
+ for field in schema.fields[kind]
126
+ if field not in {'project', 'created_by', 'updated_by'}
127
+ and schema.field_type(kind, field) in {'entity', 'multi_entity'}
128
+ ):
129
+ raise _unsupported(row, terms, 'stored entity links')
130
+ return ['', '']
131
+
132
+
133
+ def _match(
134
+ store: Store, schema: SiteSchema, row: Row, name: str, terms: list[str]
135
+ ) -> dict[str, object]:
136
+ """Project the observed match shape without reading unrelated linked values."""
137
+ kind = cast('str', row['type'])
138
+ links = _match_links(store, schema, row, terms)
139
+ fields = [
140
+ field for field in ('project', 'sg_status_list', 'image') if schema.declares(kind, field)
141
+ ]
142
+ if kind == 'Task' and schema.declares('Shot', 'image'):
143
+ fields.append('entity.Shot.image')
144
+ values = project_rows(store, schema, [row], fields)[0]
145
+ project = values.get('project')
146
+ if not isinstance(project, dict):
147
+ raise _unsupported(row, terms, 'missing project')
148
+ if values.get('image') is not None or values.get('entity.Shot.image') is not None:
149
+ raise _unsupported(row, terms, 'image')
150
+ return {
151
+ 'id': values['id'],
152
+ 'type': kind,
153
+ 'name': name,
154
+ 'project_id': project['id'],
155
+ 'links': links,
156
+ 'status': values.get('sg_status_list'),
157
+ 'image': None,
158
+ }
159
+
160
+
161
+ def _matches_name(store: Store, schema: SiteSchema, row: Row, name: str, terms: list[str]) -> bool:
162
+ """Match literal substrings while rejecting unsettled alternative search inputs."""
163
+ lowered = name.lower()
164
+ for term in terms:
165
+ if (
166
+ '_' in term
167
+ and term not in lowered
168
+ and re.search(re.escape(term).replace('_', '.'), lowered, flags=re.DOTALL)
169
+ ):
170
+ raise _unsupported(row, terms, f'wildcard interpretation of {term!r}')
171
+ if row['type'] == 'Note' and '-' in terms:
172
+ values = project_rows(store, schema, [row], ['subject', 'content'])[0]
173
+ subject, content = values.get('subject'), values.get('content')
174
+ if (
175
+ isinstance(subject, str)
176
+ and isinstance(content, str)
177
+ and '-' not in subject
178
+ and '-' not in content
179
+ ):
180
+ raise _unsupported(row, terms, 'Note name join')
181
+ if all(term in lowered for term in terms):
182
+ return True
183
+ kind = cast('str', row['type'])
184
+ fields = [
185
+ field
186
+ for field in schema.fields[kind]
187
+ if field != 'cached_display_name'
188
+ and field not in _UNSEARCHED_FIELDS.get(kind, set())
189
+ and schema.field_type(kind, field) == 'text'
190
+ ]
191
+ values = project_rows(store, schema, [row], fields)[0]
192
+ other_text = [value.lower() for field in fields if isinstance(value := values.get(field), str)]
193
+ if all(term in lowered or any(term in value for value in other_text) for term in terms):
194
+ raise _unsupported(row, terms, 'unobserved text fields')
195
+ return False
196
+
197
+
198
+ def _matching_rows(
199
+ store: Store, schema: SiteSchema, rows: list[Row], terms: list[str], project_ids: list[object]
200
+ ) -> list[dict[str, object]]:
201
+ """Keep supported matches after applying explicit Project selection."""
202
+ matches: list[dict[str, object]] = []
203
+ for row in rows:
204
+ kind = cast('str', row['type'])
205
+ if project_ids and schema.declares(kind, 'project'):
206
+ project = project_rows(store, schema, [row], ['project'])[0].get('project')
207
+ if isinstance(project, dict) and project['id'] not in project_ids:
208
+ continue
209
+ if kind == 'Reply':
210
+ continue
211
+ if kind not in _SEARCHABLE_TYPES:
212
+ raise _unsupported(row, terms, 'unobserved candidate type')
213
+ name = _candidate_name(store, row, terms)
214
+ if _matches_name(store, schema, row, name, terms):
215
+ match = _match(store, schema, row, name, terms)
216
+ # C8 Q2 observed one Shot-linked Task with a single-token name.
217
+ # Keep earlier answers for multi-row, spaced-name and multi-term forms.
218
+ if (
219
+ kind == 'Task'
220
+ and not project_ids
221
+ and len(rows) == 1
222
+ and len(terms) == 1
223
+ and not any(character.isspace() for character in name)
224
+ ):
225
+ continue
226
+ matches.append(match)
227
+ return matches
228
+
229
+
230
+ def _order_matches(matches: list[dict[str, object]], terms: list[str]) -> None:
231
+ """Order matches only where position and every surviving name order agree."""
232
+ if len(matches) <= 1:
233
+ return
234
+ error = _unsupported(matches[0], terms, 'unsettled match order')
235
+ if len(terms) != 1:
236
+ raise error
237
+ term = terms[0]
238
+ for match in matches:
239
+ name = cast('str', match['name']).lower()
240
+ if name.find(term, name.index(term) + 1) != -1:
241
+ raise error
242
+ positions = {cast('str', match['name']).lower().index(term) for match in matches}
243
+ if len(positions) > 1 and len({match['type'] for match in matches}) > 1:
244
+ raise error
245
+ matches.sort(key=lambda match: cast('str', match['name']).lower().index(term))
246
+ ordered: list[dict[str, object]] = []
247
+ for _, group in groupby(
248
+ matches, key=lambda match: cast('str', match['name']).lower().index(term)
249
+ ):
250
+ siblings = list(group)
251
+ names = [cast('str', match['name']) for match in siblings]
252
+ if (
253
+ len({len(name) for name in names}) != 1
254
+ or len({name.lower() for name in names}) != len(names)
255
+ or len({tuple(_natural_key(name)) for name in names}) != len(names)
256
+ ):
257
+ raise error
258
+ check_collation(
259
+ names,
260
+ entity_type=','.join(sorted({cast('str', match['type']) for match in siblings})),
261
+ field_name='name',
262
+ )
263
+ canonical = sorted(names)
264
+ if not (
265
+ canonical
266
+ == sorted(names, key=str.lower)
267
+ == sorted(names, key=collation_key)
268
+ == sorted(names, key=_natural_key)
269
+ ):
270
+ raise error
271
+ siblings.sort(key=lambda match: collation_key(cast('str', match['name'])))
272
+ ordered.extend(siblings)
273
+ matches[:] = ordered
274
+
275
+
276
+ def _validate_project_ids(
277
+ store: Store, schema: SiteSchema, ids: list[object], context: RequestContext
278
+ ) -> None:
279
+ """Require every requested Project to exist and remain active."""
280
+ if not ids:
281
+ return
282
+ if not schema.declares('Project') or any(type(item) is not int for item in ids):
283
+ raise MockgunError('mockgun cannot query_display_name_cache project_ids')
284
+ tree: dict[str, object] = {
285
+ 'logical_operator': 'and',
286
+ 'conditions': [{'path': 'id', 'relation': 'in', 'values': ids}],
287
+ }
288
+ active = select(
289
+ store,
290
+ 'Project',
291
+ tree,
292
+ [],
293
+ scope=ReadScope(retired=False, context=context, include_archived_projects=True),
294
+ )
295
+ retired = select(
296
+ store,
297
+ 'Project',
298
+ tree,
299
+ [],
300
+ scope=ReadScope(retired=True, context=context, include_archived_projects=True),
301
+ )
302
+ active_ids = {row['id'] for row in project_rows(store, schema, active, ['id'])}
303
+ retired_ids = {row['id'] for row in project_rows(store, schema, retired, ['id'])}
304
+ if any(item not in active_ids or item in retired_ids for item in ids):
305
+ raise MockgunError('mockgun cannot query_display_name_cache project_ids')
306
+
307
+
308
+ def _validate_text(query: str) -> None:
309
+ """Reject search text outside the characterized ASCII vocabulary."""
310
+ if (
311
+ query.startswith(' ')
312
+ or query.endswith(' ')
313
+ or (
314
+ len(query) >= _MIN_TEXT_LENGTH
315
+ and sum(character != ' ' for character in query) < _MIN_TEXT_LENGTH
316
+ )
317
+ or any(
318
+ not ('a' <= character <= 'z' or 'A' <= character <= 'Z' or '0' <= character <= '9')
319
+ and character not in ' -.,_'
320
+ for character in query
321
+ )
322
+ ):
323
+ raise MockgunError('mockgun cannot query_display_name_cache text')
324
+
325
+
326
+ def search(
327
+ store: Store, schema: SiteSchema, params: Mapping[str, object], *, context: RequestContext
328
+ ) -> dict[str, object]:
329
+ """Return current matching display names within the characterized search bounds.
330
+
331
+ Raises:
332
+ MockgunError: If request, matching, ordering or result values are
333
+ unsettled.
334
+ ServerFaultError: If the search text is too short or a filter is
335
+ invalid.
336
+ """
337
+ if params.keys() - {'text', 'entity_types', 'project_ids', 'max_results'}:
338
+ raise MockgunError('mockgun cannot query_display_name_cache unknown parameters')
339
+ query = params.get('text')
340
+ entity_types = params.get('entity_types')
341
+ project_ids = params.get('project_ids')
342
+ max_results = params.get('max_results')
343
+ if (
344
+ not isinstance(query, str)
345
+ or not isinstance(entity_types, dict)
346
+ or not entity_types
347
+ or not isinstance(project_ids, list)
348
+ or (max_results is not None and (type(max_results) is not int))
349
+ ):
350
+ raise MockgunError('mockgun cannot query_display_name_cache this request yet')
351
+ if max_results is not None and max_results < 1:
352
+ raise MockgunError('mockgun cannot query_display_name_cache max_results')
353
+ _validate_text(query)
354
+ requested_projects = cast('list[object]', project_ids)
355
+ _validate_project_ids(store, schema, requested_projects, context)
356
+
357
+ candidates = _select_candidates(
358
+ store,
359
+ schema,
360
+ cast('dict[object, object]', entity_types),
361
+ ReadScope(
362
+ retired=False,
363
+ context=context,
364
+ include_archived_projects=bool(requested_projects),
365
+ ),
366
+ short_text=len(query) < _MIN_TEXT_LENGTH,
367
+ )
368
+
369
+ if len(query) < _MIN_TEXT_LENGTH:
370
+ raise ServerFaultError(
371
+ 'API query_display_name_cache() query text too short! '
372
+ f'must be 3 or more characters :\n{params!r}',
373
+ error_code=103,
374
+ )
375
+ terms = query.lower().split()
376
+ matches = _matching_rows(store, schema, candidates, terms, requested_projects)
377
+ _order_matches(matches, terms)
378
+ return {'matches': matches[:max_results], 'terms': terms}
@@ -0,0 +1,169 @@
1
+ """Own the fake site's state and serialized RPC and file dispatch."""
2
+
3
+ from collections.abc import Generator, Mapping
4
+ from contextlib import contextmanager
5
+ from copy import deepcopy
6
+ from datetime import UTC, datetime
7
+ from threading import RLock
8
+ from typing import cast
9
+ from urllib.parse import urlsplit
10
+
11
+ from shotgun_api3._transport import TransportRequest, TransportResponse
12
+
13
+ from ._errors import MockgunError, ServerFaultError
14
+ from ._handlers import HANDLERS, UNWRAPPED_METHODS
15
+ from ._schema import SiteSchema
16
+ from ._sessions import RequestContext, Sessions
17
+ from ._site_data import SiteData
18
+ from ._storage import Storage
19
+ from ._store import Store
20
+ from ._uploads import handle_file_request
21
+ from ._wire import decode_call, fault_response, result_response
22
+
23
+ _DEFAULT_METADATA: dict[str, object] = {
24
+ 'version': [7, 10, 0],
25
+ 'full_version': [7, 10, 0, 0],
26
+ 's3_uploads_enabled': True,
27
+ 's3_direct_uploads_enabled': True,
28
+ 's3_enabled_upload_types': {'*': ['*']},
29
+ 'shotgunlocalhost_browser_integration_enabled': False,
30
+ 'unified_login_flow_enabled': False,
31
+ 'authentication_app_session_launcher_enabled': False,
32
+ 'analytics_site_id': 'mockgun',
33
+ 'analytics_site_name': 'mockgun',
34
+ 'user_authentication_method': 'default',
35
+ 'api_max_entities_per_page': 500,
36
+ }
37
+
38
+
39
+ class FakeServer:
40
+ """An isolated site's state and synchronous, serialized request dispatcher.
41
+
42
+ Attributes:
43
+ schema: Loaded declarations used by RPC handlers.
44
+ store: Records shared by every operation on this site.
45
+ storage: Uploaded bytes retained independently of record transactions.
46
+ site_data: Explicit preferences, work week, subscriptions and canned
47
+ responses.
48
+ site_host: First successful RPC hostname, or `None` before one
49
+ succeeds.
50
+ sessions: Credential admission and token identities shared by this
51
+ site.
52
+ """
53
+
54
+ def __init__(self, schema: SiteSchema) -> None:
55
+ """Create an independent site using the supplied schema snapshot."""
56
+ self.schema = schema
57
+ self.store = Store(schema)
58
+ self.storage = Storage()
59
+ self.site_data = SiteData()
60
+ self.site_host: str | None = None
61
+ self.sessions = Sessions(self.store, schema)
62
+ self._lock = RLock()
63
+ self._time: datetime | None = None
64
+ self._metadata = deepcopy(_DEFAULT_METADATA)
65
+
66
+ def admit_login(self, login: str) -> None:
67
+ """Admit a constructor login for later session resolution."""
68
+ with self._lock:
69
+ self.sessions.admit_login(login)
70
+
71
+ def now(self) -> datetime:
72
+ """Return the site's aware UTC time with whole-second precision."""
73
+ with self._lock:
74
+ return self._time or datetime.now(UTC).replace(microsecond=0)
75
+
76
+ def context_for(
77
+ self, auth: Mapping[str, object] | None, *, method_name: str
78
+ ) -> RequestContext:
79
+ """Capture the clock and resolve identities once for one request.
80
+
81
+ Call under the server's request lock for RPC or HTTP dispatch.
82
+
83
+ Args:
84
+ auth: Wire credentials, or `None` for an anonymous request.
85
+ method_name: RPC or route name controlling anonymous readiness.
86
+
87
+ Returns:
88
+ The request's actor, time, session UUID and sudo identities.
89
+
90
+ Raises:
91
+ ServerFaultError: If authentication fails.
92
+ MockgunError: If a credential combination is not emulated.
93
+ """
94
+ now = self.now()
95
+ credentials = auth or {}
96
+ actor = (
97
+ self.sessions.resolve(credentials, now=now)
98
+ if method_name != 'info' and (method_name == 'get_session_token' or auth is not None)
99
+ else None
100
+ )
101
+ sudo = isinstance(credentials.get('sudo_as_login'), str)
102
+ actual = self.sessions.script_actor(credentials, now=now) if sudo else None
103
+ return RequestContext(
104
+ actor,
105
+ now,
106
+ cast('str | None', credentials.get('session_uuid')),
107
+ sudo=sudo,
108
+ actual=actual,
109
+ )
110
+
111
+ def handle(self, request: TransportRequest) -> TransportResponse:
112
+ """Dispatch one complete request while holding the site's lock.
113
+
114
+ Raises:
115
+ MockgunError: If the request or RPC method is not emulated.
116
+ """
117
+ with self._lock:
118
+ parsed = urlsplit(request.url)
119
+ if request.method != 'POST' or not parsed.path.endswith('/api3/json'):
120
+ return handle_file_request(self, request)
121
+ try:
122
+ call = decode_call(request)
123
+ context = self.context_for(call.auth, method_name=call.method_name)
124
+ self.store.context = context
125
+ handler = HANDLERS.get(call.method_name)
126
+ if handler is None:
127
+ raise MockgunError(
128
+ f"mockgun does not emulate the server's {call.method_name!r} method yet"
129
+ )
130
+ result = handler(self, call, context)
131
+ if self.site_host is None:
132
+ self.site_host = parsed.hostname
133
+ return result_response(
134
+ result,
135
+ wrapped=call.method_name not in UNWRAPPED_METHODS,
136
+ )
137
+ except ServerFaultError as fault:
138
+ return fault_response(fault)
139
+
140
+ def server_metadata(self) -> dict[str, object]:
141
+ """Return a detached copy of the site's readiness metadata."""
142
+ with self._lock:
143
+ return deepcopy(self._metadata)
144
+
145
+ def set_time(self, when: datetime | None) -> None:
146
+ """Freeze at an aware time, or resume real UTC time with `None`.
147
+
148
+ Raises:
149
+ ValueError: If `when` is naive.
150
+ """
151
+ if when is not None and when.utcoffset() is None:
152
+ raise ValueError('site time must be an aware datetime')
153
+ with self._lock:
154
+ self._time = when.astimezone(UTC).replace(microsecond=0) if when is not None else None
155
+
156
+ @contextmanager
157
+ def site_data_update(self) -> Generator[SiteData, None, None]:
158
+ """Hold the server lock while configuring non-journaled site settings.
159
+
160
+ Yields:
161
+ The site's settings, accessible only while the lock is held.
162
+ """
163
+ with self._lock:
164
+ yield self.site_data
165
+
166
+ def update_server_metadata(self, values: Mapping[str, object]) -> None:
167
+ """Merge detached values into the site's readiness metadata."""
168
+ with self._lock:
169
+ self._metadata.update(deepcopy(values))