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
« 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."""
3from __future__ import annotations
5from dataclasses import dataclass, fields, is_dataclass
6from types import GetSetDescriptorType, MemberDescriptorType
7from typing import Iterable, Iterator
10class FieldAccessError(AttributeError):
11 """Raised when a requested dataclass field path cannot be resolved."""
14@dataclass(frozen=True, slots=True)
15class DottedFieldPath:
16 """Validated dotted dataclass field path."""
18 value: str
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)
26 @property
27 def field_name(self) -> str:
28 """Return the declared leaf name represented by this path."""
30 return self.parts[-1] if self.parts else ""
32 @property
33 def parent(self) -> "DottedFieldPath":
34 """The immediate container; a root field and the root both have an empty parent."""
36 return DottedFieldPath(self.value.rpartition(".")[0])
38 @property
39 def lineage(self) -> tuple["DottedFieldPath", ...]:
40 """This field followed by its parents, nearest first, excluding the empty root."""
42 paths = []
43 path = self
44 while path.value:
45 paths.append(path)
46 path = path.parent
47 return tuple(paths)
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}")
54 def contains_path(self, candidate: str | "DottedFieldPath") -> bool:
55 """Return whether this field path owns a dotted or structural display path."""
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 )
64 def contains_any(self, candidates: Iterable[str | "DottedFieldPath"]) -> bool:
65 """Return whether this field path owns any candidate display path."""
67 return any(self.contains_path(candidate) for candidate in candidates)
69 def intersects_path(self, other: str | "DottedFieldPath") -> bool:
70 """Return whether this path and another path overlap in either direction."""
72 other_path = other if isinstance(other, DottedFieldPath) else DottedFieldPath(other)
73 return self.contains_path(other_path) or other_path.contains_path(self)
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."""
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
87class DataclassFieldAccess:
88 """Single authority for raw dataclass reads and dotted field traversal."""
90 _SLOT_DESCRIPTOR_TYPES = (MemberDescriptorType, GetSetDescriptorType)
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))
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)
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)
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]
130 return cls._slot_field_value(instance, field_name)
132 @staticmethod
133 def _has_instance_dict(instance_type: type) -> bool:
134 return instance_type.__dictoffset__ != 0
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))
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
148 raise FieldAccessError(
149 f"{instance_type.__name__}.{field_name} is not stored on the instance."
150 )
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)
159 current = root
160 for part in path.parts:
161 current = cls.raw_value(current, part)
162 return current
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)
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."""
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 }