Skip to content

Commit fc7401f

Browse files
authored
Merge pull request #41 from DiamondLightSource/controller-nesting
Allow arbitrary nesting of SubControllers
2 parents 61a896a + 2b633a9 commit fc7401f

File tree

8 files changed

+97
-129
lines changed

8 files changed

+97
-129
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies = [
1414
"aioserial",
1515
"numpy",
1616
"pydantic",
17-
"pvi~=0.8.1",
17+
"pvi~=0.9.0",
1818
"softioc",
1919
]
2020
dynamic = ["version"]

src/fastcs/backends/epics/gui.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from fastcs.cs_methods import Command
2929
from fastcs.datatypes import Bool, DataType, Float, Int, String
3030
from fastcs.exceptions import FastCSException
31-
from fastcs.mapping import Mapping, SingleMapping
31+
from fastcs.mapping import Mapping, SingleMapping, _get_single_mapping
3232
from fastcs.util import snake_to_pascal
3333

3434

@@ -49,12 +49,10 @@ def __init__(self, mapping: Mapping, pv_prefix: str) -> None:
4949
self._mapping = mapping
5050
self._pv_prefix = pv_prefix
5151

52-
def _get_pv(self, attr_path: str, name: str):
53-
if attr_path:
54-
attr_path = ":" + attr_path
55-
attr_path += ":"
56-
57-
return f"{self._pv_prefix}{attr_path.upper()}{name.title().replace('_', '')}"
52+
def _get_pv(self, attr_path: list[str], name: str):
53+
attr_prefix = ":".join(attr_path)
54+
pv_prefix = ":".join((self._pv_prefix, attr_prefix))
55+
return f"{pv_prefix}:{name.title().replace('_', '')}"
5856

5957
@staticmethod
6058
def _get_read_widget(datatype: DataType) -> ReadWidget:
@@ -80,7 +78,9 @@ def _get_write_widget(datatype: DataType) -> WriteWidget:
8078
case _:
8179
raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}")
8280

83-
def _get_attribute_component(self, attr_path: str, name: str, attribute: Attribute):
81+
def _get_attribute_component(
82+
self, attr_path: list[str], name: str, attribute: Attribute
83+
):
8484
pv = self._get_pv(attr_path, name)
8585
name = name.title().replace("_", "")
8686

@@ -102,7 +102,7 @@ def _get_attribute_component(self, attr_path: str, name: str, attribute: Attribu
102102
write_widget = self._get_write_widget(attribute.datatype)
103103
return SignalW(name=name, write_pv=pv, write_widget=write_widget)
104104

105-
def _get_command_component(self, attr_path: str, name: str):
105+
def _get_command_component(self, attr_path: list[str], name: str):
106106
pv = self._get_pv(attr_path, name)
107107
name = name.title().replace("_", "")
108108

@@ -122,30 +122,30 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None:
122122

123123
assert options.output_path.suffix == options.file_format.value
124124

125-
formatter = DLSFormatter()
126-
127125
controller_mapping = self._mapping.get_controller_mappings()[0]
128-
sub_controller_mappings = self._mapping.get_controller_mappings()[1:]
129-
130126
components = self.extract_mapping_components(controller_mapping)
131-
132-
for sub_controller_mapping in sub_controller_mappings:
133-
components.append(
134-
Group(
135-
name=snake_to_pascal(sub_controller_mapping.controller.path),
136-
layout=SubScreen(),
137-
children=self.extract_mapping_components(sub_controller_mapping),
138-
)
139-
)
140-
141127
device = Device(label=options.title, children=components)
142128

129+
formatter = DLSFormatter()
143130
formatter.format(device, options.output_path)
144131

145132
def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]:
146133
components: Tree[Component] = []
147134
attr_path = mapping.controller.path
148135

