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.
@@ -15,10 +15,14 @@ from ._errors import MockgunError, ServerFaultError
15
15
  from ._events import check_meta
16
16
  from ._links import (
17
17
  RecordState,
18
+ connection_backed,
19
+ connection_record,
18
20
  deciding_record,
19
21
  derived_name,
20
22
  display_name,
21
23
  hidden,
24
+ implicit_link_hidden,
25
+ link_target,
22
26
  named_link,
23
27
  )
24
28
  from ._presets import (
@@ -44,7 +48,7 @@ _COLLATION_CHARACTERS = frozenset(ascii_letters + digits)
44
48
  _COLLATION_IGNORED = str.maketrans('', '', '_-. ')
45
49
  _EntityLink: TypeAlias = dict[str, FieldValue]
46
50
  _LINK_PATH_PARTS = 3
47
- _NOTE_LINK_PAIR = {'Asset', 'Shot'}
51
+ _MEMBER_ORDER_MINIMUM = 2
48
52
  _NUMERIC_TYPES = frozenset({'date', 'date_time', 'duration', 'float', 'number', 'percent'})
49
53
  _ORDER_TYPES = _NUMERIC_TYPES | {
50
54
  'checkbox',
@@ -220,9 +224,7 @@ class _RowReader:
220
224
  self._schema = schema
221
225
  self._assume: dict[tuple[str, int], RecordState] | None = None
222
226
 
223
- def _direct_field_value(
224
- self, entity_type: str, row: Row, field_name: str, *, membership: bool = False
225
- ) -> FieldValue:
227
+ def _direct_field_value(self, entity_type: str, row: Row, field_name: str) -> FieldValue:
226
228
  """Read a stored field or compute its non-retained display cache."""
227
229
  if entity_type == 'Project' and field_name == 'last_accessed_by_current_user':
228
230
  context = self._store.context
@@ -241,8 +243,8 @@ class _RowReader:
241
243
  'actor has never accessed the Project'
242
244
  )
243
245
  return value
244
- if '__unknown' in row or (not membership and '__unordered' in row):
245
- self._store.check_known(row, field_name, membership=membership)
246
+ if '__unknown' in row:
247
+ self._store.check_known(row, field_name)
246
248
  if entity_type == 'EventLogEntry' and field_name == 'meta':
247
249
  check_meta(self._store, row)
248
250
  if field_name == 'cached_display_name' and entity_type != 'EventLogEntry':
@@ -282,7 +284,7 @@ class _Projection(_RowReader):
282
284
  if len(parts) != _LINK_PATH_PARTS:
283
285
  value = self._direct_field_value(entity_type, row, path)
284
286
  visible = self._visible_value(row, path, value)
285
- return True, _ordered_note_links(row, path, value, visible, self._projection_context)
287
+ return True, visible
286
288
  field, linked_type, rest = parts
287
289
  link_value = self._direct_field_value(entity_type, row, field)
288
290
  if self._visible_value(row, field, link_value) != link_value:
@@ -368,9 +370,11 @@ class _Projection(_RowReader):
368
370
  continue
369
371
  if field_type in {'entity', 'multi_entity'}:
370
372
  if isinstance(value, list):
371
- value = [
372
- named_link(self._store, cast('_EntityLink', item)) for item in value
373
- ]
373
+ value = _member_order(
374
+ entity_type,
375
+ name,
376
+ [named_link(self._store, cast('_EntityLink', item)) for item in value],
377
+ )
374
378
  elif isinstance(value, dict):
375
379
  value = named_link(self._store, value)
376
380
  elif field_type == 'date_time':
@@ -608,7 +612,7 @@ class _Query(_RowReader):
608
612
  ) -> bool:
609
613
  """Reduce identity conditions before reading link visibility."""
610
614
  field_type = self._schema.field_type(entity_type, path)
611
- actual = self._direct_field_value(entity_type, row, path, membership=True)
615
+ actual = self._direct_field_value(entity_type, row, path)
612
616
  if (
613
617
  field_type in {'entity', 'multi_entity'}
614
618
  and relation == 'is'
@@ -646,7 +650,7 @@ class _Query(_RowReader):
646
650
  if len(parts) != _LINK_PATH_PARTS:
647
651
  return self._matches_direct(entity_type, row, path, relation, values)
648
652
  field, linked_type, rest = parts
649
- actual = self._direct_field_value(entity_type, row, field, membership=True)
653
+ actual = self._direct_field_value(entity_type, row, field)
650
654
  actual = self._hidden_filter_value(row, field, actual, relation, values)
651
655
  links = actual if isinstance(actual, list) else [actual]
652
656
  matches: list[bool] = []
@@ -956,6 +960,19 @@ class _Query(_RowReader):
956
960
  )
