Skip to content

Commit 861a828

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 46cf8af commit 861a828

File tree

6 files changed

+78
-201
lines changed

6 files changed

+78
-201
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: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from fastcs.attributes import AttrR
3+
from fastcs.attributes import AttrR, AttrRW
44
from fastcs.controller import Controller, ControllerVector
55
from fastcs.datatypes import Float, Int
66

@@ -144,3 +144,21 @@ def test_controller_vector_iter():
144144

145145
for index, child in controller_vector.items():
146146
assert sub_controllers[index] == child
147+
148+
149+
def test_controller_introspection_hint_validation():
150+
class HintedController(Controller):
151+
read_int: AttrRW[int]
152+
153+
controller = HintedController()
154+
155+
with pytest.raises(RuntimeError, match="does not match defined datatype"):
156+
controller.add_attribute("read_int", AttrRW(Float()))
157+
158+
with pytest.raises(RuntimeError, match="does not match defined access mode"):
159+
controller.add_attribute("read_int", AttrR(Int()))
160+
161+
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
162+
controller.validate_hinted_attributes()
163+
164+
controller.add_attribute("read_int", AttrRW(Int()))

tests/test_util.py

Lines changed: 2 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,10 @@
1-
import asyncio
2-
import enum
3-
41
import numpy as np
52
import pytest
63
from pvi.device import SignalR
74
from pydantic import ValidationError
85

9-
from fastcs.attributes import Attribute, AttrR, AttrRW
10-
from fastcs.controller import Controller
11-
from fastcs.datatypes import Bool, Enum, Float, Int, String
12-
from fastcs.launch import FastCS
13-
from fastcs.util import (
14-
numpy_to_fastcs_datatype,
15-
snake_to_pascal,
16-
validate_hinted_attributes,
17-
)
6+
from fastcs.datatypes import Bool, Float, Int, String
7+
from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal
188

199

2010
def test_snake_to_pascal():
@@ -66,129 +56,3 @@ def test_pvi_validation_error():
6656
)
6757
def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype):
6858
assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type)
69-
70-
71-
@pytest.mark.asyncio
72-
async def test_hinted_attributes_verified():
73-
class ControllerWithWrongType(Controller):
74-
hinted_wrong_type: AttrR[int]
75-
76-
async def initialise(self):
77-
self.hinted_wrong_type = AttrR(Float()) # type: ignore
78-
79-
controller = ControllerWithWrongType()
80-
await controller.initialise()
81-
with pytest.raises(RuntimeError) as excinfo:
82-
validate_hinted_attributes(controller)
83-
84-
assert str(excinfo.value) == (
85-
"Controller 'ControllerWithWrongType' introspection of hinted attribute "
86-
"'hinted_wrong_type' does not match defined datatype. "
87-
"Expected 'int', got 'float'."
88-
)
89-
90-
class ControllerWithMissingAttr(Controller):
91-
hinted_int_missing: AttrR[int]
92-
93-
controller = ControllerWithMissingAttr()
94-
await controller.initialise()
95-
with pytest.raises(RuntimeError) as excinfo:
96-
validate_hinted_attributes(controller)
97-
98-
assert str(excinfo.value) == (
99-
"Controller `ControllerWithMissingAttr` failed to introspect hinted attribute "
100-
"`hinted_int_missing` during initialisation"
101-
)
102-
103-
class ControllerAttrWrongAccessMode(Controller):
104-
hinted: AttrR[int]
105-
106-
async def initialise(self):
107-
self.hinted = AttrRW(Int())
108-
self.attributes["hinted"] = self.hinted
109-
110-
controller = ControllerAttrWrongAccessMode()
111-
await controller.initialise()
112-
with pytest.raises(RuntimeError) as excinfo:
113-
validate_hinted_attributes(controller)
114-
115-
assert str(excinfo.value) == (
116-
"Controller 'ControllerAttrWrongAccessMode' introspection of hinted attribute "
117-
"'hinted' does not match defined access mode. Expected 'AttrR', got 'AttrRW'."
118-
)
119-
120-
class MyEnum(enum.Enum):
121-
A = 0
122-
B = 1
123-
124-
class MyEnum2(enum.Enum):
125-
A = 2
126-
B = 3
127-
128-
class ControllerWrongEnumClass(Controller):
129-
hinted_enum: AttrRW[MyEnum] = AttrRW(Enum(MyEnum2))
130-
131-
controller = ControllerWrongEnumClass()
132-
await controller.initialise()
133-
with pytest.raises(RuntimeError) as excinfo:
134-
validate_hinted_attributes(controller)
135-
136-
assert str(excinfo.value) == (
137-
"Controller 'ControllerWrongEnumClass' introspection of hinted attribute "
138-
"'hinted_enum' does not match defined datatype. "
139-
"Expected 'MyEnum', got 'MyEnum2'."
140-
)
141-
142-
143-
def test_hinted_attributes_verified_on_subcontrollers():
144-
loop = asyncio.get_event_loop()
145-
146-
class ControllerWithWrongType(Controller):
147-
hinted_missing: AttrR[int]
148-
149-
async def connect(self):
150-
return
151-
152-
class TopController(Controller):
153-
async def initialise(self): # why does this not get called?
154-
subcontroller = ControllerWithWrongType()
155-
self.add_sub_controller("MySubController", subcontroller)
156-
157-
fastcs = FastCS(TopController(), [], loop)
158-
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
159-
fastcs.run()
160-
161-
162-
def test_hinted_attribute_access_mode_verified():
163-
# test verification works with non-GenericAlias type hints
164-
loop = asyncio.get_event_loop()
165-
166-
class ControllerAttrWrongAccessMode(Controller):
167-
read_attr: AttrR
168-
169-
async def initialise(self):
170-
self.read_attr = AttrRW(Int())
171-
172-
fastcs = FastCS(ControllerAttrWrongAccessMode(), [], loop)
173-
with pytest.raises(RuntimeError, match="does not match defined access mode"):
174-
fastcs.run()
175-
176-
177-
@pytest.mark.asyncio
178-
async def test_hinted_attributes_with_unspecified_access_mode():
179-
class ControllerUnspecifiedAccessMode(Controller):
180-
unspecified_access_mode: Attribute
181-
182-
async def initialise(self):
183-
self.unspecified_access_mode = AttrRW(Int())
184-
185-
controller = ControllerUnspecifiedAccessMode()
186-
await controller.initialise()
187-
# no assertion thrown
188-
with pytest.raises(
189-
RuntimeError,
190-
match=(
191-
"does not match defined access mode. Expected 'Attribute', got 'AttrRW'"
192-
),
193-
):
194-
validate_hinted_attributes(controller)

0 commit comments

Comments
 (0)