Skip to content

Commit 2d72f41

Browse files
authored
Hinted attribute validation (#268)
* Integrate hinted attribute validation into controller Check datatype and access mode in add_attribute, check for unintrospected type hints after initialise * Tidy BaseController API
2 parents d750b61 + 29c990a commit 2d72f41

File tree

8 files changed

+213
-295
lines changed

8 files changed

+213
-295
lines changed

docs/snippets/dynamic.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ def __init__(
9999
super().__init__(f"Ramp{index}", ios=[io])
100100

101101
async def initialise(self):
102-
self.attributes.update(create_attributes(self._parameters))
102+
for name, attribute in create_attributes(self._parameters).items():
103+
self.add_attribute(name, attribute)
103104

104105

105106
class TemperatureController(Controller):
@@ -119,7 +120,9 @@ async def initialise(self):
119120
api = json.loads((await self._connection.send_query("API?\r\n")).strip("\r\n"))
120121

121122
ramps_api = api.pop("Ramps")
122-
self.attributes.update(create_attributes(api))
123+
124+
for name, attribute in create_attributes(api).items():
125+
self.add_attribute(name, attribute)
123126

124127
for idx, ramp_parameters in enumerate(ramps_api):
125128
ramp_controller = TemperatureRampController(

src/fastcs/control_system.py

Lines changed: 1 addition & 3 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,8 +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)
89-
self._controller.connect_attribute_ios()
87+
self._controller.post_initialise()
9088

9189
self.controller_api = build_controller_api(self._controller)
9290
self._scan_coros, self._initial_coros = (

src/fastcs/controller.py

Lines changed: 153 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,30 @@
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

1515
class BaseController(Tracer):
16-
"""Base class for controller."""
16+
"""Base class for controllers
1717
18-
#: Attributes passed from the device at runtime.
19-
attributes: dict[str, Attribute]
20-
root_attribute: Attribute | None = None
18+
Instances of this class can be loaded into FastCS to expose its Attributes to
19+
the transport layer, which can then perform a specific function such as generating a
20+
UI or creating parameters for a control system.
21+
22+
This class is public for type hinting purposes, but should not be inherited to
23+
implement device drivers. Use either ``Controller`` or ``ControllerVector`` instead.
24+
25+
"""
2126

27+
# These class attributes can be overridden on child classes to define default
28+
# behaviour of instantiated controllers
29+
root_attribute: Attribute | None = None
2230
description: str | None = None
2331

2432
def __init__(
@@ -29,70 +37,48 @@ def __init__(
2937
) -> None:
3038
super().__init__()
3139

32-
if (
33-
description is not None
34-
): # Use the argument over the one class defined description.
40+
if description is not None:
41+
# Use the argument over the one class defined description.
3542
self.description = description
3643

37-
if not hasattr(self, "attributes"):
38-
self.attributes = {}
3944
self._path: list[str] = path or []
40-
self.__sub_controller_tree: dict[str, BaseController] = {}
45+
46+
# Internal state that should not be accessed directly by base classes
47+
self.__attributes: dict[str, Attribute] = {}
48+
self.__sub_controllers: dict[str, BaseController] = {}
49+
self.__hinted_attributes = self._parse_attribute_type_hints()
4150

4251
self._bind_attrs()
4352

4453
ios = ios or []
4554
self._attribute_ref_io_map = {io.ref_type: io for io in ios}
4655
self._validate_io(ios)
4756

48-
async def initialise(self):
49-
"""Hook to dynamically add attributes before building the API"""
50-
pass
51-
52-
def connect_attribute_ios(self) -> None:
53-
"""Connect ``Attribute`` callbacks to ``AttributeIO``s"""
54-
for attr in self.attributes.values():
55-
ref = attr.io_ref if attr.has_io_ref() else None
56-
if ref is None:
57+
def _parse_attribute_type_hints(
58+
self,
59+
) -> dict[str, tuple[type[Attribute], type[DataType]]]:
60+
hinted_attributes = {}
61+
for name, hint in get_type_hints(type(self)).items():
62+
if not isinstance(hint, _GenericAlias): # e.g. AttrR[int]
5763
continue
5864

59-
io = self._attribute_ref_io_map.get(type(ref))
60-
if io is None:
61-
raise ValueError(
62-
f"{self.__class__.__name__} does not have an AttributeIO "
63-
f"to handle {attr.io_ref.__class__.__name__}"
64-
)
65-
66-
if isinstance(attr, AttrW):
67-
attr.set_on_put_callback(io.send)
68-
if isinstance(attr, AttrR):
69-
attr.set_update_callback(io.update)
70-
71-
for controller in self.sub_controllers.values():
72-
controller.connect_attribute_ios()
73-
74-
@property
75-
def path(self) -> list[str]:
76-
"""Path prefix of attributes, recursively including parent Controllers."""
77-
return self._path
65+
origin = get_origin(hint)
66+
if not isinstance(origin, type) or not issubclass(origin, Attribute):
67+
continue
7868

79-
def set_path(self, path: list[str]):
80-
if self._path:
81-
raise ValueError(f"sub controller is already registered under {self.path}")
69+
hinted_attributes[name] = (origin, get_args(hint)[0])
8270

83-
self._path = path
84-
for attribute in self.attributes.values():
85-
attribute.set_path(path)
71+
return hinted_attributes
8672

8773
def _bind_attrs(self) -> None:
88-
"""Search for `Attributes` and `Methods` to bind them to this instance.
74+
"""Search for Attributes and Methods to bind them to this instance.
8975
9076
This method will search the attributes of this controller class to bind them to
91-
this specific instance. For `Attribute`s, this is just a case of copying and
92-
re-assigning to `self` to make it unique across multiple instances of this
93-
controller class. For `Method`s, this requires creating a bound method from a
77+
this specific instance. For Attributes, this is just a case of copying and
78+
re-assigning to ``self`` to make it unique across multiple instances of this
79+
controller class. For Methods, this requires creating a bound method from a
9480
class method and a controller instance, so that it can be called from any
95-
context with the controller instance passed as the `self` argument.
81+
context with the controller instance passed as the ``self`` argument.
9682
9783
"""
9884
# Lazy import to avoid circular references
@@ -126,74 +112,144 @@ def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]):
126112
f"More than one AttributeIO class handles {ref_type.__name__}"
127113
)
128114

129-
def add_attribute(self, name, attribute: Attribute):
130-
if name in self.attributes and attribute is not self.attributes[name]:
115+
def __repr__(self):
116+
name = self.__class__.__name__
117+
path = ".".join(self.path) or None
118+
sub_controllers = list(self.sub_controllers.keys()) or None
119+
120+
return f"{name}(path={path}, sub_controllers={sub_controllers})"
121+
122+
def __setattr__(self, name, value):
123+
if isinstance(value, Attribute):
124+
self.add_attribute(name, value)
125+
elif isinstance(value, Controller):
126+
self.add_sub_controller(name, value)
127+
else:
128+
super().__setattr__(name, value)
129+
130+
async def initialise(self):
131+
"""Hook for subclasses to dynamically add attributes before building the API"""
132+
pass
133+
134+
def post_initialise(self):
135+
"""Hook to call after all attributes added, before serving the application"""
136+
self._validate_hinted_attributes()
137+
self._connect_attribute_ios()
138+
139+
def _validate_hinted_attributes(self):
140+
"""Validate ``Attribute`` type-hints were introspected during initialisation"""
141+
for name in self.__hinted_attributes:
142+
attr = getattr(self, name, None)
143+
if attr is None or not isinstance(attr, Attribute):
144+
raise RuntimeError(
145+
f"Controller `{self.__class__.__name__}` failed to introspect "
146+
f"hinted attribute `{name}` during initialisation"
147+
)
148+
149+
for subcontroller in self.sub_controllers.values():
150+
subcontroller._validate_hinted_attributes() # noqa: SLF001
151+
152+
def _connect_attribute_ios(self) -> None:
153+
"""Connect ``Attribute`` callbacks to ``AttributeIO``s"""
154+
for attr in self.__attributes.values():
155+
ref = attr.io_ref if attr.has_io_ref() else None
156+
if ref is None:
157+
continue
158+
159+
io = self._attribute_ref_io_map.get(type(ref))
160+
if io is None:
161+
raise ValueError(
162+
f"{self.__class__.__name__} does not have an AttributeIO "
163+
f"to handle {attr.io_ref.__class__.__name__}"
164+
)
165+
166+
if isinstance(attr, AttrW):
167+
attr.set_on_put_callback(io.send)
168+
if isinstance(attr, AttrR):
169+
attr.set_update_callback(io.update)
170+
171+
for controller in self.sub_controllers.values():
172+
controller._connect_attribute_ios() # noqa: SLF001
173+
174+
@property
175+
def path(self) -> list[str]:
176+
"""Path prefix of attributes, recursively including parent Controllers."""
177+
return self._path
178+
179+
def set_path(self, path: list[str]):
180+
if self._path:
181+
raise ValueError(f"sub controller is already registered under {self.path}")
182+
183+
self._path = path
184+
for attribute in self.__attributes.values():
185+
attribute.set_path(path)
186+
187+
def add_attribute(self, name, attr: Attribute):
188+
if name in self.__attributes:
131189
raise ValueError(
132-
f"Cannot add attribute {attribute}. "
190+
f"Cannot add attribute {attr}. "
133191
f"Controller {self} has has existing attribute {name}: "
134-
f"{self.attributes[name]}"
192+
f"{self.__attributes[name]}"
135193
)
136-
elif name in self.__sub_controller_tree.keys():
194+
elif name in self.__hinted_attributes:
195+
attr_class, attr_dtype = self.__hinted_attributes[name]
196+
if not isinstance(attr, attr_class):
197+
raise RuntimeError(
198+
f"Controller '{self.__class__.__name__}' introspection of "
199+
f"hinted attribute '{name}' does not match defined access mode. "
200+
f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'."
201+
)
202+
if attr_dtype is not None and attr_dtype != attr.datatype.dtype:
203+
raise RuntimeError(
204+
f"Controller '{self.__class__.__name__}' introspection of "
205+
f"hinted attribute '{name}' does not match defined datatype. "
206+
f"Expected '{attr_dtype.__name__}', "
207+
f"got '{attr.datatype.dtype.__name__}'."
208+
)
209+
elif name in self.__sub_controllers.keys():
137210
raise ValueError(
138-
f"Cannot add attribute {attribute}. "
211+
f"Cannot add attribute {attr}. "
139212
f"Controller {self} has existing sub controller {name}: "
140-
f"{self.__sub_controller_tree[name]}"
213+
f"{self.__sub_controllers[name]}"
141214
)
142215

143-
attribute.set_name(name)
144-
attribute.set_path(self.path)
145-
self.attributes[name] = attribute
146-
super().__setattr__(name, attribute)
216+
attr.set_name(name)
217+
attr.set_path(self.path)
218+
self.__attributes[name] = attr
219+
super().__setattr__(name, attr)
220+
221+
@property
222+
def attributes(self) -> dict[str, Attribute]:
223+
return self.__attributes
147224

148225
def add_sub_controller(self, name: str, sub_controller: BaseController):
149-
if name in self.__sub_controller_tree.keys():
226+
if name in self.__sub_controllers.keys():
150227
raise ValueError(
151228
f"Cannot add sub controller {sub_controller}. "
152229
f"Controller {self} has existing sub controller {name}: "
153-
f"{self.__sub_controller_tree[name]}"
230+
f"{self.__sub_controllers[name]}"
154231
)
155-
elif name in self.attributes:
232+
elif name in self.__attributes:
156233
raise ValueError(
157234
f"Cannot add sub controller {sub_controller}. "
158235
f"Controller {self} has existing attribute {name}: "
159-
f"{self.attributes[name]}"
236+
f"{self.__attributes[name]}"
160237
)
161238

162239
sub_controller.set_path(self.path + [name])
163-
self.__sub_controller_tree[name] = sub_controller
240+
self.__sub_controllers[name] = sub_controller
164241
super().__setattr__(name, sub_controller)
165242

166243
if isinstance(sub_controller.root_attribute, Attribute):
167-
self.attributes[name] = sub_controller.root_attribute
244+
self.__attributes[name] = sub_controller.root_attribute
168245

169246
@property
170247
def sub_controllers(self) -> dict[str, BaseController]:
171-
return self.__sub_controller_tree
172-
173-
def __repr__(self):
174-
name = self.__class__.__name__
175-
path = ".".join(self.path) or None
176-
sub_controllers = list(self.sub_controllers.keys()) or None
177-
178-
return f"{name}(path={path}, sub_controllers={sub_controllers})"
179-
180-
def __setattr__(self, name, value):
181-
if isinstance(value, Attribute):
182-
self.add_attribute(name, value)
183-
elif isinstance(value, Controller):
184-
self.add_sub_controller(name, value)
185-
else:
186-
super().__setattr__(name, value)
248+
return self.__sub_controllers
187249

188250

189251
class Controller(BaseController):
190-
"""Top-level controller for a device.
191-
192-
This is the primary class for implementing device support in FastCS. Instances of
193-
this class can be loaded into a FastCS to expose its ``Attribute``s to the transport
194-
layer, which can then perform a specific function with the set of ``Attributes``,
195-
such as generating a UI or creating parameters for a control system.
196-
"""
252+
"""Controller containing Attributes and named sub Controllers"""
197253

198254
def __init__(
199255
self,
@@ -218,8 +274,12 @@ async def disconnect(self) -> None:
218274

219275

220276
class ControllerVector(MutableMapping[int, Controller], BaseController):
221-
"""A controller with a collection of identical sub controllers distinguished
222-
by a numeric value"""
277+
"""Controller containing Attributes and indexed sub Controllers
278+
279+
The sub controllers registered with this Controller should be instances of the same
280+
Controller type, distinguished only by an integer index. The indexes do not need
281+
to be continiguous.
282+
"""
223283

224284
def __init__(
225285
self,

0 commit comments

Comments
 (0)