From 3b4fb012f217ee2b9f4834337d90b3eadb793ba4 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 28 Oct 2024 14:15:14 +0000 Subject: [PATCH 1/6] Remove tango polling --- src/fastcs/backends/tango/dsr.py | 45 ++++----- tests/backends/tango/test_dsr.py | 154 +++++++++++++++++++++++-------- tests/conftest.py | 67 +++++++++++++- 3 files changed, 200 insertions(+), 66 deletions(-) diff --git a/src/fastcs/backends/tango/dsr.py b/src/fastcs/backends/tango/dsr.py index d8689e466..7135d758a 100644 --- a/src/fastcs/backends/tango/dsr.py +++ b/src/fastcs/backends/tango/dsr.py @@ -16,7 +16,6 @@ @dataclass class TangoDSROptions: dev_name: str = "MY/DEVICE/NAME" - dev_class: str = "FAST_CS_DEVICE" dsr_instance: str = "MY_SERVER_INSTANCE" debug: bool = False @@ -25,23 +24,12 @@ def _wrap_updater_fget( attr_name: str, attribute: AttrR, controller: BaseController ) -> Callable[[Any], Any]: async def fget(tango_device: Device): - assert attribute.updater is not None - - await attribute.updater.update(controller, attribute) tango_device.info_stream(f"called fget method: {attr_name}") return attribute.get() return fget -def _tango_polling_period(attribute: AttrR) -> int: - if attribute.updater is not None: - # Convert to integer milliseconds - return int(attribute.updater.update_period * 1000) - - return -1 # `tango.server.attribute` default for `polling_period` - - def _tango_display_format(attribute: Attribute) -> str: match attribute.datatype: case Float(prec): @@ -54,10 +42,8 @@ def _wrap_updater_fset( attr_name: str, attribute: AttrW, controller: BaseController ) -> Callable[[Any, Any], Any]: async def fset(tango_device: Device, val): - assert attribute.sender is not None - - await attribute.sender.put(controller, attribute, val) tango_device.info_stream(f"called fset method: {attr_name}") + await attribute.process(val) return fset @@ -84,7 +70,6 @@ def _collect_dev_attributes(mapping: Mapping) -> dict[str, Any]: ), access=AttrWriteType.READ_WRITE, format=_tango_display_format(attribute), - polling_period=_tango_polling_period(attribute), ) case AttrR(): collection[d_attr_name] = server.attribute( @@ -95,7 +80,6 @@ def _collect_dev_attributes(mapping: Mapping) -> dict[str, Any]: attr_name, attribute, single_mapping.controller ), format=_tango_display_format(attribute), - polling_period=_tango_polling_period(attribute), ) case AttrW(): collection[d_attr_name] = server.attribute( @@ -115,8 +99,10 @@ def _wrap_command_f( method_name: str, method: Callable, controller: BaseController ) -> Callable[..., Awaitable[None]]: async def _dynamic_f(tango_device: Device) -> None: - tango_device.info_stream(f"called {controller} f method: {method_name}") - return await MethodType(method, controller)() + tango_device.info_stream( + f"called {'_'.join(controller.path)} f method: {method_name}" + ) + return await getattr(controller, method.__name__)() _dynamic_f.__name__ = method_name return _dynamic_f @@ -146,7 +132,6 @@ def _collect_dev_init(mapping: Mapping) -> dict[str, Callable]: async def init_device(tango_device: Device): await server.Device.init_device(tango_device) # type: ignore tango_device.set_state(DevState.ON) - await mapping.controller.connect() return {"init_device": init_device} @@ -171,11 +156,10 @@ def _collect_dsr_args(options: TangoDSROptions) -> list[str]: class TangoDSR: def __init__(self, mapping: Mapping): self._mapping = mapping + self.dev_class = self._mapping.controller.__class__.__name__ + self._device = self._create_device() - def run(self, options: TangoDSROptions | None = None) -> None: - if options is None: - options = TangoDSROptions() - + def _create_device(self): class_dict: dict = { **_collect_dev_attributes(self._mapping), **_collect_dev_commands(self._mapping), @@ -185,14 +169,19 @@ def run(self, options: TangoDSROptions | None = None) -> None: } class_bases = (server.Device,) - pytango_class = type(options.dev_class, class_bases, class_dict) - register_dev(options.dev_name, options.dev_class, options.dsr_instance) + pytango_class = type(self.dev_class, class_bases, class_dict) + return pytango_class + + def run(self, options: TangoDSROptions | None = None) -> None: + if options is None: + options = TangoDSROptions() dsr_args = _collect_dsr_args(options) server.run( - (pytango_class,), - [options.dev_class, options.dsr_instance, *dsr_args], + (self._device,), + [self.dev_class, options.dsr_instance, *dsr_args], + green_mode=server.GreenMode.Asyncio, ) diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index a5839e489..fcf067e45 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -1,37 +1,119 @@ +import copy +import re +from typing import Any + import pytest -from pytest_mock import MockerFixture -from tango._tango import AttrWriteType, CmdArgType - -from fastcs.backends.tango.dsr import _collect_dev_attributes, _collect_dev_commands - - -def test_collect_attributes(mapping): - attributes = _collect_dev_attributes(mapping) - - # Check that attributes are created and of expected type - assert list(attributes.keys()) == [ - "BigEnum", - "ReadBool", - "ReadInt", - "ReadWriteFloat", - "ReadWriteInt", - "StringEnum", - "WriteBool", - ] - assert attributes["ReadInt"].attr_write == AttrWriteType.READ - assert attributes["ReadInt"].attr_type == CmdArgType.DevLong64 - assert attributes["StringEnum"].attr_write == AttrWriteType.READ_WRITE - assert attributes["StringEnum"].attr_type == CmdArgType.DevString - assert attributes["ReadWriteFloat"].attr_write == AttrWriteType.READ_WRITE - assert attributes["ReadWriteFloat"].attr_type == CmdArgType.DevDouble - assert attributes["WriteBool"].attr_write == AttrWriteType.WRITE - assert attributes["WriteBool"].attr_type == CmdArgType.DevBoolean - - -@pytest.mark.asyncio -async def test_collect_commands(mapping, mocker: MockerFixture): - commands = _collect_dev_commands(mapping) - - # Check that command is created and it can be called - assert list(commands.keys()) == ["Go"] - await commands["Go"](mocker.MagicMock()) +from tango import DevState +from tango.test_context import DeviceTestContext + +from fastcs.attributes import AttrR +from fastcs.backends.tango.backend import TangoBackend +from fastcs.datatypes import Bool, Float, Int + + +def pascal_2_snake(input: list[str]) -> list[str]: + snake_list = copy.deepcopy(input) + snake_list[-1] = re.sub(r"(? None: + super().__init__() + + self._sub_controllers: list[TestSubController] = [] + for index in range(1, 3): + controller = TestSubController() + self._sub_controllers.append(controller) + self.register_sub_controller(f"SubController{index:02d}", controller) + read_int: AttrR = AttrR(Int(), handler=TestUpdater()) read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler()) read_write_float: AttrRW = AttrRW(Float()) @@ -80,11 +96,58 @@ async def counter(self): self.count += 1 +class AssertableController(TestController): + def __init__(self, mocker: MockerFixture) -> None: + super().__init__() + self.mocker = mocker + + @contextmanager + def assertPerformed( + self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"] + ): + queue = copy.deepcopy(path) + match action: + case "READ": + method = "get" + case "WRITE": + method = "process" + case "EXECUTE": + method = "" + + # Navigate to subcontroller + controller = self + item_name = queue.pop(-1) + for item in queue: + controllers = controller.get_sub_controllers() + controller = controllers[item] + + # create probe + if method: + attr = getattr(controller, item_name) + spy = self.mocker.spy(attr, method) + else: + spy = self.mocker.spy(controller, item_name) + initial = spy.call_count + try: + yield # Enter context + finally: # Exit context + final = spy.call_count + assert final == initial + 1, ( + f"Expected {'.'.join(path + [method] if method else path)} " + f"to be called once, but it was called {final - initial} times." + ) + + @pytest.fixture def controller(): return TestController() +@pytest.fixture(scope="class") +def assertable_controller(class_mocker: MockerFixture): + return AssertableController(class_mocker) + + @pytest.fixture def mapping(controller): return Mapping(controller) From dbe58fbc30880ae524ca0f162961b81d6aaf551f Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 15 Nov 2024 09:49:46 +0000 Subject: [PATCH 2/6] Process review comments --- src/fastcs/backends/tango/dsr.py | 1 - tests/backends/tango/test_dsr.py | 12 +++++++++--- tests/conftest.py | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/fastcs/backends/tango/dsr.py b/src/fastcs/backends/tango/dsr.py index 7135d758a..6df5fc573 100644 --- a/src/fastcs/backends/tango/dsr.py +++ b/src/fastcs/backends/tango/dsr.py @@ -1,6 +1,5 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from types import MethodType from typing import Any import tango diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index fcf067e45..74c8f4682 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -8,10 +8,13 @@ from fastcs.attributes import AttrR from fastcs.backends.tango.backend import TangoBackend -from fastcs.datatypes import Bool, Float, Int +from fastcs.datatypes import Bool, Float, Int, String def pascal_2_snake(input: list[str]) -> list[str]: + """ + Converts the last entry in a list of strings + """ snake_list = copy.deepcopy(input) snake_list[-1] = re.sub(r"(? None: def assertPerformed( self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"] ): + """ + This context manager can be used to confirm that a fastcs + controller's respective attribute or command methods are called + a single time witin the context block + """ queue = copy.deepcopy(path) match action: case "READ": From 83cab26515bf1ffc1400d8dec1a3f47fde63d20a Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 15 Nov 2024 10:32:47 +0000 Subject: [PATCH 3/6] Fix epics tests --- tests/backends/epics/test_gui.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/backends/epics/test_gui.py b/tests/backends/epics/test_gui.py index 0ecabaf86..c4d9b0626 100644 --- a/tests/backends/epics/test_gui.py +++ b/tests/backends/epics/test_gui.py @@ -2,10 +2,12 @@ LED, ButtonPanel, ComboBox, + Group, SignalR, SignalRW, SignalW, SignalX, + SubScreen, TextFormat, TextRead, TextWrite, @@ -30,6 +32,28 @@ def test_get_components(mapping): components = gui.extract_mapping_components(mapping.get_controller_mappings()[0]) assert components == [ + Group( + name="SubController01", + layout=SubScreen(labelled=True), + children=[ + SignalR( + name="ReadInt", + read_pv="DEVICE:SubController01:ReadInt", + read_widget=TextRead(), + ) + ], + ), + Group( + name="SubController02", + layout=SubScreen(labelled=True), + children=[ + SignalR( + name="ReadInt", + read_pv="DEVICE:SubController01:ReadInt", + read_widget=TextRead(), + ) + ], + ), SignalR(name="BigEnum", read_pv="DEVICE:BigEnum", read_widget=TextRead()), SignalR(name="ReadBool", read_pv="DEVICE:ReadBool", read_widget=LED()), SignalR( From 46163db0eb225e66253439bde4997aeadeb65ac8 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 15 Nov 2024 11:04:35 +0000 Subject: [PATCH 4/6] Fix typo --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5fbce664c..11a2944d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,7 +108,7 @@ def assertPerformed( """ This context manager can be used to confirm that a fastcs controller's respective attribute or command methods are called - a single time witin the context block + a single time within the context block """ queue = copy.deepcopy(path) match action: From 5d9229d7d0fe6b6dfed369581f36729b995f9688 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 18 Nov 2024 16:38:25 +0000 Subject: [PATCH 5/6] Expose assertions in tests --- tests/backends/tango/test_dsr.py | 145 ++++++++++++++----------------- tests/conftest.py | 24 ++--- 2 files changed, 76 insertions(+), 93 deletions(-) diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index 74c8f4682..8a5dbda0c 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -1,65 +1,18 @@ -import copy -import re -from typing import Any - import pytest from tango import DevState from tango.test_context import DeviceTestContext -from fastcs.attributes import AttrR from fastcs.backends.tango.backend import TangoBackend -from fastcs.datatypes import Bool, Float, Int, String - - -def pascal_2_snake(input: list[str]) -> list[str]: - """ - Converts the last entry in a list of strings - """ - snake_list = copy.deepcopy(input) - snake_list[-1] = re.sub(r"(? None: self.mocker = mocker @contextmanager - def assertPerformed( - self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"] - ): + def assert_read_here(self, path: list[str]): + yield from self._assert_method(path, "get") + + @contextmanager + def assert_write_here(self, path: list[str]): + yield from self._assert_method(path, "process") + + @contextmanager + def assert_execute_here(self, path: list[str]): + yield from self._assert_method(path, "") + + def _assert_method(self, path: list[str], method: Literal["get", "process", ""]): """ This context manager can be used to confirm that a fastcs controller's respective attribute or command methods are called - a single time within the context block + a single time within a context block """ queue = copy.deepcopy(path) - match action: - case "READ": - method = "get" - case "WRITE": - method = "process" - case "EXECUTE": - method = "" # Navigate to subcontroller controller = self From 92f5e4689c727a9fd53a152540120b424c69567f Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Tue, 19 Nov 2024 11:26:24 +0000 Subject: [PATCH 6/6] Write to val before read --- tests/backends/tango/test_dsr.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index 8a5dbda0c..c55019a1e 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -40,8 +40,8 @@ def test_state(self, tango_context): assert tango_context.command_inout("State") == DevState.ON def test_status(self, tango_context): - expected = "The device is in ON state." - assert tango_context.command_inout("Status") == expected + expect = "The device is in ON state." + assert tango_context.command_inout("Status") == expect def test_read_int(self, assertable_controller, tango_context): expect = 0 @@ -54,16 +54,20 @@ def test_read_write_int(self, assertable_controller, tango_context): with assertable_controller.assert_read_here(["read_write_int"]): result = tango_context.read_attribute("ReadWriteInt").value assert result == expect + new = 9 with assertable_controller.assert_write_here(["read_write_int"]): - tango_context.write_attribute("ReadWriteInt", expect) + tango_context.write_attribute("ReadWriteInt", new) + assert tango_context.read_attribute("ReadWriteInt").value == new def test_read_write_float(self, assertable_controller, tango_context): expect = 0.0 with assertable_controller.assert_read_here(["read_write_float"]): result = tango_context.read_attribute("ReadWriteFloat").value assert result == expect + new = 0.5 with assertable_controller.assert_write_here(["read_write_float"]): - tango_context.write_attribute("ReadWriteFloat", expect) + tango_context.write_attribute("ReadWriteFloat", new) + assert tango_context.read_attribute("ReadWriteFloat").value == new def test_read_bool(self, assertable_controller, tango_context): expect = False @@ -80,8 +84,10 @@ def test_string_enum(self, assertable_controller, tango_context): with assertable_controller.assert_read_here(["string_enum"]): result = tango_context.read_attribute("StringEnum").value assert result == expect + new = "new" with assertable_controller.assert_write_here(["string_enum"]): - tango_context.write_attribute("StringEnum", expect) + tango_context.write_attribute("StringEnum", new) + assert tango_context.read_attribute("StringEnum").value == new def test_big_enum(self, assertable_controller, tango_context): expect = 0