957
961
  return bool(row['__retired'])
958
962
 
963
+ def _retired_hidden(self, row: Row, field: str, link: _EntityLink) -> _Decision:
964
+ """Hide a retired holder's links to retired targets and retired connections."""
965
+ target = link_target(self._store, link)
966
+ if target is None or (target['type'], target['id']) == (row['type'], row['id']):
967
+ return False
968
+ retired_target = self._symbolic_retired(target)
969
+ if not connection_backed(row, field, target):
970
+ return retired_target
971
+ record = connection_record(self._store, row, field, target)
972
+ if record is None:
973
+ return self._any(retired_target, implicit_link_hidden(row, field, target))
974
+ return self._any(retired_target, self._symbolic_retired(record))
975
+
959
976
  def _symbolic_hidden(self, row: Row, field: str, link: _EntityLink) -> _Decision:
960
977
  """Express hiding through the deciding record and the holder's scope."""
961
978
  deciding = deciding_record(self._store, row, field, link)
@@ -978,7 +995,9 @@ class _Query(_RowReader):
978
995
  (cast('str', row['type']), cast('int', row['id'])): RecordState(retired=False)
979
996
  }
980
997
  outcome = hidden(self._store, row, field, link, assume=active)
981
- return self._choose(self._symbolic_retired(row), yes=False, no=outcome)
998
+ return self._choose(
999
+ self._symbolic_retired(row), yes=self._retired_hidden(row, field, link), no=outcome
1000
+ )
982
1001
 
983
1002
  def _symbolic_archive(self, row: Row, scope: ReadScope) -> _Decision:
984
1003
  """Retain a project visibility bit only when its archive status matters."""
@@ -1004,7 +1023,7 @@ class _Query(_RowReader):
1004
1023
  relation = cast('str', condition['relation'])
1005
1024
  values = cast('list[FieldValue]', condition['values'])
1006
1025
  field_type = self._schema.field_type(kind, path)
1007
- actual = self._direct_field_value(kind, row, path, membership=True)
1026
+ actual = self._direct_field_value(kind, row, path)
1008
1027
  if field_type not in {'entity', 'multi_entity'}:
1009
1028
  return self._matches_direct(kind, row, path, relation, values)
1010
1029
  links = actual if isinstance(actual, list) else [actual]
@@ -1037,9 +1056,7 @@ class _Query(_RowReader):
1037
1056
  if len(path.split('.', 2)) != _LINK_PATH_PARTS:
1038
1057
  return self._symbolic_direct(row, condition)
1039
1058
  field = path.split('.', 1)[0]
1040
- actual = self._direct_field_value(
1041
- cast('str', row['type']), row, field, membership=True
1042
- )
1059
+ actual = self._direct_field_value(cast('str', row['type']), row, field)
1043
1060
  links = actual if isinstance(actual, list) else [actual]
1044
1061
  result: _Decision = False
1045
1062
  for link in links:
@@ -1952,26 +1969,51 @@ def _operand_fault(field_type: str, relation: str, value: object) -> str | None:
1952
1969
  return None
1953
1970
 
1954
1971
 