136+
for sub_controller in mapping.controller.get_sub_controllers():
137+
components.append(
138+
Group(
139+
# TODO: Build assumption that SubController has at least one path
140+
# element into typing
141+
name=snake_to_pascal(sub_controller.path[-1]),
142+
layout=SubScreen(),
143+
children=self.extract_mapping_components(
144+
_get_single_mapping(sub_controller)
145+
),
146+
)
147+
)
148+
149149
groups: dict[str, list[Component]] = {}
150150
for attr_name, attribute in mapping.attributes.items():
151151
signal = self._get_attribute_component(
@@ -159,6 +159,9 @@ def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]:
159159
if group not in groups:
160160
groups[group] = []
161161

162+
# Remove duplication of group name and signal name
163+
signal.name = signal.name.removeprefix(group)
164+
162165
groups[group].append(signal)
163166
case _:
164167
components.append(signal)

src/fastcs/backends/epics/ioc.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def _create_and_link_attribute_pvs(mapping: Mapping) -> None:
8888
path = single_mapping.controller.path
8989
for attr_name, attribute in single_mapping.attributes.items():
9090
attr_name = attr_name.title().replace("_", "")
91-
pv_name = path.upper() + ":" + attr_name if path else attr_name
91+
pv_name = f"{':'.join(path).upper()}:{attr_name}" if path else attr_name
9292

9393
match attribute:
9494
case AttrRW():
@@ -103,9 +103,9 @@ def _create_and_link_attribute_pvs(mapping: Mapping) -> None:
103103
def _create_and_link_command_pvs(mapping: Mapping) -> None:
104104
for single_mapping in mapping.get_controller_mappings():
105105
path = single_mapping.controller.path
106-
for name, method in single_mapping.command_methods.items():
107-
name = name.title().replace("_", "")
108-
pv_name = path.upper() + ":" + name if path else name
106+
for attr_name, method in single_mapping.command_methods.items():
107+
attr_name = attr_name.title().replace("_", "")
108+
pv_name = f"{':'.join(path).upper()}:{attr_name}" if path else attr_name
109109

