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,190 @@
1
+ """Run the real synchronous client against an isolated in-memory site."""
2
+
3
+ import dataclasses
4
+ from typing import ClassVar, Self
5
+
6
+ from shotgun_api3 import Shotgun as RealShotgun
7
+ from shotgun_api3 import _crud
8
+ from shotgun_api3._client import ConstructorOptions
9
+ from shotgun_api3._config import RequestContext
10
+ from shotgun_api3._transport import Transport
11
+
12
+ from ._new_client import (
13
+ SHARED_SERVER,
14
+ copy_client_settings,
15
+ default_credentials,
16
+ new_client_options,
17
+ )
18
+ from ._schema import load_schema
19
+ from ._server import FakeServer
20
+ from ._site import Site
21
+ from ._transport import InMemoryTransport
22
+
23
+
24
+ class Shotgun(RealShotgun):
25
+ """A real client whose transport reaches only its own in-memory fake site.
26
+
27
+ Configure schema paths before construction. Direct construction creates
28
+ independent site state; `new_client()` shares the site with new
29
+ credentials. Test controls are exposed through `site`.
30
+
31
+ Attributes:
32
+ finds: Number of find operations performed.
33
+ """
34
+
35
+ _in_process: ClassVar[bool] = True
36
+ _schema_entity_path: ClassVar[str | None] = None
37
+ _schema_path: ClassVar[str | None] = None
38
+
39
+ def __init__(
40
+ self,
41
+ base_url: str,
42
+ script_name: str | None = None,
43
+ api_key: str | None = None,
44
+ convert_datetimes_to_utc: bool = True,
45
+ http_proxy: str | None = None,
46
+ connect: bool = True,
47
+ ca_certs: str | None = None,
48
+ login: str | None = None,
49
+ password: str | None = None,
50
+ sudo_as_login: str | None = None,
51
+ session_token: str | None = None,
52
+ auth_token: str | None = None,
53
+ ) -> None:
54
+ """Initialize a fake site before the real client's readiness operation.
55
+
56
+ Load the configured schema files synchronously for a new fake site.
57
+ Default construction performs fake-site readiness; supplying
58
+ `auth_token` also performs the normal session-token exchange.
59
+
60
+ Args:
61
+ base_url: Flow Production Tracking site URL.
62
+ script_name: Script name for script authentication.
63
+ api_key: API key for script authentication.
64
+ convert_datetimes_to_utc: Whether to convert datetime values to
65
+ UTC.
66
+ http_proxy: Optional proxy authority in `[user:pass@]host[:port]`
67
+ form.
68
+ connect: Whether to perform eager readiness during construction.
69
+ ca_certs: Optional certificate-authority bundle path.
70
+ login: Login for human-user authentication.
71
+ password: Password for human-user authentication.
72
+ sudo_as_login: Optional user login for sudo impersonation.
73
+ session_token: Existing session token for authentication.
74
+ auth_token: Optional two-factor authentication token.
75
+
76
+ Raises:
77
+ MockgunError: If schema paths are unset, files are missing, or
78
+ readiness or session behavior is unsupported.
79
+ OSError: If a schema file cannot be opened or read.
80
+ EOFError: If a schema pickle ends prematurely.
81
+ pickle.UnpicklingError: If a schema pickle is invalid.
82
+ ValueError: If a pickle protocol is unsupported, constructor values
83
+ fail real-client validation, or the retry environment setting
84
+ is invalid.
85
+ ShotgunError: If eager readiness or session exchange returns a
86
+ modeled fault or invalid metadata.
87
+ """
88
+ options = ConstructorOptions(
89
+ base_url,
90
+ script_name,
91
+ api_key,
92
+ convert_datetimes_to_utc,
93
+ http_proxy,
94
+ connect,
95
+ ca_certs,
96
+ login,
97
+ password,
98
+ sudo_as_login,
99
+ session_token,
100
+ auth_token,
101
+ )
102
+ shared = SHARED_SERVER.get()
103
+ if shared is None:
104
+ options = default_credentials(options)
105
+ self._server = (
106
+ shared if shared is not None else FakeServer(load_schema(*self.get_schema_paths()))
107
+ )
108
+ self._options = options
109
+ self._site = Site(self._server)
110
+ if options.login is not None:
111
+ self._server.admit_login(options.login)
112
+ self.finds = 0
113
+ super().__init__(
114
+ options.base_url,
115
+ options.script_name,
116
+ options.api_key,
117
+ options.convert_datetimes_to_utc,
118
+ options.http_proxy,
119
+ options.connect,
120
+ options.ca_certs,
121
+ options.login,
122
+ options.password,
123
+ options.sudo_as_login,
124
+ options.session_token,
125
+ options.auth_token,
126
+ )
127
+
128
+ def _find(self, options: _crud.FindOptions) -> list[dict[str, object]]:
129
+ """Count each query before dispatching through the real client."""
130
+ self.finds += 1
131
+ return super()._find(options)
132
+
133
+ def _new_transport(self, context: RequestContext) -> Transport:
134
+ """Bind every operation to this client's in-memory site."""
135
+ del context
136
+ return InMemoryTransport(self._server)
137
+
138
+ @classmethod
139
+ def get_schema_paths(cls) -> tuple[str | None, str | None]:
140
+ """Return field-schema and entity-schema paths, in that order."""
141
+ return cls._schema_path, cls._schema_entity_path
142
+
143
+ def new_client(self, **credentials: object) -> Self:
144
+ """Create a client with new credentials sharing this client's fake site.
145
+
146
+ This is a plain method on both sync and async clients. The returned
147
+ client has its own readiness and session state. Timeout, retry and
148
+ user-agent settings are copied; other noncredential options use their
149
+ original constructor values.
150
+
151
+ Args:
152
+ **credentials: Constructor credential arguments. A lone
153
+ `sudo_as_login` preserves the original credentials; otherwise
154
+ only the supplied credentials are used.
155
+
156
+ Returns:
157
+ A new instance of this client's class sharing schema and records.
158
+
159
+ Raises:
160
+ ValueError: If constructor credentials, retained configuration, or
161
+ the retry environment setting fail validation.
162
+ MockgunError: If eager readiness or session behavior is
163
+ unsupported.
164
+ ShotgunError: If eager readiness or session exchange returns a
165
+ modeled fault or invalid metadata.
166
+ """
167
+ options = new_client_options(self._options, credentials)
168
+ token = SHARED_SERVER.set(self._server)
169
+ try:
170
+ client = type(self)(**dataclasses.asdict(options))
171
+ finally:
172
+ SHARED_SERVER.reset(token)
173
+ copy_client_settings(self.config, client.config)
174
+ return client
175
+
176
+ @classmethod
177
+ def set_schema_paths(cls, schema_path: str, schema_entity_path: str) -> None:
178
+ """Configure the trusted schema pickles loaded by future instances.
179
+
180
+ Args:
181
+ schema_path: Path to the field-schema pickle.
182
+ schema_entity_path: Path to the entity-schema pickle.
183
+ """
184
+ cls._schema_path = schema_path
185
+ cls._schema_entity_path = schema_entity_path
186
+
187
+ @property
188
+ def site(self) -> Site:
189
+ """The test-control facade for this client's in-memory site."""
190
+ return self._site
@@ -0,0 +1,226 @@
1
+ """Read observed Note threads from the fake site's records."""
2
+
3
+ from collections.abc import Mapping
4
+ from copy import deepcopy
5
+ from datetime import datetime
6
+ from itertools import pairwise
7
+ from typing import cast
8
+
9
+ from ._errors import MockgunError, ServerFaultError
10
+ from ._links import named_link
11
+ from ._sessions import RequestContext
12
+ from ._store import FieldValue, Row, Store
13
+ from ._wire import format_wire_datetime
14
+
15
+ _DEFAULT_FIELDS = {
16
+ 'Attachment': ('type', 'id', 'created_by', 'created_at'),
17
+ 'Note': ('type', 'id', 'content', 'created_at', 'created_by'),
18
+ 'Reply': ('type', 'id', 'content', 'user', 'created_at'),
19
+ }
20
+
21
+
22
+ def _field_type(store: Store, kind: str, field: str) -> str:
23
+ """Reject structurally unsettled selections even without rows."""
24
+ prefix = f'mockgun cannot note_thread_contents {kind}.{field}'
25
+ if field == 'updated_by':
26
+ raise MockgunError(f'{prefix}: the audit link is unsettled')
27
+ if '.' in field or not store.schema.declares(kind, field):
28
+ raise MockgunError(f'{prefix}: the field is undeclared or deep')
29
+ data_type = store.schema.field_type(kind, field)
30
+ if data_type == 'multi_entity':
31
+ raise MockgunError(f'{prefix}: multi-entity selection is unsettled')
32
+ return data_type
33
+
34
+
35
+ def _fields(store: Store, params: Mapping[str, object]) -> dict[str, list[str]]:
36
+ """Validate requested thread selections before reading any row."""
37
+ extra = set(params) - {'note_id', 'entity_fields'}
38
+ if extra:
39
+ raise MockgunError(f'mockgun cannot note_thread_contents parameter {min(extra)}')
40
+ raw = params.get('entity_fields', {})
41
+ if not isinstance(raw, dict):
42
+ raise MockgunError('mockgun cannot note_thread_contents parameter entity_fields')
43
+ result: dict[str, list[str]] = {}
44
+ for kind, names in cast('dict[object, object]', raw).items():
45
+ if (
46
+ kind not in _DEFAULT_FIELDS
47
+ or not isinstance(names, list)
48
+ or not all(isinstance(name, str) for name in cast('list[object]', names))
49
+ ):
50
+ raise MockgunError(f'mockgun cannot note_thread_contents entity_fields {kind}')
51
+ entity_type = cast('str', kind)
52
+ selected = cast('list[str]', names)
53
+ for field in selected:
54
+ if field not in _DEFAULT_FIELDS[entity_type] and not (
55
+ entity_type == 'Reply' and field == 'created_by'
56
+ ):
57
+ _field_type(store, entity_type, field)
58
+ result[entity_type] = selected
59
+ return result
60
+
61
+
62
+ def _unsettled(row: Row, field: str, reason: str) -> MockgunError:
63
+ """Name the thread field and record whose value cannot be reproduced."""
64
+ return MockgunError(
65
+ f'mockgun cannot note_thread_contents {row["type"]}.{field} '
66
+ f'on record {row["id"]}: {reason}'
67
+ )
68
+
69
+
70
+ def _known(store: Store, row: Row, field: str, *, membership: bool = False) -> None:
71
+ """Include the thread RPC in a stored unknown marker's diagnostic."""
72
+ try:
73
+ store.check_known(row, field, membership=membership)
74
+ except MockgunError as exc:
75
+ raise _unsettled(row, field, str(exc)) from exc
76
+
77
+
78
+ def _entries(store: Store, note: Row) -> list[Row]:
79
+ """Collect active Replies and Note-member Attachments."""
80
+ identity = note['id']
81
+ _known(store, note, 'attachments', membership=True)
82
+ members = cast('list[dict[str, FieldValue]]', note.get('attachments', []))
83
+ entries: list[Row] = []
84
+ if store.schema.declares('Reply'):
85
+ for row in store.rows('Reply', retired=False):
86
+ _known(store, row, 'entity')
87
+ if row.get('entity') == {'type': 'Note', 'id': identity}:
88
+ entries.append(row)
89
+ if store.schema.declares('Attachment'):
90
+ for row in store.rows('Attachment', retired=False):
91
+ _known(store, row, 'attachment_links', membership=True)
92
+ linked = {'type': 'Note', 'id': identity} in cast(
93
+ 'list[dict[str, FieldValue]]', row.get('attachment_links', [])
94
+ )
95
+ member = {'type': 'Attachment', 'id': row['id']} in members
96
+ if linked and not member:
97
+ raise _unsettled(row, 'attachment_links', 'the Note does not list this attachment')
98
+ if member:
99
+ entries.append(row)
100
+ return entries
101
+
102
+
103
+ def _named(store: Store, row: Row, field: str) -> dict[str, object] | None:
104
+ """Return an observed named single link in thread key order."""
105
+ _known(store, row, field)
106
+ value = row.get(field)
107
+ if value is None:
108
+ return None
109
+ if not isinstance(value, dict):
110
+ raise _unsettled(row, field, 'the link shape is unsettled')
111
+ try:
112
+ link = named_link(store, cast('dict[str, FieldValue]', value))
113
+ except (MockgunError, ServerFaultError) as exc:
114
+ raise _unsettled(row, field, str(exc)) from exc
115
+ if 'name' not in link:
116
+ raise _unsettled(row, field, 'the linked name is unsettled')
117
+ return {'id': link['id'], 'name': link['name'], 'type': link['type']}
118
+
119
+
120
+ def _ordered(store: Store, entries: list[Row]) -> list[Row]:
121
+ """Order thread entries while rejecting unobserved timestamp forms."""
122
+ for row in entries:
123
+ if not store.schema.declares(cast('str', row['type']), 'created_at') and len(entries) > 1:
124
+ raise _unsettled(row, 'created_at', 'the timestamp is undeclared')
125
+ _known(store, row, 'created_at')
126
+ if not isinstance(row.get('created_at'), datetime):
127
+ raise _unsettled(row, 'created_at', 'the timestamp is unsettled')
128
+ entries.sort(key=lambda row: (cast('datetime', row['created_at']), cast('int', row['id'])))
129
+ for earlier, later in pairwise(entries):
130
+ if earlier['created_at'] == later['created_at'] and earlier['type'] != later['type']:
131
+ raise MockgunError(
132
+ f'mockgun cannot note_thread_contents {earlier["type"]} {earlier["id"]} '
133
+ f'and {later["type"]} {later["id"]}: mixed-type timestamp tie is unsettled'
134
+ )
135
+ return entries
136
+
137
+
138
+ def _reply_user(store: Store, row: Row) -> dict[str, object]:
139
+ """Include the observed null image on a Reply actor link."""
140
+ link = _named(store, row, 'user')
141
+ if link is None:
142
+ raise _unsettled(row, 'user', 'the null actor is unsettled')
143
+ user_type = cast('str', link['type'])
144
+ user_id = cast('int', link['id'])
145
+ user = store.row(user_type, user_id)
146
+ if user is not None and store.schema.declares(user_type, 'image'):
147
+ _known(store, user, 'image')
148
+ if user.get('image') is not None:
149
+ raise _unsettled(row, 'user', f'{user_type}.image is non-null')
150
+ return {**link, 'image': None}
151
+
152
+
153
+ def _value(store: Store, row: Row, field: str) -> object:
154
+ """Project one declared thread field without exposing unknown state."""
155
+ kind = cast('str', row['type'])
156
+ data_type = _field_type(store, kind, field)
157
+ if kind == 'Reply' and field == 'user':
158
+ return _reply_user(store, row)
159
+ if data_type == 'entity':
160
+ link = _named(store, row, field)
161
+ if field == 'created_by' and link is None:
162
+ raise _unsettled(row, field, 'the null actor is unsettled')
163
+ return link
164
+ _known(store, row, field)
165
+ value = row.get(field)
166
+ if data_type == 'date_time' and value is not None:
167
+ if not isinstance(value, datetime):
168
+ raise _unsettled(row, field, 'the timestamp is unsettled')
169
+ return format_wire_datetime(value)
170
+ return deepcopy(value)
171
+
172
+
173
+ def _row(store: Store, row: Row, fields: list[str], *, script_reader: bool) -> dict[str, object]:
174
+ """Build default thread keys followed by requested fields."""
175
+ kind = cast('str', row['type'])
176
+ result: dict[str, object] = {}
177
+ for field in _DEFAULT_FIELDS[kind]:
178
+ result[field] = row[field] if field in {'type', 'id'} else _value(store, row, field)
179
+ for field in fields:
180
+ if field in result or (kind == 'Reply' and field == 'created_by'):
181
+ continue
182
+ user = row.get('user')
183
+ if (
184
+ kind == 'Reply'
185
+ and field == 'entity'
186
+ and script_reader
187
+ and isinstance(user, dict)
188
+ and user.get('type') == 'ApiUser'
189
+ ):
190
+ continue
191
+ result[field] = _value(store, row, field)
192
+ return result
193
+
194
+
195
+ def read_thread(
196
+ store: Store, params: Mapping[str, object], *, context: RequestContext
197
+ ) -> list[dict[str, object]]:
198
+ """Return one Note followed by its active, time-ordered thread entries.
199
+
200
+ Args:
201
+ store: Site records and schema for the current request.
202
+ params: Wire `note_id` and optional per-type `entity_fields`.
203
+ context: Request context captured for this RPC.
204
+
205
+ Returns:
206
+ Detached rows in the observed Note thread wire shape.
207
+
208
+ Raises:
209
+ MockgunError: If a requested value or ordering is unsettled.
210
+ ServerFaultError: If the Note is absent or retired.
211
+ """
212
+ script_reader = context.session_kind == 'script'
213
+ if not store.schema.declares('Note'):
214
+ raise MockgunError('mockgun cannot note_thread_contents Note: schema is undeclared')
215
+ identity = params.get('note_id')
216
+ if not isinstance(identity, int) or isinstance(identity, bool):
217
+ raise MockgunError('mockgun cannot note_thread_contents parameter note_id')
218
+ selections = _fields(store, params)
219
+ note = store.row('Note', identity)
220
+ if note is None or note['__retired']:
221
+ raise ServerFaultError(f'Note {identity} not found', error_code=103)
222
+ entries = _ordered(store, _entries(store, note))
223
+ return [_row(store, note, selections.get('Note', []), script_reader=script_reader)] + [
224
+ _row(store, row, selections.get(cast('str', row['type']), []), script_reader=script_reader)
225
+ for row in entries
226
+ ]
@@ -0,0 +1,146 @@
1
+ """Apply observed thumbnail identities, sharing and owner side effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+ from urllib.parse import urlsplit
7
+
8
+ from ._activity import taint_activity
9
+ from ._errors import MockgunError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping
13
+
14
+ from ._server import FakeServer
15
+ from ._sessions import RequestContext
16
+ from ._storage import Allocation
17
+ from ._store import Row
18
+
19
+
20
+ def _owner(server: FakeServer, kind: str, identity: int, field: str, route: str) -> Row:
21
+ """Require an active writable owner with the selected image declaration."""
22
+ row = server.store.row(kind, identity) if server.schema.declares(kind) else None
23
+ if row is None or row['__retired'] or not server.schema.declares(kind, field):
24
+ raise MockgunError(
25
+ f'mockgun POST {route} {kind} {identity} field {field}: '
26
+ 'missing or retired owner or image declaration is unsettled'
27
+ )
28
+ server.store.check_writable(row)
29
+ return row
30
+
31
+
32
+ def _reference(server: FakeServer, value: str) -> Row:
33
+ """Resolve a share form reference to an active image owner."""
34
+ kind, identity = value.rsplit('_', 1)
35
+ return _owner(server, kind, int(identity), 'image', '/upload/share_thumbnail')
36
+
37
+
38
+ def _set_image(server: FakeServer, row: Row, field: str, key: str | None, route: str) -> None:
39
+ """Replace an image while invalidating its previous identity and side effects."""
40
+ store = server.store
41
+ kind, identity = cast('str', row['type']), cast('int', row['id'])
42
+ store.remember(row)
43
+ row[field] = server.storage.image_url(key) if key is not None else None
44
+ store.forget_unknown(row, field)
45
+ store.side_set('image_identity', (kind, identity, field), None)
46
+ reason = (
47
+ f'POST {route} {kind} {identity} field {field}: audit, events and activity are unsettled'
48
+ )
49
+ for name in ('updated_at', 'updated_by'):
50
+ if server.schema.declares(kind, name):
51
+ store.mark_unknown(row, name, reason)
52
+ store.taint_events(reason)
53
+ taint_activity(store, [row], reason)
54
+
55
+
56
+ def link_thumbnail(
57
+ server: FakeServer, allocation: Allocation, form: Mapping[str, str], context: RequestContext
58
+ ) -> str:
59
+ """Link uploaded thumbnail bytes using an unreadable Attachment identity.
60
+
61
+ Args:
62
+ server: Site whose storage allocation and record are being linked.
63
+ allocation: Issued Thumbnail allocation with completed bytes.
64
+ form: Link form identifying an owner and optional filmstrip selection.
65
+ context: Authenticated request actor and instant.
66
+
67
+ Returns:
68
+ The client's successful link grammar containing the reserved identity.
69
+
70
+ Raises:
71
+ MockgunError: If owner, field or optional metadata behavior is
72
+ unsettled.
73
+ """
74
+ del context
75
+ route = '/upload/api_link_file'
76
+ kind, identity = form['entity_type'], int(form['entity_id'])
77
+ if form.keys() - {'entity_type', 'entity_id', 'filmstrip', 'upload_link_info'} or (
78
+ 'filmstrip' in form and form['filmstrip'] != 'true'
79
+ ):
80
+ raise MockgunError(
81
+ f'mockgun POST {route} {kind} {identity} thumbnail fields: metadata is unsettled'
82
+ )
83
+ field = 'filmstrip_image' if form.get('filmstrip') == 'true' else 'image'
84
+ row = _owner(server, kind, identity, field, route)
85
+ if not server.schema.declares('Attachment'):
86
+ raise MockgunError(
87
+ f'mockgun POST {route} {kind} {identity} field {field}: Attachment schema is missing'
88
+ )
89
+ # C6/C7 P4-14: the shared counter advances without creating an Attachment row.
90
+ with server.store.transaction():
91
+ thumbnail_id = server.store.reserve_id('Attachment')
92
+ _set_image(server, row, field, allocation.key, route)
93
+ server.store.side_set('image_identity', (kind, identity, field), thumbnail_id)
94
+ return f'1:{thumbnail_id}'
95
+
96
+
97
+ def share_thumbnail(server: FakeServer, form: Mapping[str, str], context: RequestContext) -> str:
98
+ """Share an uploaded source image and return its stable thumbnail identity.
99
+
100
+ Args:
101
+ server: Site containing source and target owners and stored bytes.
102
+ form: Entity references and filmstrip flag from the source-share route.
103
+ context: Authenticated request actor and instant.
104
+
105
+ Returns:
106
+ Success with the source identity, or the observed null-source failure.
107
+
108
+ Raises:
109
+ MockgunError: If filmstrip, owner or source identity behavior is
110
+ unsettled.
111
+ """
112
+ del context
113
+ route = '/upload/share_thumbnail'
114
+ if form['filmstrip_thumbnail'] != 'False':
115
+ raise MockgunError(
116
+ f'mockgun POST {route} {form["source_entity"]} filmstrip_thumbnail: '
117
+ 'share identity is unsettled'
118
+ )
119
+ source = _reference(server, form['source_entity'])
120
+ targets = [_reference(server, value) for value in form['entities'].split(',')]
121
+ server.store.check_known(source, 'image')
122
+ kind, identity = cast('str', source['type']), cast('int', source['id'])
123
+ image = source.get('image')
124
+ # C8 Q4: the empty Asset source clears one populated, non-filmstrip Asset.
125
+ if image is None:
126
+ if (
127
+ kind == 'Asset'
128
+ and len(targets) == 1
129
+ and targets[0]['type'] == 'Asset'
130
+ and isinstance(targets[0].get('image'), str)
131
+ and targets[0].get('filmstrip_image') is None
132
+ ):
133
+ with server.store.transaction():
134
+ _set_image(server, targets[0], 'image', None, route)
135
+ return f'0:mockgun share failed: {kind} {identity} has no thumbnail'
136
+ thumbnail_id = server.store.side_get('image_identity', (kind, identity, 'image'))
137
+ if not isinstance(thumbnail_id, int) or not isinstance(image, str):
138
+ raise MockgunError(
139
+ f'mockgun POST {route} {kind} {identity} image: source identity is unsettled'
140
+ )
141
+ key = urlsplit(image).path.lstrip('/')
142
+ # C7 P4-19: every share retains the path and source ID, with fresh URL tokens.
143
+ with server.store.transaction():
144
+ for target in targets:
145
+ _set_image(server, target, 'image', key, route)
146
+ return f'1:{thumbnail_id}'