Skip to content

Commit 284b2e5

Browse files
committed
Tidy BaseController API
Remove direct access to `attributes` in preference for `add_attribute` Improve docstrings Change method ordering
1 parent 861a828 commit 284b2e5

File tree

6 files changed

+124
-107
lines changed

6 files changed

+124
-107
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,7 @@ def _stop_scan_tasks(self):
8484

8585
async def serve(self, interactive: bool = True) -> None:
8686
await self._controller.initialise()
87-
self._controller.validate_hinted_attributes()
88-
self._controller.connect_attribute_ios()
87+
self._controller.post_initialise()
8988

9089
self.controller_api = build_controller_api(self._controller)
9190
self._scan_coros, self._initial_coros = (

src/fastcs/controller.py

Lines changed: 110 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@
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,17 +37,17 @@ 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] = {}
4145

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] = {}
4249
self.__hinted_attributes = self._parse_attribute_type_hints()
50+
4351
self._bind_attrs()
4452

4553
ios = ios or []
@@ -62,11 +70,73 @@ def _parse_attribute_type_hints(
6270

6371
return hinted_attributes
6472

73+
def _bind_attrs(self) -> None:
74+
"""Search for Attributes and Methods to bind them to this instance.
75+
76+
This method will search the attributes of this controller class to bind them to
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
80+
class method and a controller instance, so that it can be called from any
81+
context with the controller instance passed as the ``self`` argument.
82+
83+
"""
84+
# Lazy import to avoid circular references
85+
from fastcs.cs_methods import UnboundCommand, UnboundScan
86+
87+
# Using a dictionary instead of a set to maintain order.
88+
class_dir = {key: None for key in dir(type(self)) if not key.startswith("_")}
89+
class_type_hints = {
90+
key: value
91+
for key, value in get_type_hints(type(self)).items()
92+
if not key.startswith("_")
93+
}
94+
95+
for attr_name in {**class_dir, **class_type_hints}:
96+
if attr_name == "root_attribute":
97+
continue
98+
99+
attr = getattr(self, attr_name, None)
100+
if isinstance(attr, Attribute):
101+
setattr(self, attr_name, deepcopy(attr))
102+
elif isinstance(attr, UnboundScan | UnboundCommand):
103+
setattr(self, attr_name, attr.bind(self))
104+
105+
def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]):
106+
"""Validate that there is exactly one AttributeIO class registered to the
107+
controller for each type of AttributeIORef belonging to the attributes of the
108+
controller"""
109+
for ref_type, count in Counter([io.ref_type for io in ios]).items():
110+
if count > 1:
111+
raise RuntimeError(
112+
f"More than one AttributeIO class handles {ref_type.__name__}"
113+
)
114+
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+
65130
async def initialise(self):
66-
"""Hook to dynamically add attributes before building the API"""
131+
"""Hook for subclasses to dynamically add attributes before building the API"""
67132
pass
68133

69-
def validate_hinted_attributes(self):
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):
70140
"""Validate ``Attribute`` type-hints were introspected during initialisation"""
71141
for name in self.__hinted_attributes:
72142
attr = getattr(self, name, None)
@@ -77,11 +147,11 @@ def validate_hinted_attributes(self):
77147
)
78148

79149
for subcontroller in self.sub_controllers.values():
80-
subcontroller.validate_hinted_attributes() # noqa: SLF001
150+
subcontroller._validate_hinted_attributes() # noqa: SLF001
81151

82-
def connect_attribute_ios(self) -> None:
152+
def _connect_attribute_ios(self) -> None:
83153
"""Connect ``Attribute`` callbacks to ``AttributeIO``s"""
84-
for attr in self.attributes.values():
154+
for attr in self.__attributes.values():
85155
ref = attr.io_ref if attr.has_io_ref() else None
86156
if ref is None:
87157
continue
@@ -99,7 +169,7 @@ def connect_attribute_ios(self) -> None:
99169
attr.set_update_callback(io.update)
100170

101171
for controller in self.sub_controllers.values():
102-
controller.connect_attribute_ios()
172+
controller._connect_attribute_ios() # noqa: SLF001
103173

104174
@property
105175
def path(self) -> list[str]:
@@ -111,57 +181,15 @@ def set_path(self, path: list[str]):
111181
raise ValueError(f"sub controller is already registered under {self.path}")
112182

