Coverage for src/objectstate/field_access.py: 86%

108 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 07:22 +0000

1"""Authoritative field access helpers for dataclass-backed ObjectState values.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, fields, is_dataclass 

6from types import GetSetDescriptorType, MemberDescriptorType 

7from typing import Iterable, Iterator 

8 

9 

10class FieldAccessError(AttributeError): 

11 """Raised when a requested dataclass field path cannot be resolved.""" 

12 

13 

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

15class DottedFieldPath: 

16 """Validated dotted dataclass field path.""" 

17 

18 value: str 

19 

20 @property 

21 def parts(self) -> tuple[str, ...]: 

22 if self.value == "": 

23 return () 

24 return tuple(part for part in self.value.split(".") if part) 

25 

26 @property 

27 def field_name(self) -> str: 

28 """Return the declared leaf name represented by this path.""" 

29 

30 return self.parts[-1] if self.parts else "" 

31 

32 @property 

33 def parent(self) -> "DottedFieldPath": 

34 """The immediate container; a root field and the root both have an empty parent.""" 

35 

36 return DottedFieldPath(self.value.rpartition(".")[0]) 

37 

38 @property 

39 def lineage(self) -> tuple["DottedFieldPath", ...]: 

40 """This field followed by its parents, nearest first, excluding the empty root.""" 

41 

42 paths = [] 

43 path = self 

44 while path.value: 

45 paths.append(path) 

46 path = path.parent 

47 return tuple(paths) 

48 

49 def child(self, field_name: str) -> "DottedFieldPath": 

50 if self.value == "": 

51 return DottedFieldPath(field_name) 

52 return DottedFieldPath(f"{self.value}.{field_name}") 

53 

54 def contains_path(self, candidate: str | "DottedFieldPath") -> bool: 

55 """Return whether this field path owns a dotted or structural display path.""" 

56 

57 candidate_value = candidate.value if isinstance(candidate, DottedFieldPath) else candidate 

58 return ( 

59 candidate_value == self.value 

60 or candidate_value.startswith(f"{self.value}.") 

61 or candidate_value.startswith(f"{self.value}[") 

62 ) 

63 

64 def contains_any(self, candidates: Iterable[str | "DottedFieldPath"]) -> bool: 

65 """Return whether this field path owns any candidate display path.""" 

66 

67 return any(self.contains_path(candidate) for candidate in candidates) 

68 

69 def intersects_path(self, other: str | "DottedFieldPath") -> bool: 

70 """Return whether this path and another path overlap in either direction.""" 

71 

72 other_path = other if isinstance(other, DottedFieldPath) else DottedFieldPath(other) 

73 return self.contains_path(other_path) or other_path.contains_path(self) 

74 

75 def direct_child_name(self, candidate: str | "DottedFieldPath") -> str | None: 

76 """Return the direct dotted child field owned by this path, when one exists.""" 

77 

78 candidate_value = candidate.value if isinstance(candidate, DottedFieldPath) else candidate 

79 prefix = f"{self.value}." if self.value else "" 

80 if not candidate_value.startswith(prefix): 

81 return None 

82 child_remainder = candidate_value[len(prefix):] 

83 child_name = child_remainder.split(".", 1)[0].split("[", 1)[0] 

84 return child_name or None 

85 

86 

87class DataclassFieldAccess: 

88 """Single authority for raw dataclass reads and dotted field traversal.""" 

89 

90 _SLOT_DESCRIPTOR_TYPES = (MemberDescriptorType, GetSetDescriptorType) 

91 

92 @staticmethod 

93 def field_names(instance_or_type) -> frozenset[str]: 

94 dataclass_type = instance_or_type if isinstance(instance_or_type, type) else type(instance_or_type) 

95 if not is_dataclass(dataclass_type): 

96 raise TypeError( 

97 "DataclassFieldAccess requires a dataclass instance or type; " 

98 f"got {dataclass_type!r}." 

99 ) 

100 return frozenset(field.name for field in fields(dataclass_type)) 

101 

102 @staticmethod 

103 def init_field_names(instance_or_type) -> tuple[str, ...]: 

104 dataclass_type = instance_or_type if isinstance(instance_or_type, type) else type(instance_or_type) 

105 if not is_dataclass(dataclass_type): 

106 raise TypeError( 

107 "DataclassFieldAccess requires a dataclass instance or type; " 

108 f"got {dataclass_type!r}." 

109 ) 

110 return tuple(field.name for field in fields(dataclass_type) if field.init) 

111 

112 @classmethod 

113 def has_field(cls, instance_or_type, field_name: str) -> bool: 

114 return field_name in cls.field_names(instance_or_type) 

115 

116 @classmethod 

117 def raw_value(cls, instance, field_name: str): 

118 if instance is None: 

119 raise FieldAccessError(f"Cannot read field {field_name!r} from None.") 

120 if not cls.has_field(instance, field_name): 

121 raise FieldAccessError( 

122 f"{type(instance).__name__} has no dataclass field {field_name!r}." 

123 ) 

124 if cls._has_instance_dict(type(instance)): 

125 storage = vars(instance) 

126 if field_name not in storage: 

127 return cls._slot_field_value(instance, field_name) 

128 return storage[field_name] 

129 

130 return cls._slot_field_value(instance, field_name) 

131 

132 @staticmethod 

133 def _has_instance_dict(instance_type: type) -> bool: 

134 return instance_type.__dictoffset__ != 0 

135 

136 @classmethod 

137 def _slot_field_value(cls, instance, field_name: str): 

138 descriptor = cls._slot_descriptor(type(instance), field_name) 

139 return descriptor.__get__(instance, type(instance)) 

140 

141 @classmethod 

142 def _slot_descriptor(cls, instance_type: type, field_name: str): 

143 for owner in instance_type.__mro__: 

144 descriptor = vars(owner).get(field_name) 

145 if isinstance(descriptor, cls._SLOT_DESCRIPTOR_TYPES): 

146 return descriptor 

147 

148 raise FieldAccessError( 

149 f"{instance_type.__name__}.{field_name} is not stored on the instance." 

150 ) 

151 

152 @classmethod 

153 def raw_path(cls, root, field_path: str | DottedFieldPath): 

154 if isinstance(field_path, DottedFieldPath): 

155 path = field_path 

156 else: 

157 path = DottedFieldPath(field_path) 

158 

159 current = root 

160 for part in path.parts: 

161 current = cls.raw_value(current, part) 

162 return current 

163 

164 @classmethod 

165 def raw_items(cls, instance, field_names: Iterator[str]): 

166 for field_name in field_names: 

167 if cls.has_field(instance, field_name): 

168 yield field_name, cls.raw_value(instance, field_name) 

169 

170 @classmethod 

171 def raw_init_values(cls, instance, constructor_type: type | None = None) -> dict[str, object]: 

172 """Return raw values for dataclass fields accepted by a constructor.""" 

173 

174 field_owner = constructor_type or type(instance) 

175 return { 

176 field_name: cls.raw_value(instance, field_name) 

177 for field_name in cls.init_field_names(field_owner) 

178 }