From d1fc038cbb545852ca281146249ca94d7886ff01 Mon Sep 17 00:00:00 2001 From: Marcell Nagy <133755508+marcelldls@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:48:57 +0000 Subject: [PATCH] Remove tango polling (#66) * Remove tango polling * Process review comments * Fix epics tests * Fix typo * Expose assertions in tests * Do read after write test --- src/fastcs/backends/tango/dsr.py | 46 ++++------ tests/backends/epics/test_gui.py | 24 +++++ tests/backends/tango/test_dsr.py | 147 +++++++++++++++++++++++-------- tests/conftest.py | 74 +++++++++++++++- 4 files changed, 224 insertions(+), 67 deletions(-) diff --git a/src/fastcs/backends/tango/dsr.py b/src/fastcs/backends/tango/dsr.py index d8689e46..6df5fc57 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 @@ -16,7 +15,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 +23,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 +41,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 +69,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 +79,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 +98,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 +131,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 +155,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 +168,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/epics/test_gui.py b/tests/backends/epics/test_gui.py index 0ecabaf8..c4d9b062 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( diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index a5839e48..c55019a1 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -1,37 +1,112 @@ 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.backends.tango.backend import TangoBackend + + +class TestTangoDevice: + @pytest.fixture(scope="class") + def tango_context(self, assertable_controller): + # https://tango-controls.readthedocs.io/projects/pytango/en/v9.5.1/testing/test_context.html + device = TangoBackend(assertable_controller)._dsr._device + with DeviceTestContext(device) as proxy: + yield proxy + + def test_list_attributes(self, tango_context): + assert list(tango_context.get_attribute_list()) == [ + "BigEnum", + "ReadBool", + "ReadInt", + "ReadWriteFloat", + "ReadWriteInt", + "StringEnum", + "WriteBool", + "SubController01_ReadInt", + "SubController02_ReadInt", + "State", + "Status", + ] + + def test_list_commands(self, tango_context): + assert list(tango_context.get_command_list()) == [ + "Go", + "Init", + "State", + "Status", + ] + + def test_state(self, tango_context): + assert tango_context.command_inout("State") == DevState.ON + + def test_status(self, tango_context): + 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 + with assertable_controller.assert_read_here(["read_int"]): + result = tango_context.read_attribute("ReadInt").value + assert result == expect + + def test_read_write_int(self, assertable_controller, tango_context): + expect = 0 + 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", 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", new) + assert tango_context.read_attribute("ReadWriteFloat").value == new + + def test_read_bool(self, assertable_controller, tango_context): + expect = False + with assertable_controller.assert_read_here(["read_bool"]): + result = tango_context.read_attribute("ReadBool").value + assert result == expect + + def test_write_bool(self, assertable_controller, tango_context): + with assertable_controller.assert_write_here(["write_bool"]): + tango_context.write_attribute("WriteBool", True) + + def test_string_enum(self, assertable_controller, tango_context): + expect = "" + 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", new) + assert tango_context.read_attribute("StringEnum").value == new + + def test_big_enum(self, assertable_controller, tango_context): + expect = 0 + with assertable_controller.assert_read_here(["big_enum"]): + result = tango_context.read_attribute("BigEnum").value + assert result == expect + + def test_go(self, assertable_controller, tango_context): + with assertable_controller.assert_execute_here(["go"]): + tango_context.command_inout("Go") + + def test_read_child1(self, assertable_controller, tango_context): + expect = 0 + with assertable_controller.assert_read_here(["SubController01", "read_int"]): + result = tango_context.read_attribute("SubController01_ReadInt").value + assert result == expect + + def test_read_child2(self, assertable_controller, tango_context): + expect = 0 + with assertable_controller.assert_read_here(["SubController02", "read_int"]): + result = tango_context.read_attribute("SubController02_ReadInt").value + assert result == expect diff --git a/tests/conftest.py b/tests/conftest.py index f8b0f485..72779642 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,19 @@ +import copy import os import random import string import subprocess import time +from contextlib import contextmanager from pathlib import Path -from typing import Any +from typing import Any, Literal import pytest from aioca import purge_channel_caches +from pytest_mock import MockerFixture from fastcs.attributes import AttrR, AttrRW, AttrW, Handler, Sender, Updater -from fastcs.controller import Controller +from fastcs.controller import Controller, SubController from fastcs.datatypes import Bool, Float, Int, String from fastcs.mapping import Mapping from fastcs.wrappers import command, scan @@ -49,7 +52,20 @@ class TestHandler(Handler, TestUpdater, TestSender): pass +class TestSubController(SubController): + read_int: AttrR = AttrR(Int(), handler=TestUpdater()) + + class TestController(Controller): + def __init__(self) -> 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,65 @@ async def counter(self): self.count += 1 +class AssertableController(TestController): + def __init__(self, mocker: MockerFixture) -> None: + super().__init__() + self.mocker = mocker + + @contextmanager + 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 a context block + """ + queue = copy.deepcopy(path) + + # 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)