33from collections import Counter
44from collections .abc import Iterator , Mapping , MutableMapping , Sequence
55from 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
88from fastcs .attribute_io import AttributeIO
99from fastcs .attribute_io_ref import AttributeIORefT
1010from fastcs .attributes import Attribute , AttrR , AttrW
11- from fastcs .datatypes import T
11+ from fastcs .datatypes import DataType , T
1212from fastcs .tracer import Tracer
1313
1414
1515class 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
189251class 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
220276class 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