1955
- def _ordered_note_links(
1956
- row: Row, field: str, stored: FieldValue, visible: FieldValue, context: RequestContext | None
1957
- ) -> FieldValue:
1958
- """Return Asset before Shot for script reads of a script-authored Note's two links."""
1959
- creator = row.get('created_by')
1960
- if (
1961
- row['type'] == 'Note'
1962
- and context is not None
1963
- and context.session_kind == 'script'
1964
- and isinstance(creator, dict)
1965
- and creator.get('type') == 'ApiUser'
1966
- and field == 'note_links'
1967
- and isinstance(stored, list)
1968
- and len(stored) == len(_NOTE_LINK_PAIR)
1969
- and {cast('str', cast('_EntityLink', member)['type']) for member in stored}
1970
- == _NOTE_LINK_PAIR
1971
- and isinstance(visible, list)
1972
- ):
1973
- return sorted(visible, key=lambda member: cast('str', cast('_EntityLink', member)['type']))
1974
- return visible
1972
+ def _settled_member_keys(keys: Collection[str]) -> bool:
1973
+ """Return whether observed comparisons order distinct lowered member names.
1974
+
1975
+ Names compare by character code (2026-11-10), with non-ASCII characters
1976
+ after ASCII ones; two differing non-ASCII characters were observed only in
1977
+ lowercase.
1978
+ """
1979
+ for left, right in combinations(keys, 2):
1980
+ for first, second in zip(left, right, strict=False):
1981
+ if first != second:
1982
+ if not (first.isascii() or second.isascii()) and (
1983
+ first != first.lower() or second != second.lower()
1984
+ ):
1985
+ return False
1986
+ break
1987
+ return True
1988
+
1989
+
1990
+ def _member_order(entity_type: str, field: str, links: list[_EntityLink]) -> list[_EntityLink]:
1991
+ """Order members as the site reads them: by lowercased name, then by ID.
1992
+
1993
+ Members without a modeled display name keep the stored order when the named
1994
+ members already appear in their observed order within it.
1995
+ """
1996
+ if len(links) < _MEMBER_ORDER_MINIMUM:
1997
+ return links
1998
+ keyed: list[tuple[str, int, int]] = []
1999
+ for index, link in enumerate(links):
2000
+ name = link.get('name')
2001
+ if isinstance(name, str):
2002
+ keyed.append((name.translate(_ASCII_LOWER), cast('int', link['id']), index))
2003
+ if not _settled_member_keys({key for key, _, _ in keyed}):
2004
+ raise MockgunError(
2005
+ f'mockgun cannot order {entity_type}.{field} members: their names compare by '
2006
+ 'unobserved rules'
2007
+ )
2008
+ order = [index for _, _, index in sorted(keyed)]
2009
+ if len(keyed) == len(links):
2010
+ return [links[index] for index in order]
2011
+ if order != sorted(order):
2012
+ raise MockgunError(
2013
+ f'mockgun cannot order {entity_type}.{field} members: the order depends on '
2014
+ 'members without a modeled display name; declare their display fields'
2015
+ )
2016
+ return links
1975
2017
 
1976
2018
 
