Coverage for src/objectstate/parameter_structure.py: 96%
45 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"""Canonical extraction structure, separate from live/saved parameter values."""
3from __future__ import annotations
5import copy
6from collections.abc import Iterator
7from dataclasses import dataclass, field, fields, is_dataclass, replace
8from typing import Any
10from python_introspect import ParameterInfo
12from objectstate.field_access import DottedFieldPath
13from objectstate.parameter_owner import ParameterOwner
16@dataclass
17class ParameterStructure:
18 """One owner for extracted topology, defaults, descriptions and exclusions."""
20 owner_paths: dict[str, ParameterOwner] = field(default_factory=dict)
21 direct_paths: dict[DottedFieldPath, tuple[DottedFieldPath, ...]] = field(
22 default_factory=dict
23 )
24 declarations: dict[str, ParameterInfo] = field(default_factory=dict)
25 exclusions: list[str] = field(default_factory=list)
26 excluded_values: dict[str, Any] = field(default_factory=dict)
28 @property
29 def defaults(self) -> dict[str, Any]:
30 """Derive editor reset values from admitted parameter declarations."""
32 return {path: info.default_value for path, info in self.declarations.items()}
34 @property
35 def descriptions(self) -> dict[str, str | None]:
36 """Request presentation help only when a consumer reads it."""
38 return {path: info.description for path, info in self.declarations.items()}
40 def path(self, name: str) -> DottedFieldPath:
41 """Resolve a name to its admitted path, without analyzing declarations."""
43 requested = DottedFieldPath(name)
44 for path in self.direct_paths.get(requested.parent, ()):
45 if path.value == name:
46 return path
47 raise KeyError(f"ObjectState path is not registered: {name!r}")
49 def ancestors(self, path: DottedFieldPath) -> tuple[DottedFieldPath, ...]:
50 """The admitted fields down to this one, itself last."""
52 return tuple(self.path(step.value) for step in reversed(path.lineage))
54 def leaf_paths(self, parent: str = "") -> Iterator[DottedFieldPath]:
55 """Enumerate leaves through the extracted topology, never the source type."""
57 for path in self.direct_paths.get(DottedFieldPath(parent), ()):
58 if self.direct_paths.get(path):
59 yield from self.leaf_paths(path.value)
60 else:
61 yield path
63 def is_container_parameter(
64 self, name: str, value: Any, parameters: dict[str, Any]
65 ) -> bool:
66 """Identify a nested dataclass entry from its actual extracted children."""
68 if value is None or not is_dataclass(type(value)):
69 return False
70 prefix = f"{name}."
71 return any(path.startswith(prefix) for path in parameters)
73 @classmethod
74 def for_target(
75 cls, target: Any, exclusions: list[str] | None
76 ) -> ParameterStructure:
77 """Start extraction from the actual target's excluded constructor values."""
79 names = list(exclusions or [])
80 return cls(
81 exclusions=names,
82 excluded_values={
83 name: getattr(target, name) for name in names if hasattr(target, name)
84 },
85 )
87 def clone(self) -> ParameterStructure:
88 """Detach owned maps without cloning declaration/target identities.
90 Used only for prepared schema transfer and local rollback. Historical
91 values are detached as a complete deep-copied construction/value graph.
92 """
94 return replace(
95 self,
96 **{item.name: copy.copy(getattr(self, item.name)) for item in fields(self)},
97 )