Skip to content

Commit 92c8217

Browse files
committed
Integrate hinted attribute validation into controller
Check datatype and access mode in add_attribute, check for unintrospected type hints after initialise
1 parent d750b61 commit 92c8217

File tree

6 files changed

+99
-202
lines changed

6 files changed

+99
-202
lines changed

src/fastcs/control_system.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from fastcs.logging import logger as _fastcs_logger
1414
from fastcs.tracer import Tracer
1515
from fastcs.transport import Transport
16-
from fastcs.util import validate_hinted_attributes
1716

1817
tracer = Tracer(name=__name__)
1918
logger = _fastcs_logger.bind(logger_name=__name__)
@@ -85,7 +84,7 @@ def _stop_scan_tasks(self):
8584

8685
async def serve(self, interactive: bool = True) -> None:
8786
await self._controller.initialise()
88-
validate_hinted_attributes(self._controller)
87+
self._controller.validate_hinted_attributes()
8988
self._controller.connect_attribute_ios()
9089

9190
self.controller_api = build_controller_api(self._controller)

src/fastcs/controller.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from collections import Counter
44
from collections.abc import Iterator, Mapping, MutableMapping, Sequence
55
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
77

88
from fastcs.attribute_io import AttributeIO
99
from fastcs.attribute_io_ref import AttributeIORefT
1010
from fastcs.attributes import Attribute, AttrR, AttrW
11-
from fastcs.datatypes import T
11+
from fastcs.datatypes import DataType, T
1212
from fastcs.tracer import Tracer
1313

1414

@@ -39,16 +39,46 @@ def __init__(
3939
self._path: list[str] = path or []
4040
self.__sub_controller_tree: dict[str, BaseController] = {}
4141

42+
self.__hinted_attributes = self._parse_attribute_type_hints()
4243
self._bind_attrs()
4344

4445
ios = ios or []
4546
self._attribute_ref_io_map = {io.ref_type: io for io in ios}
4647
self._validate_io(ios)
4748

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+
4865
async def initialise(self):
4966
"""Hook to dynamically add attributes before building the API"""
5067
pass
5168

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+
5282
def connect_attribute_ios(self) -> None:
5383
"""Connect ``Attribute`` callbacks to ``AttributeIO``s"""
5484
for attr in self.attributes.values():
@@ -126,24 +156,39 @@ def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]):
126156
f"More than one AttributeIO class handles {ref_type.__name__}"
127157
)
128158

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:
131161
raise ValueError(
132-
f"Cannot add attribute {attribute}. "
162+
f"Cannot add attribute {attr}. "
133163
f"Controller {self} has has existing attribute {name}: "
134164
f"{self.attributes[name]}"
135165
)
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+
)
136181
elif name in self.__sub_controller_tree.keys():
137182
raise ValueError(
138-
f"Cannot add attribute {attribute}. "
183+
f"Cannot add attribute {attr}. "
139184
f"Controller {self} has existing sub controller {name}: "
140185
f"{self.__sub_controller_tree[name]}"
141186
)
142187

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)
147192

148193
def add_sub_controller(self, name: str, sub_controller: BaseController):
149194
if name in self.__sub_controller_tree.keys():

src/fastcs/util.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import re
2-
from typing import _GenericAlias, get_args, get_origin, get_type_hints # type: ignore
32

43
import numpy as np
54

6-
from fastcs.attributes import Attribute
7-
from fastcs.controller import BaseController
85
from fastcs.datatypes import Bool, DataType, Float, Int, String
96

107

@@ -29,47 +26,3 @@ def numpy_to_fastcs_datatype(np_type) -> DataType:
2926
return Bool()
3027
else:
3128
return String()
32-
33-
34-
def validate_hinted_attributes(controller: BaseController):
35-
"""Validate ``Attribute`` type-hints match dynamically intropected instances
36-
37-
For each type-hinted attribute, validate that a corresponding instance exists in the
38-
controller with the correct access mode and datatype.
39-
"""
40-
for subcontroller in controller.sub_controllers.values():
41-
validate_hinted_attributes(subcontroller)
42-
hints = {
43-
k: v
44-
for k, v in get_type_hints(type(controller)).items()
45-
if isinstance(v, _GenericAlias | type)
46-
}
47-
for name, hint in hints.items():
48-
if isinstance(hint, type):
49-
attr_class = hint
50-
attr_dtype = None
51-
else:
52-
attr_class = get_origin(hint)
53-
attr_dtype = get_args(hint)[0]
54-
if not issubclass(attr_class, Attribute):
55-
continue
56-
57-
attr = getattr(controller, name, None)
58-
if attr is None:
59-
raise RuntimeError(
60-
f"Controller `{controller.__class__.__name__}` failed to introspect "
61-
f"hinted attribute `{name}` during initialisation"
62-
)
63-
if attr_class is not type(attr):
64-
raise RuntimeError(
65-
f"Controller '{controller.__class__.__name__}' introspection of "
66-
f"hinted attribute '{name}' does not match defined access mode. "
67-
f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'."
68-
)
69-
if attr_dtype is not None and attr_dtype != attr.datatype.dtype:
70-
raise RuntimeError(
71-
f"Controller '{controller.__class__.__name__}' introspection of hinted "
72-
f"attribute '{name}' does not match defined datatype. "
73-
f"Expected '{attr_dtype.__name__}', "
74-
f"got '{attr.datatype.dtype.__name__}'."
75-
)

tests/test_attribute.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,7 @@ async def initialise(self):
208208
initial_value=parameter_response.get("value", None),
209209
)
210210

211-
self.attributes[ref.name] = attr
212-
setattr(self, ref.name, attr)
213-
211+
self.add_attribute(ref.name, attr)
214212
except Exception as e:
215213
print(
216214
"Exception constructing attribute from parameter response:",

tests/test_controller.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import enum
2+
13
import pytest
24

3-
from fastcs.attributes import AttrR
5+
from fastcs.attributes import AttrR, AttrRW
46
from fastcs.controller import Controller, ControllerVector
5-
from fastcs.datatypes import Float, Int
7+
from fastcs.datatypes import Enum, Float, Int
68

79

810
def test_controller_nesting():
@@ -144,3 +146,39 @@ def test_controller_vector_iter():
144146

145147
for index, child in controller_vector.items():
146148
assert sub_controllers[index] == child
149+
150+
151+
def test_controller_introspection_hint_validation():
152+
class HintedController(Controller):
153+
read_write_int: AttrRW[int]
154+
155+
controller = HintedController()
156+
157+
with pytest.raises(RuntimeError, match="does not match defined datatype"):
158+
controller.add_attribute("read_write_int", AttrRW(Float()))
159+
160+
with pytest.raises(RuntimeError, match="does not match defined access mode"):
161+
controller.add_attribute("read_write_int", AttrR(Int()))
162+
163+
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
164+
controller.validate_hinted_attributes()
165+
166+
controller.add_attribute("read_write_int", AttrRW(Int()))
167+
168+
169+
def test_controller_introspection_hint_validation_enum():
170+
class GoodEnum(enum.IntEnum):
171+
VAL = 0
172+
173+
class BadEnum(enum.IntEnum):
174+
VAL = 0
175+
176+
class HintedController(Controller):
177+
enum: AttrRW[GoodEnum]
178+
179+
controller = HintedController()
180+
181+
with pytest.raises(RuntimeError, match="does not match defined datatype"):
182+
controller.add_attribute("enum", AttrRW(Enum(BadEnum)))
183+
184+
controller.add_attribute("enum", AttrRW(Enum(GoodEnum)))

0 commit comments

Comments
 (0)