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.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {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}'
|