1977
2019
  def _validate_operands(
@@ -5,10 +5,27 @@ from dataclasses import dataclass
5
5
  from datetime import datetime
6
6
  from typing import cast
7
7
 
8
- from ._links import RetirementEdge, hidden, retirement_edge
8
+ from ._links import (
9
+ RetirementEdge,
10
+ hidden,
11
+ implicit_connection_fields,
12
+ link_target,
13
+ retirement_edge,
14
+ )
9
15
  from ._sessions import RequestContext
10
16
  from ._store import FieldValue, Row, Store
11
17
 
18
+ # A cascaded child revived while its parent stays retired hides its link to the
19
+ # parent and is untouched by the parent's later revival (a Shot's Version
20
+ # earlier; the rest on 2026-11-10). Each child type maps to its parent field and
21
+ # the parent types observed.
22
+ _SETTLED_ALONE = {
23
+ 'Note': ('note_links', frozenset({'Asset', 'Sequence', 'Shot', 'Version'})),
24
+ 'Shot': ('project', frozenset({'Project'})),
25
+ 'Task': ('entity', frozenset({'Asset', 'Sequence', 'Shot'})),
26
+ 'Version': ('entity', frozenset({'Asset', 'Sequence', 'Shot'})),
27
+ }
28
+
12
29
 
13
30
  @dataclass(frozen=True)
14
31
  class RetirementEffects:
@@ -86,21 +103,54 @@ def _holder_groups(
86
103
  return list(groups.values())
87
104
 
88
105
 
106
+ def _mark_hidden_links(store: Store, rows: list[Row]) -> None:
107
+ """Record the NoteLink and NoteTask members already hidden when these rows retired."""
108
+ operation = {_identity(row) for row in rows}
109
+ for row in rows:
110
+ members: list[FieldValue] = []
111
+ for field in implicit_connection_fields(cast('str', row['type'])):
112
+ value = row.get(field)
113
+ for link in cast(
114
+ 'list[dict[str, FieldValue]]', value if isinstance(value, list) else []
115
+ ):
116
+ target = link_target(store, link)
117
+ if (
118
+ target is not None
119
+ and target['__retired']
120
+ and _identity(target) not in operation
121
+ ):
122
+ members.append([field, target['type'], target['id']])
123
+ if members:
124
+ store.remember(row)
125
+ row['__retired_hidden'] = members
126
+
127
+
128
+ def _names(value: FieldValue, target: Row) -> bool:
129
+ """Return whether a single or multi-entity value names the target."""
130
+ identity: FieldValue = {'type': target['type'], 'id': target['id']}
131
+ return value == identity or (isinstance(value, list) and identity in value)
132
+
133
+
134
+ def _settled_alone(root: Row, source: Row) -> bool:
135
+ """Identify the observed standalone revivals of cascaded children."""
136
+ entry = _SETTLED_ALONE.get(cast('str', root['type']))
137
+ if entry is None or source['type'] not in entry[1]:
138
+ return False
139
+ if root['type'] == 'Note' and root.get('tasks'):
140
+ return False
141
+ return _names(root.get(entry[0]), source)
142
+
143
+
89
144
  def _revived_alone(store: Store, roots: tuple[Row, Row | None], children: list[Row]) -> None:
90
- """Taint the unobserved standalone revival while preserving the Version exception."""
145
+ """Taint unobserved standalone revivals, keeping the observed ones settled."""
91
146
  root, source = roots
92
147
  if source is None or not source['__retired']:
93
148
  return
94
- if (
95
- root['type'] == 'Version'
96
- and source['type'] == 'Shot'
97
- and root.get('entity') == {'type': 'Shot', 'id': source['id']}
98
- ):
99
- return
100
149
  reason = f'unsettled revival while {source["type"]} {source["id"]} remains retired'
101
- for holder, field in store.linking(*_identity(source)):
102
- if holder is root:
103
- store.taint(root, reason, source, link=(field, source), active=True)
150
+ if not _settled_alone(root, source):
151
+ for holder, field in store.linking(*_identity(source)):
152
+ if holder is root:
153
+ store.taint(root, reason, source, link=(field, source), active=True)
104
154
  for row in children:
105
155
  store.taint(row, reason, source)
106
156
 
@@ -146,23 +196,25 @@ def _holder_effect(
146
196
  _touch(store, holder, context)
147
197
  return 'touch'
148
198
  return 'hide'
149
- if effect == 'keep' and not store.tainted(target):
150
- return 'keep'
151
199
  for field, _ in edges:
152
200
  store.taint(holder, reason, root, link=(field, target))
153
201
  return 'taint'
154
202
 
155
203
 
156
204
  def _restore_children(store: Store, parent: Row, context: RequestContext) -> list[Row]:
157
- """Revive only still-retired children owned by this cascade marker."""
205
+ """Revive the children this cascade retired that still link the parent."""
158
206
  children: list[Row] = []
159
207
  for kind in store.schema.fields:
160
208
  for row in store.rows(kind, retired=True):
161
209
  if row.get('__cascaded_by') == [parent['type'], parent['id']]:
162
210
  store.check_writable(row)
163
- store.revive(*_identity(row))
164
211
  store.remember(row)
165
212
  row.pop('__cascaded_by', None)
213
+ # 2026-11-10: a child relinked or cleared while retired stays retired.
214
+ if not any(_names(row.get(field), parent) for field in store.schema.fields[kind]):
215
+ continue
216
+ store.revive(*_identity(row))
217
+ row.pop('__retired_hidden', None)
166
218
  _touch(store, row, context)
167
219
  children.append(row)
168
220
  return children
@@ -175,11 +227,59 @@ def _restore_touches(store: Store, parent: Row, context: RequestContext) -> list
175
227
  for link in cast('list[list[FieldValue]]', parent.pop('__retirement_touched', [])):
176
228
  holder = store.require(cast('str', link[0]), cast('int', link[1]))
177
229
  store.check_writable(holder)
230
+ # 2026-11-10: a holder retired meanwhile keeps its audit fields.
231
+ if holder['__retired']:
232
+ continue
178
233
  _touch(store, holder, context)
179
234
  holders.append(holder)
180
235
  return holders
181
236
 
182
237
 
238
+ def _revive_marked_notes(store: Store, parent: Row, context: RequestContext) -> list[Row]:
239
+ """Revive the observed Notes retired after this parent, or taint other shapes.
240
+
241
+ A Note linking one Shot and one Asset, retired while the Shot was retired,
242
+ revives with the Shot; a one-link Note, or one retired after its Task,
243
+ stays retired (2026-11-10).
244
+ """
245
+ revived: list[Row] = []
246
+ if not store.schema.declares('Note', 'note_links'):
247
+ return revived
248
+ for note in store.rows('Note', retired=True):
249
+ markers = cast('list[list[FieldValue]]', note.get('__retired_hidden', []))
250
+ fields = {
251
+ cast('str', marker[0])
252
+ for marker in markers
253
+ if marker[1:] == [parent['type'], parent['id']]
254
+ }
255
+ if not fields:
256
+ continue
257
+ links = cast('list[dict[str, FieldValue]]', note.get('note_links', []))
258
+ # 2026-11-10: a one-link Note, or one retired after its Task, stays retired.
259
+ if fields == {'tasks'} or (
260
+ fields == {'note_links'} and len(links) == 1 and not note.get('tasks')
261
+ ):
262
+ continue
263
+ store.check_writable(note)
264
+ if (
265
+ fields == {'note_links'}
266
+ and parent['type'] == 'Shot'
267
+ and not note.get('tasks')
268
+ and sorted(cast('str', link['type']) for link in links) == ['Asset', 'Shot']
269
+ ):
270
+ store.revive(*_identity(note))
271
+ store.remember(note)
272
+ note.pop('__retired_hidden', None)
273
+ _touch(store, note, context)
274
+ revived.append(note)
275
+ else:
276
+ reason = (
277
+ f'unsettled revival of {parent["type"]} {parent["id"]} for a Note retired after it'
278
+ )
279
+ store.taint(note, reason, None)
280
+ return revived
281
+
282
+
183
283
  def _revive(store: Store, root: Row, context: RequestContext) -> RetirementEffects:
184
284
  """Restore marked children and touches, then resolve source-owned taints."""
185
285
  store.remember(root)
@@ -191,6 +291,7 @@ def _revive(store: Store, root: Row, context: RequestContext) -> RetirementEffec
191
291
  )
192
292
  retired_at = cast('datetime | None', root.get('__retired_at'))
193
293
  store.revive(*_identity(root), context=context)
294
+ root.pop('__retired_hidden', None)
194
295
  cascaded: list[Row] = []
195
296
  touched: list[Row] = []
196
297
  queue = [root]
@@ -207,6 +308,7 @@ def _revive(store: Store, root: Row, context: RequestContext) -> RetirementEffec
207
308
  cascaded.extend(children)
208
309
  queue.extend(children)
209
310
  touched.extend(_restore_touches(store, parent, context))
311
+ cascaded.extend(_revive_marked_notes(store, root, context))
210
312
  cleared = _clear_source(store, root)
211
313
  _revived_alone(store, (root, source), cascaded)
212
314
  return RetirementEffects(
@@ -253,6 +355,7 @@ def _scan(store: Store, root: Row, context: RequestContext) -> RetirementEffects
253
355
  queue.append(holder)
254
356
  store.remember(root)
255
357
  root['__retirement_touched'] = [[row['type'], row['id']] for row in touched]
358
+ _mark_hidden_links(store, [root, *cascaded])
256
359
  return RetirementEffects(
257
360
  changed=True,
258
361
  root=root,
@@ -270,14 +373,16 @@ def kept_members(
270
373
  entity_id: int,
271
374
  fields: Mapping[str, object],
272
375
  ) -> dict[str, list[dict[str, FieldValue]]]:
273
- """Keep hidden Asset.shots members when an edit names visible members only.
376
+ """Keep hidden Asset.shots members through an edit of the visible members.
377
+
378
+ The edit cannot name a hidden member: the site refuses links to retired
379
+ records before this runs.
274
380
 
275
381
  Args:
276
382
  store: Site records used to inspect existing shot links.
277
383
  entity_type: Entity type being updated.
278
384
  entity_id: Existing entity identifier.
279
- fields: Incoming field values. Naming any hidden shot explicitly
280
- disables automatic preservation.
385
+ fields: Incoming field values.
281
386
 
282
387
  Returns:
283
388
  A `shots` entry containing preserved stored link dictionaries, or an
@@ -287,23 +392,13 @@ def kept_members(
287
392
  ServerFaultError: If the Asset record cannot be resolved.
288
393
  MockgunError: If an existing shot link's visibility is unsettled.
289
394
  """
290
- if entity_type != 'Asset' or 'shots' not in fields:
395
+ if entity_type != 'Asset' or not isinstance(fields.get('shots'), list):
291
396
  return {}
292
397
  row = store.require(entity_type, entity_id)
293
398
  if row['__retired']:
294
399
  return {}
295
400
  members = cast('list[dict[str, FieldValue]]', row.get('shots', []))
296
401
  kept = [link for link in members if hidden(store, row, 'shots', link)]
297
- incoming = fields['shots']
298
- if not isinstance(incoming, list):
299
- return {}
300
- identities = {
301
- (cast('dict[str, object]', item).get('type'), cast('dict[str, object]', item).get('id'))
302
- for item in cast('list[object]', incoming)
303
- if isinstance(item, dict)
304
- }
305
- if any((link['type'], link['id']) in identities for link in kept):
306
- return {}
307
402
  return {'shots': kept} if kept else {}
308
403
 
309
404