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
shotgun_api3/_version.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Compatibility target reported by `__version__` and the package release version."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from importlib.metadata import distributions
|
|
4
|
+
|
|
5
|
+
COMPATIBILITY_VERSION = '3.10.5'
|
|
6
|
+
PACKAGE_VERSION = next(
|
|
7
|
+
(distribution.version for distribution in distributions(name='sg-python-api')), '0+unknown'
|
|
8
|
+
)
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Run real sync and async Shotgun clients against an in-memory fake site.
|
|
2
|
+
|
|
3
|
+
Site controls configure explicit preferences, a Monday-to-Sunday working week,
|
|
4
|
+
HumanUser subscriptions, and exact navigation/export responses or faults
|
|
5
|
+
through `set_preferences`, `set_work_week`, `set_subscriptions`,
|
|
6
|
+
`set_canned_response` and `set_canned_fault`. Inputs are detached under the
|
|
7
|
+
site lock. Unconfigured server-dependent values raise `MockgunError`.
|
|
2
8
|
|
|
3
9
|
Configure the chosen client's `set_schema_paths()` with field-schema and
|
|
4
10
|
entity-schema pickle files before constructing instances. Await
|
|
5
11
|
`AsyncShotgun.set_schema_paths()` and `AsyncShotgun.get_schema_paths()`.
|
|
6
12
|
`generate_schema()` can create the files from an existing client.
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
Constructor credentials are validated by the real client; a client constructed
|
|
15
|
+
without any credentials acts as the `mockgun` script. Both clients operate
|
|
16
|
+
without network connections, and `AsyncShotgun` needs no optional async
|
|
17
|
+
transport extra. Use each client's `site` facade to control its clock,
|
|
18
|
+
readiness metadata, and maximum page size. Call `new_client()` with new
|
|
19
|
+
credentials to share the same site's schema and records. It is a plain method
|
|
20
|
+
on both clients; directly constructed clients have independent sites.
|
|
11
21
|
"""
|
|
12
22
|
|
|
13
|
-
from .
|
|
14
|
-
from .
|
|
23
|
+
from ._async_client import AsyncShotgun
|
|
24
|
+
from ._errors import MockgunError
|
|
25
|
+
from ._schema import generate_schema
|
|
26
|
+
from ._site import Site
|
|
27
|
+
from ._sync_client import Shotgun
|
|
15
28
|
|
|
16
|
-
__all__ = ['AsyncShotgun', 'MockgunError', 'Shotgun', 'generate_schema']
|
|
29
|
+
__all__ = ['AsyncShotgun', 'MockgunError', 'Shotgun', 'Site', 'generate_schema']
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Record and render the observed activity stream forms."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from ._errors import MockgunError
|
|
8
|
+
from ._links import display_name
|
|
9
|
+
from ._query import project_rows
|
|
10
|
+
from ._retirement import RetirementEffects
|
|
11
|
+
from ._sessions import RequestContext
|
|
12
|
+
from ._store import FieldValue, Row, Store
|
|
13
|
+
from ._wire import format_wire_datetime
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
_READABLE_TYPES = frozenset({'Asset', 'Note', 'Project', 'Shot', 'Task'})
|
|
19
|
+
_ACTOR_PARTS = 2
|
|
20
|
+
_AUDIT_LINKS = frozenset({'created_by', 'updated_by', 'user'})
|
|
21
|
+
_SILENT_FIELDS = {
|
|
22
|
+
'Shot': frozenset(
|
|
23
|
+
{'description', 'code', 'sg_cut_in', 'sg_shot_type', 'sg_sequence', 'assets'}
|
|
24
|
+
),
|
|
25
|
+
'Task': frozenset({'start_date', 'est_in_mins', 'sg_status_list'}),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _error(row: Row, reason: str) -> MockgunError:
|
|
30
|
+
"""Name the stream record and the unobserved answer."""
|
|
31
|
+
return MockgunError(f'mockgun cannot activity_stream {row["type"]} {row["id"]}: {reason}')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _earliest(
|
|
35
|
+
row: Row, entries: list[Row], kept: list[Row], shown: list[Row], minimum: int | None
|
|
36
|
+
) -> int:
|
|
37
|
+
"""Resolve only the observed lower-bound and limit header cases."""
|
|
38
|
+
if not shown:
|
|
39
|
+
return 0
|
|
40
|
+
lowest = cast('int', shown[-1]['id'])
|
|
41
|
+
if minimum is not None and any(cast('int', entry['id']) <= minimum for entry in entries):
|
|
42
|
+
if lowest != minimum + 1:
|
|
43
|
+
raise _error(row, 'min_id cut the scan with an unsettled earliest_update_id')
|
|
44
|
+
return lowest
|
|
45
|
+
return lowest if len(shown) < len(kept) else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _human_status(store: Store, entry: Row, identity: int) -> FieldValue:
|
|
49
|
+
"""Require O2 S1's known, unchanged creator status and null image."""
|
|
50
|
+
label = f'HumanUser {identity}'
|
|
51
|
+
if not store.schema.declares('HumanUser'):
|
|
52
|
+
raise MockgunError(f'{label} creator record is missing')
|
|
53
|
+
human = store.row('HumanUser', identity)
|
|
54
|
+
if human is None:
|
|
55
|
+
raise MockgunError(f'{label} creator record is missing')
|
|
56
|
+
store.check_known(human, 'image')
|
|
57
|
+
if human.get('image') is not None:
|
|
58
|
+
raise MockgunError(f'{label} image is unsettled')
|
|
59
|
+
if not store.schema.declares('HumanUser', 'sg_status_list'):
|
|
60
|
+
raise MockgunError(f'{label} sg_status_list is undeclared')
|
|
61
|
+
store.check_known(human, 'sg_status_list')
|
|
62
|
+
if 'actor_status' not in entry:
|
|
63
|
+
raise MockgunError(f'{label} sg_status_list at the write is unknown')
|
|
64
|
+
status = human.get('sg_status_list')
|
|
65
|
+
if status != entry['actor_status']:
|
|
66
|
+
raise MockgunError(f'{label} sg_status_list changed since the write')
|
|
67
|
+
return status
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _creator(store: Store, entry: Row, stream: Row) -> dict[str, object]:
|
|
71
|
+
"""Render ApiUser creators and HumanUser creators with unchanged status and null image."""
|
|
72
|
+
actor = cast('list[FieldValue]', entry['actor'])
|
|
73
|
+
if len(actor) != _ACTOR_PARTS or actor[0] not in {'ApiUser', 'HumanUser'}:
|
|
74
|
+
raise _error(stream, f'entry {entry["id"]} absent created_by is unsettled')
|
|
75
|
+
kind, identity = cast('str', actor[0]), cast('int', actor[1])
|
|
76
|
+
try:
|
|
77
|
+
status = _human_status(store, entry, identity) if kind == 'HumanUser' else None
|
|
78
|
+
name = display_name(store, kind, identity)
|
|
79
|
+
except MockgunError as exc:
|
|
80
|
+
raise _error(stream, f'entry {entry["id"]} {exc}') from exc
|
|
81
|
+
if name is None:
|
|
82
|
+
raise _error(stream, f'entry {entry["id"]} {kind} {identity} display name is unmodeled')
|
|
83
|
+
return {'type': kind, 'id': identity, 'name': name, 'status': status, 'image': None}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _entry_row(
|
|
87
|
+
store: Store, entry: Row, stream: Row, fields: dict[str, list[str]]
|
|
88
|
+
) -> dict[str, object]:
|
|
89
|
+
"""Render an entry only when its actor, source write and current primary are settled."""
|
|
90
|
+
if not entry['settled']:
|
|
91
|
+
raise _error(stream, f'entry {entry["id"]} has unsettled source event rows')
|
|
92
|
+
creator = _creator(store, entry, stream)
|
|
93
|
+
primary = cast('list[FieldValue]', entry['primary'])
|
|
94
|
+
kind, identity = cast('str', primary[0]), cast('int', primary[1])
|
|
95
|
+
row = store.require(kind, identity)
|
|
96
|
+
if row['__retired']:
|
|
97
|
+
raise _error(stream, f'entry {entry["id"]} primary {kind} {identity} is retired')
|
|
98
|
+
if stream['type'] == 'Project':
|
|
99
|
+
for link in cast('list[list[FieldValue]]', entry['streams']):
|
|
100
|
+
linked = store.require(cast('str', link[0]), cast('int', link[1]))
|
|
101
|
+
if linked['__retired']:
|
|
102
|
+
raise _error(stream, f'entry {entry["id"]} stream record {link} is retired')
|
|
103
|
+
if not store.schema.declares(kind, 'sg_status_list'):
|
|
104
|
+
raise _error(stream, f'{kind}.sg_status_list is undeclared')
|
|
105
|
+
try:
|
|
106
|
+
store.check_known(row, 'sg_status_list')
|
|
107
|
+
name = display_name(store, kind, identity)
|
|
108
|
+
selected = project_rows(store, store.schema, [row], fields.get(kind, []))[0]
|
|
109
|
+
except MockgunError as exc:
|
|
110
|
+
raise _error(stream, str(exc)) from exc
|
|
111
|
+
if name is None:
|
|
112
|
+
raise _error(stream, f'entry {entry["id"]} display name is unmodeled')
|
|
113
|
+
primary_entity: dict[str, object] = {
|
|
114
|
+
'type': kind,
|
|
115
|
+
'id': identity,
|
|
116
|
+
'name': name,
|
|
117
|
+
'status': row.get('sg_status_list'),
|
|
118
|
+
}
|
|
119
|
+
primary_entity.update({field: selected[field] for field in fields.get(kind, [])})
|
|
120
|
+
return {
|
|
121
|
+
'id': entry['id'],
|
|
122
|
+
'update_type': entry['update_type'],
|
|
123
|
+
'meta': deepcopy(entry['meta']),
|
|
124
|
+
'created_at': format_wire_datetime(cast('datetime', entry['created_at'])),
|
|
125
|
+
'read': False,
|
|
126
|
+
'primary_entity': primary_entity,
|
|
127
|
+
'created_by': creator,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _kept(candidates: list[Row]) -> list[Row]:
|
|
132
|
+
"""Keep only the newest bounded candidate for each Note."""
|
|
133
|
+
result: list[Row] = []
|
|
134
|
+
seen: set[int] = set()
|
|
135
|
+
for entry in candidates:
|
|
136
|
+
note = cast('int | None', entry['note'])
|
|
137
|
+
if note is not None and note in seen:
|
|
138
|
+
continue
|
|
139
|
+
if note is not None:
|
|
140
|
+
seen.add(note)
|
|
141
|
+
result.append(entry)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _kept_actor_status(store: Store, context: RequestContext) -> Row:
|
|
146
|
+
"""Keep only known HumanUser status in the journaled entry without blocking writes."""
|
|
147
|
+
actor = context.actor
|
|
148
|
+
if (
|
|
149
|
+
actor is None
|
|
150
|
+
or actor.entity_type != 'HumanUser'
|
|
151
|
+
or not store.schema.declares('HumanUser', 'sg_status_list')
|
|
152
|
+
):
|
|
153
|
+
return {}
|
|
154
|
+
human = store.row('HumanUser', actor.entity_id)
|
|
155
|
+
if human is None:
|
|
156
|
+
return {}
|
|
157
|
+
try:
|
|
158
|
+
store.check_known(human, 'sg_status_list')
|
|
159
|
+
except MockgunError:
|
|
160
|
+
return {}
|
|
161
|
+
return {'actor_status': human.get('sg_status_list')}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _key(row: Row) -> tuple[str, int]:
|
|
165
|
+
"""Return a record's stream identity."""
|
|
166
|
+
return cast('str', row['type']), cast('int', row['id'])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _selections(store: Store, raw: object) -> dict[str, list[str]]:
|
|
170
|
+
"""Validate direct declared fields for each requested primary type."""
|
|
171
|
+
if not isinstance(raw, dict):
|
|
172
|
+
raise MockgunError('mockgun cannot activity_stream parameter entity_fields')
|
|
173
|
+
result: dict[str, list[str]] = {}
|
|
174
|
+
for kind, names in cast('dict[object, object]', raw).items():
|
|
175
|
+
if (
|
|
176
|
+
not isinstance(kind, str)
|
|
177
|
+
or not store.schema.declares(kind)
|
|
178
|
+
or not isinstance(names, list)
|
|
179
|
+
):
|
|
180
|
+
raise MockgunError(f'mockgun cannot activity_stream entity_fields {kind}')
|
|
181
|
+
selected: list[str] = []
|
|
182
|
+
for field in cast('list[object]', names):
|
|
183
|
+
if (
|
|
184
|
+
not isinstance(field, str)
|
|
185
|
+
or '.' in field
|
|
186
|
+
or not store.schema.declares(kind, field)
|
|
187
|
+
):
|
|
188
|
+
raise MockgunError(
|
|
189
|
+
f'mockgun cannot activity_stream {kind}.{field}: undeclared or deep field'
|
|
190
|
+
)
|
|
191
|
+
selected.append(field)
|
|
192
|
+
result[kind] = selected
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parameters(store: Store, params: Mapping[str, object]) -> dict[str, list[str]]:
|
|
197
|
+
"""Require the exact client parameter shape and direct declared selections."""
|
|
198
|
+
required = {'type', 'id', 'max_id', 'min_id', 'limit', 'entity_fields'}
|
|
199
|
+
mismatch = required.symmetric_difference(params)
|
|
200
|
+
if mismatch:
|
|
201
|
+
raise MockgunError(f'mockgun cannot activity_stream parameter {min(mismatch)}')
|
|
202
|
+
for name in ('id', 'min_id', 'max_id', 'limit'):
|
|
203
|
+
value = params[name]
|
|
204
|
+
if value is None and name != 'id':
|
|
205
|
+
continue
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(value, int)
|
|
208
|
+
or isinstance(value, bool)
|
|
209
|
+
or (name == 'limit' and value < 1)
|
|
210
|
+
):
|
|
211
|
+
raise MockgunError(f'mockgun cannot activity_stream parameter {name}')
|
|
212
|
+
if not isinstance(params['type'], str):
|
|
213
|
+
raise MockgunError('mockgun cannot activity_stream parameter type')
|
|
214
|
+
return _selections(store, params['entity_fields'])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _target(store: Store, link: dict[str, FieldValue]) -> Row | None:
|
|
218
|
+
"""Resolve only declared targets so activity hooks preserve partial-schema writes."""
|
|
219
|
+
kind = cast('str', link['type'])
|
|
220
|
+
if not store.schema.declares(kind):
|
|
221
|
+
return None
|
|
222
|
+
return store.row(kind, cast('int', link['id']))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _linked(store: Store, row: Row) -> list[Row]:
|
|
226
|
+
"""Collect direct entity links except the three excluded actor fields."""
|
|
227
|
+
result: list[Row] = []
|
|
228
|
+
kind = cast('str', row['type'])
|
|
229
|
+
for field in store.schema.fields[kind]:
|
|
230
|
+
if field in _AUDIT_LINKS:
|
|
231
|
+
continue
|
|
232
|
+
data_type = store.schema.field_type(kind, field)
|
|
233
|
+
value = row.get(field)
|
|
234
|
+
values = value if data_type == 'multi_entity' and isinstance(value, list) else [value]
|
|
235
|
+
if data_type not in {'entity', 'multi_entity'}:
|
|
236
|
+
continue
|
|
237
|
+
for link in values:
|
|
238
|
+
if isinstance(link, dict):
|
|
239
|
+
target = _target(store, link)
|
|
240
|
+
if target is not None:
|
|
241
|
+
result.append(target)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _primary(store: Store, row: Row) -> Row:
|
|
246
|
+
"""Map a Reply write onto its Note stream record."""
|
|
247
|
+
parent = row.get('entity')
|
|
248
|
+
if row['type'] == 'Reply' and isinstance(parent, dict) and parent.get('type') == 'Note':
|
|
249
|
+
note = _target(store, parent)
|
|
250
|
+
if note is not None:
|
|
251
|
+
return note
|
|
252
|
+
return row
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _with_projects(store: Store, rows: list[Row]) -> dict[tuple[str, int], Row]:
|
|
256
|
+
"""Include each record's current Project without traversing other links."""
|
|
257
|
+
result = {_key(row): row for row in rows}
|
|
258
|
+
for row in rows:
|
|
259
|
+
project = row.get('project')
|
|
260
|
+
if isinstance(project, dict) and project.get('type') == 'Project':
|
|
261
|
+
target = _target(store, project)
|
|
262
|
+
if target is not None:
|
|
263
|
+
result[_key(target)] = target
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _affected(store: Store, row: Row) -> dict[tuple[str, int], Row]:
|
|
268
|
+
"""Collect a write's primary, direct links, and their current Projects."""
|
|
269
|
+
primary = _primary(store, row)
|
|
270
|
+
rows = [primary, *_linked(store, row)]
|
|
271
|
+
if primary is not row:
|
|
272
|
+
rows.extend(_linked(store, primary))
|
|
273
|
+
return {
|
|
274
|
+
key: target
|
|
275
|
+
for key, target in _with_projects(store, rows).items()
|
|
276
|
+
if key[0] in _READABLE_TYPES
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _streams(store: Store, row: Row) -> list[list[FieldValue]]:
|
|
281
|
+
"""Capture the observed create audience and its Projects at write time."""
|
|
282
|
+
rows = [row]
|
|
283
|
+
if row['type'] == 'Task':
|
|
284
|
+
links = [row.get('entity')]
|
|
285
|
+
elif row['type'] == 'Note':
|
|
286
|
+
links = cast('list[FieldValue]', row.get('note_links', []))
|
|
287
|
+
else:
|
|
288
|
+
links = []
|
|
289
|
+
for link in links:
|
|
290
|
+
if isinstance(link, dict) and link.get('type') in {'Shot', 'Asset'}:
|
|
291
|
+
target = _target(store, link)
|
|
292
|
+
if target is not None:
|
|
293
|
+
rows.append(target)
|
|
294
|
+
return [[kind, identity] for kind, identity in _with_projects(store, rows)]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _write_entry(
|
|
298
|
+
store: Store, row: Row, entry: Row, context: RequestContext, *, before: Row | None = None
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Journal an entry and taint linked readable records outside its observed audience."""
|
|
301
|
+
primary = _primary(store, row)
|
|
302
|
+
streams = _streams(store, primary)
|
|
303
|
+
actor = context.actor
|
|
304
|
+
identity = store.next_side_id('activity')
|
|
305
|
+
store.side_set(
|
|
306
|
+
'activity',
|
|
307
|
+
(identity,),
|
|
308
|
+
{
|
|
309
|
+
'id': identity,
|
|
310
|
+
**entry,
|
|
311
|
+
**_kept_actor_status(store, context),
|
|
312
|
+
'created_at': context.now,
|
|
313
|
+
'actor': [actor.entity_type, actor.entity_id] if actor else [],
|
|
314
|
+
'primary': [primary['type'], primary['id']],
|
|
315
|
+
'streams': cast('list[FieldValue]', streams),
|
|
316
|
+
'note': primary['id'] if primary['type'] == 'Note' else None,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
included = {tuple(link) for link in streams}
|
|
320
|
+
affected = _affected(store, row)
|
|
321
|
+
if before is not None:
|
|
322
|
+
affected.update(_affected(store, before))
|
|
323
|
+
for key in affected:
|
|
324
|
+
if key not in included:
|
|
325
|
+
store.side_set(
|
|
326
|
+
'activity_taint',
|
|
327
|
+
key,
|
|
328
|
+
f'{row["type"]} {row["id"]} {entry["update_type"]}: linked activity is unsettled',
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def read_stream(
|
|
333
|
+
store: Store, params: Mapping[str, object], *, context: RequestContext
|
|
334
|
+
) -> dict[str, object]:
|
|
335
|
+
"""Read the bounded activity view for a declared readable record.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
store: Site records and journaled activity history.
|
|
339
|
+
params: Exact client wire parameters, including bounds and field
|
|
340
|
+
selections.
|
|
341
|
+
context: Request context captured for this RPC.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
An unwrapped stream envelope with newest-first updates and observed
|
|
345
|
+
headers.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
MockgunError: If the record, view, source entry or rendering is
|
|
349
|
+
unsettled.
|
|
350
|
+
"""
|
|
351
|
+
del context
|
|
352
|
+
fields = _parameters(store, params)
|
|
353
|
+
kind, identity = cast('str', params['type']), cast('int', params['id'])
|
|
354
|
+
if not store.schema.declares(kind) or kind not in _READABLE_TYPES:
|
|
355
|
+
raise MockgunError(f'mockgun cannot activity_stream {kind} {identity}: unreadable type')
|
|
356
|
+
row = store.row(kind, identity)
|
|
357
|
+
if row is None:
|
|
358
|
+
raise MockgunError(f'mockgun cannot activity_stream {kind} {identity}: missing record')
|
|
359
|
+
taint = store.side_get('activity_taint', (kind, identity))
|
|
360
|
+
if taint is not None:
|
|
361
|
+
raise _error(row, str(taint))
|
|
362
|
+
entries = [
|
|
363
|
+
cast('Row', value)
|
|
364
|
+
for _, value in store.side_items('activity')
|
|
365
|
+
if [kind, identity] in cast('list[list[FieldValue]]', cast('Row', value)['streams'])
|
|
366
|
+
]
|
|
367
|
+
minimum, maximum = cast('int | None', params['min_id']), cast('int | None', params['max_id'])
|
|
368
|
+
candidates = [
|
|
369
|
+
entry
|
|
370
|
+
for entry in entries
|
|
371
|
+
if (minimum is None or cast('int', entry['id']) > minimum)
|
|
372
|
+
and (maximum is None or cast('int', entry['id']) < maximum)
|
|
373
|
+
]
|
|
374
|
+
candidates.sort(key=lambda entry: cast('int', entry['id']), reverse=True)
|
|
375
|
+
if not candidates:
|
|
376
|
+
raise _error(row, 'no candidates: activity headers are unsettled')
|
|
377
|
+
kept = _kept(candidates)
|
|
378
|
+
shown = [] if row['__retired'] else kept[: cast('int | None', params['limit'])]
|
|
379
|
+
return {
|
|
380
|
+
'entity_type': kind,
|
|
381
|
+
'entity_id': identity,
|
|
382
|
+
'latest_update_id': candidates[0]['id'],
|
|
383
|
+
'earliest_update_id': _earliest(row, entries, kept, shown, minimum),
|
|
384
|
+
'updates': [_entry_row(store, entry, row, fields) for entry in shown],
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def taint_activity(store: Store, records: Iterable[Row], reason: str) -> None:
|
|
389
|
+
"""Mark every affected readable stream as unsettled.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
store: Site records and journaled side tables.
|
|
393
|
+
records: Written records, including before and after snapshots when
|
|
394
|
+
applicable.
|
|
395
|
+
reason: Diagnostic naming the write whose activity is unobserved.
|
|
396
|
+
"""
|
|
397
|
+
for row in records:
|
|
398
|
+
for key in _affected(store, row):
|
|
399
|
+
store.side_set('activity_taint', key, reason)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def record_create_activity(
|
|
403
|
+
store: Store, row: Row, *, settled: bool, context: RequestContext
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Record observed entity or Reply creates and taint every other create.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
store: Site records and journaled side tables.
|
|
409
|
+
row: Newly created record.
|
|
410
|
+
settled: Whether this operation's event planner returned a complete
|
|
411
|
+
plan.
|
|
412
|
+
context: Actor and instant captured for the write.
|
|
413
|
+
"""
|
|
414
|
+
kind = row['type']
|
|
415
|
+
if kind not in {'Asset', 'Shot', 'Task', 'Note'} and not (
|
|
416
|
+
kind == 'Reply' and _primary(store, row)['type'] == 'Note'
|
|
417
|
+
):
|
|
418
|
+
taint_activity(store, [row], f'create {kind} {row["id"]}: activity is unsettled')
|
|
419
|
+
return
|
|
420
|
+
meta: Row = {'type': 'new_entity', 'entity_type': kind, 'entity_id': row['id']}
|
|
421
|
+
if kind == 'Reply':
|
|
422
|
+
meta['content'] = row.get('content')
|
|
423
|
+
elif context.sudo and context.actual is not None:
|
|
424
|
+
actual = context.actual
|
|
425
|
+
meta['sudo_actual_user'] = {
|
|
426
|
+
'type': actual.entity_type,
|
|
427
|
+
'id': actual.entity_id,
|
|
428
|
+
'name': display_name(store, actual.entity_type, actual.entity_id),
|
|
429
|
+
'valid': 'valid',
|
|
430
|
+
'uuid': store.link_uuid(actual.entity_type, actual.entity_id),
|
|
431
|
+
}
|
|
432
|
+
_write_entry(
|
|
433
|
+
store,
|
|
434
|
+
row,
|
|
435
|
+
{
|
|
436
|
+
'update_type': 'create_reply' if kind == 'Reply' else 'create',
|
|
437
|
+
'meta': meta,
|
|
438
|
+
'settled': settled,
|
|
439
|
+
},
|
|
440
|
+
context,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def record_lifecycle_activity(
|
|
445
|
+
store: Store, effects: RetirementEffects, *, context: RequestContext
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Keep Shot lifecycles silent and taint other lifecycle audiences.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
store: Site records and journaled side tables.
|
|
451
|
+
effects: Root, cascaded, touched and tainted rows returned by the
|
|
452
|
+
lifecycle.
|
|
453
|
+
context: Request context captured for the write.
|
|
454
|
+
"""
|
|
455
|
+
del context
|
|
456
|
+
if not effects.changed or effects.root['type'] == 'Shot':
|
|
457
|
+
return
|
|
458
|
+
reason = (
|
|
459
|
+
f'retirement or revival {effects.root["type"]} {effects.root["id"]}: activity is unsettled'
|
|
460
|
+
)
|
|
461
|
+
taint_activity(
|
|
462
|
+
store, [effects.root, *effects.cascaded, *effects.touched, *effects.tainted], reason
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def record_update_activity(
|
|
467
|
+
store: Store, before: Row, after: Row, *, settled: bool, context: RequestContext
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Record Shot and Asset status changes while guarding unobserved changed fields.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
store: Site records and journaled side tables.
|
|
473
|
+
before: Detached record before this operation.
|
|
474
|
+
after: Updated record including side effects.
|
|
475
|
+
settled: Whether this operation's event planner returned a complete
|
|
476
|
+
plan.
|
|
477
|
+
context: Actor and instant captured for the write.
|
|
478
|
+
"""
|
|
479
|
+
kind = cast('str', after['type'])
|
|
480
|
+
changed = {
|
|
481
|
+
field for field in store.schema.fields[kind] if before.get(field) != after.get(field)
|
|
482
|
+
} - {'updated_at', 'updated_by'}
|
|
483
|
+
status = kind in {'Shot', 'Asset'} and 'sg_status_list' in changed
|
|
484
|
+
if status:
|
|
485
|
+
meta: Row = {
|
|
486
|
+
'type': 'attribute_change',
|
|
487
|
+
'attribute_name': 'sg_status_list',
|
|
488
|
+
'entity_type': kind,
|
|
489
|
+
'entity_id': after['id'],
|
|
490
|
+
'field_data_type': 'status_list',
|
|
491
|
+
'old_value': before.get('sg_status_list'),
|
|
492
|
+
'new_value': after.get('sg_status_list'),
|
|
493
|
+
}
|
|
494
|
+
_write_entry(
|
|
495
|
+
store,
|
|
496
|
+
after,
|
|
497
|
+
{'update_type': 'update', 'meta': meta, 'settled': settled},
|
|
498
|
+
context,
|
|
499
|
+
before=before,
|
|
500
|
+
)
|
|
501
|
+
other = (
|
|
502
|
+
changed - _SILENT_FIELDS.get(kind, frozenset()) - ({'sg_status_list'} if status else set())
|
|
503
|
+
)
|
|
504
|
+
if other:
|
|
505
|
+
taint_activity(
|
|
506
|
+
store,
|
|
507
|
+
[before, after],
|
|
508
|
+
f'update {kind} {after["id"]} fields {", ".join(sorted(other))}: '
|
|
509
|
+
'activity is unsettled',
|
|
510
|
+
)
|