diff --git a/.github/workflows/deploy-pypi.yaml b/.github/workflows/deploy-pypi.yaml index ecd5671..4cf8a66 100644 --- a/.github/workflows/deploy-pypi.yaml +++ b/.github/workflows/deploy-pypi.yaml @@ -17,6 +17,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/ruff_formatting.yml b/.github/workflows/ruff_formatting.yml new file mode 100644 index 0000000..bbce2cc --- /dev/null +++ b/.github/workflows/ruff_formatting.yml @@ -0,0 +1,24 @@ +name: Ruff Format Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + ruff-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" # or your preferred version + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff Check + run: ruff check --diff . diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5215324 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,49 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: run tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: ">=3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytango + pip install . + pip install numpy + pip install scipy + pip install pydantic + pip install accelerator-toolbox + pip install matplotlib + pip install h5py + pip install accelerator-middle-layer + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: python -m pytest tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f3a3141 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.18.2 + # hooks: + # - id: mypy + # additional_dependencies: [ + # "pydantic>=2.0", + # ] diff --git a/pyproject.toml b/pyproject.toml index 279930e..2810022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ classifiers = [ dependencies = [ "PyTango>=10.1.1", - "accelerator-middle-layer<=0.1.1", + "accelerator-middle-layer<=0.2.2", "pydantic>=2.0" ] @@ -53,6 +53,7 @@ dev = [ "ruff", # Linter (optionnel) "mypy", # Typage statique (optionnel) "ipython", # Débogage interactif + "pre-commit", ] [project.urls] diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index f6e6afe..096e4f9 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.1" +__version__ = "0.3.2" import logging.config import os @@ -10,5 +10,5 @@ logger = logging.getLogger("tango.pyaml") level = os.getenv("TANGO_PYAML_LOG_LEVEL", "").upper() -if len(level)>0: +if len(level) > 0: logger.setLevel(getattr(logging, level, logging.WARNING)) diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index ace7969..9ddd267 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,18 +1,20 @@ import logging +from typing import Optional, Tuple + from pydantic import BaseModel from pyaml.control.deviceaccess import DeviceAccess from pyaml.control.readback_value import Value, Quality -from .controlsystem import TangoControlSystem from .initializable_element import InitializableElement from .device_factory import DeviceFactory from .tango_pyaml_utils import * -PYAMLCLASS : str = "Attribute" +PYAMLCLASS: str = "Attribute" logger = logging.getLogger(__name__) + class ConfigModel(BaseModel): """ Configuration model for Tango attributes. @@ -23,9 +25,14 @@ class ConfigModel(BaseModel): Full path of the Tango attribute (e.g., 'my/ps/device/current'). unit : str, optional The unit of the attribute. + range : tuple(min, max), optional + Range of valid values. Use null for -∞ or +∞. """ + attribute: str unit: str = "" + range: Optional[Tuple[Optional[float], Optional[float]]] = None + class Attribute(DeviceAccess, InitializableElement): """ @@ -41,31 +48,43 @@ class Attribute(DeviceAccess, InitializableElement): pyaml.PyAMLException If the Tango attribute is not writable. """ + def __init__(self, cfg: ConfigModel, writable=True): super().__init__() self._cfg = cfg self._writable = writable - self._attribute_dev:tango.DeviceProxy = None + self._attribute_dev: tango.DeviceProxy = None + self._attr_config: tango.AttributeConfig = None + self._attribute_dev_name: str = None + self._attr_name: str = None def initialize(self): super().initialize() try: - self._attribute_dev_name, self._attr_name = self._cfg.attribute.rsplit("/", 1) + self._attribute_dev_name, self._attr_name = self._cfg.attribute.rsplit( + "/", 1 + ) self._attribute_dev = DeviceFactory().get_device(self._attribute_dev_name) except tango.DevFailed as df: raise tango_to_PyAMLException(df) - - self._attr_config = self._attribute_dev.get_attribute_config(self._attr_name, wait=True) + + self._attr_config: tango.AttributeConfig = ( + self._attribute_dev.get_attribute_config(self._attr_name, wait=True) + ) if self._writable: - if self._attr_config .writable not in [tango._tango.AttrWriteType.READ_WRITE, - tango._tango.AttrWriteType.WRITE, - tango._tango.AttrWriteType.READ_WITH_WRITE]: - raise pyaml.PyAMLException(f"Tango attribute {self._cfg.attribute} is not writable.") - + if self._attr_config.writable not in [ + tango.AttrWriteType.READ_WRITE, + tango.AttrWriteType.WRITE, + tango.AttrWriteType.READ_WITH_WRITE, + ]: + raise pyaml.PyAMLException( + f"Tango attribute {self._cfg.attribute} is not writable." + ) + def is_writable(self): return self._writable - + def set(self, value: float): """ Write a value asynchronously to the Tango attribute. @@ -81,13 +100,14 @@ def set(self, value: float): If the Tango write fails. """ self._ensure_initialized() - logger.log(logging.DEBUG, f"Setting asynchronously {self._cfg.attribute} to {value}") + logger.log( + logging.DEBUG, f"Setting asynchronously {self._cfg.attribute} to {value}" + ) try: self._attribute_dev.write_attribute_asynch(self._attr_name, value) except tango.DevFailed as df: raise tango_to_PyAMLException(df) - def set_and_wait(self, value: float): """ Write a value synchronously to the Tango attribute. @@ -127,8 +147,10 @@ def readback(self) -> Value: logger.log(logging.DEBUG, f"Reading {self._cfg.attribute}") try: attr_value = self._attribute_dev.read_attribute(self._attr_name) - quality = Quality[attr_value.quality.name.rsplit('_', 1)[1]] # AttrQuality.ATTR_VALID gives Quality.VALID - value = Value(attr_value.value, quality, attr_value.time.todatetime() ) + quality = Quality[ + attr_value.quality.name.rsplit("_", 1)[1] + ] # AttrQuality.ATTR_VALID gives Quality.VALID + value = Value(attr_value.value, quality, attr_value.time.todatetime()) except tango.DevFailed as df: raise tango_to_PyAMLException(df) return value @@ -164,7 +186,7 @@ def measure_name(self) -> str: str The attribute name (e.g., 'current'). """ - return self._cfg.attribute.rsplit('/', 1)[1] + return self._cfg.attribute.rsplit("/", 1)[1] def get(self) -> float: """ @@ -185,6 +207,33 @@ def get(self) -> float: return self._attribute_dev.read_attribute(self._attr_name).w_value except tango.DevFailed as df: raise tango_to_PyAMLException(df) - + + def get_range(self) -> list[float]: + attr_range: list[float] = [None, None] + if self._cfg.range is not None: + attr_range[0] = ( + self._cfg.range[0] if self._cfg.range[0] is not None else None + ) + attr_range[1] = ( + self._cfg.range[1] if self._cfg.range[1] is not None else None + ) + else: + self._ensure_initialized() + min_value = self._attr_config.min_value + max_value = self._attr_config.max_value + attr_range[0] = to_float_or_none(min_value) + attr_range[1] = to_float_or_none(max_value) + + return attr_range + + def check_device_availability(self) -> bool: + available = True + try: + self._ensure_initialized() + self._attribute_dev.ping() + except tango.DevFailed | pyaml.PyAMLException: + available = False + return available + def __repr__(self): - return repr(self._cfg).replace("ConfigModel",self.__class__.__name__) \ No newline at end of file + return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index 7c38142..4d1eddf 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -8,6 +8,7 @@ import tango from .initializable_element import InitializableElement +from .tango_pyaml_utils import to_float_or_none PYAMLCLASS: str = "AttributeList" @@ -27,6 +28,7 @@ class ConfigModel(BaseModel): unit : str, optional Unit of the attributes. """ + attributes: list[str] name: str = "" unit: str = "" @@ -93,8 +95,13 @@ def set(self, value: float): Value to write. """ self._ensure_initialized() - logger.log(logging.DEBUG, f"Setting asynchronously list {self.name()} to {value}") - [group.write_attribute_asynch(attr_name, value) for attr_name, group in self._tango_groups.items()] + logger.log( + logging.DEBUG, f"Setting asynchronously list {self.name()} to {value}" + ) + [ + group.write_attribute_asynch(attr_name, value) + for attr_name, group in self._tango_groups.items() + ] def set_and_wait(self, value: float): """ @@ -107,7 +114,10 @@ def set_and_wait(self, value: float): """ self._ensure_initialized() logger.log(logging.DEBUG, f"Setting list {self.name()} to {value}") - [group.write_attribute(attr_name, value) for attr_name, group in self._tango_groups.items()] + [ + group.write_attribute(attr_name, value) + for attr_name, group in self._tango_groups.items() + ] def get(self) -> array: """ @@ -120,14 +130,17 @@ def get(self) -> array: """ self._ensure_initialized() result = {} - grp_vals = [group.read_attribute(attr_name) for attr_name, group in self._tango_groups.items()] + grp_vals = [ + group.read_attribute(attr_name) + for attr_name, group in self._tango_groups.items() + ] for vals in grp_vals: for val in vals: attr_value = val.data if attr_value is not None: - result[val.dev_name + '/' + val.obj_name] = attr_value.w_value + result[val.dev_name + "/" + val.obj_name] = attr_value.w_value else: - result[val.dev_name + '/' + val.obj_name] = None + result[val.dev_name + "/" + val.obj_name] = None return array([result[attribute] for attribute in self._cfg.attributes]) def readback(self) -> array: @@ -142,17 +155,23 @@ def readback(self) -> array: self._ensure_initialized() logger.log(logging.DEBUG, f"Reading list {self.name()}") result = {} - grp_vals = [group.read_attribute(attr_name) for attr_name, group in self._tango_groups.items()] + grp_vals = [ + group.read_attribute(attr_name) + for attr_name, group in self._tango_groups.items() + ] for vals in grp_vals: for val in vals: attr_value = val.data if attr_value is not None: quality = Quality[ - attr_value.quality.name.rsplit('_', 1)[1]] # AttrQuality.ATTR_VALID gives Quality.VALID - value = Value(attr_value.value, quality, attr_value.time.todatetime()) - result[val.dev_name + '/' + val.obj_name] = value + attr_value.quality.name.rsplit("_", 1)[1] + ] # AttrQuality.ATTR_VALID gives Quality.VALID + value = Value( + attr_value.value, quality, attr_value.time.todatetime() + ) + result[val.dev_name + "/" + val.obj_name] = value else: - result[val.dev_name + '/' + val.obj_name] = None + result[val.dev_name + "/" + val.obj_name] = None list_res = [result[attribute] for attribute in self._cfg.attributes] return array(list_res) @@ -166,3 +185,39 @@ def unit(self) -> str: Unit string. """ return self._cfg.unit + + def get_range(self) -> list[float]: + attr_range: list[float] = [None, None] + if self._cfg.range is not None: + attr_range[0] = ( + self._cfg.range[0] if self._cfg.range[0] is not None else None + ) + attr_range[1] = ( + self._cfg.range[1] if self._cfg.range[1] is not None else None + ) + else: + self._ensure_initialized() + devices: list[tango.DeviceProxy] = [] + [ + devices.extend(group.get_device_list()) + for group in self._tango_groups.values() + ] + attr_confs = [dev.get_attribute_config() for dev in devices] + attr_range: list[float] = [] + for conf in attr_confs: + attr_range.append(to_float_or_none(conf.min_value)) + attr_range.append(to_float_or_none(conf.max_value)) + + return attr_range + + def check_device_availability(self) -> bool: + available = True + try: + self._ensure_initialized() + [group.ping() for group in self._tango_groups.values()] + except tango.DevFailed | pyaml.PyAMLException: + available = False + return available + + def __repr__(self): + return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index 506e8a6..104e561 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -3,10 +3,11 @@ import pyaml from .attribute_list import AttributeList, ConfigModel -PYAMLCLASS : str = "AttributeListReadOnly" +PYAMLCLASS: str = "AttributeListReadOnly" logger = logging.getLogger(__name__) + class AttributeListReadOnly(AttributeList): """ Handle a list of Tango attributes using Tango Groups. @@ -20,7 +21,6 @@ class AttributeListReadOnly(AttributeList): def __init__(self, cfg: ConfigModel): super().__init__(cfg) - def set(self, value: float): """ Write a value asynchronously to all Tango attributes. @@ -30,8 +30,9 @@ def set(self, value: float): value : float Value to write. """ - raise pyaml.PyAMLException(f"Tango attribute list {self.name()} is not writable.") - + raise pyaml.PyAMLException( + f"Tango attribute list {self.name()} is not writable." + ) def set_and_wait(self, value: float): """ @@ -42,5 +43,7 @@ def set_and_wait(self, value: float): value : float Value to write. """ - [group.write_attribute(attr_name, value) for attr_name, group in self._tango_groups.items()] - + [ + group.write_attribute(attr_name, value) + for attr_name, group in self._tango_groups.items() + ] diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index 415defd..1dccc03 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -3,10 +3,11 @@ from .attribute import Attribute, ConfigModel from .tango_pyaml_utils import * -PYAMLCLASS : str = "AttributeReadOnly" +PYAMLCLASS: str = "AttributeReadOnly" logger = logging.getLogger(__name__) + class AttributeReadOnly(Attribute): """ Read-only Tango attribute. @@ -16,6 +17,7 @@ class AttributeReadOnly(Attribute): cfg : ConfigModel Configuration model containing attribute path and unit. """ + def __init__(self, cfg: ConfigModel): super().__init__(cfg, False) @@ -28,7 +30,9 @@ def set(self, value: float): pyaml.PyAMLException Always raised because the attribute is read-only. """ - raise pyaml.PyAMLException(f"Tango attribute {self._cfg.attribute} is not writable.") + raise pyaml.PyAMLException( + f"Tango attribute {self._cfg.attribute} is not writable." + ) def set_and_wait(self, value: float): """ @@ -39,7 +43,9 @@ def set_and_wait(self, value: float): pyaml.PyAMLException Always raised because the attribute is read-only. """ - raise pyaml.PyAMLException(f"Tango attribute {self._cfg.attribute} is not writable.") + raise pyaml.PyAMLException( + f"Tango attribute {self._cfg.attribute} is not writable." + ) def get(self) -> float: return self.readback().value diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index a642b7a..eecf133 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,13 +1,11 @@ -import os import logging import copy -from pydantic import BaseModel, field_validator +from pydantic import BaseModel from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess -from .device_factory import DeviceFactory -PYAMLCLASS : str = "TangoControlSystem" +PYAMLCLASS: str = "TangoControlSystem" logger = logging.getLogger(__name__) @@ -25,20 +23,22 @@ class ConfigModel(BaseModel): debug_level : int Debug verbosity level. scalar_aggregator : str - Aggregator module for scalar values. If none specified, writings and readings of sclar value are serialized. + Aggregator module for scalar values. If none specified, writings and readings of sclar value are serialized. vector_aggregator : str - Aggregator module for vecrors. If none specified, writings and readings of vector are serialized. + Aggregator module for vecrors. If none specified, writings and readings of vector are serialized. timeout_ms : int Device timeout in milli seconds. """ + name: str tango_host: str | None = None - debug_level: str=None + debug_level: str = None lazy_devices: bool = True scalar_aggregator: str | None = "tango.pyaml.multi_attribute" vector_aggregator: str | None = None timeout_ms: int = 3000 + class TangoControlSystem(ControlSystem): """ Tango-specific implementation of a Control System. @@ -52,17 +52,20 @@ class TangoControlSystem(ControlSystem): def __init__(self, cfg: ConfigModel): super().__init__() self._cfg = cfg - self.__devices = {} # Dict containing all attached DeviceAccess + self.__devices = {} # Dict containing all attached DeviceAccess if self._cfg.debug_level: - log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) - logger.parent.setLevel(log_level) - logger.setLevel(log_level) + log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) + logger.parent.setLevel(log_level) + logger.setLevel(log_level) - logger.log(logging.WARNING, f"Tango control system binding for PyAML initialized with name '{self._cfg.name}'" - f" and TANGO_HOST={self._cfg.tango_host}") + logger.log( + logging.WARNING, + f"Tango control system binding for PyAML initialized with name '{self._cfg.name}'" + f" and TANGO_HOST={self._cfg.tango_host}", + ) - def __newref(self,obj,new_name:str): + def __newref(self, obj, new_name: str): # Shallow copy the object newObj = copy.copy(obj) # Shallow copy the config object @@ -81,12 +84,15 @@ def attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: else: full_name = d._cfg.attribute if full_name not in self.__devices: - self.__devices[full_name] = self.__newref(d,full_name) + self.__devices[full_name] = self.__newref(d, full_name) newDevs.append(self.__devices[full_name]) else: newDevs.append(None) return newDevs + def attach_array(self, dev: list[DeviceAccess]) -> list[DeviceAccess]: + pass + def name(self) -> str: """ Return the name of the control system. @@ -97,7 +103,7 @@ def name(self) -> str: Name of the control system. """ return self._cfg.name - + def scalar_aggregator(self) -> str | None: """ Returns the module name used for handling aggregator of DeviceAccess @@ -112,7 +118,7 @@ def scalar_aggregator(self) -> str | None: def vector_aggregator(self) -> str | None: """ Returns the module name used for handling aggregator of DeviceVectorAccess - + Returns ------- str @@ -121,4 +127,4 @@ def vector_aggregator(self) -> str | None: return self._cfg.vector_aggregator def __repr__(self): - return repr(self._cfg).replace("ConfigModel",self.__class__.__name__) \ No newline at end of file + return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/tango/pyaml/device_factory.py b/tango/pyaml/device_factory.py index 0c0a8ee..abfa5ef 100644 --- a/tango/pyaml/device_factory.py +++ b/tango/pyaml/device_factory.py @@ -17,16 +17,16 @@ def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._elements = defaultdict() - cls._instance._timeout = 3000 # in ms + cls._instance._timeout = 3000 # in ms return cls._instance - - def set_timeout_ms(self,timeout:int): + + def set_timeout_ms(self, timeout: int): self._timeout = timeout def get_timeout_ms(self) -> int: return self._timeout - def get_device(self, device_name:str) -> tango.DeviceProxy: + def get_device(self, device_name: str) -> tango.DeviceProxy: if device_name not in self._elements: dp = tango.DeviceProxy(device_name) dp.set_timeout_millis(self._timeout) diff --git a/tango/pyaml/initializable_element.py b/tango/pyaml/initializable_element.py index e6f3969..5c2947b 100644 --- a/tango/pyaml/initializable_element.py +++ b/tango/pyaml/initializable_element.py @@ -1,9 +1,7 @@ from abc import ABCMeta, abstractmethod -import pyaml class InitializableElement(metaclass=ABCMeta): - def __init__(self): self._initialized = False diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 0e07d79..25ca05c 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -11,10 +11,11 @@ from .attribute import Attribute, ConfigModel as AttrConfig from .device_factory import DeviceFactory -PYAMLCLASS : str = "MultiAttribute" +PYAMLCLASS: str = "MultiAttribute" logger = logging.getLogger(__name__) + class ConfigModel(BaseModel): """ Configuration model for a list of Tango attributes. @@ -28,13 +29,14 @@ class ConfigModel(BaseModel): unit : str, optional Unit of the attributes. """ + attributes: list[str] = [] name: str = "" unit: str = "" -class MultiAttribute(DeviceAccessList): - def __init__(self, cfg:ConfigModel=None): +class MultiAttribute(DeviceAccessList): + def __init__(self, cfg: ConfigModel = None): super().__init__() self._cfg = cfg if self._cfg: @@ -46,36 +48,44 @@ def __init__(self, cfg:ConfigModel=None): def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): if isinstance(devices, list): if any([not isinstance(device, Attribute) for device in devices]): - raise pyaml.PyAMLException("All devices must be instances of Attribute (tango.pyaml.attribute).") + raise pyaml.PyAMLException( + "All devices must be instances of Attribute (tango.pyaml.attribute)." + ) super().extend(devices) else: if not isinstance(devices, Attribute): - raise pyaml.PyAMLException("Device must be an instance of Attribute (tango.pyaml.attribute).") + raise pyaml.PyAMLException( + "Device must be an instance of Attribute (tango.pyaml.attribute)." + ) super().append(devices) def get_devices(self) -> DeviceAccess | list[DeviceAccess]: - if len(self)==1: + if len(self) == 1: return self[0] else: return self def set(self, value: npt.NDArray[np.float64]): - if len(value)!=len(self): - raise pyaml.PyAMLException(f"Size of value ({len(value)} do not match the number of managed devices ({len(self)})") + if len(value) != len(self): + raise pyaml.PyAMLException( + f"Size of value ({len(value)} do not match the number of managed devices ({len(self)})" + ) asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() # Set part for index, device in enumerate(self): device._ensure_initialized() - asynch_call_id = device._attribute_dev.write_attribute_asynch(device._attr_config, value[index]) + asynch_call_id = device._attribute_dev.write_attribute_asynch( + device._attr_name, value[index] + ) asynch_call_ids.append(asynch_call_id) # Wait part for index, call_id in enumerate(asynch_call_ids): - self[index]._attribute_dev.write_attribute_reply(call_id,timeout) + self[index]._attribute_dev.write_attribute_reply(call_id, timeout) def set_and_wait(self, value: npt.NDArray[np.float64]): - raise NotImplemented("Not implemented yet.") + raise NotImplementedError("Not implemented yet.") def get(self) -> npt.NDArray[np.float64]: values = [] @@ -84,12 +94,14 @@ def get(self) -> npt.NDArray[np.float64]: # Read asynch for index, device in enumerate(self): device._ensure_initialized() - asynch_call_id = device._attribute_dev.read_attribute_asynch(device._attr_name) + asynch_call_id = device._attribute_dev.read_attribute_asynch( + device._attr_name + ) asynch_call_ids.append(asynch_call_id) # Wait to read the set_point, ie the write part in a tango attribute. for index, call_id in enumerate(asynch_call_ids): - dev_attr = self[index]._attribute_dev.read_attribute_reply(call_id,timeout) + dev_attr = self[index]._attribute_dev.read_attribute_reply(call_id, timeout) if self[index].is_writable(): values.append(dev_attr.w_value) else: @@ -104,12 +116,14 @@ def readback(self) -> np.array: # Readback with asynch optim for index, device in enumerate(self): device._ensure_initialized() - asynch_call_id = device._attribute_dev.read_attribute_asynch(device._attr_name) + asynch_call_id = device._attribute_dev.read_attribute_asynch( + device._attr_name + ) asynch_call_ids.append(asynch_call_id) # Wait to read the value for index, call_id in enumerate(asynch_call_ids): - dev_attr = self[index]._attribute_dev.read_attribute_reply(call_id,timeout) + dev_attr = self[index]._attribute_dev.read_attribute_reply(call_id, timeout) values.append(dev_attr.value) return np.array(values) @@ -121,4 +135,4 @@ def unit(self) -> str: return "" def __repr__(self): - return repr(self._cfg).replace("ConfigModel",self.__class__.__name__) \ No newline at end of file + return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/tango/pyaml/tango_pyaml_utils.py b/tango/pyaml/tango_pyaml_utils.py index 899a935..fb48686 100644 --- a/tango/pyaml/tango_pyaml_utils.py +++ b/tango/pyaml/tango_pyaml_utils.py @@ -2,6 +2,13 @@ import pyaml +def to_float_or_none(s): + try: + return float(s) + except (TypeError, ValueError): + return None + + def tango_to_PyAMLException(df: tango.DevFailed) -> pyaml.PyAMLException: """ Convert a Tango DevFailed exception to a PyAMLException. @@ -16,9 +23,9 @@ def tango_to_PyAMLException(df: tango.DevFailed) -> pyaml.PyAMLException: pyaml.PyAMLException Converted exception including reason, description, origin and severity. """ - if len(df.args)>0: + if len(df.args) > 0: err = df.args[0] message = f"{err.reason}: {err.desc} Origin: {err.origin} Severity: {err.severity.name}" else: message = "Unknown tango error!" - return pyaml.PyAMLException(message) \ No newline at end of file + return pyaml.PyAMLException(message) diff --git a/tests/conftest.py b/tests/conftest.py index 7ef22ec..ac09f9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest import yaml -from tango.pyaml.attribute_list import AttributeList, ConfigModel as GrpCM +from tango.pyaml.attribute_list import ConfigModel as GrpCM from tango.pyaml.attribute import ConfigModel as AttrCM from tango.pyaml.multi_attribute import ConfigModel as MultiAttrCM from tango.pyaml.controlsystem import ConfigModel as CsCM, TangoControlSystem @@ -23,6 +23,29 @@ def config(): cfg_dict = yaml.safe_load(conf) return AttrCM(**cfg_dict) + +@pytest.fixture +def config_range(): + conf = """ +attribute: "sys/tg_test/1/float_scalar" +unit: "A" +range: [-15,15] +""" + cfg_dict = yaml.safe_load(conf) + return AttrCM(**cfg_dict) + + +@pytest.fixture +def config_range_with_null(): + conf = """ +attribute: "sys/tg_test/1/float_scalar" +unit: "A" +range: [0,null] +""" + cfg_dict = yaml.safe_load(conf) + return AttrCM(**cfg_dict) + + @pytest.fixture def config_group(): conf = """ @@ -36,6 +59,7 @@ def config_group(): cfg_dict = yaml.safe_load(conf) return GrpCM(**cfg_dict) + @pytest.fixture def config_multi(): conf = """ @@ -49,6 +73,7 @@ def config_multi(): cfg_dict = yaml.safe_load(conf) return MultiAttrCM(**cfg_dict) + @pytest.fixture def config_tango_cs(): conf = """ @@ -60,6 +85,7 @@ def config_tango_cs(): cfg_dict = yaml.safe_load(conf) return CsCM(**cfg_dict) + @pytest.fixture def config_tango_cs_lazy_default(): conf = """ @@ -70,6 +96,7 @@ def config_tango_cs_lazy_default(): cfg_dict = yaml.safe_load(conf) return CsCM(**cfg_dict) + @pytest.fixture def config_tango_cs_false(): conf = """ diff --git a/tests/mocked_control_system_initialized.py b/tests/mocked_control_system_initialized.py index 4e323d8..d069a9b 100644 --- a/tests/mocked_control_system_initialized.py +++ b/tests/mocked_control_system_initialized.py @@ -1,8 +1,7 @@ -from tango.pyaml.controlsystem import TangoControlSystem, ConfigModel +from tango.pyaml.controlsystem import TangoControlSystem class MockedControlSystemInitialized(TangoControlSystem): - @classmethod def is_initialized(cls): return True diff --git a/tests/mocked_device_proxy.py b/tests/mocked_device_proxy.py index 0530af6..7e2c220 100644 --- a/tests/mocked_device_proxy.py +++ b/tests/mocked_device_proxy.py @@ -2,13 +2,19 @@ import numpy as np from unittest.mock import MagicMock -from tango import ExtractAs - class MockedAttributeInfoEx: - def __init__(self, name, writable = tango._tango.AttrWriteType.READ_WRITE): + def __init__( + self, + name, + writable=tango.AttrWriteType.READ_WRITE, + min_value: str = "", + max_value: str = "", + ): self.name = name self.writable = writable + self.min_value = min_value + self.max_value = max_value class MockedDeviceAttribute: @@ -19,7 +25,7 @@ def __init__(self, name, value): self.quality = tango.AttrQuality.ATTR_VALID self.time = tango.TimeVal.now() if isinstance(value, np.ndarray): - if len(value.shape)==1: + if len(value.shape) == 1: self.data_format = tango.AttrDataFormat.SPECTRUM self.dim_x = value.shape[0] self.dim_y = 0 @@ -47,7 +53,7 @@ def __init__(self, device_name, *args, **kwargs): self.device_name = device_name self.values = { "state": MockedDeviceAttribute("state", tango.DevState.ON), - "status": MockedDeviceAttribute("status", "") + "status": MockedDeviceAttribute("status", ""), } self.asynch_values = {} @@ -60,10 +66,10 @@ def state(self) -> tango.DevState: def status(self) -> str: return self.read_attribute("status").value - def command_inout(self, cmd_name, cmd_param = None): + def command_inout(self, cmd_name, cmd_param=None): return None - def command_inout_asynch(self, cmd_name, cmd_param = None, forget = False): + def command_inout_asynch(self, cmd_name, cmd_param=None, forget=False): asynch_index = max(self.asynch_values.keys()) + 1 if not forget: self.asynch_values[asynch_index] = self.command_inout(cmd_name, cmd_param) @@ -73,7 +79,7 @@ def command_inout_reply(self, idx, timeout=None): val = self.asynch_values.pop(idx) return val - def read_attribute(self, attr_name:str): + def read_attribute(self, attr_name: str): if attr_name not in self.values.keys(): return MockedDeviceAttribute(attr_name, None) return self.values[attr_name] @@ -83,24 +89,26 @@ def write_attribute(self, attr_name, value): def read_attribute_asynch(self, attr_name) -> int: asynch_index = 0 - if len(self.asynch_values)>0: + if len(self.asynch_values) > 0: asynch_index = max(self.asynch_values.keys()) + 1 self.asynch_values[asynch_index] = self.read_attribute(attr_name) return asynch_index def write_attribute_asynch(self, attr_name, value) -> int: asynch_index = 0 - if len(self.asynch_values)>0: + if len(self.asynch_values) > 0: asynch_index = max(self.asynch_values.keys()) + 1 self.write_attribute(attr_name, value) self.asynch_values[asynch_index] = None return asynch_index - def read_attribute_reply(self, idx, extract_as=ExtractAs.Numpy, green_mode=None, wait=True) -> MockedDeviceAttribute: + def read_attribute_reply( + self, idx, extract_as=None, green_mode=None, wait=True + ) -> MockedDeviceAttribute: val = self.asynch_values.pop(idx) return val - #def read_attribute_reply(self, idx, poll_timeout, extract_as=ExtractAs.Numpy, green_mode=None, wait=True) -> MockedDeviceAttribute: + # def read_attribute_reply(self, idx, poll_timeout, extract_as=None, green_mode=None, wait=True) -> MockedDeviceAttribute: # return self.read_attribute_reply(idx, extract_as, green_mode, wait) def write_attribute_reply(self, idx, green_mode=None, wait=True): @@ -112,9 +120,15 @@ def write_attribute_reply(self, idx, green_mode=None, wait=True): def attribute_query(self, attr_name): return MockedAttributeInfoEx(attr_name) + def get_attribute_config(self, attr_name, wait=True): + return self.attribute_query(attr_name) + def attribute_list_query(self): pass + def set_timeout_millis(self, timeout: float): + pass + def ping(self, green_mode=None, wait=True, timeout=True) -> int: return 1 @@ -154,4 +168,4 @@ def get_device_proxy(self): return self.device_proxy def ping(self): - return self.device_proxy.ping() \ No newline at end of file + return self.device_proxy.ping() diff --git a/tests/mocked_group.py b/tests/mocked_group.py index 6f71656..e26d2d2 100644 --- a/tests/mocked_group.py +++ b/tests/mocked_group.py @@ -1,5 +1,6 @@ from .mocked_device_proxy import * + class MockedGroupReply: def __init__(self, dev_name, obj_name, error=None): self.dev_name = dev_name @@ -14,7 +15,9 @@ def __repr__(self): class MockedGroupAttrReply(MockedGroupReply): - def __init__(self, dev_name, obj_name, data:MockedDeviceAttribute = None, error=None): + def __init__( + self, dev_name, obj_name, data: MockedDeviceAttribute = None, error=None + ): super().__init__(dev_name, obj_name, error) self.data = data @@ -23,7 +26,7 @@ def get_data(self) -> MockedDeviceAttribute: class MockedGroupCmdReply(MockedGroupReply): - def __init__(self, dev_name, obj_name, data = None, error=None): + def __init__(self, dev_name, obj_name, data=None, error=None): super().__init__(dev_name, obj_name, error) self.data = data @@ -73,21 +76,18 @@ def read_attribute(self, attr_name) -> list[MockedGroupAttrReply]: replies.append(MockedGroupAttrReply(name, attr_name, dev_attr)) return replies - def read_attribute_asynch(self, attr_name) -> int: asynch_index = 0 - if len(self.asynch_values)>0: + if len(self.asynch_values) > 0: asynch_index = max(self.asynch_values.keys()) + 1 self.asynch_values[asynch_index] = self.read_attribute(attr_name) return asynch_index - def read_attribute_reply(self, idx): val = self.asynch_values[idx] del self.asynch_values[idx] return val - def write_attribute(self, attr_name, value): replies = [] for name, dev in self.devices.items(): @@ -98,15 +98,13 @@ def write_attribute(self, attr_name, value): replies.append(MockedGroupReply(name, attr_name, e)) return replies - def write_attribute_asynch(self, attr_name, value) -> int: asynch_index = 0 - if len(self.asynch_values)>0: + if len(self.asynch_values) > 0: asynch_index = max(self.asynch_values.keys()) + 1 self.write_attribute(attr_name, value) self.asynch_values[asynch_index] = None return asynch_index - def write_attribute_reply(self, idx): del self.asynch_values[idx] diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 64a7d58..a983a34 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -13,18 +13,26 @@ class MockedReadExceptDeviceProxy(MockedDeviceProxy): def read_attribute(self, name): - raise tango.Except.throw_exception("mocked reason", f"mocked desc for attr {name}", "mocked origin") + raise tango.Except.throw_exception( + "mocked reason", f"mocked desc for attr {name}", "mocked origin" + ) + class MockedROAttrDeviceProxy(MockedDeviceProxy): def attribute_query(self, name): - attr_ro_info = MockedAttributeInfoEx(name, tango._tango.AttrWriteType.READ) + attr_ro_info = MockedAttributeInfoEx(name, tango.AttrWriteType.READ) return attr_ro_info -class TestAttributes: +class TestAttributes: def test_attribute_get_set(self, config): - with (patch("tango.DeviceProxy", new=MockedDeviceProxy), - patch("tango.pyaml.attribute.TangoControlSystem", new=MockedControlSystemInitialized)): + with ( + patch("tango.DeviceProxy", new=MockedDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): attr = Attribute(config) attr.set_and_wait(42.0) assert attr.get() == 42.0 @@ -37,23 +45,34 @@ def test_attribute_get_set(self, config): assert attr.name() == "sys/tg_test/1/float_scalar" assert attr.measure_name() == "float_scalar" - def test_attribute_except(self, config): - with (patch("tango.DeviceProxy", new=MockedReadExceptDeviceProxy), - patch("tango.pyaml.attribute.TangoControlSystem", new=MockedControlSystemInitialized)): + with ( + patch("tango.DeviceProxy", new=MockedReadExceptDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): attr = Attribute(config) with pytest.raises(pyaml.PyAMLException) as exc: attr.readback() assert exc is not None - def test_attribute_read_only(self, config): - with (patch("tango.DeviceProxy", new=MockedROAttrDeviceProxy), - patch("tango.pyaml.attribute.TangoControlSystem", new=MockedControlSystemInitialized)): + with ( + patch("tango.DeviceProxy", new=MockedROAttrDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): # Cannot create an attribute with a read-only tango attribute. - expected_message = 'Tango attribute sys/tg_test/1/float_scalar is not writable.' + expected_message = ( + "Tango attribute sys/tg_test/1/float_scalar is not writable." + ) + attr1 = Attribute(config) with pytest.raises(pyaml.PyAMLException) as exc: - Attribute(config) + attr1.get() assert exc.value.message == expected_message # Read-only attributes cannot be sets. @@ -62,22 +81,28 @@ def test_attribute_read_only(self, config): attr.set(10) assert exc2.value.message == expected_message - with pytest.raises(pyaml.PyAMLException) as exc3: - attr.get() - assert exc3.value.message == expected_message - def test_group_read_write(self, config_group): - with (patch("tango.Group", new=MockedGroup), - patch("tango.pyaml.attribute_list.TangoControlSystem", new=MockedControlSystemInitialized)): - attr_list = AttributeList(config_group) - attr_list.set_and_wait(10) - vals = attr_list.readback() - for val in vals: - assert val == 10 + with ( + patch("tango.Group", new=MockedGroup), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr_list = AttributeList(config_group) + attr_list.set_and_wait(10) + vals = attr_list.readback() + for val in vals: + assert val == 10 def test_unique_device(self, config): - with (patch("tango.DeviceProxy", new=MockedDeviceProxy), - patch("tango.pyaml.attribute.TangoControlSystem", new=MockedControlSystemInitialized)): + with ( + patch("tango.DeviceProxy", new=MockedDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): attr1 = Attribute(config) attr2 = Attribute(config) assert attr1._attribute_dev is attr2._attribute_dev diff --git a/tests/test_attribute_range.py b/tests/test_attribute_range.py new file mode 100644 index 0000000..a4d9119 --- /dev/null +++ b/tests/test_attribute_range.py @@ -0,0 +1,85 @@ +from .mocked_device_proxy import * + +from unittest.mock import patch +from tango.pyaml.attribute import Attribute +from .mocked_control_system_initialized import MockedControlSystemInitialized + + +class MockedMinMaxAttrDeviceProxy(MockedDeviceProxy): + def attribute_query(self, name): + attr_info = MockedAttributeInfoEx( + name, tango.AttrWriteType.READ_WRITE, "-10", "10" + ) + return attr_info + + +class MockedMinAttrDeviceProxy(MockedDeviceProxy): + def attribute_query(self, name): + attr_info = MockedAttributeInfoEx( + name, tango.AttrWriteType.READ_WRITE, "-10", "" + ) + return attr_info + + +def test_attribute_range_by_conf(config_range): + with ( + patch("tango.DeviceProxy", new=MockedDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr = Attribute(config_range) + + attr_range = attr.get_range() + assert attr_range is not None + assert len(attr_range) == 2 + assert attr_range[0] == -15 and attr_range[1] == 15 + + +def test_attribute_range_by_conf_with_null(config_range_with_null): + with ( + patch("tango.DeviceProxy", new=MockedDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr = Attribute(config_range_with_null) + + attr_range = attr.get_range() + assert attr_range is not None + assert len(attr_range) == 2 + assert attr_range[0] == 0 and attr_range[1] == None + + +def test_attribute_range_by_device(config): + with ( + patch("tango.DeviceProxy", new=MockedMinMaxAttrDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr = Attribute(config) + + attr_range = attr.get_range() + assert attr_range is not None + assert len(attr_range) == 2 + assert attr_range[0] == -10 and attr_range[1] == 10 + + +def test_attribute_range_by_device_min_only(config): + with ( + patch("tango.DeviceProxy", new=MockedMinAttrDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr = Attribute(config) + + attr_range = attr.get_range() + assert attr_range is not None + assert len(attr_range) == 2 + assert attr_range[0] == -10 and attr_range[1] == None diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index cd5dcd3..b04735f 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,84 +1,33 @@ import logging -import os -import pyaml -import pytest from tango.pyaml.controlsystem import TangoControlSystem -from tango.pyaml.attribute_read_only import AttributeReadOnly from .mocked_device_proxy import * from unittest.mock import patch from tango.pyaml.attribute import Attribute -from tango.pyaml.attribute_list import AttributeList def test_init_cs(caplog, config_tango_cs): # Capture logs with caplog.at_level(logging.INFO): tango_cs = TangoControlSystem(config_tango_cs) - tango_cs.init_cs() - # Check tango host - assert os.environ["TANGO_HOST"] == "tangodb:10000" - - expected_message = (f"Tango control system binding for PyAML initialized with name '{config_tango_cs.name}'" - f" and TANGO_HOST={config_tango_cs.tango_host}") - - # Check that the INFO init message was actually logged with correct values - assert any(expected_message == record.message for record in caplog.records) - -def test_cs_singleton(caplog, config_tango_cs, config_tango_cs_false): - tango_cs1 = TangoControlSystem(config_tango_cs) - tango_cs1.init_cs() - - with caplog.at_level(logging.WARNING): - tango_cs2 = TangoControlSystem(config_tango_cs_false) - tango_cs2.init_cs() - - # Check tango host matches tango_cs1 - assert tango_cs1 is tango_cs2 - assert os.environ["TANGO_HOST"] == "tangodb:10000" - expected_message = (f"Tango control system binding for PyAML was already initialized" - f" with name '{config_tango_cs.name}' and TANGO_HOST={config_tango_cs.tango_host}") + expected_message = ( + f"Tango control system binding for PyAML initialized with name '{config_tango_cs.name}'" + f" and TANGO_HOST={config_tango_cs.tango_host}" + ) # Check that the INFO init message was actually logged with correct values assert any(expected_message == record.message for record in caplog.records) -def test_init_cs_attribute(config_tango_cs, config): - tango_cs = TangoControlSystem(config_tango_cs) - with patch("tango.DeviceProxy", new=MockedDeviceProxy): - attr = Attribute(config) - with pytest.raises(pyaml.PyAMLException) as exc: - attr.set_and_wait(42.0) - expected_message = f"The attribute {attr.name()} is not initialized." - assert exc.value.message == expected_message - tango_cs.init_cs() - attr.set_and_wait(42.0) - assert attr.get() == 42.0 - def test_laziness_init_cs_attribute(config_tango_cs_lazy_default, config): - tango_cs = TangoControlSystem(config_tango_cs_lazy_default) with patch("tango.DeviceProxy", side_effect=MockedDeviceProxy) as mock_ctor: attr = Attribute(config) mock_ctor.assert_not_called() - tango_cs.init_cs() - mock_ctor.assert_not_called() attr.set_and_wait(42.0) mock_ctor.assert_called_once() attr.set_and_wait(42.0) mock_ctor.assert_called_once() assert attr.get() == 42.0 - - -def test_laziness_init_cs_go_eager(config_tango_cs_lazy_default, config): - tango_cs = TangoControlSystem(config_tango_cs_lazy_default) - with patch("tango.DeviceProxy", side_effect=MockedDeviceProxy) as mock_ctor: - attr = Attribute(config) - mock_ctor.assert_not_called() - tango_cs.init_cs() - tango_cs.warmup() - mock_ctor.assert_called_once() - attr.set_and_wait(42.0) - assert attr.get() == 42.0 diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 4338110..2385282 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -1,5 +1,6 @@ from tango.pyaml.device_factory import DeviceFactory + def test_factory(): factory1 = DeviceFactory() factory2 = DeviceFactory() diff --git a/tests/test_multi_attribute.py b/tests/test_multi_attribute.py index 166b62c..a56a748 100644 --- a/tests/test_multi_attribute.py +++ b/tests/test_multi_attribute.py @@ -1,24 +1,26 @@ import random -from tango.pyaml.attribute_read_only import AttributeReadOnly from .mocked_control_system_initialized import MockedControlSystemInitialized from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch -from tango.pyaml.attribute import Attribute from tango.pyaml.multi_attribute import MultiAttribute class TestMultiAttributes: - def test_multi_read_write(self, config_multi): - with (patch("tango.DeviceProxy", new=MockedDeviceProxy), - patch("tango.pyaml.attribute.TangoControlSystem", new=MockedControlSystemInitialized)): - attr_list = MultiAttribute(config_multi) - rand = random.Random() - values = [rand.random() for _ in range(4)] - attr_list.set_and_wait(values) - vals = attr_list.readback() - assert len(vals)==len(values) - for index, val in enumerate(vals): - assert val == values[index] + with ( + patch("tango.DeviceProxy", new=MockedDeviceProxy), + patch( + "tango.pyaml.controlsystem.TangoControlSystem", + new=MockedControlSystemInitialized, + ), + ): + attr_list = MultiAttribute(config_multi) + rand = random.Random() + values = [rand.random() for _ in range(4)] + attr_list.set(values) + vals = attr_list.readback() + assert len(vals) == len(values) + for index, val in enumerate(vals): + assert val == values[index]