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.
@@ -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
- if self._schema_field_type(entity_type, field_name) == 'multi_entity':
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] = copy.deepcopy(default_value['value'])
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, *, membership: bool = False) -> None:
453
- """Reject an unknown field value or an order-dependent read.
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 or order is unknown.
461
+ MockgunError: If the requested value is unknown.
463
462
  """
464
- for key in ('__unknown',) if membership else ('__unknown', '__unordered'):
465
- markers = cast('dict[str, FieldValue]', row.get(key, {}))
466
- if field_name in markers:
467
- raise MockgunError(
468
- f'{row["type"]}.{field_name} on record {row["id"]} is unknown: '
469
- f'{markers[field_name]}'
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 value and order markers for a field made known by a write."""
530
+ """Clear the unknown marker for a field made known by a write."""
518
531
  self.remember(row)
519
- for key in ('__unknown', '__unordered'):
520
- markers = cast('dict[str, FieldValue]', row.get(key, {}))
521
- markers.pop(field_name, None)
522
- if not markers:
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
- self, row: Row, field_name: str, reason: str, *, order_only: bool = False
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 or order is unsettled.
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
- key = '__unordered' if order_only else '__unknown'
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
- self._apply_pairs(entity_type, {}, row, fields.keys(), context)
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
- row[field_name] = cast(
1294
- 'FieldValue',
1295
- self._update_multi_entity(
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
- self._apply_pairs(entity_type, before, stored, fields.keys(), context)
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 target archive state through selection and projection only."""
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
- found: set[int] = set()
155
- for retired in (False, True):
156
- rows = select(
157
- ctx.store,
158
- kind,
159
- tree,
160
- [],
161
- scope=ReadScope(
162
- retired=retired, context=ctx.scope.context, include_archived_projects=True
163
- ),
164
- )
165
- projected = project_rows(ctx.store, ctx.schema, rows, fields)
166
- found.update(cast('int', row['id']) for row in projected)
167
- if any(row.get(field_name) is True for row in projected):
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, *, membership: bool = False) -> None:
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, membership=membership)
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', membership=True)
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', membership=True)
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
  )