Skip to content

Commit 68cddc8

Browse files
authored
Merge pull request #281 from DiamondLightSource/type-hint-validation
Improve Attribute hint validation and add Controller hint validation
2 parents c6122bf + 2499e6d commit 68cddc8

File tree

7 files changed

+148
-45
lines changed

7 files changed

+148
-45
lines changed

src/fastcs/attributes/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
from .attribute_io import AttributeIO as AttributeIO
77
from .attribute_io_ref import AttributeIORef as AttributeIORef
88
from .attribute_io_ref import AttributeIORefT as AttributeIORefT
9+
from .hinted_attribute import HintedAttribute as HintedAttribute

src/fastcs/attributes/attribute.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Generic
33

44
from fastcs.attributes.attribute_io_ref import AttributeIORefT
5-
from fastcs.datatypes import DATATYPE_DTYPES, DataType, DType_T
5+
from fastcs.datatypes import DataType, DType, DType_T
66
from fastcs.logging import bind_logger
77
from fastcs.tracer import Tracer
88

@@ -24,9 +24,8 @@ def __init__(
2424
) -> None:
2525
super().__init__()
2626

27-
assert issubclass(datatype.dtype, DATATYPE_DTYPES), (
28-
f"Attr type must be one of {DATATYPE_DTYPES}, "
29-
"received type {datatype.dtype}"
27+
assert issubclass(datatype.dtype, DType), (
28+
f"Attr type must be one of {DType}, received type {datatype.dtype}"
3029
)
3130
self._io_ref = io_ref
3231
self._datatype: DataType[DType_T] = datatype
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from dataclasses import dataclass
2+
3+
from fastcs.attributes.attribute import Attribute
4+
from fastcs.datatypes import DType
5+
6+
7+
@dataclass(kw_only=True)
8+
class HintedAttribute:
9+
"""An `Attribute` type hint found on a `Controller` class
10+
11+
e.g. ``attr: AttrR[int]``
12+
13+
"""
14+
15+
attr_type: type[Attribute]
16+
"""The type of the `Attribute` in the type hint - e.g. `AttrR`"""
17+
dtype: type[DType] | None
18+
"""The dtype of the `Attribute` in the type hint, if any - e.g. `int`"""

src/fastcs/controllers/base_controller.py

Lines changed: 94 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@
55
from copy import deepcopy
66
from typing import _GenericAlias, get_args, get_origin, get_type_hints # type: ignore
77

8-
from fastcs.attributes import Attribute, AttributeIO, AttributeIORefT, AttrR, AttrW
9-
from fastcs.datatypes import DataType, DType_T
8+
from fastcs.attributes import (
9+
Attribute,
10+
AttributeIO,
11+
AttributeIORefT,
12+
AttrR,
13+
AttrW,
14+
HintedAttribute,
15+
)
16+
from fastcs.datatypes import DType_T
17+
from fastcs.logging import bind_logger
1018
from fastcs.tracer import Tracer
1119

20+
logger = bind_logger(logger_name=__name__)
21+
1222

1323
class BaseController(Tracer):
1424
"""Base class for controllers
@@ -44,29 +54,43 @@ def __init__(
4454
# Internal state that should not be accessed directly by base classes
4555
self.__attributes: dict[str, Attribute] = {}
4656
self.__sub_controllers: dict[str, BaseController] = {}
47-
self.__hinted_attributes = self._parse_attribute_type_hints()
57+
58+
self.__hinted_attributes: dict[str, HintedAttribute] = {}
59+
self.__hinted_sub_controllers: dict[str, type[BaseController]] = {}
60+
self._find_type_hints()
4861

4962
self._bind_attrs()
5063

5164
ios = ios or []
5265
self._attribute_ref_io_map = {io.ref_type: io for io in ios}
5366
self._validate_io(ios)
5467

55-
def _parse_attribute_type_hints(
56-
self,
57-
) -> dict[str, tuple[type[Attribute], type[DataType]]]:
58-
hinted_attributes = {}
68+
def _find_type_hints(self):
69+
"""Find `Attribute` and `Controller` type hints for introspection validation"""
5970
for name, hint in get_type_hints(type(self)).items():
60-
if not isinstance(hint, _GenericAlias): # e.g. AttrR[int]
61-
continue
62-
63-
origin = get_origin(hint)
64-
if not isinstance(origin, type) or not issubclass(origin, Attribute):
65-
continue
66-
67-
hinted_attributes[name] = (origin, get_args(hint)[0])
71+
if isinstance(hint, _GenericAlias): # e.g. AttrR[int]
72+
args = get_args(hint)
73+
hint = get_origin(hint)
74+
else:
75+
args = None
76+
77+
if isinstance(hint, type) and issubclass(hint, Attribute):
78+
if args is None:
79+
dtype = None
80+
else:
81+
if len(args) == 2:
82+
dtype = args[0]
83+
else:
84+
raise TypeError(
85+
f"Invalid type hint for attribute {name}: {hint}"
86+
)
87+
88+
self.__hinted_attributes[name] = HintedAttribute(
89+
attr_type=hint, dtype=dtype
90+
)
6891

69-
return hinted_attributes
92+
elif isinstance(hint, type) and issubclass(hint, BaseController):
93+
self.__hinted_sub_controllers[name] = hint
7094

7195
def _bind_attrs(self) -> None:
7296
"""Search for Attributes and Methods to bind them to this instance.
@@ -132,21 +156,51 @@ async def initialise(self):
132156

133157
def post_initialise(self):
134158
"""Hook to call after all attributes added, before serving the application"""
135-
self._validate_hinted_attributes()
159+
self._validate_type_hints()
136160
self._connect_attribute_ios()
137161

138-
def _validate_hinted_attributes(self):
139-
"""Validate ``Attribute`` type-hints were introspected during initialisation"""
162+
def _validate_type_hints(self):
163+
"""Validate all `Attribute` and `Controller` type-hints were introspected"""
140164
for name in self.__hinted_attributes:
141-
attr = getattr(self, name, None)
142-
if attr is None or not isinstance(attr, Attribute):
143-
raise RuntimeError(
144-
f"Controller `{self.__class__.__name__}` failed to introspect "
145-
f"hinted attribute `{name}` during initialisation"
146-
)
165+
self._validate_hinted_attribute(name)
166+
167+
for name in self.__hinted_sub_controllers:
168+
self._validate_hinted_controller(name)
147169

148170
for subcontroller in self.sub_controllers.values():
149-
subcontroller._validate_hinted_attributes() # noqa: SLF001
171+
subcontroller._validate_type_hints() # noqa: SLF001
172+
173+
def _validate_hinted_attribute(self, name: str):
174+
"""Check that an `Attribute` with the given name exists on the controller"""
175+
attr = getattr(self, name, None)
176+
if attr is None or not isinstance(attr, Attribute):
177+
raise RuntimeError(
178+
f"Controller `{self.__class__.__name__}` failed to introspect "
179+
f"hinted attribute `{name}` during initialisation"
180+
)
181+
else:
182+
logger.debug(
183+
"Validated hinted attribute",
184+
name=name,
185+
controller=self,
186+
attribute=attr,
187+
)
188+
189+
def _validate_hinted_controller(self, name: str):
190+
"""Check that a sub controller with the given name exists on the controller"""
191+
controller = getattr(self, name, None)
192+
if controller is None or not isinstance(controller, BaseController):
193+
raise RuntimeError(
194+
f"Controller `{self.__class__.__name__}` failed to introspect "
195+
f"hinted controller `{name}` during initialisation"
196+
)
197+
else:
198+
logger.debug(
199+
"Validated hinted sub controller",
200+
name=name,
201+
controller=self,
202+
sub_controller=controller,
203+
)
150204

151205
def _connect_attribute_ios(self) -> None:
152206
"""Connect ``Attribute`` callbacks to ``AttributeIO``s"""
@@ -191,18 +245,18 @@ def add_attribute(self, name, attr: Attribute):
191245
f"{self.__attributes[name]}"
192246
)
193247
elif name in self.__hinted_attributes:
194-
attr_class, attr_dtype = self.__hinted_attributes[name]
195-
if not isinstance(attr, attr_class):
248+
hint = self.__hinted_attributes[name]
249+
if not isinstance(attr, hint.attr_type):
196250
raise RuntimeError(
197251
f"Controller '{self.__class__.__name__}' introspection of "
198252
f"hinted attribute '{name}' does not match defined access mode. "
199-
f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'."
253+
f"Expected '{hint.attr_type.__name__}' got '{type(attr).__name__}'."
200254
)
201-
if attr_dtype is not None and attr_dtype != attr.datatype.dtype:
255+
if hint.dtype is not None and hint.dtype != attr.datatype.dtype:
202256
raise RuntimeError(
203257
f"Controller '{self.__class__.__name__}' introspection of "
204258
f"hinted attribute '{name}' does not match defined datatype. "
205-
f"Expected '{attr_dtype.__name__}', "
259+
f"Expected '{hint.dtype.__name__}', "
206260
f"got '{attr.datatype.dtype.__name__}'."
207261
)
208262
elif name in self.__sub_controllers.keys():
@@ -228,6 +282,15 @@ def add_sub_controller(self, name: str, sub_controller: BaseController):
228282
f"Controller {self} has existing sub controller {name}: "
229283
f"{self.__sub_controllers[name]}"
230284
)
285+
elif name in self.__hinted_sub_controllers:
286+
hint = self.__hinted_sub_controllers[name]
287+
if not isinstance(sub_controller, hint):
288+
raise RuntimeError(
289+
f"Controller '{self.__class__.__name__}' introspection of "
290+
f"hinted sub controller '{name}' does not match defined type. "
291+
f"Expected '{hint.__name__}' got "
292+
f"'{sub_controller.__class__.__name__}'."
293+
)
231294
elif name in self.__attributes:
232295
raise ValueError(
233296
f"Cannot add sub controller {sub_controller}. "

src/fastcs/datatypes/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from ._util import numpy_to_fastcs_datatype as numpy_to_fastcs_datatype
22
from .bool import Bool as Bool
3-
from .datatype import DATATYPE_DTYPES as DATATYPE_DTYPES
43
from .datatype import DataType as DataType
4+
from .datatype import DType as DType
55
from .datatype import DType_T as DType_T
66
from .enum import Enum as Enum
77
from .float import Float as Float

src/fastcs/datatypes/datatype.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
from typing import Any, Generic, TypeVar
55

66
import numpy as np
7-
from numpy.typing import DTypeLike
7+
8+
DType = (
9+
int # Int
10+
| float # Float
11+
| bool # Bool
12+
| str # String
13+
| enum.Enum # Enum
14+
| np.ndarray # Waveform / Table
15+
)
816

917
DType_T = TypeVar(
1018
"DType_T",
@@ -13,13 +21,10 @@
1321
bool, # Bool
1422
str, # String
1523
enum.Enum, # Enum
16-
np.ndarray, # Waveform
17-
list[tuple[str, DTypeLike]], # Table
24+
np.ndarray, # Waveform / Table
1825
)
1926
"""A builtin (or numpy) type supported by a corresponding FastCS Attribute DataType"""
2027

21-
DATATYPE_DTYPES: tuple[type] = DType_T.__constraints__ # type: ignore
22-
2328

2429
@dataclass(frozen=True)
2530
class DataType(Generic[DType_T]):

tests/test_controllers.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def test_controller_vector_iter():
148148
assert sub_controllers[index] == child
149149

150150

151-
def test_controller_introspection_hint_validation():
151+
def test_attribute_hint_validation():
152152
class HintedController(Controller):
153153
read_write_int: AttrRW[int]
154154

@@ -162,15 +162,15 @@ class HintedController(Controller):
162162

163163
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
164164
controller.read_write_int = 5 # type: ignore
165-
controller._validate_hinted_attributes()
165+
controller._validate_type_hints()
166166

167167
with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"):
168-
controller._validate_hinted_attributes()
168+
controller._validate_type_hints()
169169

170170
controller.add_attribute("read_write_int", AttrRW(Int()))
171171

172172

173-
def test_controller_introspection_hint_validation_enum():
173+
def test_enum_attribute_hint_validation():
174174
class GoodEnum(enum.IntEnum):
175175
VAL = 0
176176

@@ -186,3 +186,20 @@ class HintedController(Controller):
186186
controller.add_attribute("enum", AttrRW(Enum(BadEnum)))
187187

188188
controller.add_attribute("enum", AttrRW(Enum(GoodEnum)))
189+
190+
191+
@pytest.mark.asyncio
192+
async def test_sub_controller_hint_validation():
193+
class HintedController(Controller):
194+
child: SomeSubController
195+
196+
controller = HintedController()
197+
198+
with pytest.raises(RuntimeError, match="failed to introspect hinted controller"):
199+
controller._validate_type_hints()
200+
201+
with pytest.raises(RuntimeError, match="does not match defined type"):
202+
controller.add_sub_controller("child", Controller())
203+
204+
controller.add_sub_controller("child", SomeSubController())
205+
controller._validate_type_hints()

0 commit comments

Comments
 (0)