113183
self._path = path
114-
for attribute in self.attributes.values():
184+
for attribute in self.__attributes.values():
115185
attribute.set_path(path)
116186

117-
def _bind_attrs(self) -> None:
118-
"""Search for `Attributes` and `Methods` to bind them to this instance.
119-
120-
This method will search the attributes of this controller class to bind them to
121-
this specific instance. For `Attribute`s, this is just a case of copying and
122-
re-assigning to `self` to make it unique across multiple instances of this
123-
controller class. For `Method`s, this requires creating a bound method from a
124-
class method and a controller instance, so that it can be called from any
125-
context with the controller instance passed as the `self` argument.
126-
127-
"""
128-
# Lazy import to avoid circular references
129-
from fastcs.cs_methods import UnboundCommand, UnboundScan
130-
131-
# Using a dictionary instead of a set to maintain order.
132-
class_dir = {key: None for key in dir(type(self)) if not key.startswith("_")}
133-
class_type_hints = {
134-
key: value
135-
for key, value in get_type_hints(type(self)).items()
136-
if not key.startswith("_")
137-
}
138-
139-
for attr_name in {**class_dir, **class_type_hints}:
140-
if attr_name == "root_attribute":
141-
continue
142-
143-
attr = getattr(self, attr_name, None)
144-
if isinstance(attr, Attribute):
145-
setattr(self, attr_name, deepcopy(attr))
146-
elif isinstance(attr, UnboundScan | UnboundCommand):
147-
setattr(self, attr_name, attr.bind(self))
148-
149-
def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]):
150-
"""Validate that there is exactly one AttributeIO class registered to the
151-
controller for each type of AttributeIORef belonging to the attributes of the
152-
controller"""
153-
for ref_type, count in Counter([io.ref_type for io in ios]).items():
154-
if count > 1:
155-
raise RuntimeError(
156-
f"More than one AttributeIO class handles {ref_type.__name__}"
157-
)
158-
159187
def add_attribute(self, name, attr: Attribute):
160-
if name in self.attributes:
188+
if name in self.__attributes:
161189
raise ValueError(
162190
f"Cannot add attribute {attr}. "
163191
f"Controller {self} has has existing attribute {name}: "
164-
f"{self.attributes[name]}"
192+
f"{self.__attributes[name]}"
165193
)
166194
elif name in self.__hinted_attributes:
167195
attr_class, attr_dtype = self.__hinted_attributes[name]
@@ -178,67 +206,50 @@ def add_attribute(self, name, attr: Attribute):
178206
f"Expected '{attr_dtype.__name__}', "
179207
f"got '{attr.datatype.dtype.__name__}'."
180208
)
181-
elif name in self.__sub_controller_tree.keys():
209+
elif name in self.__sub_controllers.keys():
182210
raise ValueError(
183211
f"Cannot add attribute {attr}. "
184212
f"Controller {self} has existing sub controller {name}: "
185-
f"{self.__sub_controller_tree[name]}"
213+
f"{self.__sub_controllers[name]}"
186214
)
187215

188216
attr.set_name(name)
189217
attr.set_path(self.path)
190-
self.attributes[name] = attr
218+
self.__attributes[name] = attr
191219
super().__setattr__(name, attr)
192220

221+
@property
222+
def attributes(self) -> dict[str, Attribute]:
223+
return self.__attributes
224+
193225
def add_sub_controller(self, name: str, sub_controller: BaseController):
194-
if name in self.__sub_controller_tree.keys():
226+
if name in self.__sub_controllers.keys():
195227
raise ValueError(
196228
f"Cannot add sub controller {sub_controller}. "
197229
f"Controller {self} has existing sub controller {name}: "
198-
f"{self.__sub_controller_tree[name]}"
230+
f"{self.__sub_controllers[name]}"
199231
)
200-
elif name in self.attributes:
232+
elif name in self.__attributes:
201233
raise ValueError(
202234
f"Cannot add sub controller {sub_controller}. "
203235
f"Controller {self} has existing attribute {name}: "
204-
f"{self.attributes[name]}"
236+
f"{self.__attributes[name]}"
205237
)
206238

