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,145 @@
1
+ """Connect real client drivers directly to an in-memory fake site."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator, Iterator
5
+ from dataclasses import replace
6
+
7
+ from shotgun_api3._async_transport import AsyncStreamingResponse
8
+ from shotgun_api3._transport import (
9
+ StreamingResponse,
10
+ TransportError,
11
+ TransportRequest,
12
+ TransportResponse,
13
+ )
14
+
15
+ from ._server import FakeServer
16
+
17
+
18
+ class AsyncInMemoryTransport:
19
+ """An async transport whose RPC handlers execute without yielding.
20
+
21
+ Logical leases and reserved children retain their lifetime bookkeeping;
22
+ no sockets or asynchronous resources are allocated.
23
+ """
24
+
25
+ def __init__(self, server: FakeServer) -> None:
26
+ """Bind transport requests to the supplied in-memory site."""
27
+ self._server = server
28
+ self._leases = 0
29
+ self._reserved: set[asyncio.Task[object]] = set()
30
+
31
+ def checkin(self) -> None:
32
+ """Release one logical request lease."""
33
+ self._leases -= 1
34
+
35
+ def checkout(self) -> None:
36
+ """Acquire one logical request lease."""
37
+ self._leases += 1
38
+
39
+ async def close(self) -> None:
40
+ """Release transport resources; the fake site remains available."""
41
+
42
+ def reserve(self, task: asyncio.Task[object]) -> None:
43
+ """Retain a child task until its completion callback runs.
44
+
45
+ Raises:
46
+ TransportError: If the child is already reserved.
47
+ """
48
+ if task in self._reserved:
49
+ raise TransportError('task is already reserved')
50
+ self._reserved.add(task)
51
+ task.add_done_callback(self._reserved.discard)
52
+
53
+ async def send(self, request: TransportRequest) -> TransportResponse:
54
+ """Dispatch one buffered request without yielding to the event loop.
55
+
56
+ Raises:
57
+ MockgunError: If the request or required fake-site behavior is
58
+ unsupported or unsettled.
59
+ Exception: If consuming a supplied request-body iterator fails.
60
+ """
61
+ body = request.body
62
+ if body is not None and not isinstance(body, bytes):
63
+ request = replace(request, body=b''.join(body))
64
+ return self._server.handle(request)
65
+
66
+ async def send_streaming(self, request: TransportRequest) -> AsyncStreamingResponse:
67
+ """Wrap a buffered site response as a chunked async response.
68
+
69
+ The request and complete response are processed before the streaming
70
+ wrapper is returned.
71
+
72
+ Raises:
73
+ MockgunError: If the request or required fake-site behavior is
74
+ unsupported or unsettled.
75
+ Exception: If consuming a supplied request-body iterator fails.
76
+ """
77
+ response = await self.send(request)
78
+ return AsyncStreamingResponse(
79
+ response.status,
80
+ response.reason,
81
+ response.headers,
82
+ lambda size: _async_chunks(response.content, size),
83
+ lambda: _close_stream(response),
84
+ )
85
+
86
+
87
+ class InMemoryTransport:
88
+ """A synchronous transport that allocates no network resources."""
89
+
90
+ def __init__(self, server: FakeServer) -> None:
91
+ """Bind transport requests to the supplied in-memory site."""
92
+ self._server = server
93
+
94
+ def close(self) -> None:
95
+ """Release transport resources; the fake site remains available."""
96
+
97
+ def send(self, request: TransportRequest) -> TransportResponse:
98
+ """Dispatch one request directly to the fake site.
99
+
100
+ Raises:
101
+ MockgunError: If the request or required fake-site behavior is
102
+ unsupported or unsettled.
103
+ Exception: If consuming a supplied request-body iterator fails.
104
+ """
105
+ body = request.body
106
+ if body is not None and not isinstance(body, bytes):
107
+ request = replace(request, body=b''.join(body))
108
+ return self._server.handle(request)
109
+
110
+ def send_streaming(self, request: TransportRequest) -> StreamingResponse:
111
+ """Wrap a buffered site response as a chunked synchronous response.
112
+
113
+ The request and complete response are processed before the streaming
114
+ wrapper is returned.
115
+
116
+ Raises:
117
+ MockgunError: If the request or required fake-site behavior is
118
+ unsupported or unsettled.
119
+ Exception: If consuming a supplied request-body iterator fails.
120
+ """
121
+ response = self.send(request)
122
+ return StreamingResponse(
123
+ response.status,
124
+ response.reason,
125
+ response.headers,
126
+ lambda size: _chunks(response.content, size),
127
+ response.close,
128
+ )
129
+
130
+
131
+ def _chunks(content: bytes, size: int) -> Iterator[bytes]:
132
+ """Yield buffered response bytes in the requested chunk size."""
133
+ for start in range(0, len(content), size):
134
+ yield content[start : start + size]
135
+
136
+
137
+ async def _async_chunks(content: bytes, size: int) -> AsyncIterator[bytes]:
138
+ """Yield buffered response bytes through an async iterator."""
139
+ for chunk in _chunks(content, size):
140
+ yield chunk
141
+
142
+
143
+ async def _close_stream(response: TransportResponse) -> None:
144
+ """Release the buffered response after an async stream closes."""
145
+ response.close()
@@ -0,0 +1,417 @@
1
+ """Dispatch observed upload forms and bind uploaded bytes to Attachments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from mimetypes import guess_type
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, cast
9
+ from urllib.parse import parse_qs, urlsplit
10
+
11
+ from shotgun_api3._transport import TransportRequest, TransportResponse
12
+
13
+ from ._activity import taint_activity
14
+ from ._errors import MockgunError, ServerFaultError
15
+ from ._events import record_attachment_upload
16
+ from ._storage import STORAGE_HOST, Allocation
17
+ from ._store import UPLOAD_LINK_REASON_PREFIX
18
+ from ._thumbnails import link_thumbnail, share_thumbnail
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+ from ._server import FakeServer
24
+ from ._sessions import RequestContext
25
+ from ._store import FieldValue, Row
26
+
27
+ _ASCII_LIMIT = 128
28
+ _AUTH_FIELDS = frozenset(
29
+ {
30
+ 'script_name',
31
+ 'script_key',
32
+ 'user_login',
33
+ 'user_password',
34
+ 'auth_token',
35
+ 'session_token',
36
+ 'sudo_as_login',
37
+ 'session_uuid',
38
+ }
39
+ )
40
+ _ROUTES: dict[tuple[str, str], Callable[[FakeServer, dict[str, str], RequestContext], str]] = {}
41
+
42
+
43
+ def _created_at(form: dict[str, str], route: str) -> datetime | None:
44
+ """Parse an aware optional creation instant without inventing a timezone."""
45
+ if 'created_at' not in form:
46
+ return None
47
+ entity_type = form.get('upload_type', 'Attachment')
48
+ try:
49
+ value = datetime.fromisoformat(form['created_at'])
50
+ except ValueError as error:
51
+ raise MockgunError(
52
+ f'mockgun POST {route} {entity_type}.created_at: datetime is unsettled'
53
+ ) from error
54
+ if value.utcoffset() is None:
55
+ raise MockgunError(
56
+ f'mockgun POST {route} {entity_type}.created_at: naive datetime is unsettled'
57
+ )
58
+ return value.astimezone(UTC)
59
+
60
+
61
+ def _attachment_fields(
62
+ server: FakeServer, owner: Row, allocation: Allocation, form: dict[str, str]
63
+ ) -> dict[str, object]:
64
+ """Select declared Attachment fields from the observed upload metadata."""
65
+ fields: dict[str, object] = {
66
+ 'filename': allocation.filename,
67
+ 'original_fname': allocation.filename,
68
+ 'display_name': form['display_name'],
69
+ 'attachment_links': [{'type': owner['type'], 'id': owner['id']}],
70
+ }
71
+ stamp = _created_at(form, '/upload/api_link_file')
72
+ if stamp is not None:
73
+ fields['created_at'] = stamp
74
+ if server.schema.declares(cast('str', owner['type']), 'project'):
75
+ server.store.check_known(owner, 'project')
76
+ fields['project'] = owner.get('project')
77
+ return {
78
+ key: value for key, value in fields.items() if server.schema.declares('Attachment', key)
79
+ }
80
+
81
+
82
+ def _fields(form: dict[str, str], route: str, names: set[str]) -> None:
83
+ """Reject unknown fields before the route changes state."""
84
+ for name in form.keys() - names:
85
+ raise MockgunError(f'mockgun POST {route} field {name}: form behavior is unsettled')
86
+
87
+
88
+ def _owner_changes(
89
+ server: FakeServer, owner: Row, attachment: Row, form: dict[str, str], known_file: bool
90
+ ) -> None:
91
+ """Apply observed owner links and mark unsettled computed fields unknown."""
92
+ store = server.store
93
+ entity_type = cast('str', owner['type'])
94
+ field = form.get('field_name')
95
+ store.remember(owner)
96
+ if field is not None and not known_file:
97
+ reason = (
98
+ 'Attachment.this_file declaration is missing'
99
+ if not server.schema.declares('Attachment', 'this_file')
100
+ else 'file kind outside the observed .txt family is unsettled'
101
+ )
102
+ store.mark_unknown(
103
+ owner,
104
+ field,
105
+ f'{UPLOAD_LINK_REASON_PREFIX}{reason}',
106
+ )
107
+ elif field is not None:
108
+ store.forget_unknown(owner, field)
109
+ if field is not None:
110
+ if entity_type == 'Version' and server.schema.declares(
111
+ entity_type, 'sg_uploaded_movie_transcoding_status'
112
+ ):
113
+ store.mark_unknown(
114
+ owner,
115
+ 'sg_uploaded_movie_transcoding_status',
116
+ f'{UPLOAD_LINK_REASON_PREFIX}transcoding status is unsettled',
117
+ )
118
+ elif server.schema.declares(entity_type, 'attachments'):
119
+ if entity_type == 'Note':
120
+ store.check_known(owner, 'attachments')
121
+ owner['attachments'] = [
122
+ *cast('list[FieldValue]', owner.get('attachments') or []),
123
+ {'type': 'Attachment', 'id': attachment['id']},
124
+ ]
125
+ else:
126
+ store.mark_unknown(
127
+ owner, 'attachments', f'{UPLOAD_LINK_REASON_PREFIX}inverse links are unsettled'
128
+ )
129
+ if field is not None or entity_type == 'Note':
130
+ for name in ('updated_at', 'updated_by'):
131
+ if server.schema.declares(entity_type, name):
132
+ store.mark_unknown(
133
+ owner, name, f'{UPLOAD_LINK_REASON_PREFIX}owner audit is unsettled'
134
+ )
135
+
136
+
137
+ def _create_attachment(
138
+ server: FakeServer,
139
+ owner: Row,
140
+ allocation: Allocation,
141
+ form: dict[str, str],
142
+ context: RequestContext,
143
+ ) -> tuple[Row, bool]:
144
+ """Create the Attachment and report whether its file family is observed."""
145
+ store = server.store
146
+ attachment = store.create(
147
+ 'Attachment', _attachment_fields(server, owner, allocation, form), context=context
148
+ )
149
+ known_file = (
150
+ Path(allocation.filename).suffix == '.txt'
151
+ and all(
152
+ byte < _ASCII_LIMIT
153
+ for byte in server.storage.read(f'https://{STORAGE_HOST}/{allocation.key}')
154
+ )
155
+ and server.schema.declares('Attachment', 'this_file')
156
+ )
157
+ file_value: Row = {
158
+ 'url': f'https://{STORAGE_HOST}/{allocation.key}',
159
+ 'name': form['display_name'],
160
+ 'content_type': 'text/plain' if known_file else guess_type(allocation.filename)[0],
161
+ 'link_type': 'upload',
162
+ 'type': 'Attachment',
163
+ 'id': attachment['id'],
164
+ }
165
+ if server.schema.declares('Attachment', 'this_file'):
166
+ attachment['this_file'] = file_value
167
+ if not known_file:
168
+ store.mark_unknown(
169
+ attachment,
170
+ 'this_file',
171
+ f'{UPLOAD_LINK_REASON_PREFIX}file kind outside the observed '
172
+ '.txt family is unsettled',
173
+ )
174
+ if server.schema.declares('Attachment', 'project') and not server.schema.declares(
175
+ cast('str', owner['type']), 'project'
176
+ ):
177
+ store.mark_unknown(
178
+ attachment,
179
+ 'project',
180
+ f'{UPLOAD_LINK_REASON_PREFIX}owner project declaration is missing',
181
+ )
182
+ # The owner field needs the dictionary even in a partial Attachment schema.
183
+ if 'field_name' in form:
184
+ store.remember(owner)
185
+ owner[form['field_name']] = file_value.copy()
186
+ _owner_changes(server, owner, attachment, form, known_file)
187
+ return attachment, known_file
188
+
189
+
190
+ def _route(
191
+ method: str, suffix: str
192
+ ) -> Callable[
193
+ [Callable[[FakeServer, dict[str, str], RequestContext], str]],
194
+ Callable[[FakeServer, dict[str, str], RequestContext], str],
195
+ ]:
196
+ """Register a file route by HTTP method and site path suffix."""
197
+
198
+ def register(
199
+ handler: Callable[[FakeServer, dict[str, str], RequestContext], str],
200
+ ) -> Callable[[FakeServer, dict[str, str], RequestContext], str]:
201
+ _ROUTES[method, suffix] = handler
202
+ return handler
203
+
204
+ return register
205
+
206
+
207
+ @_route('POST', '/upload/api_get_upload_link_info')
208
+ def _allocate(server: FakeServer, form: dict[str, str], context: RequestContext) -> str:
209
+ """Allocate an upload according to the client's selected direct route."""
210
+ route = '/upload/api_get_upload_link_info'
211
+ _fields(form, route, {'filename', 'upload_type', 'multipart_upload', 'created_at'})
212
+ _created_at(form, route)
213
+ if form['upload_type'] not in {'Attachment', 'Thumbnail'} or form['multipart_upload'] not in {
214
+ 'true',
215
+ 'false',
216
+ }:
217
+ raise MockgunError(
218
+ f'mockgun POST {route} upload_type {form["upload_type"]}: '
219
+ 'allocation kind or multipart_upload is unsettled'
220
+ )
221
+ return server.storage.allocate(
222
+ form['filename'],
223
+ form['upload_type'],
224
+ multipart=form['multipart_upload'] == 'true',
225
+ now=context.now,
226
+ ).encoded
227
+
228
+
229
+ @_route('POST', '/upload/api_complete_multipart_upload')
230
+ def _complete(server: FakeServer, form: dict[str, str], context: RequestContext) -> str:
231
+ """Complete the uploaded parts in their supplied ETag order."""
232
+ del context
233
+ _fields(
234
+ form,
235
+ '/upload/api_complete_multipart_upload',
236
+ {'etags', 'filename', 'timestamp', 'upload_id', 'upload_type'},
237
+ )
238
+ return str(server.storage.complete(form['upload_id'], form['etags'].split(',')))
239
+
240
+
241
+ @_route('POST', '/upload/api_get_upload_link_for_part')
242
+ def _part(server: FakeServer, form: dict[str, str], context: RequestContext) -> str:
243
+ """Issue a URL for one client-selected multipart part."""
244
+ del context
245
+ _fields(
246
+ form,
247
+ '/upload/api_get_upload_link_for_part',
248
+ {'filename', 'part_number', 'timestamp', 'upload_id', 'upload_type'},
249
+ )
250
+ return '1 ' + server.storage.part_url(form['upload_id'], int(form['part_number']))
251
+
252
+
253
+ @_route('POST', '/upload/api_link_file')
254
+ def _link(server: FakeServer, form: dict[str, str], context: RequestContext) -> str:
255
+ """Bind an uploaded file to its observed Attachment and owner links."""
256
+ route = '/upload/api_link_file'
257
+ allocation = server.storage.allocation(form['upload_link_info'])
258
+ if not server.storage.has_bytes(allocation.key):
259
+ raise MockgunError(
260
+ f'mockgun POST {route} upload_id {allocation.upload_id}: uploaded bytes are missing'
261
+ )
262
+ if allocation.upload_type == 'Thumbnail':
263
+ return link_thumbnail(server, allocation, form, context)
264
+ _fields(
265
+ form,
266
+ route,
267
+ {
268
+ 'entity_type',
269
+ 'entity_id',
270
+ 'field_name',
271
+ 'display_name',
272
+ 'created_at',
273
+ 'tag_list',
274
+ 'upload_link_info',
275
+ },
276
+ )
277
+ entity_type, entity_id = form['entity_type'], int(form['entity_id'])
278
+ owner = (
279
+ server.store.row(entity_type, entity_id) if server.schema.declares(entity_type) else None
280
+ )
281
+ if owner is None:
282
+ return f'0:mockgun upload link failed: {entity_type} {entity_id} does not exist'
283
+ if owner['__retired'] or 'tag_list' in form:
284
+ raise MockgunError(
285
+ f'mockgun POST {route} {entity_type} {entity_id}: '
286
+ 'retired owner or tag_list behavior is unsettled'
287
+ )
288
+ server.store.check_writable(owner)
289
+ if not server.schema.declares('Attachment', 'attachment_links'):
290
+ raise MockgunError(
291
+ f'mockgun POST {route} Attachment.attachment_links for {entity_type} {entity_id}: '
292
+ 'declaration is missing'
293
+ )
294
+ field = form.get('field_name')
295
+ if field is not None and (
296
+ not server.schema.declares(entity_type, field)
297
+ or server.schema.field_type(entity_type, field) != 'url'
298
+ ):
299
+ raise MockgunError(
300
+ f'mockgun POST {route} {entity_type} {entity_id} field {field}: '
301
+ 'URL field declaration is missing'
302
+ )
303
+ with server.store.transaction():
304
+ attachment, known_file = _create_attachment(server, owner, allocation, form, context)
305
+ if field is None and entity_type == 'Note' and context.session_kind == 'script':
306
+ record_attachment_upload(
307
+ server.store,
308
+ attachment,
309
+ owner,
310
+ context=context,
311
+ display_name_changed=(
312
+ not known_file or form['display_name'] != allocation.filename
313
+ ),
314
+ )
315
+ else:
316
+ reason = (
317
+ f'POST {route} {entity_type} {entity_id}: upload events and activity are unsettled'
318
+ )
319
+ server.store.taint_events(reason)
320
+ if field is not None or entity_type != 'Note':
321
+ taint_activity(server.store, [owner], reason)
322
+ server.storage.bind(cast('int', attachment['id']), allocation.key)
323
+ return f'1:{attachment["id"]} {form["display_name"]}'
324
+
325
+
326
+ def _serve_attachment(
327
+ server: FakeServer, request: TransportRequest, path: str
328
+ ) -> TransportResponse:
329
+ """Resolve a cookie-bearing Attachment URL to stored bytes or a missing row."""
330
+ identity_text = path.rsplit('/file_serve/attachment/', 1)[1]
331
+ if not identity_text.isdecimal():
332
+ raise MockgunError(f'mockgun GET {path} Attachment {identity_text}: identity is unsettled')
333
+ identity = int(identity_text)
334
+ cookie = next(
335
+ (value for name, value in request.headers.items() if name.lower() == 'cookie'), None
336
+ )
337
+ token = next(
338
+ (
339
+ item.strip().split('=', 1)[1]
340
+ for item in (cookie or '').split(';')
341
+ if item.strip().startswith('_session_id=')
342
+ ),
343
+ None,
344
+ )
345
+ if token is None:
346
+ raise MockgunError(f'mockgun GET {path} Attachment {identity}: session cookie is missing')
347
+ context = server.context_for({'session_token': token}, method_name=path)
348
+ server.store.context = context
349
+ row = server.store.row('Attachment', identity)
350
+ if row is None or row['__retired']:
351
+ return TransportResponse(404, 'Not Found', {}, b'')
352
+ key = server.storage.bound_key(identity)
353
+ if key is None or not server.storage.has_bytes(key):
354
+ raise MockgunError(f'mockgun GET {path} Attachment {identity}: stored bytes are unsettled')
355
+ return TransportResponse(302, 'Found', {'location': f'https://{STORAGE_HOST}/{key}'}, b'')
356
+
357
+
358
+ def _file_request(server: FakeServer, request: TransportRequest) -> TransportResponse | None:
359
+ """Run a matching storage or site route without translating failures."""
360
+ parsed = urlsplit(request.url)
361
+ route = f'{request.method} {parsed.path}'
362
+ if parsed.hostname == STORAGE_HOST and request.method == 'GET':
363
+ return TransportResponse(200, 'OK', {}, server.storage.read(request.url))
364
+ if (
365
+ request.method == 'GET'
366
+ and parsed.hostname == server.site_host
367
+ and '/file_serve/attachment/' in parsed.path
368
+ ):
369
+ return _serve_attachment(server, request, parsed.path)
370
+ if parsed.hostname == STORAGE_HOST and request.method == 'PUT':
371
+ if not isinstance(request.body, bytes):
372
+ raise MockgunError(f'mockgun {route} body: non-byte storage data is unsettled')
373
+ etag = server.storage.put(request.url, request.body)
374
+ return TransportResponse(200, 'OK', {'ETag': etag} if etag else {}, b'')
375
+ for (method, suffix), handler in _ROUTES.items():
376
+ if (
377
+ request.method == method
378
+ and parsed.path.endswith(suffix)
379
+ and parsed.hostname == server.site_host
380
+ ):
381
+ if not isinstance(request.body, bytes):
382
+ raise MockgunError(f'mockgun {route} body: non-byte form data is unsettled')
383
+ values = parse_qs(request.body.decode(), keep_blank_values=True)
384
+ if any(len(value) != 1 for value in values.values()):
385
+ raise MockgunError(f'mockgun {route} form fields: repeated values are unsettled')
386
+ form = {key: value[0] for key, value in values.items()}
387
+ auth = {key: form.pop(key) for key in list(form) if key in _AUTH_FIELDS}
388
+ context = server.context_for(auth, method_name=suffix)
389
+ server.store.context = context
390
+ return TransportResponse(200, 'OK', {}, handler(server, form, context).encode())
391
+ return None
392
+
393
+
394
+ @_route('POST', '/upload/share_thumbnail')
395
+ def _share(server: FakeServer, form: dict[str, str], context: RequestContext) -> str:
396
+ """Dispatch the observed source-thumbnail share form."""
397
+ _fields(form, '/upload/share_thumbnail', {'entities', 'source_entity', 'filmstrip_thumbnail'})
398
+ return share_thumbnail(server, form, context)
399
+
400
+
401
+ def handle_file_request(server: FakeServer, request: TransportRequest) -> TransportResponse:
402
+ """Dispatch an observed HTTP upload without producing JSON-RPC faults.
403
+
404
+ Raises:
405
+ MockgunError: If the route, credentials, form or store rejection is
406
+ outside the observed HTTP answers.
407
+ """
408
+ route = f'{request.method} {urlsplit(request.url).path}'
409
+ try:
410
+ response = _file_request(server, request)
411
+ except (MockgunError, ServerFaultError, KeyError, ValueError) as error:
412
+ raise MockgunError(
413
+ f'mockgun {route} form or entity fields: rejection is unsettled: {error}'
414
+ ) from error
415
+ if response is None:
416
+ raise MockgunError(f'mockgun does not emulate {route}')
417
+ return response
@@ -0,0 +1,120 @@
1
+ """Decode in-memory RPC requests and encode server responses."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from typing import cast
7
+ from urllib.parse import urlsplit
8
+
9
+ from shotgun_api3._transport import TransportRequest, TransportResponse
10
+
11
+ from ._errors import MockgunError, ServerFaultError
12
+
13
+ _AUTH_KEYS = frozenset(
14
+ {'script_name', 'user_login', 'session_token', 'auth_token', 'sudo_as_login'}
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RpcCall:
20
+ """An RPC method with separated authentication and method parameters.
21
+
22
+ Attributes:
23
+ method_name: Server method to dispatch.
24
+ auth: Authentication dictionary, or `None` for an anonymous call.
25
+ params: Method payload, or `None` when omitted.
26
+ """
27
+
28
+ method_name: str
29
+ auth: dict[str, object] | None
30
+ params: object
31
+
32
+
33
+ def decode_call(request: TransportRequest) -> RpcCall:
34
+ """Decode a JSON POST to an RPC path, including an optional site prefix.
35
+
36
+ Raises:
37
+ MockgunError: If the method, path, body, or call envelope is
38
+ unsupported.
39
+ """
40
+ path = urlsplit(request.url).path
41
+ message = f'mockgun does not emulate {request.method} {path}'
42
+ if (
43
+ request.method != 'POST'
44
+ or not path.endswith('/api3/json')
45
+ or not isinstance(request.body, bytes)
46
+ ):
47
+ raise MockgunError(message)
48
+ try:
49
+ body: object = json.loads(request.body)
50
+ except (ValueError, UnicodeDecodeError) as exc:
51
+ raise MockgunError(message) from exc
52
+ if not isinstance(body, dict):
53
+ raise MockgunError(message)
54
+ envelope = cast('dict[str, object]', body)
55
+ method = envelope.get('method_name')
56
+ values = envelope.get('params')
57
+ if not isinstance(method, str) or not isinstance(values, list):
58
+ raise MockgunError(message)
59
+ params = cast('list[object]', values).copy()
60
+ auth = None
61
+ if params:
62
+ first = params[0]
63
+ if isinstance(first, dict) and _AUTH_KEYS.intersection(cast('dict[str, object]', first)):
64
+ auth = cast('dict[str, object]', params.pop(0))
65
+ if len(params) > 1:
66
+ raise MockgunError(message)
67
+ return RpcCall(method, auth, params[0] if params else None)
68
+
69
+
70
+ def format_wire_datetime(value: datetime) -> str:
71
+ """Format a datetime as UTC with whole-second wire precision.
72
+
73
+ Args:
74
+ value: Datetime to convert; a naive value is interpreted in the
75
+ system-local timezone.
76
+
77
+ Returns:
78
+ A UTC date and time separated by `T` and ending in `Z`, without
79
+ microseconds. Year padding follows the platform
80
+ `strftime("%Y-%m-%dT%H:%M:%SZ")` behavior.
81
+
82
+ Raises:
83
+ OverflowError: If UTC conversion exceeds the supported datetime range.
84
+ ValueError: If the platform cannot convert a naive datetime.
85
+ OSError: If local timezone conversion fails.
86
+ """
87
+ return value.astimezone(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')
88
+
89
+
90
+ def parse_wire_datetime(value: str) -> datetime:
91
+ """Parse a wire timestamp into an aware UTC datetime.
92
+
93
+ Args:
94
+ value: Timestamp to parse using `datetime.fromisoformat`. A timestamp
95
+ without an offset is interpreted as UTC.
96
+
97
+ Raises:
98
+ ValueError: If the timestamp cannot be parsed.
99
+ OverflowError: If converting its offset to UTC exceeds the supported
100
+ datetime range.
101
+ """
102
+ parsed = datetime.fromisoformat(value)
103
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
104
+
105
+
106
+ def result_response(result: object, *, wrapped: bool) -> TransportResponse:
107
+ """Encode a result as JSON, optionally nested under `results`."""
108
+ return TransportResponse(
109
+ status=200,
110
+ reason='OK',
111
+ headers={'content-type': 'application/json'},
112
+ content=json.dumps({'results': result} if wrapped else result).encode(),
113
+ )
114
+
115
+
116
+ def fault_response(fault: ServerFaultError) -> TransportResponse:
117
+ """Encode a server rejection in the recorded status-200 fault envelope."""
118
+ return result_response(
119
+ {'exception': True, 'message': str(fault), 'error_code': fault.error_code}, wrapped=False
120
+ )