|
3 | 3 | from collections import Counter |
4 | 4 | from collections.abc import Iterator, Mapping, MutableMapping, Sequence |
5 | 5 | from copy import deepcopy |
6 | | -from typing import get_type_hints |
| 6 | +from typing import _GenericAlias, get_args, get_origin, get_type_hints # type: ignore |
7 | 7 |
|
8 | 8 | from fastcs.attribute_io import AttributeIO |
9 | 9 | from fastcs.attribute_io_ref import AttributeIORefT |
10 | 10 | from fastcs.attributes import Attribute, AttrR, AttrW |
11 | | -from fastcs.datatypes import T |
| 11 | +from fastcs.datatypes import DataType, T |
12 | 12 | from fastcs.tracer import Tracer |
13 | 13 |
|
14 | 14 |
|
@@ -39,16 +39,46 @@ def __init__( |
39 | 39 | self._path: list[str] = path or [] |
40 | 40 | self.__sub_controller_tree: dict[str, BaseController] = {} |
41 | 41 |
|
| 42 | + self.__hinted_attributes = self._parse_attribute_type_hints() |
42 | 43 | self._bind_attrs() |
43 | 44 |
|
44 | 45 | ios = ios or [] |
45 | 46 | self._attribute_ref_io_map = {io.ref_type: io for io in ios} |
46 | 47 | self._validate_io(ios) |
47 | 48 |
|
| 49 | + def _parse_attribute_type_hints( |
| 50 | + self, |
| 51 | + ) -> dict[str, tuple[type[Attribute], type[DataType]]]: |
| 52 | + hinted_attributes = {} |
| 53 | + for name, hint in get_type_hints(type(self)).items(): |
| 54 | + if not isinstance(hint, _GenericAlias): # e.g. AttrR[int] |
| 55 | + continue |
| 56 | + |
| 57 | + origin = get_origin(hint) |
| 58 | + if not isinstance(origin, type) or not issubclass(origin, Attribute): |
| 59 | + continue |
| 60 | + |
| 61 | + hinted_attributes[name] = (origin, get_args(hint)[0]) |
| 62 | + |
| 63 | + return hinted_attributes |
| 64 | + |
48 | 65 | async def initialise(self): |
49 | 66 | """Hook to dynamically add attributes before building the API""" |
50 | 67 | pass |
51 | 68 |
|
| 69 | + def validate_hinted_attributes(self): |
| 70 | + """Validate ``Attribute`` type-hints were introspected during initialisation""" |
| 71 | + for name in self.__hinted_attributes: |
| 72 | + attr = getattr(self, name, None) |
| 73 | + if attr is None: |
| 74 | + raise RuntimeError( |
| 75 | + f"Controller `{self.__class__.__name__}` failed to introspect " |
| 76 | + f"hinted attribute `{name}` during initialisation" |
| 77 | + ) |
| 78 | + |
| 79 | + for subcontroller in self.sub_controllers.values(): |
| 80 | + subcontroller.validate_hinted_attributes() # noqa: SLF001 |
| 81 | + |
52 | 82 | def connect_attribute_ios(self) -> None: |
53 | 83 | """Connect ``Attribute`` callbacks to ``AttributeIO``s""" |
54 | 84 | for attr in self.attributes.values(): |
@@ -126,24 +156,39 @@ def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): |
126 | 156 | f"More than one AttributeIO class handles {ref_type.__name__}" |
127 | 157 | ) |
128 | 158 |
|
129 | | - def add_attribute(self, name, attribute: Attribute): |
130 | | - if name in self.attributes and attribute is not self.attributes[name]: |
| 159 | + def add_attribute(self, name, attr: Attribute): |
| 160 | + if name in self.attributes: |
131 | 161 | raise ValueError( |
132 | | - f"Cannot add attribute {attribute}. " |
| 162 | + f"Cannot add attribute {attr}. " |
133 | 163 | f"Controller {self} has has existing attribute {name}: " |
134 | 164 | f"{self.attributes[name]}" |
135 | 165 | ) |
| 166 | + elif name in self.__hinted_attributes: |
| 167 | + attr_class, attr_dtype = self.__hinted_attributes[name] |
| 168 | + if not isinstance(attr, attr_class): |
| 169 | + raise RuntimeError( |
| 170 | + f"Controller '{self.__class__.__name__}' introspection of " |
| 171 | + f"hinted attribute '{name}' does not match defined access mode. " |
| 172 | + f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." |
| 173 | + ) |
| 174 | + if attr_dtype is not None and attr_dtype != attr.datatype.dtype: |
| 175 | + raise RuntimeError( |
| 176 | + f"Controller '{self.__class__.__name__}' introspection of " |
| 177 | + f"hinted attribute '{name}' does not match defined datatype. " |
| 178 | + f"Expected '{attr_dtype.__name__}', " |
| 179 | + f"got '{attr.datatype.dtype.__name__}'." |
| 180 | + ) |
136 | 181 | elif name in self.__sub_controller_tree.keys(): |
137 | 182 | raise ValueError( |
138 | | - f"Cannot add attribute {attribute}. " |
| 183 | + f"Cannot add attribute {attr}. " |
139 | 184 | f"Controller {self} has existing sub controller {name}: " |
140 | 185 | f"{self.__sub_controller_tree[name]}" |
141 | 186 | ) |
142 | 187 |
|
143 | | - attribute.set_name(name) |
144 | | - attribute.set_path(self.path) |
145 | | - self.attributes[name] = attribute |
146 | | - super().__setattr__(name, attribute) |
| 188 | + attr.set_name(name) |
| 189 | + attr.set_path(self.path) |
| 190 | + self.attributes[name] = attr |
| 191 | + super().__setattr__(name, attr) |
147 | 192 |
|
148 | 193 | def add_sub_controller(self, name: str, sub_controller: BaseController): |
149 | 194 | if name in self.__sub_controller_tree.keys(): |
|
0 commit comments