sg-python-api 0.2.0__py3-none-any.whl → 0.2.1__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.2.0.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +14 -14
- {sg_python_api-0.2.0.dist-info → sg_python_api-0.2.1.dist-info}/RECORD +12 -12
- shotgun_api3/lib/mockgun/_events.py +25 -4
- shotgun_api3/lib/mockgun/_handlers.py +1 -6
- shotgun_api3/lib/mockgun/_links.py +256 -98
- shotgun_api3/lib/mockgun/_query.py +79 -37
- shotgun_api3/lib/mockgun/_retirement.py +124 -29
- shotgun_api3/lib/mockgun/_store.py +153 -40
- shotgun_api3/lib/mockgun/_summaries.py +18 -17
- shotgun_api3/lib/mockgun/_threads.py +4 -4
- {sg_python_api-0.2.0.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
- {sg_python_api-0.2.0.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -196,12 +196,13 @@ class Store:
|
|
|
196
196
|
entity_schema = cast('_Schema', self._schema[entity_type])
|
|
197
197
|
for field_name, field_schema_value in entity_schema.items():
|
|
198
198
|
field_schema = cast('_Schema', field_schema_value)
|
|
199
|
-
|
|
199
|
+
data_type = self._schema_field_type(entity_type, field_name)
|
|
200
|
+
if data_type == 'multi_entity':
|
|
200
201
|
row[field_name] = []
|
|
201
202
|
else:
|
|
202
203
|
properties = cast('_Schema', field_schema['properties'])
|
|
203
204
|
default_value = cast('_Schema', properties['default_value'])
|
|
204
|
-
row[field_name] =
|
|
205
|
+
row[field_name] = _row_default(data_type, default_value['value'])
|
|
205
206
|
return row
|
|
206
207
|
|
|
207
208
|
def _set_created_audit(
|
|
@@ -449,25 +450,37 @@ class Store:
|
|
|
449
450
|
return self._actors.get((entity_type, entity_id))
|
|
450
451
|
|
|
451
452
|
@staticmethod
|
|
452
|
-
def check_known(row: Row, field_name: str
|
|
453
|
-
"""Reject an unknown field value
|
|
453
|
+
def check_known(row: Row, field_name: str) -> None:
|
|
454
|
+
"""Reject a read of an unknown field value.
|
|
454
455
|
|
|
455
456
|
Args:
|
|
456
457
|
row: Stored or detached record whose field is being read.
|
|
457
458
|
field_name: Direct field name.
|
|
458
|
-
membership: Whether only membership, rather than member order, is
|
|
459
|
-
read.
|
|
460
459
|
|
|
461
460
|
Raises:
|
|
462
|
-
MockgunError: If the requested value
|
|
461
|
+
MockgunError: If the requested value is unknown.
|
|
463
462
|
"""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
463
|
+
markers = cast('dict[str, FieldValue]', row.get('__unknown', {}))
|
|
464
|
+
if field_name in markers:
|
|
465
|
+
raise MockgunError(
|
|
466
|
+
f'{row["type"]}.{field_name} on record {row["id"]} is unknown: '
|
|
467
|
+
f'{markers[field_name]}'
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def connection_row(
|
|
471
|
+
self, connection_type: str, endpoints: Mapping[str, _EntityLink]
|
|
472
|
+
) -> Row | None:
|
|
473
|
+
"""Return the stored connection record joining both endpoints, retired or not.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
connection_type: Declared connection entity type.
|
|
477
|
+
endpoints: Endpoint field names mapped to their `type` and `id`
|
|
478
|
+
links.
|
|
479
|
+
"""
|
|
480
|
+
for row in self._db[connection_type].values():
|
|
481
|
+
if all(row.get(name) == link for name, link in endpoints.items()):
|
|
482
|
+
return row
|
|
483
|
+
return None
|
|
471
484
|
|
|
472
485
|
def followers(self, entity_type: str, entity_id: int) -> list[int]:
|
|
473
486
|
"""Return following HumanUser IDs in ascending order for one record."""
|
|
@@ -514,13 +527,12 @@ class Store:
|
|
|
514
527
|
self._remember_identity(entity_type, entity_id)
|
|
515
528
|
|
|
516
529
|
def forget_unknown(self, row: Row, field_name: str) -> None:
|
|
517
|
-
"""Clear the
|
|
530
|
+
"""Clear the unknown marker for a field made known by a write."""
|
|
518
531
|
self.remember(row)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
row.pop(key, None)
|
|
532
|
+
markers = cast('dict[str, FieldValue]', row.get('__unknown', {}))
|
|
533
|
+
markers.pop(field_name, None)
|
|
534
|
+
if not markers:
|
|
535
|
+
row.pop('__unknown', None)
|
|
524
536
|
|
|
525
537
|
def _set_updated_audit(self, entity_type: str, row: Row, context: RequestContext) -> None:
|
|
526
538
|
"""Record the actor and time of a successful change when declared."""
|
|
@@ -542,20 +554,16 @@ class Store:
|
|
|
542
554
|
row.pop(field_name, None)
|
|
543
555
|
self.forget_unknown(row, field_name)
|
|
544
556
|
|
|
545
|
-
def mark_unknown(
|
|
546
|
-
|
|
547
|
-
) -> None:
|
|
548
|
-
"""Record an unsettled value or member order alongside its stored row.
|
|
557
|
+
def mark_unknown(self, row: Row, field_name: str, reason: str) -> None:
|
|
558
|
+
"""Record an unsettled value alongside its stored row.
|
|
549
559
|
|
|
550
560
|
Args:
|
|
551
561
|
row: Record to mark; snapshots include these markers.
|
|
552
|
-
field_name: Declared field whose value
|
|
562
|
+
field_name: Declared field whose value is unsettled.
|
|
553
563
|
reason: Evidence-specific explanation returned by read failures.
|
|
554
|
-
order_only: Mark only member order, retaining known membership.
|
|
555
564
|
"""
|
|
556
565
|
self.remember(row)
|
|
557
|
-
|
|
558
|
-
markers = cast('dict[str, FieldValue]', row.setdefault(key, {}))
|
|
566
|
+
markers = cast('dict[str, FieldValue]', row.setdefault('__unknown', {}))
|
|
559
567
|
markers[field_name] = reason
|
|
560
568
|
|
|
561
569
|
def _apply_create_computed(
|
|
@@ -617,7 +625,6 @@ class Store:
|
|
|
617
625
|
continue
|
|
618
626
|
field_type = self._schema_field_type(entity_type, field_name)
|
|
619
627
|
unknown = field_name in cast('dict[str, FieldValue]', stored.get('__unknown', {}))
|
|
620
|
-
unordered = field_name in cast('dict[str, FieldValue]', stored.get('__unordered', {}))
|
|
621
628
|
if unknown:
|
|
622
629
|
uncertain = True
|
|
623
630
|
changed = changed or (
|
|
@@ -627,8 +634,6 @@ class Store:
|
|
|
627
634
|
self.forget_unknown(row, field_name)
|
|
628
635
|
else:
|
|
629
636
|
changed = changed or row[field_name] != stored[field_name]
|
|
630
|
-
if unordered:
|
|
631
|
-
self.forget_unknown(row, field_name)
|
|
632
637
|
if changed:
|
|
633
638
|
self._set_updated_audit(entity_type, row, context)
|
|
634
639
|
elif uncertain:
|
|
@@ -735,6 +740,82 @@ class Store:
|
|
|
735
740
|
return candidate
|
|
736
741
|
return self._db[entity_type].get(entity_id)
|
|
737
742
|
|
|
743
|
+
def _link_task_entities(self, row: Row, added: Sequence[_EntityLink]) -> None:
|
|
744
|
+
"""Add the entities of a Note's newly linked Tasks to its note_links, as the site does.
|
|
745
|
+
|
|
746
|
+
Raises:
|
|
747
|
+
MockgunError: If a Task's entity is unknown, or is retired, whose
|
|
748
|
+
effect is unobserved.
|
|
749
|
+
"""
|
|
750
|
+
if (
|
|
751
|
+
row['type'] != 'Note'
|
|
752
|
+
or not self.schema.declares('Note', 'note_links')
|
|
753
|
+
or not self.schema.declares('Task', 'entity')
|
|
754
|
+
):
|
|
755
|
+
return
|
|
756
|
+
members = list(cast('list[_EntityLink]', row.get('note_links') or []))
|
|
757
|
+
for link in added:
|
|
758
|
+
task = self.row('Task', cast('int', link['id']))
|
|
759
|
+
if task is None:
|
|
760
|
+
continue
|
|
761
|
+
self.check_known(task, 'entity')
|
|
762
|
+
entity = task.get('entity')
|
|
763
|
+
if not isinstance(entity, dict):
|
|
764
|
+
continue
|
|
765
|
+
target = self.row(cast('str', entity['type']), cast('int', entity['id']))
|
|
766
|
+
if target is None:
|
|
767
|
+
continue
|
|
768
|
+
if target['__retired']:
|
|
769
|
+
raise MockgunError(
|
|
770
|
+
f'Note.note_links from Task {task["id"]}: its retired '
|
|
771
|
+
f'{target["type"]} {target["id"]} has an unobserved effect'
|
|
772
|
+
)
|
|
773
|
+
identity: _EntityLink = {'type': target['type'], 'id': target['id']}
|
|
774
|
+
if identity not in members:
|
|
775
|
+
members.append(identity)
|
|
776
|
+
# 2026-11-10: creates and updates add each new Task's entity; removing a Task
|
|
777
|
+
# leaves note_links alone.
|
|
778
|
+
row['note_links'] = cast('FieldValue', members)
|
|
779
|
+
|
|
780
|
+
def check_link_targets(
|
|
781
|
+
self, entity_type: str, fields: Mapping[str, object], operation: str
|
|
782
|
+
) -> None:
|
|
783
|
+
"""Refuse a write naming a retired record in a link field, in any update mode.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
entity_type: Entity type being written.
|
|
787
|
+
fields: Supplied field values; undeclared fields are left to
|
|
788
|
+
validation.
|
|
789
|
+
operation: `create` or `update`, selecting the site's message.
|
|
790
|
+
|
|
791
|
+
Raises:
|
|
792
|
+
ServerFaultError: If a supplied link names a retired record, with
|
|
793
|
+
error code 104 (live comparison, 2026-11-10).
|
|
794
|
+
"""
|
|
795
|
+
for field_name, value in fields.items():
|
|
796
|
+
if not self.schema.declares(entity_type, field_name) or self._schema_field_type(
|
|
797
|
+
entity_type, field_name
|
|
798
|
+
) not in {'entity', 'multi_entity'}:
|
|
799
|
+
continue
|
|
800
|
+
for item in cast('list[object]', value) if isinstance(value, list) else [value]:
|
|
801
|
+
if not isinstance(item, dict):
|
|
802
|
+
continue
|
|
803
|
+
link = cast('dict[str, object]', item)
|
|
804
|
+
kind, identity = link.get('type'), link.get('id')
|
|
805
|
+
if not isinstance(kind, str) or not isinstance(identity, int):
|
|
806
|
+
continue
|
|
807
|
+
target = self.row(kind, identity) if self.schema.declares(kind) else None
|
|
808
|
+
if target is None or not target['__retired']:
|
|
809
|
+
continue
|
|
810
|
+
failure = f'Update failed for [{entity_type}.{field_name}]: Value is not legal.'
|
|
811
|
+
message = (
|
|
812
|
+
f'API update() CRUD ERROR #5: {failure}'
|
|
813
|
+
if operation == 'update'
|
|
814
|
+
else f'API create() CRUD ERROR #6: Invalid field value, update failed '
|
|
815
|
+
f'[5 - {failure}]'
|
|
816
|
+
)
|
|
817
|
+
raise ServerFaultError(message, error_code=104)
|
|
818
|
+
|
|
738
819
|
def rows(self, entity_type: str, *, retired: bool) -> list[Row]:
|
|
739
820
|
"""Return live rows of the requested retirement state in ascending ID order.
|
|
740
821
|
|
|
@@ -1188,6 +1269,7 @@ class Store:
|
|
|
1188
1269
|
self._set_created_audit(entity_type, data, context)
|
|
1189
1270
|
self._validate_entity_data(entity_type, data, 'create')
|
|
1190
1271
|
self._check_link_writes(entity_type, data)
|
|
1272
|
+
self.check_link_targets(entity_type, data, 'create')
|
|
1191
1273
|
row = self._new_row(entity_type)
|
|
1192
1274
|
for field_name, value in data.items():
|
|
1193
1275
|
if field_name == 'cached_display_name':
|
|
@@ -1196,6 +1278,8 @@ class Store:
|
|
|
1196
1278
|
row[field_name] = self._normalize_field_value(field_type, cast('FieldValue', value))
|
|
1197
1279
|
row['id'] = self._next_ids[entity_type]
|
|
1198
1280
|
self._apply_create_computed(row, fields, context)
|
|
1281
|
+
if 'tasks' in fields:
|
|
1282
|
+
self._link_task_entities(row, self._links(row.get('tasks'), 'Task'))
|
|
1199
1283
|
return row
|
|
1200
1284
|
|
|
1201
1285
|
def create(
|
|
@@ -1231,7 +1315,11 @@ class Store:
|
|
|
1231
1315
|
self._next_ids[entity_type] += 1
|
|
1232
1316
|
self._remember_identity(entity_type, cast('int', row['id']))
|
|
1233
1317
|
self._db[entity_type][cast('int', row['id'])] = row
|
|
1234
|
-
|
|
1318
|
+
mirrored = [
|
|
1319
|
+
*fields,
|
|
1320
|
+
*(['note_links'] if entity_type == 'Note' and 'tasks' in fields else []),
|
|
1321
|
+
]
|
|
1322
|
+
self._apply_pairs(entity_type, {}, row, mirrored, context)
|
|
1235
1323
|
if context.actor is not None:
|
|
1236
1324
|
self._mark_task_inverse({}, row)
|
|
1237
1325
|
return row
|
|
@@ -1290,18 +1378,23 @@ class Store:
|
|
|
1290
1378
|
continue
|
|
1291
1379
|
field_type = self._schema_field_type(entity_type, field_name)
|
|
1292
1380
|
if field_type == 'multi_entity':
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
cast('list[_EntityLink]', row[field_name]),
|
|
1297
|
-
cast('list[_EntityLink]', value),
|
|
1298
|
-
modes.get(field_name, 'set'),
|
|
1299
|
-
),
|
|
1381
|
+
members = cast('list[_EntityLink]', row[field_name])
|
|
1382
|
+
updated = self._update_multi_entity(
|
|
1383
|
+
members, cast('list[_EntityLink]', value), modes.get(field_name, 'set')
|
|
1300
1384
|
)
|
|
1385
|
+
# 2026-11-10: the site keeps no member order, so a reordering set changes
|
|
1386
|
+
# nothing, including audit fields and event rows.
|
|
1387
|
+
if _identities(updated) != _identities(members):
|
|
1388
|
+
row[field_name] = cast('FieldValue', updated)
|
|
1301
1389
|
else:
|
|
1302
1390
|
row[field_name] = self._normalize_field_value(
|
|
1303
1391
|
field_type, cast('FieldValue', value)
|
|
1304
1392
|
)
|
|
1393
|
+
if 'tasks' in data:
|
|
1394
|
+
old = self._links(stored.get('tasks'), 'Task')
|
|
1395
|
+
self._link_task_entities(
|
|
1396
|
+
row, [link for link in self._links(row.get('tasks'), 'Task') if link not in old]
|
|
1397
|
+
)
|
|
1305
1398
|
self._update_certainty(row, stored, data, modes, context)
|
|
1306
1399
|
self._sequence_shots_provenance(stored, row, data)
|
|
1307
1400
|
return row
|
|
@@ -1410,7 +1503,11 @@ class Store:
|
|
|
1410
1503
|
self.remember(stored)
|
|
1411
1504
|
stored.clear()
|
|
1412
1505
|
stored.update(row)
|
|
1413
|
-
|
|
1506
|
+
mirrored = [
|
|
1507
|
+
*fields,
|
|
1508
|
+
*(['note_links'] if entity_type == 'Note' and 'tasks' in fields else []),
|
|
1509
|
+
]
|
|
1510
|
+
self._apply_pairs(entity_type, before, stored, mirrored, context)
|
|
1414
1511
|
self._mark_task_inverse(before, stored)
|
|
1415
1512
|
image_fields = {'image', 'filmstrip_image'} & fields.keys()
|
|
1416
1513
|
if 'image' in image_fields and fields['image'] is None:
|
|
@@ -1426,6 +1523,11 @@ class Store:
|
|
|
1426
1523
|
return stored
|
|
1427
1524
|
|
|
1428
1525
|
|
|
1526
|
+
def _identities(links: Sequence[_EntityLink]) -> list[tuple[str, int]]:
|
|
1527
|
+
"""Return validated member identities in a stable order for membership comparison."""
|
|
1528
|
+
return sorted((cast('str', link['type']), cast('int', link['id'])) for link in links)
|
|
1529
|
+
|
|
1530
|
+
|
|
1429
1531
|
def _observed_task_form(fields: Mapping[str, object], context: RequestContext) -> bool:
|
|
1430
1532
|
"""Identify the script Task create forms with observed computed values."""
|
|
1431
1533
|
entity = fields.get('entity')
|
|
@@ -1444,3 +1546,14 @@ def _observed_task_form(fields: Mapping[str, object], context: RequestContext) -
|
|
|
1444
1546
|
)
|
|
1445
1547
|
)
|
|
1446
1548
|
)
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _row_default(data_type: str, value: FieldValue) -> FieldValue:
|
|
1552
|
+
"""Copy a schema default, reading a generated schema's integer text as an integer."""
|
|
1553
|
+
# 2026-11-10 trial capture: generated schemas give number, percent and duration defaults
|
|
1554
|
+
# as text such as '0' and '100', and the site reads those fields as integers.
|
|
1555
|
+
if _FIELD_PYTHON_TYPES.get(data_type) is int and isinstance(value, str):
|
|
1556
|
+
digits = value.removeprefix('-')
|
|
1557
|
+
if digits.isascii() and digits.isdigit():
|
|
1558
|
+
return int(value)
|
|
1559
|
+
return copy.deepcopy(value)
|
|
@@ -140,7 +140,10 @@ class _Grouping:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def _check_archive_targets(ctx: _Grouping, targets: set[tuple[str, int]], message: str) -> None:
|
|
143
|
-
"""Resolve linked
|
|
143
|
+
"""Resolve visible linked targets' archive state through selection and projection only.
|
|
144
|
+
|
|
145
|
+
Visible group links name active records; links to retired ones are hidden.
|
|
146
|
+
"""
|
|
144
147
|
if ctx.scope.include_archived_projects or not ctx.schema.declares('Project', 'archived'):
|
|
145
148
|
return
|
|
146
149
|
for kind in sorted({kind for kind, _ in targets}):
|
|
@@ -151,22 +154,20 @@ def _check_archive_targets(ctx: _Grouping, targets: set[tuple[str, int]], messag
|
|
|
151
154
|
}
|
|
152
155
|
field_name = 'archived' if kind == 'Project' else 'project.Project.archived'
|
|
153
156
|
fields = [field_name] if kind == 'Project' or ctx.schema.declares(kind, 'project') else []
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
raise MockgunError(message)
|
|
169
|
-
if found != ids:
|
|
157
|
+
rows = select(
|
|
158
|
+
ctx.store,
|
|
159
|
+
kind,
|
|
160
|
+
tree,
|
|
161
|
+
[],
|
|
162
|
+
scope=ReadScope(
|
|
163
|
+
retired=False, context=ctx.scope.context, include_archived_projects=True
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
projected = project_rows(ctx.store, ctx.schema, rows, fields)
|
|
167
|
+
if (
|
|
168
|
+
any(row.get(field_name) is True for row in projected)
|
|
169
|
+
or {cast('int', row['id']) for row in projected} != ids
|
|
170
|
+
):
|
|
170
171
|
raise MockgunError(message)
|
|
171
172
|
|
|
172
173
|
|
|
@@ -67,10 +67,10 @@ def _unsettled(row: Row, field: str, reason: str) -> MockgunError:
|
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _known(store: Store, row: Row, field: str
|
|
70
|
+
def _known(store: Store, row: Row, field: str) -> None:
|
|
71
71
|
"""Include the thread RPC in a stored unknown marker's diagnostic."""
|
|
72
72
|
try:
|
|
73
|
-
store.check_known(row, field
|
|
73
|
+
store.check_known(row, field)
|
|
74
74
|
except MockgunError as exc:
|
|
75
75
|
raise _unsettled(row, field, str(exc)) from exc
|
|
76
76
|
|
|
@@ -78,7 +78,7 @@ def _known(store: Store, row: Row, field: str, *, membership: bool = False) -> N
|
|
|
78
78
|
def _entries(store: Store, note: Row) -> list[Row]:
|
|
79
79
|
"""Collect active Replies and Note-member Attachments."""
|
|
80
80
|
identity = note['id']
|
|
81
|
-
_known(store, note, 'attachments'
|
|
81
|
+
_known(store, note, 'attachments')
|
|
82
82
|
members = cast('list[dict[str, FieldValue]]', note.get('attachments', []))
|
|
83
83
|
entries: list[Row] = []
|
|
84
84
|
if store.schema.declares('Reply'):
|
|
@@ -88,7 +88,7 @@ def _entries(store: Store, note: Row) -> list[Row]:
|
|
|
88
88
|
entries.append(row)
|
|
89
89
|
if store.schema.declares('Attachment'):
|
|
90
90
|
for row in store.rows('Attachment', retired=False):
|
|
91
|
-
_known(store, row, 'attachment_links'
|
|
91
|
+
_known(store, row, 'attachment_links')
|
|
92
92
|
linked = {'type': 'Note', 'id': identity} in cast(
|
|
93
93
|
'list[dict[str, FieldValue]]', row.get('attachment_links', [])
|
|
94
94
|
)
|
|
File without changes
|
|
File without changes
|