207239
sub_controller.set_path(self.path + [name])
208-
self.__sub_controller_tree[name] = sub_controller
240+
self.__sub_controllers[name] = sub_controller
209241
super().__setattr__(name, sub_controller)
210242

211243
if isinstance(sub_controller.root_attribute, Attribute):
212-
self.attributes[name] = sub_controller.root_attribute
244+
self.__attributes[name] = sub_controller.root_attribute
213245

214246
@property
215247
def sub_controllers(self) -> dict[str, BaseController]:
216-
return self.__sub_controller_tree
217-
218-
def __repr__(self):
219-
name = self.__class__.__name__
220-
path = ".".join(self.path) or None
221-
sub_controllers = list(self.sub_controllers.keys()) or None
222-
223-
return f"{name}(path={path}, sub_controllers={sub_controllers})"
224-
225-
def __setattr__(self, name, value):
226-
if isinstance(value, Attribute):
227-
self.add_attribute(name, value)
228-
elif isinstance(value, Controller):
229-
self.add_sub_controller(name, value)
230-
else:
231-
super().__setattr__(name, value)
248+
return self.__sub_controllers
232249

233250

234251
class Controller(BaseController):
235-
"""Top-level controller for a device.
236-
237-
This is the primary class for implementing device support in FastCS. Instances of
238-
this class can be loaded into a FastCS to expose its ``Attribute``s to the transport
239-
layer, which can then perform a specific function with the set of ``Attributes``,
240-
such as generating a UI or creating parameters for a control system.
241-
"""
252+
"""Controller containing Attributes and named sub Controllers"""
242253

243254
def __init__(
244255
self,
@@ -263,8 +274,12 @@ async def disconnect(self) -> None:
263274

264275

265276
class ControllerVector(MutableMapping[int, Controller], BaseController):
266-
"""A controller with a collection of identical sub controllers distinguished
267-
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+
"""
268283

269284
def __init__(
270285
self,

tests/test_attribute.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ class MissingIOController(Controller):
7676

7777
with pytest.raises(ValueError, match="does not have an AttributeIO to handle"):
7878
controller = MissingIOController()
79-
controller.connect_attribute_ios()
79+
controller._connect_attribute_ios()
8080

8181
await c.initialise()
82-
c.connect_attribute_ios()
82+
c._connect_attribute_ios()
8383
await c.my_attr.bind_update_callback()()
8484

8585

@@ -218,7 +218,7 @@ async def initialise(self):
218218

219219
c = DemoParameterController(ios=[DemoParameterAttributeIO()])
220220
await c.initialise()
221-
c.connect_attribute_ios()
221+
c._connect_attribute_ios()
222222
await c.ro_int_parameter.bind_update_callback()()
223223
assert c.ro_int_parameter.get() == 10
224224
await c.ro_int_parameter.bind_update_callback()()
@@ -239,7 +239,7 @@ class MyController(Controller):
239239
match="MyController does not have an AttributeIO to handle AttributeIORef",
240240
):
241241
c = MyController()
242-
c.connect_attribute_ios()
242+
c._connect_attribute_ios()
243243

244244
class SimpleAttributeIO(AttributeIO[T, AttributeIORef]):
245245
async def update(self, attr):
@@ -259,7 +259,7 @@ async def update(self, attr):
259259
assert c.base_class_ref.has_io_ref()
260260

261261
await c.initialise()
262-
c.connect_attribute_ios()
262+
c._connect_attribute_ios()
263263

264264
# There is a difference between providing an AttributeIO for the default
265265
# AttributeIORef class and not specifying the io_ref for an Attribute
@@ -278,7 +278,7 @@ async def update(self, attr):
278278
c2 = MyController(ios=[SimpleAttributeIO()])
279279

280280
await c2.initialise()
281-
c2.connect_attribute_ios()
281+
c2._connect_attribute_ios()
282282

283283
assert c2.base_class_ref.get() == 0
284284
await c2.base_class_ref.bind_update_callback()()

tests/test_control_system.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class MyTestController(Controller):
3636
def __init__(self):
3737
super().__init__(description="Controller for testing")
3838

39-
self.attributes["attr2"] = AttrRW(Int())
39+
self.attr2 = AttrRW(Int())
4040

4141
@command()
4242
async def do_nothing(self):

0 commit comments

Comments
 (0)