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

1"""Structural semantic projection for values owned by one ObjectState field.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, fields as dataclass_fields, is_dataclass 

6from enum import Enum 

7from typing import Generic, TypeVar 

8 

9from objectstate.field_access import DataclassFieldAccess, DottedFieldPath 

10 

11 

12SubfieldValueT = TypeVar("SubfieldValueT") 

13 

14 

15@dataclass(frozen=True, slots=True) 

16class MissingValue: 

17 """Nominal sentinel for an absent structural leaf.""" 

18 

19 

20MISSING = MissingValue() 

21 

22 

23class StructuralSegmentKind(str, Enum): 

24 """Closed segment kinds for structural value paths.""" 

25 

26 DATACLASS_FIELD = "dataclass_field" 

27 SEQUENCE_INDEX = "sequence_index" 

28 MAPPING_KEY = "mapping_key" 

29 

30 

31@dataclass(frozen=True, slots=True) 

32class StructuralPathSegment: 

33 """One typed segment inside a structural value path.""" 

34 

35 kind: StructuralSegmentKind 

36 value: str | int 

37 

38 

39@dataclass(frozen=True, slots=True) 

40class StructuralValuePath: 

41 """Typed relative path from an ObjectState owner field into its value.""" 

42 

43 segments: tuple[StructuralPathSegment, ...] = () 

44 

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 ) 

55 

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 ) 

66 

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 

77 

78 @classmethod 

79 def from_display_suffix(cls, suffix: str) -> "StructuralValuePath": 

80 """Parse a suffix emitted by :meth:`display_suffix`.""" 

81 

82 if not suffix: 

83 return cls() 

84 

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 

114 

115 

116@dataclass(frozen=True, slots=True) 

117class StructuralFieldPath: 

118 """A writable ObjectState owner field plus a projected structural child path.""" 

119 

120 owner_field_path: DottedFieldPath 

121 relative_path: StructuralValuePath 

122 

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.""" 

126 

127 first_sequence_index = field_path.find("[") 

128 if first_sequence_index < 0: 

129 return None 

130 

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 ) 

143 

144 return cls( 

145 owner_field_path=DottedFieldPath(owner_value), 

146 relative_path=StructuralValuePath.from_display_suffix(suffix_value), 

147 ) 

148 

149 

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) 

157 

158 

159@dataclass(frozen=True, slots=True) 

160class ObjectStateSubfieldSemantic(Generic[SubfieldValueT]): 

161 """Semantic state for one structural leaf under an ObjectState owner field.""" 

162 

163 owner_field_path: DottedFieldPath 

164 relative_path: StructuralValuePath 

165 display_path: str 

166 value_type_name: str | None 

167 

168 raw_value: SubfieldValueT | MissingValue 

169 resolved_value: SubfieldValueT | MissingValue 

170 saved_resolved_value: SubfieldValueT | MissingValue 

171 signature_default_value: SubfieldValueT | MissingValue 

172 

173 raw_present: bool 

174 resolved_present: bool 

175 saved_resolved_present: bool 

176 signature_default_present: bool 

177 

178 dirty: bool 

179 signature_diff: bool 

180 inherited_value: bool 

181 semantic_markers: tuple[str, ...] 

182 

183 

184@dataclass(frozen=True, slots=True) 

185class ObjectStateSubfieldSemanticIndex: 

186 """Semantic projection for all structural leaves under one owner field.""" 

187 

188 owner_field_path: DottedFieldPath 

189 owner_dirty: bool 

190 owner_signature_diff: bool 

191 owner_inherited_value: bool 

192 leaves: tuple[ObjectStateSubfieldSemantic, ...] 

193 

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 

202 

203 

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.""" 

215 

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) 

220 

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) 

224 

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 ) 

236 

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 ) 

244 

245 

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.""" 

253 

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 ) 

267 

268 

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__ 

293 

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 ) 

312 

313 

314def _leaf_values(value) -> dict: 

315 if value is MISSING: 

316 return {} 

317 return dict(_iter_leaf_values(value, StructuralValuePath())) 

318 

319 

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 

327 

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 

332 

333 yield path, value 

334 

335 

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) 

345 

346 

347def _is_dataclass_instance(value) -> bool: 

348 return is_dataclass(value) and not isinstance(value, type)