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

1"""Canonical extraction structure, separate from live/saved parameter values.""" 

2 

3from __future__ import annotations 

4 

5import copy 

6from collections.abc import Iterator 

7from dataclasses import dataclass, field, fields, is_dataclass, replace 

8from typing import Any 

9 

10from python_introspect import ParameterInfo 

11 

12from objectstate.field_access import DottedFieldPath 

13from objectstate.parameter_owner import ParameterOwner 

14 

15 

16@dataclass 

17class ParameterStructure: 

18 """One owner for extracted topology, defaults, descriptions and exclusions.""" 

19 

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) 

27 

28 @property 

29 def defaults(self) -> dict[str, Any]: 

30 """Derive editor reset values from admitted parameter declarations.""" 

31 

32 return {path: info.default_value for path, info in self.declarations.items()} 

33 

34 @property 

35 def descriptions(self) -> dict[str, str | None]: 

36 """Request presentation help only when a consumer reads it.""" 

37 

38 return {path: info.description for path, info in self.declarations.items()} 

39 

40 def path(self, name: str) -> DottedFieldPath: 

41 """Resolve a name to its admitted path, without analyzing declarations.""" 

42 

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}") 

48 

49 def ancestors(self, path: DottedFieldPath) -> tuple[DottedFieldPath, ...]: 

50 """The admitted fields down to this one, itself last.""" 

51 

52 return tuple(self.path(step.value) for step in reversed(path.lineage)) 

53 

54 def leaf_paths(self, parent: str = "") -> Iterator[DottedFieldPath]: 

55 """Enumerate leaves through the extracted topology, never the source type.""" 

56 

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 

62 

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

67 

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) 

72 

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

78 

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 ) 

86 

87 def clone(self) -> ParameterStructure: 

88 """Detach owned maps without cloning declaration/target identities. 

89 

90 Used only for prepared schema transfer and local rollback. Historical 

91 values are detached as a complete deep-copied construction/value graph. 

92 """ 

93 

94 return replace( 

95 self, 

96 **{item.name: copy.copy(getattr(self, item.name)) for item in fields(self)}, 

97 )