110110
_create_and_link_command_pv(
111111
pv_name, MethodType(method.fn, single_mapping.controller)

src/fastcs/controller.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77

88
class BaseController:
9-
def __init__(self, path="") -> None:
10-
self._path: str = path
9+
def __init__(self, path: list[str] | None = None) -> None:
10+
self._path: list[str] = path or []
11+
self.__sub_controllers: list[SubController] = []
12+
1113
self._bind_attrs()
1214

1315
@property
14-
def path(self):
16+
def path(self) -> list[str]:
17+
"""Path prefix of attributes, recursively including parent ``Controller``s."""
1518
return self._path
1619

1720
def _bind_attrs(self) -> None:
@@ -21,6 +24,12 @@ def _bind_attrs(self) -> None:
2124
new_attribute = copy(attr)
2225
setattr(self, attr_name, new_attribute)
2326

27+
def register_sub_controller(self, controller: SubController):
28+
self.__sub_controllers.append(controller)
29+
30+
def get_sub_controllers(self) -> list[SubController]:
31+
return self.__sub_controllers
32+
2433

2534
class Controller(BaseController):
2635
"""Top-level controller for a device.
@@ -33,17 +42,10 @@ class Controller(BaseController):
3342

3443
def __init__(self) -> None:
3544
super().__init__()
36-
self.__sub_controllers: list[SubController] = []
3745

3846
async def connect(self) -> None:
3947
pass
4048

41-
def register_sub_controller(self, controller: SubController):
42-
self.__sub_controllers.append(controller)
43-
44-
def get_sub_controllers(self) -> list[SubController]:
45-
return self.__sub_controllers
46-
4749

4850
class SubController(BaseController):
4951
"""A subordinate to a ``Controller`` for managing a subset of a device.
@@ -52,5 +54,5 @@ class SubController(BaseController):
5254
it as part of a larger device.
5355
"""
5456

55-
def __init__(self, path: str) -> None:
57+
def __init__(self, path: list[str]) -> None:
5658
super().__init__(path)

src/fastcs/mapping.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Iterator
12
from dataclasses import dataclass
23

34
from .attributes import Attribute
@@ -18,36 +19,7 @@ class SingleMapping:
1819
class Mapping:
1920
def __init__(self, controller: Controller) -> None:
2021
self.controller = controller
21-
22-
self._controller_mappings: list[SingleMapping] = []
23-
self._controller_mappings.append(self._get_single_mapping(controller))
24-
25-
for sub_controller in controller.get_sub_controllers():
26-
self._controller_mappings.append(self._get_single_mapping(sub_controller))
27-
28-
@staticmethod
29-
def _get_single_mapping(controller: BaseController) -> SingleMapping:
30-
scan_methods = {}
31-
put_methods = {}
32-
command_methods = {}
33-
attributes = {}
34-
for attr_name in dir(controller):
35-
attr = getattr(controller, attr_name)
36-
match attr:
37-
case WrappedMethod(fastcs_method=fastcs_method):
38-
match fastcs_method:
39-
case Put():
40-
put_methods[attr_name] = fastcs_method
41-
case Scan():
42-
scan_methods[attr_name] = fastcs_method
43-
case Command():
44-
command_methods[attr_name] = fastcs_method
45-
case Attribute():
46-
attributes[attr_name] = attr
47-
48-
return SingleMapping(
49-
controller, scan_methods, put_methods, command_methods, attributes
50-
)
22+
self._controller_mappings = list(_walk_mappings(controller))
5123

5224
def __str__(self) -> str:
5325
result = "Controller mappings:\n"
@@ -57,3 +29,33 @@ def __str__(self) -> str:
5729

5830
def get_controller_mappings(self) -> list[SingleMapping]:
5931
return self._controller_mappings
32+
33+
34+
def _walk_mappings(controller: BaseController) -> Iterator[SingleMapping]:
35+
yield _get_single_mapping(controller)
36+
for sub_controller in controller.get_sub_controllers():
37+
yield from _walk_mappings(sub_controller)
38+
39+
40+
def _get_single_mapping(controller: BaseController) -> SingleMapping:
41+
scan_methods = {}
42+
put_methods = {}
43+
command_methods = {}
44+
attributes = {}
45+
for attr_name in dir(controller):
46+
attr = getattr(controller, attr_name)
47+
match attr:
48+
case WrappedMethod(fastcs_method=fastcs_method):
49+
match fastcs_method:
50+
case Put():
51+
put_methods[attr_name] = fastcs_method
52+
case Scan():
53+
scan_methods[attr_name] = fastcs_method
54+
case Command():
55+
command_methods[attr_name] = fastcs_method
56+
case Attribute():
57+
attributes[attr_name] = attr
58+
59+
return SingleMapping(
60+
controller, scan_methods, put_methods, command_methods, attributes
61+
)

src/fastcs/util.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
def snake_to_pascal(input: str) -> str:
2-
"""Convert a snake_case or UPPER_SNAKE_CASE string to PascalCase."""
3-
return input.lower().replace("_", " ").title().replace(" ", "")
2+
"""Convert a snake_case string to PascalCase."""
3+
return "".join(
4+
part.title() if part.islower() else part for part in input.split("_")
5+
)

tests/test_boilerplate_removed.py

Lines changed: 0 additions & 58 deletions
This file was deleted.

tests/test_controller.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from fastcs.controller import Controller, SubController
2+
from fastcs.mapping import _get_single_mapping, _walk_mappings
3+
4+
5+
def test_controller_nesting():
6+
controller = Controller()
7+
sub_controller = SubController(["a"])
8+
sub_sub_controller = SubController(["a", "b"])
9+
10+
controller.register_sub_controller(sub_controller)
11+
sub_controller.register_sub_controller(sub_sub_controller)
12+
13+
assert list(_walk_mappings(controller)) == [
14+
_get_single_mapping(controller),
15+
_get_single_mapping(sub_controller),
16+
_get_single_mapping(sub_sub_controller),
17+
]

0 commit comments

Comments
 (0)