Coverage for src/objectstate/subfield_semantics.py: 93%
174 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 07:22 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 07:22 +0000
1"""Structural semantic projection for values owned by one ObjectState field."""
3from __future__ import annotations
5from dataclasses import dataclass, fields as dataclass_fields, is_dataclass
6from enum import Enum
7from typing import Generic, TypeVar
9from objectstate.field_access import DataclassFieldAccess, DottedFieldPath
12SubfieldValueT = TypeVar("SubfieldValueT")
15@dataclass(frozen=True, slots=True)
16class MissingValue:
17 """Nominal sentinel for an absent structural leaf."""
20MISSING = MissingValue()
23class StructuralSegmentKind(str, Enum):
24 """Closed segment kinds for structural value paths."""
26 DATACLASS_FIELD = "dataclass_field"
27 SEQUENCE_INDEX = "sequence_index"
28 MAPPING_KEY = "mapping_key"
31@dataclass(frozen=True, slots=True)
32class StructuralPathSegment:
33 """One typed segment inside a structural value path."""
35 kind: StructuralSegmentKind
36 value: str | int
39@dataclass(frozen=True, slots=True)
40class StructuralValuePath:
41 """Typed relative path from an ObjectState owner field into its value."""
43 segments: tuple[StructuralPathSegment, ...] = ()
45 def child_field(self, name: str) -> "StructuralValuePath":
46 return StructuralValuePath(
47 self.segments
48 + (
49 StructuralPathSegment(
50 StructuralSegmentKind.DATACLASS_FIELD,
51 name,
52 ),
53 )
54 )
56 def child_index(self, index: int) -> "StructuralValuePath":
57 return StructuralValuePath(
58 self.segments
59 + (
60 StructuralPathSegment(
61 StructuralSegmentKind.SEQUENCE_INDEX,
62 index,
63 ),
64 )
65 )
67 def display_suffix(self) -> str:
68 suffix = ""
69 for segment in self.segments:
70 if segment.kind is StructuralSegmentKind.DATACLASS_FIELD:
71 suffix += f".{segment.value}"
72 elif segment.kind is StructuralSegmentKind.SEQUENCE_INDEX:
73 suffix += f"[{segment.value}]"
74 elif segment.kind is StructuralSegmentKind.MAPPING_KEY:
75 suffix += f"[{segment.value!r}]"
76 return suffix
78 @classmethod
79 def from_display_suffix(cls, suffix: str) -> "StructuralValuePath":
80 """Parse a suffix emitted by :meth:`display_suffix`."""
82 if not suffix:
83 return cls()
85 index = 0
86 path = cls()
87 while index < len(suffix):
88 char = suffix[index]
89 if char == ".":
90 next_index = _next_structural_delimiter(suffix, index + 1)
91 field_name = suffix[index + 1 : next_index]
92 if not field_name:
93 raise ValueError(f"Empty structural field segment in {suffix!r}.")
94 path = path.child_field(field_name)
95 index = next_index
96 continue
97 if char == "[":
98 close_index = suffix.find("]", index + 1)
99 if close_index < 0:
100 raise ValueError(f"Unclosed structural index segment in {suffix!r}.")
101 value = suffix[index + 1 : close_index]
102 if not value.isdecimal():
103 raise ValueError(
104 "Only sequence-index structural suffixes are supported; "
105 f"got {value!r} in {suffix!r}."
106 )
107 path = path.child_index(int(value))
108 index = close_index + 1
109 continue
110 raise ValueError(
111 f"Structural suffix must use '.' or '[' segments; got {suffix!r}."
112 )
113 return path
116@dataclass(frozen=True, slots=True)
117class StructuralFieldPath:
118 """A writable ObjectState owner field plus a projected structural child path."""
120 owner_field_path: DottedFieldPath
121 relative_path: StructuralValuePath
123 @classmethod
124 def from_display_path(cls, field_path: str) -> "StructuralFieldPath | None":
125 """Parse a display path emitted from an owner path plus structural suffix."""
127 first_sequence_index = field_path.find("[")
128 if first_sequence_index < 0:
129 return None
131 owner_value = field_path[:first_sequence_index]
132 suffix_value = field_path[first_sequence_index:]
133 if not owner_value:
134 raise ValueError(
135 f"Structural field path {field_path!r} has no owner field path."
136 )
137 if "." in suffix_value:
138 first_relative_field = suffix_value.split(".", 1)[1]
139 if not first_relative_field:
140 raise ValueError(
141 f"Structural field path {field_path!r} has an empty relative field."
142 )
144 return cls(
145 owner_field_path=DottedFieldPath(owner_value),
146 relative_path=StructuralValuePath.from_display_suffix(suffix_value),
147 )
150def _next_structural_delimiter(text: str, start: int) -> int:
151 dot_index = text.find(".", start)
152 bracket_index = text.find("[", start)
153 candidates = tuple(
154 index for index in (dot_index, bracket_index) if index >= 0
155 )
156 return min(candidates) if candidates else len(text)
159@dataclass(frozen=True, slots=True)
160class ObjectStateSubfieldSemantic(Generic[SubfieldValueT]):
161 """Semantic state for one structural leaf under an ObjectState owner field."""
163 owner_field_path: DottedFieldPath
164 relative_path: StructuralValuePath
165 display_path: str
166 value_type_name: str | None
168 raw_value: SubfieldValueT | MissingValue
169 resolved_value: SubfieldValueT | MissingValue
170 saved_resolved_value: SubfieldValueT | MissingValue
171 signature_default_value: SubfieldValueT | MissingValue
173 raw_present: bool
174 resolved_present: bool
175 saved_resolved_present: bool
176 signature_default_present: bool
178 dirty: bool
179 signature_diff: bool
180 inherited_value: bool
181 semantic_markers: tuple[str, ...]
184@dataclass(frozen=True, slots=True)
185class ObjectStateSubfieldSemanticIndex:
186 """Semantic projection for all structural leaves under one owner field."""
188 owner_field_path: DottedFieldPath
189 owner_dirty: bool
190 owner_signature_diff: bool
191 owner_inherited_value: bool
192 leaves: tuple[ObjectStateSubfieldSemantic, ...]
194 def leaf_for(
195 self,
196 relative_path: StructuralValuePath,
197 ) -> ObjectStateSubfieldSemantic | None:
198 for leaf in self.leaves:
199 if leaf.relative_path == relative_path:
200 return leaf
201 return None
204def build_subfield_semantic_index(
205 *,
206 owner_field_path: DottedFieldPath,
207 raw_value,
208 resolved_value,
209 saved_resolved_value,
210 signature_default_value,
211 owner_dirty: bool,
212 owner_signature_diff: bool,
213) -> ObjectStateSubfieldSemanticIndex:
214 """Build structural leaf semantics from ObjectState-owned values."""
216 raw_leaves = _leaf_values(raw_value)
217 resolved_leaves = _leaf_values(resolved_value)
218 saved_leaves = _leaf_values(saved_resolved_value)
219 default_leaves = _leaf_values(signature_default_value)
221 ordered_paths = _ordered_paths(raw_leaves, resolved_leaves, saved_leaves, default_leaves)
222 if any(path.segments for path in ordered_paths):
223 ordered_paths = tuple(path for path in ordered_paths if path.segments)
225 leaves = tuple(
226 _build_leaf(
227 owner_field_path=owner_field_path,
228 relative_path=relative_path,
229 raw_value=raw_leaves.get(relative_path, MISSING),
230 resolved_value=resolved_leaves.get(relative_path, MISSING),
231 saved_resolved_value=saved_leaves.get(relative_path, MISSING),
232 signature_default_value=default_leaves.get(relative_path, MISSING),
233 )
234 for relative_path in ordered_paths
235 )
237 return ObjectStateSubfieldSemanticIndex(
238 owner_field_path=owner_field_path,
239 owner_dirty=owner_dirty,
240 owner_signature_diff=owner_signature_diff,
241 owner_inherited_value=raw_value is None and resolved_value is not None,
242 leaves=leaves,
243 )
246def changed_structural_leaf_paths(
247 *,
248 owner_field_path: DottedFieldPath,
249 old_value,
250 new_value,
251) -> tuple[str, ...]:
252 """Return display paths for structural leaves whose values changed."""
254 old_leaves = _leaf_values(old_value)
255 new_leaves = _leaf_values(new_value)
256 changed_paths = tuple(
257 relative_path
258 for relative_path in _ordered_paths(old_leaves, new_leaves)
259 if relative_path.segments
260 and old_leaves.get(relative_path, MISSING)
261 != new_leaves.get(relative_path, MISSING)
262 )
263 return tuple(
264 f"{owner_field_path.value}{relative_path.display_suffix()}"
265 for relative_path in changed_paths
266 )
269def _build_leaf(
270 *,
271 owner_field_path: DottedFieldPath,
272 relative_path: StructuralValuePath,
273 raw_value,
274 resolved_value,
275 saved_resolved_value,
276 signature_default_value,
277) -> ObjectStateSubfieldSemantic:
278 raw_present = raw_value is not MISSING
279 resolved_present = resolved_value is not MISSING
280 saved_resolved_present = saved_resolved_value is not MISSING
281 signature_default_present = signature_default_value is not MISSING
282 dirty = resolved_value != saved_resolved_value
283 signature_diff = raw_value != signature_default_value
284 inherited_value = not raw_present and resolved_present
285 display_path = f"{owner_field_path.value}{relative_path.display_suffix()}"
286 markers: list[str] = []
287 if dirty:
288 markers.append("*")
289 if signature_diff or inherited_value:
290 markers.append("_")
291 value_type = resolved_value if resolved_present else raw_value
292 value_type_name = None if value_type is MISSING else type(value_type).__qualname__
294 return ObjectStateSubfieldSemantic(
295 owner_field_path=owner_field_path,
296 relative_path=relative_path,
297 display_path=display_path,
298 value_type_name=value_type_name,
299 raw_value=raw_value,
300 resolved_value=resolved_value,
301 saved_resolved_value=saved_resolved_value,
302 signature_default_value=signature_default_value,
303 raw_present=raw_present,
304 resolved_present=resolved_present,
305 saved_resolved_present=saved_resolved_present,
306 signature_default_present=signature_default_present,
307 dirty=dirty,
308 signature_diff=signature_diff,
309 inherited_value=inherited_value,
310 semantic_markers=tuple(markers),
311 )
314def _leaf_values(value) -> dict:
315 if value is MISSING:
316 return {}
317 return dict(_iter_leaf_values(value, StructuralValuePath()))
320def _iter_leaf_values(value, path: StructuralValuePath):
321 if _is_dataclass_instance(value):
322 for dataclass_field in dataclass_fields(type(value)):
323 child_path = path.child_field(dataclass_field.name)
324 child_value = DataclassFieldAccess.raw_value(value, dataclass_field.name)
325 yield from _iter_leaf_values(child_value, child_path)
326 return
328 if isinstance(value, (tuple, list)):
329 for index, child_value in enumerate(value):
330 yield from _iter_leaf_values(child_value, path.child_index(index))
331 return
333 yield path, value
336def _ordered_paths(*leaf_sets: dict) -> tuple[StructuralValuePath, ...]:
337 ordered: list[StructuralValuePath] = []
338 seen: set[StructuralValuePath] = set()
339 for leaves in leaf_sets:
340 for path in leaves:
341 if path not in seen:
342 seen.add(path)
343 ordered.append(path)
344 return tuple(ordered)
347def _is_dataclass_instance(value) -> bool:
348 return is_dataclass(value) and not isinstance(value, type)