Skip to content

Commit 64a7828

Browse files
authored
Validate hinted attrs are bound and have right dtype after initialisation (#187)
1 parent e9e7d1e commit 64a7828

File tree

3 files changed

+106
-1
lines changed

3 files changed

+106
-1
lines changed

src/fastcs/backend.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .controller import BaseController, Controller
1010
from .controller_api import ControllerAPI
1111
from .exceptions import FastCSException
12+
from .util import validate_hinted_attributes
1213

1314

1415
class Backend:
@@ -28,6 +29,7 @@ def __init__(
2829
# Initialise controller and then build its APIs
2930
loop.run_until_complete(controller.initialise())
3031
loop.run_until_complete(controller.attribute_initialise())
32+
validate_hinted_attributes(controller)
3133
self.controller_api = build_controller_api(controller)
3234
self._link_process_tasks()
3335

src/fastcs/util.py

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

34
import numpy as np
45

6+
from fastcs.attributes import Attribute
7+
from fastcs.controller import BaseController
58
from fastcs.datatypes import Bool, DataType, Float, Int, String
69

710

@@ -26,3 +29,35 @@ def numpy_to_fastcs_datatype(np_type) -> DataType:
2629
return Bool()
2730
else:
2831
return String()
32+
33+
34+
def validate_hinted_attributes(controller: BaseController):
35+
"""Validates that type-hinted attributes exist in the controller, and are accessible
36+
via the dot accessor, from the attributes dictionary and with the right datatype.
37+
"""
38+
hints = get_type_hints(type(controller))
39+
alias_hints = {k: v for k, v in hints.items() if isinstance(v, _GenericAlias)}
40+
for name, hint in alias_hints.items():
41+
attr_class = get_origin(hint)
42+
if not issubclass(attr_class, Attribute):
43+
continue
44+
attr = getattr(controller, name, None)
45+
if attr is None:
46+
raise RuntimeError(
47+
f"Controller `{controller.__class__.__name__}` failed to introspect "
48+
f"hinted attribute `{name}` during initialisation"
49+
)
50+
if type(attr) is not attr_class:
51+
raise RuntimeError(
52+
f"Controller '{controller.__class__.__name__}' introspection of hinted "
53+
f"attribute '{name}' does not match defined access mode. "
54+
f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'."
55+
)
56+
(attr_dtype,) = get_args(hint)
57+
if attr.datatype.dtype != attr_dtype:
58+
raise RuntimeError(
59+
f"Controller '{controller.__class__.__name__}' introspection of hinted "
60+
f"attribute '{name}' does not match defined datatype. "
61+
f"Expected '{attr_dtype.__name__}', "
62+
f"got '{attr.datatype.dtype.__name__}'."
63+
)

tests/test_util.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import asyncio
2+
import enum
3+
14
import numpy as np
25
import pytest
36
from pvi.device import SignalR
47
from pydantic import ValidationError
58

6-
from fastcs.datatypes import Bool, Float, Int, String
9+
from fastcs.attributes import AttrR, AttrRW
10+
from fastcs.backend import Backend
11+
from fastcs.controller import Controller
12+
from fastcs.datatypes import Bool, Enum, Float, Int, String
713
from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal
814

915

@@ -56,3 +62,65 @@ def test_pvi_validation_error():
5662
)
5763
def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype):
5864
assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type)
65+
66+
67+
def test_hinted_attributes_verified():
68+
loop = asyncio.get_event_loop()
69+
70+
class ControllerWithWrongType(Controller):
71+
hinted_wrong_type: AttrR[int]
72+
73+
async def initialise(self):
74+
self.hinted_wrong_type = AttrR(Float()) # type: ignore
75+
self.attributes["hinted_wrong_type"] = self.hinted_wrong_type
76+
77+
with pytest.raises(RuntimeError) as excinfo:
78+
Backend(ControllerWithWrongType(), loop)
79+
assert str(excinfo.value) == (
80+
"Controller 'ControllerWithWrongType' introspection of hinted attribute "
81+
"'hinted_wrong_type' does not match defined datatype. "
82+
"Expected 'int', got 'float'."
83+
)
84+
85+
class ControllerWithMissingAttr(Controller):
86+
hinted_int_missing: AttrR[int]
87+
88+
with pytest.raises(RuntimeError) as excinfo:
89+
Backend(ControllerWithMissingAttr(), loop)
90+
assert str(excinfo.value) == (
91+
"Controller `ControllerWithMissingAttr` failed to introspect hinted attribute "
92+
"`hinted_int_missing` during initialisation"
93+
)
94+
95+
class ControllerAttrWrongAccessMode(Controller):
96+
hinted: AttrR[int]
97+
98+
async def initialise(self):
99+
self.hinted = AttrRW(Int())
100+
self.attributes["hinted"] = self.hinted
101+
102+
with pytest.raises(RuntimeError) as excinfo:
103+
Backend(ControllerAttrWrongAccessMode(), loop)
104+
assert str(excinfo.value) == (
105+
"Controller 'ControllerAttrWrongAccessMode' introspection of hinted attribute "
106+
"'hinted' does not match defined access mode. Expected 'AttrR', got 'AttrRW'."
107+
)
108+
109+
class MyEnum(enum.Enum):
110+
A = 0
111+
B = 1
112+
113+
class MyEnum2(enum.Enum):
114+
A = 2
115+
B = 3
116+
117+
class ControllerWrongEnumClass(Controller):
118+
hinted_enum: AttrRW[MyEnum] = AttrRW(Enum(MyEnum2))
119+
120+
with pytest.raises(RuntimeError) as excinfo:
121+
Backend(ControllerWrongEnumClass(), loop)
122+
assert str(excinfo.value) == (
123+
"Controller 'ControllerWrongEnumClass' introspection of hinted attribute "
124+
"'hinted_enum' does not match defined datatype. "
125+
"Expected 'MyEnum', got 'MyEnum2'."
126+
)

0 commit comments

Comments
 (0)