From 8ca4654fa3082759521a524cbfb227fd46eb915f Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Tue, 21 Apr 2026 15:37:54 +0200 Subject: [PATCH 01/36] Add catalog support to Tango control system --- tango/pyaml/controlsystem.py | 10 +++++-- tests/test_controlsystem.py | 53 ++++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 744eb35..16e90c3 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,7 +1,8 @@ import logging import copy -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict +from pyaml.configuration.catalog import Catalog from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from . import __version__ @@ -21,6 +22,8 @@ class ConfigModel(BaseModel): Name of the control system. tango_host : str Tango host URL. Default is the TANGO_HOST variable. + catalog : Catalog | str | None + Catalog instance or catalog name used to resolve PyAML device keys. debug_level : int Debug verbosity level. scalar_aggregator : str @@ -31,9 +34,12 @@ class ConfigModel(BaseModel): Device timeout in milli seconds. """ + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + name: str tango_host: str | None = None - debug_level: str = None + catalog: Catalog | str | None = None + debug_level: str | None = None lazy_devices: bool = True scalar_aggregator: str | None = "tango.pyaml.multi_attribute" vector_aggregator: str | None = None diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index f8f7255..fe0a19b 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,11 +1,19 @@ import logging -from tango.pyaml.controlsystem import TangoControlSystem +from pyaml.configuration.static_catalog import ConfigModel as StaticCatalogConfigModel +from pyaml.configuration.static_catalog import StaticCatalog +from pyaml.configuration.static_catalog_entry import ( + ConfigModel as StaticCatalogEntryConfigModel, +) +from pyaml.configuration.static_catalog_entry import StaticCatalogEntry + +from tango.pyaml.controlsystem import ConfigModel, TangoControlSystem from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch -from tango.pyaml.attribute import Attribute +from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel +from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml import __version__ @@ -32,3 +40,44 @@ def test_laziness_init_cs_attribute(config_tango_cs_lazy_default, config): attr.set_and_wait(42.0) mock_ctor.assert_called_once() assert attr.get() == 42.0 + + +def test_catalog_can_be_configured_and_resolved(): + device = AttributeReadOnly( + AttributeConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") + ) + catalog = StaticCatalog( + StaticCatalogConfigModel( + name="device-catalog", + entries=[ + StaticCatalogEntry( + StaticCatalogEntryConfigModel( + key="BPM_C01-01/x", + device=device, + ) + ) + ], + ) + ) + cs = TangoControlSystem( + ConfigModel( + name="test_tango_cs", + tango_host="tangodb:10000", + catalog=catalog, + ) + ) + + cs.set_catalog(catalog) + resolved = cs.resolve_device("BPM_C01-01/x") + attached = cs.attach([resolved])[0] + + assert cs.get_catalog_config() is catalog + assert cs.get_catalog() is catalog + assert resolved is device + assert attached.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" + + +def test_named_catalog_config_is_accepted(): + cfg = ConfigModel(name="test_tango_cs", catalog="device-catalog") + + assert cfg.catalog == "device-catalog" From 334f3888e7665a9a71a3ae932cb0804a517fb4d9 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Tue, 21 Apr 2026 17:32:57 +0200 Subject: [PATCH 02/36] Add Tango direct attribute catalog --- tango/pyaml/tango_catalog.py | 135 +++++++++++++++++++++++++++++++ tests/mocked_device_proxy.py | 11 ++- tests/test_tango_catalog.py | 151 +++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 tango/pyaml/tango_catalog.py create mode 100644 tests/test_tango_catalog.py diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py new file mode 100644 index 0000000..cc2121d --- /dev/null +++ b/tango/pyaml/tango_catalog.py @@ -0,0 +1,135 @@ +import tango +import pyaml + +from pydantic import ConfigDict +from pyaml.configuration.catalog import Catalog, CatalogConfigModel, CatalogResolver +from pyaml.control.deviceaccess import DeviceAccess + +from .attribute import Attribute, ConfigModel as AttributeConfigModel +from .attribute_read_only import AttributeReadOnly +from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none + +PYAMLCLASS = "TangoCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Configuration model for a Tango catalog. + + Attributes + ---------- + name : str + Catalog identifier. + disconnected : bool + If true, resolve Tango attribute names without querying Tango. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + disconnected: bool = False + + +class TangoCatalog(Catalog): + """ + Catalog resolving keys that are direct Tango attribute references. + """ + + def resolve(self, key: str) -> DeviceAccess: + raise pyaml.PyAMLException( + f"Tango catalog '{self.get_name()}' must be attached to a TangoControlSystem " + f"before resolving key '{key}'" + ) + + def attach_control_system(self, control_system): + from .controlsystem import TangoControlSystem + + if not isinstance(control_system, TangoControlSystem): + raise pyaml.PyAMLException( + f"Tango catalog '{self.get_name()}' can only be attached to TangoControlSystem" + ) + return TangoCatalogResolver(self, control_system) + + +class TangoCatalogResolver(CatalogResolver): + """ + Resolver bound to one TangoControlSystem. + """ + + _WRITABLE_TYPES = { + tango.AttrWriteType.READ_WRITE, + tango.AttrWriteType.WRITE, + tango.AttrWriteType.READ_WITH_WRITE, + } + + def __init__(self, catalog: TangoCatalog, control_system): + self._catalog = catalog + self._control_system = control_system + # Resolved DeviceAccess objects are bound to one control system context, + # so cache them in the resolver returned by attach_control_system(). + self._refs: dict[str, DeviceAccess] = {} + self._data_formats: dict[str, tango.AttrDataFormat] = {} + + def resolve(self, key: str) -> DeviceAccess: + """ + Resolve a Tango attribute reference into a DeviceAccess. + """ + self._validate_key(key) + + if key not in self._refs: + if self._catalog._cfg.disconnected: + self._refs[key] = self._build_disconnected_attribute(key) + else: + self._refs[key] = self._build_connected_attribute(key) + + return self._refs[key] + + def get_data_format(self, key: str) -> tango.AttrDataFormat: + """ + Return the Tango data format for a resolved attribute. + """ + self.resolve(key) + return self._data_formats[key] + + def _validate_key(self, key: str): + if not isinstance(key, str): + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' expects string keys, got {type(key).__name__}" + ) + + parts = key.split("/") + if len(parts) != 4 or any(part == "" for part in parts): + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' cannot resolve invalid Tango attribute " + f"reference '{key}'. Expected 'domain/family/member/attribute'." + ) + + def _build_disconnected_attribute(self, key: str) -> DeviceAccess: + # In disconnected mode, keep all metadata local. In particular, setting + # range avoids Attribute.get_range() from lazily querying Tango later. + self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN + return Attribute(AttributeConfigModel(attribute=key, range=(None, None))) + + def _build_connected_attribute(self, key: str) -> DeviceAccess: + try: + # AttributeProxy.get_config() is the most direct way to retrieve + # writability, unit, range and data format from Tango. + attr_config = tango.AttributeProxy(key).get_config() + except tango.DevFailed as df: + pyaml_exception = tango_to_PyAMLException(df) + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" + ) from df + + unit = getattr(attr_config, "unit", "") or "" + self._data_formats[key] = getattr( + attr_config, "data_format", tango.AttrDataFormat.FMT_UNKNOWN + ) + attr_range = ( + to_float_or_none(getattr(attr_config, "min_value", None)), + to_float_or_none(getattr(attr_config, "max_value", None)), + ) + cfg = AttributeConfigModel(attribute=key, unit=unit, range=attr_range) + + if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: + return Attribute(cfg) + return AttributeReadOnly(cfg) diff --git a/tests/mocked_device_proxy.py b/tests/mocked_device_proxy.py index 7e2c220..f6d7f7f 100644 --- a/tests/mocked_device_proxy.py +++ b/tests/mocked_device_proxy.py @@ -10,11 +10,15 @@ def __init__( writable=tango.AttrWriteType.READ_WRITE, min_value: str = "", max_value: str = "", + unit: str = "", + data_format=tango.AttrDataFormat.SCALAR, ): self.name = name self.writable = writable + self.unit = unit self.min_value = min_value self.max_value = max_value + self.data_format = data_format class MockedDeviceAttribute: @@ -134,13 +138,18 @@ def ping(self, green_mode=None, wait=True, timeout=True) -> int: class MockedAttributeProxy(MagicMock): - def __init__(self, attr_full_name, *args, **kwargs): + def __init__(self, attr_full_name, attr_config=None, *args, **kwargs): super().__init__(*args, **kwargs) self.attr_full_name = attr_full_name self.device_name, self._attr_name = attr_full_name.rsplit("/", 1) self.device_proxy = MockedDeviceProxy(self.device_name) + # Tests can inject a specific config to exercise catalog metadata + # handling without creating a dedicated proxy class each time. + self.attr_config = attr_config def get_config(self, *args, **kwds): + if self.attr_config is not None: + return self.attr_config return self.device_proxy.get_attribute_config(self.name(), *args, **kwds) def read(self, *args, **kwds): diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py new file mode 100644 index 0000000..60ad14e --- /dev/null +++ b/tests/test_tango_catalog.py @@ -0,0 +1,151 @@ +from unittest.mock import patch + +import pyaml +import pytest +import tango +from pyaml.control.controlsystem import ControlSystemAdapter + +from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy +from tango.pyaml.attribute import Attribute +from tango.pyaml.attribute_read_only import AttributeReadOnly +from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel +from tango.pyaml.controlsystem import TangoControlSystem +from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog + + +def build_resolver(catalog: TangoCatalog, name="live"): + control_system = TangoControlSystem(TangoControlSystemConfigModel(name=name)) + return catalog.attach_control_system(control_system) + + +def test_tango_catalog_disconnected_resolves_without_querying_tango(): + catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) + resolver = build_resolver(catalog) + + with patch("tango.AttributeProxy") as attr_proxy: + device = resolver.resolve("domain/family/member/attribute") + + attr_proxy.assert_not_called() + assert isinstance(device, Attribute) + assert device.name() == "domain/family/member/attribute" + assert device.unit() == "" + assert device.get_range() == [None, None] + + +def test_tango_catalog_connected_resolves_writable_attribute(): + attr_config = MockedAttributeInfoEx( + name="current", + writable=tango.AttrWriteType.READ_WRITE, + unit="A", + min_value="-10.5", + max_value="12.0", + data_format=tango.AttrDataFormat.SPECTRUM, + ) + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/current", attr_config), + ): + device = resolver.resolve("domain/family/member/current") + + assert isinstance(device, Attribute) + assert not isinstance(device, AttributeReadOnly) + assert device.name() == "domain/family/member/current" + assert device.unit() == "A" + assert device.get_range() == [-10.5, 12.0] + assert resolver.get_data_format("domain/family/member/current") == tango.AttrDataFormat.SPECTRUM + + +def test_tango_catalog_connected_resolves_read_only_attribute(): + attr_config = MockedAttributeInfoEx(name="position", writable=tango.AttrWriteType.READ, unit="mm") + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + device = resolver.resolve("domain/family/member/position") + + assert isinstance(device, AttributeReadOnly) + assert device.unit() == "mm" + + +def test_tango_catalog_caches_resolved_devices(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/attribute"), + ) as attr_proxy: + first = resolver.resolve("domain/family/member/attribute") + second = resolver.resolve("domain/family/member/attribute") + + attr_proxy.assert_called_once_with("domain/family/member/attribute") + assert first is second + + +def test_tango_catalog_cache_is_bound_to_control_system_resolver(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + live_resolver = build_resolver(catalog, name="live") + ops_resolver = build_resolver(catalog, name="ops") + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/attribute"), + ) as attr_proxy: + live_first = live_resolver.resolve("domain/family/member/attribute") + live_second = live_resolver.resolve("domain/family/member/attribute") + ops_device = ops_resolver.resolve("domain/family/member/attribute") + + assert attr_proxy.call_count == 2 + assert live_first is live_second + assert ops_device is not live_first + + +def test_tango_catalog_can_be_used_through_tango_control_system(): + catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) + control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) + control_system.set_catalog(catalog) + + device = control_system.resolve_device("domain/family/member/attribute") + + assert isinstance(device, Attribute) + assert control_system.get_catalog() is catalog + + +def test_tango_catalog_rejects_non_tango_control_system(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + + with pytest.raises(pyaml.PyAMLException, match="can only be attached to TangoControlSystem"): + catalog.attach_control_system(ControlSystemAdapter()) + + +def test_tango_catalog_requires_control_system_attachment(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + + with pytest.raises(pyaml.PyAMLException, match="must be attached to a TangoControlSystem"): + catalog.resolve("domain/family/member/attribute") + + +def test_tango_catalog_rejects_invalid_tango_reference(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with pytest.raises(pyaml.PyAMLException, match="Expected 'domain/family/member/attribute'"): + resolver.resolve("domain/family/member") + + +def test_tango_catalog_wraps_tango_errors(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): + with pytest.raises( + pyaml.PyAMLException, + match="Tango catalog 'tango-direct' cannot resolve 'domain/family/member/attribute'", + ): + resolver.resolve("domain/family/member/attribute") From 77b0a5cc85c0fb4d74cdf5b1a8b912707f93586c Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 24 Apr 2026 16:04:05 +0200 Subject: [PATCH 03/36] Add static catalog (tango.pyaml.static_catalog) and indexed attribute support (tango.pyaml.attribute_indexed). TangoCatalog handles the attr@index syntax with SPECTRUM validation in connected mode. --- tango/pyaml/static_catalog.py | 82 +++++++++++++++ tango/pyaml/static_catalog_entry.py | 45 ++++++++ tango/pyaml/tango_catalog.py | 142 ++++++++++++++++++++++++-- tests/test_attribute_indexed.py | 153 ++++++++++++++++++++++++++++ tests/test_controlsystem.py | 11 +- tests/test_static_catalog.py | 148 +++++++++++++++++++++++++++ tests/test_tango_catalog.py | 103 +++++++++++++++++++ 7 files changed, 669 insertions(+), 15 deletions(-) create mode 100644 tango/pyaml/static_catalog.py create mode 100644 tango/pyaml/static_catalog_entry.py create mode 100644 tests/test_attribute_indexed.py create mode 100644 tests/test_static_catalog.py diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py new file mode 100644 index 0000000..4a23ce0 --- /dev/null +++ b/tango/pyaml/static_catalog.py @@ -0,0 +1,82 @@ +from pydantic import ConfigDict + +from pyaml import PyAMLException +from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pyaml.control.deviceaccess import DeviceAccess + +from .static_catalog_entry import StaticCatalogEntry + +PYAMLCLASS = "StaticCatalog" + + +class ConfigModel(CatalogConfigModel): + """ + Configuration model for a static catalog. + + Attributes + ---------- + name : str + Catalog identifier. + entries : list[StaticCatalogEntry] + Explicit list of key-to-device mappings. Must contain at least one + entry, and keys must be unique within the catalog. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + entries: list[StaticCatalogEntry] + + +class StaticCatalog(Catalog): + """ + Catalog backed by a fixed list of key-to-device mappings. + + All entries are validated at construction time: the list must be + non-empty and every key must be unique. Resolution is an O(1) dictionary + lookup; no Tango connection is required. + + Parameters + ---------- + cfg : ConfigModel + Configuration containing the catalog name and its entries. + + Raises + ------ + pyaml.PyAMLException + If ``cfg.entries`` is empty or contains duplicate keys. + """ + + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) + if len(cfg.entries) == 0: + raise PyAMLException("StaticCatalog.entries must contain at least one entry") + self._refs: dict[str, DeviceAccess] = {} + for entry in cfg.entries: + key = entry.get_key() + if key in self._refs: + raise PyAMLException(f"StaticCatalog.entries contains duplicate key '{key}'") + self._refs[key] = entry.get_device() + + def resolve(self, key: str) -> DeviceAccess: + """ + Return the device associated with ``key``. + + Parameters + ---------- + key : str + Catalog key to resolve. + + Returns + ------- + DeviceAccess + The device access object registered under ``key``. + + Raises + ------ + pyaml.PyAMLException + If ``key`` is not present in the catalog. + """ + try: + return self._refs[key] + except KeyError as exc: + raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc diff --git a/tango/pyaml/static_catalog_entry.py b/tango/pyaml/static_catalog_entry.py new file mode 100644 index 0000000..600fcb1 --- /dev/null +++ b/tango/pyaml/static_catalog_entry.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, ConfigDict + +from pyaml.control.deviceaccess import DeviceAccess + +PYAMLCLASS = "StaticCatalogEntry" + + +class ConfigModel(BaseModel): + """ + Configuration model for a static catalog entry. + + Attributes + ---------- + key : str + Catalog key used to look up the device. + device : DeviceAccess + Device access object returned when the key is resolved. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + key: str + device: DeviceAccess + + +class StaticCatalogEntry: + """ + A single key-to-device mapping in a static catalog. + + Parameters + ---------- + cfg : ConfigModel + Configuration containing the key and device. + """ + + def __init__(self, cfg: ConfigModel): + self._cfg = cfg + + def get_key(self) -> str: + """Return the catalog key for this entry.""" + return self._cfg.key + + def get_device(self) -> DeviceAccess: + """Return the device access object associated with this entry.""" + return self._cfg.device diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index cc2121d..0fcc937 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -7,6 +7,8 @@ from .attribute import Attribute, ConfigModel as AttributeConfigModel from .attribute_read_only import AttributeReadOnly +from .attribute_indexed import AttributeIndexed, ConfigModel as IndexedConfigModel +from .attribute_indexed_read_only import AttributeIndexedReadOnly from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none PYAMLCLASS = "TangoCatalog" @@ -32,6 +34,10 @@ class ConfigModel(CatalogConfigModel): class TangoCatalog(Catalog): """ Catalog resolving keys that are direct Tango attribute references. + + Keys can be plain Tango attribute paths (``domain/family/member/attribute``) + or indexed references into a SPECTRUM attribute + (``domain/family/member/attribute@index``). """ def resolve(self, key: str) -> DeviceAccess: @@ -53,6 +59,19 @@ def attach_control_system(self, control_system): class TangoCatalogResolver(CatalogResolver): """ Resolver bound to one TangoControlSystem. + + Supports two key formats: + + - ``domain/family/member/attribute`` — resolves to a scalar + :class:`~tango.pyaml.attribute.Attribute` or + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`. + - ``domain/family/member/attribute@index`` — resolves to a scalar view + of one element in a SPECTRUM attribute + (:class:`~tango.pyaml.attribute_indexed.AttributeIndexed` or + :class:`~tango.pyaml.attribute_indexed_read_only.AttributeIndexedReadOnly`). + + In connected mode (``disconnected=False``) indexed keys additionally verify + that the Tango attribute is a SPECTRUM. """ _WRITABLE_TYPES = { @@ -72,43 +91,113 @@ def __init__(self, catalog: TangoCatalog, control_system): def resolve(self, key: str) -> DeviceAccess: """ Resolve a Tango attribute reference into a DeviceAccess. + + Parameters + ---------- + key : str + Plain attribute path or indexed path (``attribute@index``). + + Returns + ------- + DeviceAccess + Resolved device access, cached for subsequent calls. + + Raises + ------ + pyaml.PyAMLException + If the key is malformed, the Tango call fails, or (in connected + mode) an indexed key targets a non-SPECTRUM attribute. """ - self._validate_key(key) + attr_path, index = self._parse_key(key) if key not in self._refs: - if self._catalog._cfg.disconnected: - self._refs[key] = self._build_disconnected_attribute(key) + if index is not None: + if self._catalog._cfg.disconnected: + self._refs[key] = self._build_disconnected_indexed(attr_path, index) + else: + self._refs[key] = self._build_connected_indexed(attr_path, index) else: - self._refs[key] = self._build_connected_attribute(key) + if self._catalog._cfg.disconnected: + self._refs[key] = self._build_disconnected_attribute(key) + else: + self._refs[key] = self._build_connected_attribute(key) return self._refs[key] def get_data_format(self, key: str) -> tango.AttrDataFormat: """ Return the Tango data format for a resolved attribute. + + Parameters + ---------- + key : str + Catalog key (must have been resolved at least once, or will be + resolved now). + + Returns + ------- + tango.AttrDataFormat + Data format reported by Tango, or ``FMT_UNKNOWN`` in disconnected + mode. """ self.resolve(key) return self._data_formats[key] - def _validate_key(self, key: str): + def _parse_key(self, key: str) -> tuple[str, int | None]: + """ + Validate and split a catalog key into ``(attr_path, index)``. + + The ``index`` is ``None`` for plain attribute paths and an integer for + indexed paths (``attr_path@index``). + + Raises + ------ + pyaml.PyAMLException + If the key is not a string, the attribute path does not have + exactly four slash-separated components, or the index suffix is + not a valid integer. + """ if not isinstance(key, str): raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' expects string keys, got {type(key).__name__}" + f"Tango catalog '{self._catalog.get_name()}' expects string keys, " + f"got {type(key).__name__}" ) - parts = key.split("/") + if "@" in key: + attr_path, idx_str = key.rsplit("@", 1) + try: + index = int(idx_str) + except ValueError: + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' invalid index " + f"'{idx_str}' in key '{key}'." + ) + else: + attr_path = key + index = None + + parts = attr_path.split("/") if len(parts) != 4 or any(part == "" for part in parts): raise pyaml.PyAMLException( f"Tango catalog '{self._catalog.get_name()}' cannot resolve invalid Tango attribute " - f"reference '{key}'. Expected 'domain/family/member/attribute'." + f"reference '{key}'. Expected 'domain/family/member/attribute' or " + f"'domain/family/member/attribute@index'." ) + return attr_path, index + def _build_disconnected_attribute(self, key: str) -> DeviceAccess: # In disconnected mode, keep all metadata local. In particular, setting # range avoids Attribute.get_range() from lazily querying Tango later. self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN return Attribute(AttributeConfigModel(attribute=key, range=(None, None))) + def _build_disconnected_indexed(self, attr_path: str, index: int) -> DeviceAccess: + # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. + key = f"{attr_path}@{index}" + self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN + return AttributeIndexed(IndexedConfigModel(attribute=attr_path, index=index, range=(None, None))) + def _build_connected_attribute(self, key: str) -> DeviceAccess: try: # AttributeProxy.get_config() is the most direct way to retrieve @@ -133,3 +222,40 @@ def _build_connected_attribute(self, key: str) -> DeviceAccess: if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: return Attribute(cfg) return AttributeReadOnly(cfg) + + def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: + """ + Build an indexed device access after verifying the attribute is a SPECTRUM. + + Raises + ------ + pyaml.PyAMLException + If the Tango call fails or the attribute is not a SPECTRUM. + """ + key = f"{attr_path}@{index}" + try: + attr_config = tango.AttributeProxy(attr_path).get_config() + except tango.DevFailed as df: + pyaml_exception = tango_to_PyAMLException(df) + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" + ) from df + + data_format = getattr(attr_config, "data_format", tango.AttrDataFormat.FMT_UNKNOWN) + if data_format != tango.AttrDataFormat.SPECTRUM: + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' cannot use '{key}' as an indexed " + "key: the Tango attribute is not a SPECTRUM." + ) + + unit = getattr(attr_config, "unit", "") or "" + self._data_formats[key] = tango.AttrDataFormat.SPECTRUM + attr_range = ( + to_float_or_none(getattr(attr_config, "min_value", None)), + to_float_or_none(getattr(attr_config, "max_value", None)), + ) + cfg = IndexedConfigModel(attribute=attr_path, index=index, unit=unit, range=attr_range) + + if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: + return AttributeIndexed(cfg) + return AttributeIndexedReadOnly(cfg) diff --git a/tests/test_attribute_indexed.py b/tests/test_attribute_indexed.py new file mode 100644 index 0000000..2fc8a6c --- /dev/null +++ b/tests/test_attribute_indexed.py @@ -0,0 +1,153 @@ +import numpy as np +import pytest +import tango +from unittest.mock import patch + +import pyaml + +from tango.pyaml.attribute_indexed import AttributeIndexed, ConfigModel +from tango.pyaml.attribute_indexed_read_only import AttributeIndexedReadOnly +from .mocked_device_proxy import MockedAttributeInfoEx, MockedDeviceProxy, MockedDeviceAttribute + + +SPECTRUM_ARRAY = np.array([10.0, 20.0, 30.0]) + + +class MockedSpectrumDeviceProxy(MockedDeviceProxy): + """DeviceProxy that returns a SPECTRUM (READ_WRITE) attribute.""" + + def attribute_query(self, name): + return MockedAttributeInfoEx( + name, + writable=tango.AttrWriteType.READ_WRITE, + data_format=tango.AttrDataFormat.SPECTRUM, + unit="mm", + ) + + def read_attribute(self, name): + return MockedDeviceAttribute(name, SPECTRUM_ARRAY) + + +class MockedSpectrumRODeviceProxy(MockedDeviceProxy): + """DeviceProxy that returns a SPECTRUM (READ) attribute.""" + + def attribute_query(self, name): + return MockedAttributeInfoEx( + name, + writable=tango.AttrWriteType.READ, + data_format=tango.AttrDataFormat.SPECTRUM, + unit="mm", + ) + + def read_attribute(self, name): + return MockedDeviceAttribute(name, SPECTRUM_ARRAY) + + +class MockedScalarDeviceProxy(MockedDeviceProxy): + """DeviceProxy that returns a SCALAR attribute.""" + + def attribute_query(self, name): + return MockedAttributeInfoEx( + name, + data_format=tango.AttrDataFormat.SCALAR, + ) + + +# --- AttributeIndexed --- + + +def test_attribute_indexed_get_returns_w_value_at_index(): + cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = AttributeIndexed(cfg) + assert attr.get() == SPECTRUM_ARRAY[1] + + +def test_attribute_indexed_readback_returns_value_at_index(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = AttributeIndexed(cfg) + rb = attr.readback() + assert rb.value == SPECTRUM_ARRAY[0] + + +def test_attribute_indexed_set_raises(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = AttributeIndexed(cfg) + with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): + attr.set(99.0) + + +def test_attribute_indexed_set_and_wait_raises(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = AttributeIndexed(cfg) + with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): + attr.set_and_wait(99.0) + + +def test_attribute_indexed_name_includes_index(): + cfg = ConfigModel(attribute="domain/family/member/position", index=2) + attr = AttributeIndexed(cfg) + assert attr.name() == "domain/family/member/position[2]" + + +def test_attribute_indexed_measure_name_includes_index(): + cfg = ConfigModel(attribute="domain/family/member/position", index=2) + attr = AttributeIndexed(cfg) + assert attr.measure_name() == "position[2]" + + +def test_attribute_indexed_unit(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") + attr = AttributeIndexed(cfg) + assert attr.unit() == "mm" + + +def test_attribute_indexed_raises_when_not_spectrum(): + cfg = ConfigModel(attribute="domain/family/member/current", index=0) + with patch("tango.DeviceProxy", new=MockedScalarDeviceProxy): + attr = AttributeIndexed(cfg) + with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): + attr.get() + + +def test_attribute_indexed_range_from_config(): + cfg = ConfigModel( + attribute="domain/family/member/position", index=0, unit="mm", range=(-5.0, 5.0) + ) + attr = AttributeIndexed(cfg) + assert attr.get_range() == [-5.0, 5.0] + + +# --- AttributeIndexedReadOnly --- + + +def test_attribute_indexed_read_only_get_returns_measured_value(): + cfg = ConfigModel(attribute="domain/family/member/position", index=2, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeIndexedReadOnly(cfg) + assert attr.get() == SPECTRUM_ARRAY[2] + + +def test_attribute_indexed_read_only_readback_returns_value_at_index(): + cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeIndexedReadOnly(cfg) + assert attr.readback().value == SPECTRUM_ARRAY[1] + + +def test_attribute_indexed_read_only_set_raises(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeIndexedReadOnly(cfg) + with pytest.raises(pyaml.PyAMLException): + attr.set(1.0) + + +def test_attribute_indexed_read_only_get_equals_readback(): + cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeIndexedReadOnly(cfg) + assert attr.get() == attr.readback().value diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index fe0a19b..a66db05 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,12 +1,9 @@ import logging -from pyaml.configuration.static_catalog import ConfigModel as StaticCatalogConfigModel -from pyaml.configuration.static_catalog import StaticCatalog -from pyaml.configuration.static_catalog_entry import ( - ConfigModel as StaticCatalogEntryConfigModel, -) -from pyaml.configuration.static_catalog_entry import StaticCatalogEntry - +from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel +from tango.pyaml.static_catalog import StaticCatalog +from tango.pyaml.static_catalog_entry import ConfigModel as StaticCatalogEntryConfigModel +from tango.pyaml.static_catalog_entry import StaticCatalogEntry from tango.pyaml.controlsystem import ConfigModel, TangoControlSystem diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py new file mode 100644 index 0000000..b08cd66 --- /dev/null +++ b/tests/test_static_catalog.py @@ -0,0 +1,148 @@ +import pytest + +import pyaml +from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel +from tango.pyaml.attribute_read_only import AttributeReadOnly +from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel +from tango.pyaml.controlsystem import TangoControlSystem +from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel +from tango.pyaml.static_catalog import StaticCatalog +from tango.pyaml.static_catalog_entry import ConfigModel as EntryConfigModel +from tango.pyaml.static_catalog_entry import StaticCatalogEntry + + +def make_attribute(path: str = "domain/family/member/attr", unit: str = "mm") -> Attribute: + return Attribute(AttributeConfigModel(attribute=path, unit=unit)) + + +def make_entry(key: str, device=None) -> StaticCatalogEntry: + if device is None: + device = make_attribute() + return StaticCatalogEntry(EntryConfigModel(key=key, device=device)) + + +def make_catalog(name: str = "static", entries=None) -> StaticCatalog: + if entries is None: + entries = [make_entry("default/key")] + return StaticCatalog(StaticCatalogConfigModel(name=name, entries=entries)) + + +# --- StaticCatalogEntry --- + + +def test_static_catalog_entry_returns_key(): + entry = make_entry("BPM/x") + assert entry.get_key() == "BPM/x" + + +def test_static_catalog_entry_returns_device(): + device = make_attribute("sr/bpm/c01-01/x", unit="mm") + entry = make_entry("BPM/x", device=device) + assert entry.get_device() is device + + +# --- StaticCatalog construction --- + + +def test_static_catalog_rejects_empty_entries(): + with pytest.raises(pyaml.PyAMLException, match="must contain at least one entry"): + StaticCatalog(StaticCatalogConfigModel(name="empty", entries=[])) + + +def test_static_catalog_rejects_duplicate_keys(): + entries = [make_entry("BPM/x"), make_entry("BPM/x")] + with pytest.raises(pyaml.PyAMLException, match="duplicate key 'BPM/x'"): + StaticCatalog(StaticCatalogConfigModel(name="dup", entries=entries)) + + +def test_static_catalog_get_name(): + catalog = make_catalog(name="my-catalog") + assert catalog.get_name() == "my-catalog" + + +# --- StaticCatalog.resolve --- + + +def test_static_catalog_resolves_known_key(): + device = make_attribute("sr/bpm/c01-01/position") + catalog = make_catalog(entries=[make_entry("BPM_C01-01/x", device=device)]) + + resolved = catalog.resolve("BPM_C01-01/x") + + assert resolved is device + + +def test_static_catalog_resolves_multiple_entries(): + device_x = make_attribute("sr/bpm/c01-01/x") + device_y = make_attribute("sr/bpm/c01-01/y") + catalog = make_catalog( + entries=[make_entry("BPM/x", device=device_x), make_entry("BPM/y", device=device_y)] + ) + + assert catalog.resolve("BPM/x") is device_x + assert catalog.resolve("BPM/y") is device_y + + +def test_static_catalog_raises_on_unknown_key(): + catalog = make_catalog(entries=[make_entry("BPM/x")]) + + with pytest.raises(pyaml.PyAMLException, match="cannot resolve key 'BPM/y'"): + catalog.resolve("BPM/y") + + +def test_static_catalog_error_includes_catalog_name(): + catalog = make_catalog(name="my-catalog", entries=[make_entry("BPM/x")]) + + with pytest.raises(pyaml.PyAMLException, match="Catalog 'my-catalog'"): + catalog.resolve("missing") + + +# --- attach_control_system --- + + +def test_static_catalog_attach_control_system_returns_self(): + catalog = make_catalog() + control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) + + resolver = catalog.attach_control_system(control_system) + + assert resolver is catalog + + +def test_static_catalog_is_shared_across_control_systems(): + device = make_attribute() + catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) + live = TangoControlSystem(TangoControlSystemConfigModel(name="live")) + ops = TangoControlSystem(TangoControlSystemConfigModel(name="ops")) + + live.set_catalog(catalog) + ops.set_catalog(catalog) + + assert live.get_catalog() is catalog + assert ops.get_catalog() is catalog + assert live.resolve_device("BPM/x") is device + assert ops.resolve_device("BPM/x") is device + + +# --- Integration with DeviceAccess types --- + + +def test_static_catalog_works_with_attribute_read_only(): + device = AttributeReadOnly(AttributeConfigModel(attribute="sr/bpm/c01-01/pos", unit="mm")) + catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) + + resolved = catalog.resolve("BPM/x") + + assert isinstance(resolved, AttributeReadOnly) + assert resolved.unit() == "mm" + + +def test_static_catalog_can_be_used_through_tango_control_system(): + device = make_attribute("sr/bpm/c01-01/x", unit="mm") + catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) + control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) + control_system.set_catalog(catalog) + + resolved = control_system.resolve_device("BPM/x") + + assert resolved is device diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 60ad14e..90a9d4f 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -8,6 +8,8 @@ from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly +from tango.pyaml.attribute_indexed import AttributeIndexed +from tango.pyaml.attribute_indexed_read_only import AttributeIndexedReadOnly from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog @@ -139,6 +141,107 @@ def test_tango_catalog_rejects_invalid_tango_reference(): resolver.resolve("domain/family/member") +def test_tango_catalog_rejects_invalid_index(): + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with pytest.raises(pyaml.PyAMLException, match="invalid index"): + resolver.resolve("domain/family/member/attribute@notanint") + + +def test_tango_catalog_disconnected_resolves_indexed_attribute(): + catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) + resolver = build_resolver(catalog) + + with patch("tango.AttributeProxy") as attr_proxy: + device = resolver.resolve("domain/family/member/attribute@1") + + attr_proxy.assert_not_called() + assert isinstance(device, AttributeIndexed) + assert device.name() == "domain/family/member/attribute[1]" + assert device.unit() == "" + assert device.get_range() == [None, None] + + +def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): + attr_config = MockedAttributeInfoEx( + name="position", + writable=tango.AttrWriteType.READ_WRITE, + unit="mm", + data_format=tango.AttrDataFormat.SPECTRUM, + ) + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + device = resolver.resolve("domain/family/member/position@0") + + assert isinstance(device, AttributeIndexed) + assert not isinstance(device, AttributeIndexedReadOnly) + assert device.name() == "domain/family/member/position[0]" + assert device.unit() == "mm" + assert resolver.get_data_format("domain/family/member/position@0") == tango.AttrDataFormat.SPECTRUM + + +def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): + attr_config = MockedAttributeInfoEx( + name="position", + writable=tango.AttrWriteType.READ, + unit="mm", + data_format=tango.AttrDataFormat.SPECTRUM, + ) + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + device = resolver.resolve("domain/family/member/position@2") + + assert isinstance(device, AttributeIndexedReadOnly) + assert device.unit() == "mm" + + +def test_tango_catalog_connected_rejects_indexed_scalar_attribute(): + attr_config = MockedAttributeInfoEx( + name="current", + writable=tango.AttrWriteType.READ_WRITE, + data_format=tango.AttrDataFormat.SCALAR, + ) + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/current", attr_config), + ): + with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): + resolver.resolve("domain/family/member/current@0") + + +def test_tango_catalog_indexed_caches_resolved_devices(): + attr_config = MockedAttributeInfoEx( + name="position", + data_format=tango.AttrDataFormat.SPECTRUM, + ) + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ) as attr_proxy: + first = resolver.resolve("domain/family/member/position@1") + second = resolver.resolve("domain/family/member/position@1") + + attr_proxy.assert_called_once_with("domain/family/member/position") + assert first is second + + def test_tango_catalog_wraps_tango_errors(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) resolver = build_resolver(catalog) From c677ebbf6a4d568b1ea44f873bc80a97381ce444 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 24 Apr 2026 16:08:24 +0200 Subject: [PATCH 04/36] Missing files in the last commit --- tango/pyaml/attribute_indexed.py | 138 +++++++++++++++++++++ tango/pyaml/attribute_indexed_read_only.py | 26 ++++ 2 files changed, 164 insertions(+) create mode 100644 tango/pyaml/attribute_indexed.py create mode 100644 tango/pyaml/attribute_indexed_read_only.py diff --git a/tango/pyaml/attribute_indexed.py b/tango/pyaml/attribute_indexed.py new file mode 100644 index 0000000..d7f5e24 --- /dev/null +++ b/tango/pyaml/attribute_indexed.py @@ -0,0 +1,138 @@ +import logging + +import pyaml +import tango + +from pyaml.control.readback_value import Value, Quality + +from .attribute import Attribute, ConfigModel as AttributeConfigModel +from .tango_pyaml_utils import tango_to_PyAMLException + +PYAMLCLASS = "AttributeIndexed" + +logger = logging.getLogger(__name__) + + +class ConfigModel(AttributeConfigModel): + """ + Configuration model for an indexed Tango SPECTRUM attribute. + + Attributes + ---------- + attribute : str + Full path of the Tango SPECTRUM attribute. + index : int + Zero-based index of the element to extract from the vector. + unit : str, optional + Unit of the extracted scalar value. + range : tuple, optional + Valid range ``[min, max]`` for the scalar. Use ``null`` for open bounds. + """ + + index: int + + +class AttributeIndexed(Attribute): + """ + Scalar view of one element in a Tango SPECTRUM (vector) attribute. + + The underlying Tango attribute must have ``data_format == SPECTRUM``, + which is enforced at first use via lazy initialisation. ``get()`` returns + the setpoint component (``w_value[index]``); use + :class:`AttributeIndexedReadOnly` for READ-only Tango attributes where + ``w_value`` is undefined. + + ``set()`` and ``set_and_wait()`` always raise: writing individual array + elements back to Tango is not supported. + + Parameters + ---------- + cfg : ConfigModel + Configuration including the attribute path and target index. + + Raises + ------ + pyaml.PyAMLException + At first use if the Tango attribute is not a SPECTRUM. + """ + + def __init__(self, cfg: ConfigModel): + super().__init__(cfg, writable=False) + self._index = cfg.index + + def initialize(self): + super().initialize() + if self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM: + raise pyaml.PyAMLException( + f"Tango attribute '{self._cfg.attribute}' is not a SPECTRUM; " + "indexed access requires a vector attribute." + ) + + def get(self): + """ + Return the setpoint element at the configured index (``w_value[index]``). + + Raises + ------ + pyaml.PyAMLException + If the Tango read fails. + """ + self._ensure_initialized() + try: + return self._attribute_dev.read_attribute(self._attr_name).w_value[self._index] + except tango.DevFailed as df: + raise tango_to_PyAMLException(df) + + def readback(self) -> Value: + """ + Return the measured element at the configured index (``value[index]``). + + Returns + ------- + Value + Measured scalar with quality and timestamp. + + Raises + ------ + pyaml.PyAMLException + If the Tango read fails. + """ + self._ensure_initialized() + try: + attr_value = self._attribute_dev.read_attribute(self._attr_name) + quality = Quality[attr_value.quality.name.rsplit("_", 1)[1]] + return Value(attr_value.value[self._index], quality, attr_value.time.todatetime()) + except tango.DevFailed as df: + raise tango_to_PyAMLException(df) + + def set(self, value): + """ + Raises + ------ + pyaml.PyAMLException + Always raised: element-level writes are not supported. + """ + raise pyaml.PyAMLException( + f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + "does not support individual element writes." + ) + + def set_and_wait(self, value): + """ + Raises + ------ + pyaml.PyAMLException + Always raised: element-level writes are not supported. + """ + raise pyaml.PyAMLException( + f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + "does not support individual element writes." + ) + + def name(self) -> str: + """Return the attribute path with index, e.g. ``'domain/family/member/attr[2]'``.""" + return f"{self._cfg.attribute}[{self._index}]" + + def measure_name(self) -> str: + """Return the short attribute name with index, e.g. ``'attr[2]'``.""" + return f"{self._cfg.attribute.rsplit('/', 1)[1]}[{self._index}]" diff --git a/tango/pyaml/attribute_indexed_read_only.py b/tango/pyaml/attribute_indexed_read_only.py new file mode 100644 index 0000000..c618c7e --- /dev/null +++ b/tango/pyaml/attribute_indexed_read_only.py @@ -0,0 +1,26 @@ +import logging + +from .attribute_indexed import AttributeIndexed, ConfigModel # noqa: F401 — ConfigModel re-exported + +PYAMLCLASS = "AttributeIndexedReadOnly" + +logger = logging.getLogger(__name__) + + +class AttributeIndexedReadOnly(AttributeIndexed): + """ + Read-only scalar view of one element in a Tango SPECTRUM attribute. + + Use this class for READ Tango attributes where ``w_value`` is undefined. + ``get()`` returns the measured value (``value[index]``), identical to + :meth:`readback`. + + Parameters + ---------- + cfg : ConfigModel + Configuration including the attribute path and target index. + """ + + def get(self): + """Return the measured element at the configured index (same as readback).""" + return self.readback().value From 65267f7718f4e085194b1b8f4c7893dcb6738966 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 24 Apr 2026 18:46:13 +0200 Subject: [PATCH 05/36] Merge AttributeIndexed into Attribute by making index optional, remove attribute_indexed.py and attribute_indexed_read_only.py, update catalog and tests. --- tango/pyaml/attribute.py | 54 ++++++-- tango/pyaml/attribute_indexed.py | 138 --------------------- tango/pyaml/attribute_indexed_read_only.py | 26 ---- tango/pyaml/tango_catalog.py | 10 +- tests/test_attribute_indexed.py | 34 ++--- tests/test_tango_catalog.py | 10 +- 6 files changed, 71 insertions(+), 201 deletions(-) delete mode 100644 tango/pyaml/attribute_indexed.py delete mode 100644 tango/pyaml/attribute_indexed_read_only.py diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index 9ddd267..da783c1 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -27,11 +27,16 @@ class ConfigModel(BaseModel): The unit of the attribute. range : tuple(min, max), optional Range of valid values. Use null for -∞ or +∞. + index : int, optional + Zero-based index into a SPECTRUM attribute. When set, the instance + behaves as a read-only scalar view of one vector element; writes are + always rejected and a SPECTRUM data_format is enforced on init. """ attribute: str unit: str = "" range: Optional[Tuple[Optional[float], Optional[float]]] = None + index: Optional[int] = None class Attribute(DeviceAccess, InitializableElement): @@ -52,7 +57,9 @@ class Attribute(DeviceAccess, InitializableElement): def __init__(self, cfg: ConfigModel, writable=True): super().__init__() self._cfg = cfg - self._writable = writable + self._index = cfg.index + # Indexed access never writes individual array elements. + self._writable = writable and self._index is None self._attribute_dev: tango.DeviceProxy = None self._attr_config: tango.AttributeConfig = None self._attribute_dev_name: str = None @@ -72,6 +79,13 @@ def initialize(self): self._attribute_dev.get_attribute_config(self._attr_name, wait=True) ) + if self._index is not None: + if self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM: + raise pyaml.PyAMLException( + f"Tango attribute '{self._cfg.attribute}' is not a SPECTRUM; " + "indexed access requires a vector attribute." + ) + if self._writable: if self._attr_config.writable not in [ tango.AttrWriteType.READ_WRITE, @@ -97,8 +111,13 @@ def set(self, value: float): Raises ------ pyaml.PyAMLException - If the Tango write fails. + If the Tango write fails or this is an indexed attribute. """ + if self._index is not None: + raise pyaml.PyAMLException( + f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + "does not support individual element writes." + ) self._ensure_initialized() logger.log( logging.DEBUG, f"Setting asynchronously {self._cfg.attribute} to {value}" @@ -120,8 +139,13 @@ def set_and_wait(self, value: float): Raises ------ pyaml.PyAMLException - If the Tango write fails. + If the Tango write fails or this is an indexed attribute. """ + if self._index is not None: + raise pyaml.PyAMLException( + f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + "does not support individual element writes." + ) self._ensure_initialized() logger.log(logging.DEBUG, f"Setting {self._cfg.attribute} to {value}") try: @@ -150,7 +174,8 @@ def readback(self) -> Value: 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()) + raw = attr_value.value[self._index] if self._index is not None else attr_value.value + value = Value(raw, quality, attr_value.time.todatetime()) except tango.DevFailed as df: raise tango_to_PyAMLException(df) return value @@ -173,8 +198,11 @@ def name(self) -> str: Returns ------- str - The attribute path (e.g., 'my/ps/device/current'). + The attribute path (e.g., 'my/ps/device/current'), or with index + notation when indexed (e.g., 'my/ps/device/current[2]'). """ + if self._index is not None: + return f"{self._cfg.attribute}[{self._index}]" return self._cfg.attribute def measure_name(self) -> str: @@ -184,14 +212,21 @@ def measure_name(self) -> str: Returns ------- str - The attribute name (e.g., 'current'). + The attribute name (e.g., 'current'), with index notation when + indexed (e.g., 'current[2]'). """ - return self._cfg.attribute.rsplit("/", 1)[1] + short = self._cfg.attribute.rsplit("/", 1)[1] + if self._index is not None: + return f"{short}[{self._index}]" + return short def get(self) -> float: """ Get the last written value of the attribute. + For indexed attributes, returns the setpoint element at the configured + index (``w_value[index]``). + Returns ------- float @@ -204,7 +239,10 @@ def get(self) -> float: """ self._ensure_initialized() try: - return self._attribute_dev.read_attribute(self._attr_name).w_value + attr_val = self._attribute_dev.read_attribute(self._attr_name) + if self._index is not None: + return attr_val.w_value[self._index] + return attr_val.w_value except tango.DevFailed as df: raise tango_to_PyAMLException(df) diff --git a/tango/pyaml/attribute_indexed.py b/tango/pyaml/attribute_indexed.py deleted file mode 100644 index d7f5e24..0000000 --- a/tango/pyaml/attribute_indexed.py +++ /dev/null @@ -1,138 +0,0 @@ -import logging - -import pyaml -import tango - -from pyaml.control.readback_value import Value, Quality - -from .attribute import Attribute, ConfigModel as AttributeConfigModel -from .tango_pyaml_utils import tango_to_PyAMLException - -PYAMLCLASS = "AttributeIndexed" - -logger = logging.getLogger(__name__) - - -class ConfigModel(AttributeConfigModel): - """ - Configuration model for an indexed Tango SPECTRUM attribute. - - Attributes - ---------- - attribute : str - Full path of the Tango SPECTRUM attribute. - index : int - Zero-based index of the element to extract from the vector. - unit : str, optional - Unit of the extracted scalar value. - range : tuple, optional - Valid range ``[min, max]`` for the scalar. Use ``null`` for open bounds. - """ - - index: int - - -class AttributeIndexed(Attribute): - """ - Scalar view of one element in a Tango SPECTRUM (vector) attribute. - - The underlying Tango attribute must have ``data_format == SPECTRUM``, - which is enforced at first use via lazy initialisation. ``get()`` returns - the setpoint component (``w_value[index]``); use - :class:`AttributeIndexedReadOnly` for READ-only Tango attributes where - ``w_value`` is undefined. - - ``set()`` and ``set_and_wait()`` always raise: writing individual array - elements back to Tango is not supported. - - Parameters - ---------- - cfg : ConfigModel - Configuration including the attribute path and target index. - - Raises - ------ - pyaml.PyAMLException - At first use if the Tango attribute is not a SPECTRUM. - """ - - def __init__(self, cfg: ConfigModel): - super().__init__(cfg, writable=False) - self._index = cfg.index - - def initialize(self): - super().initialize() - if self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM: - raise pyaml.PyAMLException( - f"Tango attribute '{self._cfg.attribute}' is not a SPECTRUM; " - "indexed access requires a vector attribute." - ) - - def get(self): - """ - Return the setpoint element at the configured index (``w_value[index]``). - - Raises - ------ - pyaml.PyAMLException - If the Tango read fails. - """ - self._ensure_initialized() - try: - return self._attribute_dev.read_attribute(self._attr_name).w_value[self._index] - except tango.DevFailed as df: - raise tango_to_PyAMLException(df) - - def readback(self) -> Value: - """ - Return the measured element at the configured index (``value[index]``). - - Returns - ------- - Value - Measured scalar with quality and timestamp. - - Raises - ------ - pyaml.PyAMLException - If the Tango read fails. - """ - self._ensure_initialized() - try: - attr_value = self._attribute_dev.read_attribute(self._attr_name) - quality = Quality[attr_value.quality.name.rsplit("_", 1)[1]] - return Value(attr_value.value[self._index], quality, attr_value.time.todatetime()) - except tango.DevFailed as df: - raise tango_to_PyAMLException(df) - - def set(self, value): - """ - Raises - ------ - pyaml.PyAMLException - Always raised: element-level writes are not supported. - """ - raise pyaml.PyAMLException( - f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " - "does not support individual element writes." - ) - - def set_and_wait(self, value): - """ - Raises - ------ - pyaml.PyAMLException - Always raised: element-level writes are not supported. - """ - raise pyaml.PyAMLException( - f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " - "does not support individual element writes." - ) - - def name(self) -> str: - """Return the attribute path with index, e.g. ``'domain/family/member/attr[2]'``.""" - return f"{self._cfg.attribute}[{self._index}]" - - def measure_name(self) -> str: - """Return the short attribute name with index, e.g. ``'attr[2]'``.""" - return f"{self._cfg.attribute.rsplit('/', 1)[1]}[{self._index}]" diff --git a/tango/pyaml/attribute_indexed_read_only.py b/tango/pyaml/attribute_indexed_read_only.py deleted file mode 100644 index c618c7e..0000000 --- a/tango/pyaml/attribute_indexed_read_only.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging - -from .attribute_indexed import AttributeIndexed, ConfigModel # noqa: F401 — ConfigModel re-exported - -PYAMLCLASS = "AttributeIndexedReadOnly" - -logger = logging.getLogger(__name__) - - -class AttributeIndexedReadOnly(AttributeIndexed): - """ - Read-only scalar view of one element in a Tango SPECTRUM attribute. - - Use this class for READ Tango attributes where ``w_value`` is undefined. - ``get()`` returns the measured value (``value[index]``), identical to - :meth:`readback`. - - Parameters - ---------- - cfg : ConfigModel - Configuration including the attribute path and target index. - """ - - def get(self): - """Return the measured element at the configured index (same as readback).""" - return self.readback().value diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 0fcc937..ea69fa8 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -7,8 +7,6 @@ from .attribute import Attribute, ConfigModel as AttributeConfigModel from .attribute_read_only import AttributeReadOnly -from .attribute_indexed import AttributeIndexed, ConfigModel as IndexedConfigModel -from .attribute_indexed_read_only import AttributeIndexedReadOnly from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none PYAMLCLASS = "TangoCatalog" @@ -196,7 +194,7 @@ def _build_disconnected_indexed(self, attr_path: str, index: int) -> DeviceAcces # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. key = f"{attr_path}@{index}" self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN - return AttributeIndexed(IndexedConfigModel(attribute=attr_path, index=index, range=(None, None))) + return Attribute(AttributeConfigModel(attribute=attr_path, index=index, range=(None, None))) def _build_connected_attribute(self, key: str) -> DeviceAccess: try: @@ -254,8 +252,8 @@ def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: to_float_or_none(getattr(attr_config, "min_value", None)), to_float_or_none(getattr(attr_config, "max_value", None)), ) - cfg = IndexedConfigModel(attribute=attr_path, index=index, unit=unit, range=attr_range) + cfg = AttributeConfigModel(attribute=attr_path, index=index, unit=unit, range=attr_range) if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: - return AttributeIndexed(cfg) - return AttributeIndexedReadOnly(cfg) + return Attribute(cfg) + return AttributeReadOnly(cfg) diff --git a/tests/test_attribute_indexed.py b/tests/test_attribute_indexed.py index 2fc8a6c..3d36aa9 100644 --- a/tests/test_attribute_indexed.py +++ b/tests/test_attribute_indexed.py @@ -5,8 +5,8 @@ import pyaml -from tango.pyaml.attribute_indexed import AttributeIndexed, ConfigModel -from tango.pyaml.attribute_indexed_read_only import AttributeIndexedReadOnly +from tango.pyaml.attribute import Attribute, ConfigModel +from tango.pyaml.attribute_read_only import AttributeReadOnly from .mocked_device_proxy import MockedAttributeInfoEx, MockedDeviceProxy, MockedDeviceAttribute @@ -53,20 +53,20 @@ def attribute_query(self, name): ) -# --- AttributeIndexed --- +# --- Attribute with index --- def test_attribute_indexed_get_returns_w_value_at_index(): cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) assert attr.get() == SPECTRUM_ARRAY[1] def test_attribute_indexed_readback_returns_value_at_index(): cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) rb = attr.readback() assert rb.value == SPECTRUM_ARRAY[0] @@ -74,7 +74,7 @@ def test_attribute_indexed_readback_returns_value_at_index(): def test_attribute_indexed_set_raises(): cfg = ConfigModel(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): attr.set(99.0) @@ -82,33 +82,33 @@ def test_attribute_indexed_set_raises(): def test_attribute_indexed_set_and_wait_raises(): cfg = ConfigModel(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): attr.set_and_wait(99.0) def test_attribute_indexed_name_includes_index(): cfg = ConfigModel(attribute="domain/family/member/position", index=2) - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) assert attr.name() == "domain/family/member/position[2]" def test_attribute_indexed_measure_name_includes_index(): cfg = ConfigModel(attribute="domain/family/member/position", index=2) - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) assert attr.measure_name() == "position[2]" def test_attribute_indexed_unit(): cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) assert attr.unit() == "mm" def test_attribute_indexed_raises_when_not_spectrum(): cfg = ConfigModel(attribute="domain/family/member/current", index=0) with patch("tango.DeviceProxy", new=MockedScalarDeviceProxy): - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): attr.get() @@ -117,31 +117,31 @@ def test_attribute_indexed_range_from_config(): cfg = ConfigModel( attribute="domain/family/member/position", index=0, unit="mm", range=(-5.0, 5.0) ) - attr = AttributeIndexed(cfg) + attr = Attribute(cfg) assert attr.get_range() == [-5.0, 5.0] -# --- AttributeIndexedReadOnly --- +# --- AttributeReadOnly with index --- def test_attribute_indexed_read_only_get_returns_measured_value(): cfg = ConfigModel(attribute="domain/family/member/position", index=2, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeIndexedReadOnly(cfg) + attr = AttributeReadOnly(cfg) assert attr.get() == SPECTRUM_ARRAY[2] def test_attribute_indexed_read_only_readback_returns_value_at_index(): cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeIndexedReadOnly(cfg) + attr = AttributeReadOnly(cfg) assert attr.readback().value == SPECTRUM_ARRAY[1] def test_attribute_indexed_read_only_set_raises(): cfg = ConfigModel(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeIndexedReadOnly(cfg) + attr = AttributeReadOnly(cfg) with pytest.raises(pyaml.PyAMLException): attr.set(1.0) @@ -149,5 +149,5 @@ def test_attribute_indexed_read_only_set_raises(): def test_attribute_indexed_read_only_get_equals_readback(): cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeIndexedReadOnly(cfg) + attr = AttributeReadOnly(cfg) assert attr.get() == attr.readback().value diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 90a9d4f..bbd3cc1 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -8,8 +8,6 @@ from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly -from tango.pyaml.attribute_indexed import AttributeIndexed -from tango.pyaml.attribute_indexed_read_only import AttributeIndexedReadOnly from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog @@ -157,7 +155,7 @@ def test_tango_catalog_disconnected_resolves_indexed_attribute(): device = resolver.resolve("domain/family/member/attribute@1") attr_proxy.assert_not_called() - assert isinstance(device, AttributeIndexed) + assert isinstance(device, Attribute) and device._index is not None assert device.name() == "domain/family/member/attribute[1]" assert device.unit() == "" assert device.get_range() == [None, None] @@ -179,8 +177,8 @@ def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): ): device = resolver.resolve("domain/family/member/position@0") - assert isinstance(device, AttributeIndexed) - assert not isinstance(device, AttributeIndexedReadOnly) + assert isinstance(device, Attribute) and device._index is not None + assert not isinstance(device, AttributeReadOnly) assert device.name() == "domain/family/member/position[0]" assert device.unit() == "mm" assert resolver.get_data_format("domain/family/member/position@0") == tango.AttrDataFormat.SPECTRUM @@ -202,7 +200,7 @@ def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): ): device = resolver.resolve("domain/family/member/position@2") - assert isinstance(device, AttributeIndexedReadOnly) + assert isinstance(device, AttributeReadOnly) and device._index is not None assert device.unit() == "mm" From 3558d5a186aa4450b2942c0d551a9f59de748992 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 29 Apr 2026 15:16:56 +0200 Subject: [PATCH 06/36] Fix TangoCatalog control-system binding - Use the attached TangoControlSystem tango_host when resolving Tango metadata - Keep shared TangoCatalog instances isolated through per-control-system resolvers - Add public accessors to avoid cross-object private config access - Validate attached control system type explicitly - Add tests for shared catalogs with different Tango databases and metadata ranges --- tango/pyaml/attribute.py | 26 +++++++++ tango/pyaml/controlsystem.py | 38 ++++++++----- tango/pyaml/tango_catalog.py | 93 ++++++++++++++++++++---------- tests/test_controlsystem.py | 20 +++++-- tests/test_static_catalog.py | 24 +++++--- tests/test_tango_catalog.py | 107 ++++++++++++++++++++++++++++++++--- 6 files changed, 243 insertions(+), 65 deletions(-) diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index da783c1..88aedbc 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,3 +1,4 @@ +import copy import logging from typing import Optional, Tuple @@ -205,6 +206,31 @@ def name(self) -> str: return f"{self._cfg.attribute}[{self._index}]" return self._cfg.attribute + def get_tango_attribute(self) -> str: + """ + Return the raw Tango attribute path without index decoration. + + Returns + ------- + str + Tango attribute path stored in the configuration. + """ + return self._cfg.attribute + + def clone_with_tango_attribute(self, attribute: str) -> "Attribute": + """ + Return a shallow copy configured with another Tango attribute path. + + Parameters + ---------- + attribute : str + Tango attribute path to store in the cloned instance. + """ + new_obj = copy.copy(self) + new_obj._cfg = copy.copy(self._cfg) + new_obj._cfg.attribute = attribute + return new_obj + def measure_name(self) -> str: """ Return the short attribute name (last component). diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 16e90c3..435588d 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,7 +1,7 @@ import logging -import copy from pydantic import BaseModel, ConfigDict +from pyaml import PyAMLException from pyaml.configuration.catalog import Catalog from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess @@ -72,15 +72,6 @@ def __init__(self, cfg: ConfigModel): f" and TANGO_HOST={self._cfg.tango_host}", ) - def __newref(self, obj, new_name: str): - # Shallow copy the object - newObj = copy.copy(obj) - # Shallow copy the config object - # to allow a new attribute name - newObj._cfg = copy.copy(obj._cfg) - newObj._cfg.attribute = new_name - return newObj - def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: return self._attach(devs) @@ -92,12 +83,20 @@ def _attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: newDevs = [] for d in devs: if d is not None: - if self._cfg.tango_host: - full_name = "//" + self._cfg.tango_host + "/" + d._cfg.attribute + try: + attribute = d.get_tango_attribute() + except AttributeError as exc: + raise PyAMLException( + f"Cannot attach device {d!r}: expected a Tango attribute with get_tango_attribute()." + ) from exc + + tango_host = self.get_tango_host() + if tango_host: + full_name = "//" + tango_host + "/" + attribute else: - full_name = d._cfg.attribute + full_name = attribute if full_name not in self.__devices: - self.__devices[full_name] = self.__newref(d, full_name) + self.__devices[full_name] = d.clone_with_tango_attribute(full_name) newDevs.append(self.__devices[full_name]) else: newDevs.append(None) @@ -114,6 +113,17 @@ def name(self) -> str: """ return self._cfg.name + def get_tango_host(self) -> str | None: + """ + Return the Tango host configured for this control system. + + Returns + ------- + str | None + Tango host URL, or ``None`` when unconfigured. + """ + return self._cfg.tango_host + def scalar_aggregator(self) -> str | None: """ Returns the module name used for handling aggregator of DeviceAccess diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index ea69fa8..29106cc 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,6 +1,7 @@ import tango import pyaml +from typing import TYPE_CHECKING from pydantic import ConfigDict from pyaml.configuration.catalog import Catalog, CatalogConfigModel, CatalogResolver from pyaml.control.deviceaccess import DeviceAccess @@ -11,6 +12,9 @@ PYAMLCLASS = "TangoCatalog" +if TYPE_CHECKING: + from .controlsystem import TangoControlSystem + class ConfigModel(CatalogConfigModel): """ @@ -40,11 +44,13 @@ class TangoCatalog(Catalog): def resolve(self, key: str) -> DeviceAccess: raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' must be attached to a TangoControlSystem " - f"before resolving key '{key}'" + f"Tango catalog '{self.get_name()}' must be attached to a TangoControlSystem before resolving key '{key}'" ) - def attach_control_system(self, control_system): + def is_disconnected(self) -> bool: + return self._cfg.disconnected + + def attach_control_system(self, control_system: object) -> "TangoCatalogResolver": from .controlsystem import TangoControlSystem if not isinstance(control_system, TangoControlSystem): @@ -78,7 +84,7 @@ class TangoCatalogResolver(CatalogResolver): tango.AttrWriteType.READ_WITH_WRITE, } - def __init__(self, catalog: TangoCatalog, control_system): + def __init__(self, catalog: TangoCatalog, control_system: "TangoControlSystem"): self._catalog = catalog self._control_system = control_system # Resolved DeviceAccess objects are bound to one control system context, @@ -110,12 +116,12 @@ def resolve(self, key: str) -> DeviceAccess: if key not in self._refs: if index is not None: - if self._catalog._cfg.disconnected: + if self._catalog.is_disconnected(): self._refs[key] = self._build_disconnected_indexed(attr_path, index) else: self._refs[key] = self._build_connected_indexed(attr_path, index) else: - if self._catalog._cfg.disconnected: + if self._catalog.is_disconnected(): self._refs[key] = self._build_disconnected_attribute(key) else: self._refs[key] = self._build_connected_attribute(key) @@ -157,19 +163,17 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: """ if not isinstance(key, str): raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' expects string keys, " - f"got {type(key).__name__}" + f"Tango catalog '{self._catalog.get_name()}' expects string keys, got {type(key).__name__}" ) if "@" in key: attr_path, idx_str = key.rsplit("@", 1) try: index = int(idx_str) - except ValueError: + except ValueError as exc: raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' invalid index " - f"'{idx_str}' in key '{key}'." - ) + f"Tango catalog '{self._catalog.get_name()}' invalid index '{idx_str}' in key '{key}'." + ) from exc else: attr_path = key index = None @@ -194,30 +198,29 @@ def _build_disconnected_indexed(self, attr_path: str, index: int) -> DeviceAcces # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. key = f"{attr_path}@{index}" self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN - return Attribute(AttributeConfigModel(attribute=attr_path, index=index, range=(None, None))) + return Attribute( + AttributeConfigModel(attribute=attr_path, index=index, range=(None, None)) + ) def _build_connected_attribute(self, key: str) -> DeviceAccess: + tango_attr_name = self._tango_attribute_name(key) try: # AttributeProxy.get_config() is the most direct way to retrieve # writability, unit, range and data format from Tango. - attr_config = tango.AttributeProxy(key).get_config() + attr_config = tango.AttributeProxy(tango_attr_name).get_config() except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) raise pyaml.PyAMLException( f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" ) from df - unit = getattr(attr_config, "unit", "") or "" - self._data_formats[key] = getattr( - attr_config, "data_format", tango.AttrDataFormat.FMT_UNKNOWN - ) - attr_range = ( - to_float_or_none(getattr(attr_config, "min_value", None)), - to_float_or_none(getattr(attr_config, "max_value", None)), + unit, attr_range, data_format, writable = self._read_config_metadata( + attr_config, key ) + self._data_formats[key] = data_format cfg = AttributeConfigModel(attribute=key, unit=unit, range=attr_range) - if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: + if writable in self._WRITABLE_TYPES: return Attribute(cfg) return AttributeReadOnly(cfg) @@ -231,29 +234,59 @@ def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: If the Tango call fails or the attribute is not a SPECTRUM. """ key = f"{attr_path}@{index}" + tango_attr_name = self._tango_attribute_name(attr_path) try: - attr_config = tango.AttributeProxy(attr_path).get_config() + attr_config = tango.AttributeProxy(tango_attr_name).get_config() except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) raise pyaml.PyAMLException( f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" ) from df - data_format = getattr(attr_config, "data_format", tango.AttrDataFormat.FMT_UNKNOWN) + unit, attr_range, data_format, writable = self._read_config_metadata( + attr_config, key + ) if data_format != tango.AttrDataFormat.SPECTRUM: raise pyaml.PyAMLException( f"Tango catalog '{self._catalog.get_name()}' cannot use '{key}' as an indexed " "key: the Tango attribute is not a SPECTRUM." ) - unit = getattr(attr_config, "unit", "") or "" self._data_formats[key] = tango.AttrDataFormat.SPECTRUM - attr_range = ( - to_float_or_none(getattr(attr_config, "min_value", None)), - to_float_or_none(getattr(attr_config, "max_value", None)), + cfg = AttributeConfigModel( + attribute=attr_path, index=index, unit=unit, range=attr_range ) - cfg = AttributeConfigModel(attribute=attr_path, index=index, unit=unit, range=attr_range) - if getattr(attr_config, "writable", tango.AttrWriteType.WT_UNKNOWN) in self._WRITABLE_TYPES: + if writable in self._WRITABLE_TYPES: return Attribute(cfg) return AttributeReadOnly(cfg) + + def _read_config_metadata( + self, attr_config, key: str + ) -> tuple[ + str, + tuple[float | None, float | None], + tango.AttrDataFormat, + tango.AttrWriteType, + ]: + try: + unit = attr_config.unit or "" + attr_range = ( + to_float_or_none(attr_config.min_value), + to_float_or_none(attr_config.max_value), + ) + data_format = attr_config.data_format + writable = attr_config.writable + except AttributeError as exc: + raise pyaml.PyAMLException( + f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': " + f"incomplete Tango attribute config, missing '{exc.name}'." + ) from exc + + return unit, attr_range, data_format, writable + + def _tango_attribute_name(self, attr_path: str) -> str: + tango_host = self._control_system.get_tango_host() + if tango_host: + return f"//{tango_host}/{attr_path}" + return attr_path diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index a66db05..594bef0 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -2,11 +2,12 @@ from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel from tango.pyaml.static_catalog import StaticCatalog -from tango.pyaml.static_catalog_entry import ConfigModel as StaticCatalogEntryConfigModel +from tango.pyaml.static_catalog_entry import ( + ConfigModel as StaticCatalogEntryConfigModel, +) from tango.pyaml.static_catalog_entry import StaticCatalogEntry from tango.pyaml.controlsystem import ConfigModel, TangoControlSystem - from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel @@ -65,16 +66,23 @@ def test_catalog_can_be_configured_and_resolved(): ) cs.set_catalog(catalog) - resolved = cs.resolve_device("BPM_C01-01/x") - attached = cs.attach([resolved])[0] + resolved = cs.get_device("BPM_C01-01/x") assert cs.get_catalog_config() is catalog assert cs.get_catalog() is catalog - assert resolved is device - assert attached.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" + assert catalog.resolve("BPM_C01-01/x") is device + assert resolved.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" def test_named_catalog_config_is_accepted(): cfg = ConfigModel(name="test_tango_cs", catalog="device-catalog") assert cfg.catalog == "device-catalog" + + +def test_tango_control_system_exposes_tango_host(): + cs = TangoControlSystem( + ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") + ) + + assert cs.get_tango_host() == "tangodb:10000" diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index b08cd66..d15568b 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -11,7 +11,9 @@ from tango.pyaml.static_catalog_entry import StaticCatalogEntry -def make_attribute(path: str = "domain/family/member/attr", unit: str = "mm") -> Attribute: +def make_attribute( + path: str = "domain/family/member/attr", unit: str = "mm" +) -> Attribute: return Attribute(AttributeConfigModel(attribute=path, unit=unit)) @@ -76,7 +78,10 @@ def test_static_catalog_resolves_multiple_entries(): device_x = make_attribute("sr/bpm/c01-01/x") device_y = make_attribute("sr/bpm/c01-01/y") catalog = make_catalog( - entries=[make_entry("BPM/x", device=device_x), make_entry("BPM/y", device=device_y)] + entries=[ + make_entry("BPM/x", device=device_x), + make_entry("BPM/y", device=device_y), + ] ) assert catalog.resolve("BPM/x") is device_x @@ -120,15 +125,19 @@ def test_static_catalog_is_shared_across_control_systems(): assert live.get_catalog() is catalog assert ops.get_catalog() is catalog - assert live.resolve_device("BPM/x") is device - assert ops.resolve_device("BPM/x") is device + assert catalog.resolve("BPM/x") is device + assert live.get_device("BPM/x") is not device + assert ops.get_device("BPM/x") is not device + assert live.get_device("BPM/x") is not ops.get_device("BPM/x") # --- Integration with DeviceAccess types --- def test_static_catalog_works_with_attribute_read_only(): - device = AttributeReadOnly(AttributeConfigModel(attribute="sr/bpm/c01-01/pos", unit="mm")) + device = AttributeReadOnly( + AttributeConfigModel(attribute="sr/bpm/c01-01/pos", unit="mm") + ) catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) resolved = catalog.resolve("BPM/x") @@ -143,6 +152,7 @@ def test_static_catalog_can_be_used_through_tango_control_system(): control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) control_system.set_catalog(catalog) - resolved = control_system.resolve_device("BPM/x") + resolved = control_system.get_device("BPM/x") - assert resolved is device + assert resolved is not device + assert resolved.name() == "sr/bpm/c01-01/x" diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index bbd3cc1..5da26f7 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import call, patch import pyaml import pytest @@ -55,11 +55,16 @@ def test_tango_catalog_connected_resolves_writable_attribute(): assert device.name() == "domain/family/member/current" assert device.unit() == "A" assert device.get_range() == [-10.5, 12.0] - assert resolver.get_data_format("domain/family/member/current") == tango.AttrDataFormat.SPECTRUM + assert ( + resolver.get_data_format("domain/family/member/current") + == tango.AttrDataFormat.SPECTRUM + ) def test_tango_catalog_connected_resolves_read_only_attribute(): - attr_config = MockedAttributeInfoEx(name="position", writable=tango.AttrWriteType.READ, unit="mm") + attr_config = MockedAttributeInfoEx( + name="position", writable=tango.AttrWriteType.READ, unit="mm" + ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) resolver = build_resolver(catalog) @@ -106,12 +111,54 @@ def test_tango_catalog_cache_is_bound_to_control_system_resolver(): assert ops_device is not live_first +def test_tango_catalog_connected_metadata_uses_control_system_tango_host(): + key = "domain/family/member/current" + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + live = TangoControlSystem( + TangoControlSystemConfigModel(name="live", tango_host="live-db:10000") + ) + ops = TangoControlSystem( + TangoControlSystemConfigModel(name="ops", tango_host="ops-db:10000") + ) + live.set_catalog(catalog) + ops.set_catalog(catalog) + + attr_configs = { + "//live-db:10000/domain/family/member/current": MockedAttributeInfoEx( + name="current", + min_value="-10.0", + max_value="10.0", + ), + "//ops-db:10000/domain/family/member/current": MockedAttributeInfoEx( + name="current", + min_value="-2.5", + max_value="2.5", + ), + } + + def attribute_proxy(attr_full_name): + return MockedAttributeProxy(attr_full_name, attr_configs[attr_full_name]) + + with patch("tango.AttributeProxy", side_effect=attribute_proxy) as attr_proxy: + live_device = live.get_device(key) + ops_device = ops.get_device(key) + + assert attr_proxy.call_args_list == [ + call("//live-db:10000/domain/family/member/current"), + call("//ops-db:10000/domain/family/member/current"), + ] + assert live_device.name() == "//live-db:10000/domain/family/member/current" + assert ops_device.name() == "//ops-db:10000/domain/family/member/current" + assert live_device.get_range() == [-10.0, 10.0] + assert ops_device.get_range() == [-2.5, 2.5] + + def test_tango_catalog_can_be_used_through_tango_control_system(): catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) control_system.set_catalog(catalog) - device = control_system.resolve_device("domain/family/member/attribute") + device = control_system.get_device("domain/family/member/attribute") assert isinstance(device, Attribute) assert control_system.get_catalog() is catalog @@ -120,14 +167,30 @@ def test_tango_catalog_can_be_used_through_tango_control_system(): def test_tango_catalog_rejects_non_tango_control_system(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - with pytest.raises(pyaml.PyAMLException, match="can only be attached to TangoControlSystem"): + with pytest.raises( + pyaml.PyAMLException, match="can only be attached to TangoControlSystem" + ): catalog.attach_control_system(ControlSystemAdapter()) +def test_tango_catalog_rejects_external_tango_control_system_class(): + class TangoControlSystem: + pass + + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + + with pytest.raises( + pyaml.PyAMLException, match="can only be attached to TangoControlSystem" + ): + catalog.attach_control_system(TangoControlSystem()) + + def test_tango_catalog_requires_control_system_attachment(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - with pytest.raises(pyaml.PyAMLException, match="must be attached to a TangoControlSystem"): + with pytest.raises( + pyaml.PyAMLException, match="must be attached to a TangoControlSystem" + ): catalog.resolve("domain/family/member/attribute") @@ -135,7 +198,9 @@ def test_tango_catalog_rejects_invalid_tango_reference(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) resolver = build_resolver(catalog) - with pytest.raises(pyaml.PyAMLException, match="Expected 'domain/family/member/attribute'"): + with pytest.raises( + pyaml.PyAMLException, match="Expected 'domain/family/member/attribute'" + ): resolver.resolve("domain/family/member") @@ -181,7 +246,10 @@ def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): assert not isinstance(device, AttributeReadOnly) assert device.name() == "domain/family/member/position[0]" assert device.unit() == "mm" - assert resolver.get_data_format("domain/family/member/position@0") == tango.AttrDataFormat.SPECTRUM + assert ( + resolver.get_data_format("domain/family/member/position@0") + == tango.AttrDataFormat.SPECTRUM + ) def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): @@ -250,3 +318,26 @@ def test_tango_catalog_wraps_tango_errors(): match="Tango catalog 'tango-direct' cannot resolve 'domain/family/member/attribute'", ): resolver.resolve("domain/family/member/attribute") + + +def test_tango_catalog_rejects_incomplete_tango_config(): + class IncompleteAttributeConfig: + unit = "A" + min_value = "-1" + max_value = "1" + data_format = tango.AttrDataFormat.SCALAR + + catalog = TangoCatalog(ConfigModel(name="tango-direct")) + resolver = build_resolver(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy( + "domain/family/member/attribute", IncompleteAttributeConfig() + ), + ): + with pytest.raises( + pyaml.PyAMLException, + match="incomplete Tango attribute config, missing 'writable'", + ): + resolver.resolve("domain/family/member/attribute") From 76946c4e7f1c9f701307698efa472c63e2663d9d Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 4 May 2026 11:37:39 +0200 Subject: [PATCH 07/36] Adapt Tango catalog resolution to new PyAML backend contract Move public device reference resolution into TangoControlSystem.get_device(). Resolve string refs through the runtime catalog, construct supported Tango DeviceAccess objects from backend config models, and expose get_catalog_config(). Remove the runtime dependency on PyAML CatalogResolver by making TangoCatalog resolve keys with an explicit TangoControlSystem context. Keep Tango key parsing, indexed SPECTRUM handling, and per-control-system catalog caches in the backend. Add distinct read-only config models and cover static catalog, Tango catalog, indexed lookup, config-model construction, missing catalog, and unknown key errors in tests. --- tango/pyaml/attribute_list.py | 11 ++ tango/pyaml/attribute_list_read_only.py | 6 +- tango/pyaml/attribute_read_only.py | 6 +- tango/pyaml/controlsystem.py | 97 +++++++++++++ tango/pyaml/static_catalog.py | 17 ++- tango/pyaml/tango_catalog.py | 176 +++++++++++++----------- tests/test_controlsystem.py | 163 ++++++++++++++++++++++ tests/test_static_catalog.py | 12 -- tests/test_tango_catalog.py | 85 ++++++------ 9 files changed, 433 insertions(+), 140 deletions(-) diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index 4d1eddf..44156e5 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -85,6 +85,17 @@ def measure_name(self) -> str: """ return self._cfg.name + def get_tango_attributes(self) -> list[str]: + """ + Return the raw Tango attribute paths stored in the configuration. + + Returns + ------- + list[str] + Tango attribute paths in configured order. + """ + return self._cfg.attributes + def set(self, value: float): """ Write a value asynchronously to all Tango attributes. diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index 104e561..32828b5 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,13 +1,17 @@ import logging import pyaml -from .attribute_list import AttributeList, ConfigModel +from .attribute_list import AttributeList, ConfigModel as AttributeListConfigModel PYAMLCLASS: str = "AttributeListReadOnly" logger = logging.getLogger(__name__) +class ConfigModel(AttributeListConfigModel): + """Configuration model for a read-only Tango attribute list.""" + + class AttributeListReadOnly(AttributeList): """ Handle a list of Tango attributes using Tango Groups. diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index 1dccc03..614fe94 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,6 +1,6 @@ import logging -from .attribute import Attribute, ConfigModel +from .attribute import Attribute, ConfigModel as AttributeConfigModel from .tango_pyaml_utils import * PYAMLCLASS: str = "AttributeReadOnly" @@ -8,6 +8,10 @@ logger = logging.getLogger(__name__) +class ConfigModel(AttributeConfigModel): + """Configuration model for a read-only Tango attribute.""" + + class AttributeReadOnly(Attribute): """ Read-only Tango attribute. diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 435588d..6b82dce 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -6,6 +6,16 @@ from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from . import __version__ +from .attribute import Attribute, ConfigModel as AttributeConfigModel +from .attribute_list import AttributeList, ConfigModel as AttributeListConfigModel +from .attribute_list_read_only import ( + AttributeListReadOnly, + ConfigModel as AttributeListReadOnlyConfigModel, +) +from .attribute_read_only import ( + AttributeReadOnly, + ConfigModel as AttributeReadOnlyConfigModel, +) PYAMLCLASS: str = "TangoControlSystem" @@ -102,6 +112,93 @@ def _attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: newDevs.append(None) return newDevs + def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: + """ + Resolve a public device reference for this Tango control system. + + YAML references are opaque strings resolved by the configured backend + catalog. Public Python APIs may pass Tango backend configuration models. + Already constructed DeviceAccess instances are intentionally rejected: + attach() remains the internal compatibility API for those. + """ + if ref is None: + return None + + if isinstance(ref, DeviceAccess): + raise PyAMLException( + "TangoControlSystem.get_device() expects a catalog key, Tango " + "ConfigModel, or None. Use attach() for already constructed " + "DeviceAccess objects." + ) + + if isinstance(ref, str): + catalog = self.get_catalog() + if catalog is None: + raise PyAMLException( + f"TangoControlSystem '{self.name()}' has no catalog configured." + ) + if not isinstance(catalog, Catalog): + raise PyAMLException( + f"TangoControlSystem '{self.name()}' has unsupported catalog type " + f"{type(catalog).__name__}." + ) + try: + resolve = catalog.resolve + except AttributeError as exc: + raise PyAMLException( + f"Catalog '{catalog.get_name()}' cannot resolve key '{ref}': " + "missing backend resolve() method." + ) from exc + device = resolve(ref, self) + return self.attach([device])[0] + + if isinstance(ref, AttributeReadOnlyConfigModel): + return self.attach([AttributeReadOnly(ref)])[0] + + if isinstance(ref, AttributeConfigModel): + return self.attach([Attribute(ref)])[0] + + if isinstance(ref, AttributeListReadOnlyConfigModel): + return AttributeListReadOnly(self._attach_attribute_list_config(ref)) + + if isinstance(ref, AttributeListConfigModel): + return AttributeList(self._attach_attribute_list_config(ref)) + + if isinstance(ref, BaseModel): + raise PyAMLException( + f"TangoControlSystem cannot construct a device from config model " + f"{type(ref).__name__}." + ) + + raise PyAMLException( + f"TangoControlSystem.get_device() cannot resolve references of type " + f"{type(ref).__name__}; expected str, Tango ConfigModel, or None." + ) + + def get_catalog_config(self) -> Catalog | str | None: + """ + Return the catalog configured for this Tango control system. + + PyAML keeps this value as backend configuration only; runtime catalog + resolution is owned by ``get_device()``. + """ + return self._cfg.catalog + + def _attach_attribute_list_config( + self, cfg: AttributeListConfigModel + ) -> AttributeListConfigModel: + tango_host = self.get_tango_host() + if not tango_host: + return cfg + + return cfg.model_copy( + update={ + "attributes": [ + f"//{tango_host}/{attribute}" for attribute in cfg.attributes + ] + } + ) + def name(self) -> str: """ Return the name of the control system. diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index 4a23ce0..3a97428 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -49,15 +49,19 @@ class StaticCatalog(Catalog): def __init__(self, cfg: ConfigModel): super().__init__(cfg) if len(cfg.entries) == 0: - raise PyAMLException("StaticCatalog.entries must contain at least one entry") + raise PyAMLException( + "StaticCatalog.entries must contain at least one entry" + ) self._refs: dict[str, DeviceAccess] = {} for entry in cfg.entries: key = entry.get_key() if key in self._refs: - raise PyAMLException(f"StaticCatalog.entries contains duplicate key '{key}'") + raise PyAMLException( + f"StaticCatalog.entries contains duplicate key '{key}'" + ) self._refs[key] = entry.get_device() - def resolve(self, key: str) -> DeviceAccess: + def resolve(self, key: str, control_system: object | None = None) -> DeviceAccess: """ Return the device associated with ``key``. @@ -65,6 +69,9 @@ def resolve(self, key: str) -> DeviceAccess: ---------- key : str Catalog key to resolve. + control_system : object | None + Optional backend context. Static catalogs do not need it, but the + argument keeps the backend catalog API uniform. Returns ------- @@ -79,4 +86,6 @@ def resolve(self, key: str) -> DeviceAccess: try: return self._refs[key] except KeyError as exc: - raise PyAMLException(f"Catalog '{self.get_name()}' cannot resolve key '{key}'") from exc + raise PyAMLException( + f"Catalog '{self.get_name()}' cannot resolve key '{key}'" + ) from exc diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 29106cc..c13b88c 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,9 +1,8 @@ import tango import pyaml -from typing import TYPE_CHECKING from pydantic import ConfigDict -from pyaml.configuration.catalog import Catalog, CatalogConfigModel, CatalogResolver +from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess from .attribute import Attribute, ConfigModel as AttributeConfigModel @@ -12,9 +11,6 @@ PYAMLCLASS = "TangoCatalog" -if TYPE_CHECKING: - from .controlsystem import TangoControlSystem - class ConfigModel(CatalogConfigModel): """ @@ -42,64 +38,42 @@ class TangoCatalog(Catalog): (``domain/family/member/attribute@index``). """ - def resolve(self, key: str) -> DeviceAccess: - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' must be attached to a TangoControlSystem before resolving key '{key}'" - ) - - def is_disconnected(self) -> bool: - return self._cfg.disconnected - - def attach_control_system(self, control_system: object) -> "TangoCatalogResolver": - from .controlsystem import TangoControlSystem - - if not isinstance(control_system, TangoControlSystem): - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' can only be attached to TangoControlSystem" - ) - return TangoCatalogResolver(self, control_system) - - -class TangoCatalogResolver(CatalogResolver): - """ - Resolver bound to one TangoControlSystem. - - Supports two key formats: - - - ``domain/family/member/attribute`` — resolves to a scalar - :class:`~tango.pyaml.attribute.Attribute` or - :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`. - - ``domain/family/member/attribute@index`` — resolves to a scalar view - of one element in a SPECTRUM attribute - (:class:`~tango.pyaml.attribute_indexed.AttributeIndexed` or - :class:`~tango.pyaml.attribute_indexed_read_only.AttributeIndexedReadOnly`). - - In connected mode (``disconnected=False``) indexed keys additionally verify - that the Tango attribute is a SPECTRUM. - """ - _WRITABLE_TYPES = { tango.AttrWriteType.READ_WRITE, tango.AttrWriteType.WRITE, tango.AttrWriteType.READ_WITH_WRITE, } - def __init__(self, catalog: TangoCatalog, control_system: "TangoControlSystem"): - self._catalog = catalog - self._control_system = control_system - # Resolved DeviceAccess objects are bound to one control system context, - # so cache them in the resolver returned by attach_control_system(). - self._refs: dict[str, DeviceAccess] = {} - self._data_formats: dict[str, tango.AttrDataFormat] = {} + def __init__(self, cfg: ConfigModel): + super().__init__(cfg) + # Resolved DeviceAccess objects are bound to one control-system context + # because metadata lookup depends on that control system's Tango host. + self._refs: dict[tuple[int, str], DeviceAccess] = {} + self._data_formats: dict[tuple[int, str], tango.AttrDataFormat] = {} - def resolve(self, key: str) -> DeviceAccess: + def resolve(self, key: str, control_system: object | None = None) -> DeviceAccess: """ Resolve a Tango attribute reference into a DeviceAccess. + Supports two key formats: + + - ``domain/family/member/attribute`` — resolves to a scalar + :class:`~tango.pyaml.attribute.Attribute` or + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`. + - ``domain/family/member/attribute@index`` — resolves to a scalar view + of one element in a SPECTRUM attribute + (:class:`~tango.pyaml.attribute_indexed.AttributeIndexed` or + :class:`~tango.pyaml.attribute_indexed_read_only.AttributeIndexedReadOnly`). + + In connected mode (``disconnected=False``) indexed keys additionally verify + that the Tango attribute is a SPECTRUM. + Parameters ---------- key : str Plain attribute path or indexed path (``attribute@index``). + control_system : object + Tango control-system context used for Tango host handling. Returns ------- @@ -112,23 +86,38 @@ def resolve(self, key: str) -> DeviceAccess: If the key is malformed, the Tango call fails, or (in connected mode) an indexed key targets a non-SPECTRUM attribute. """ + self._validate_control_system(control_system, key) attr_path, index = self._parse_key(key) + cache_key = (id(control_system), key) - if key not in self._refs: + if cache_key not in self._refs: if index is not None: - if self._catalog.is_disconnected(): - self._refs[key] = self._build_disconnected_indexed(attr_path, index) + if self.is_disconnected(): + self._refs[cache_key] = self._build_disconnected_indexed( + cache_key, attr_path, index + ) else: - self._refs[key] = self._build_connected_indexed(attr_path, index) + self._refs[cache_key] = self._build_connected_indexed( + cache_key, control_system, attr_path, index + ) else: - if self._catalog.is_disconnected(): - self._refs[key] = self._build_disconnected_attribute(key) + if self.is_disconnected(): + self._refs[cache_key] = self._build_disconnected_attribute( + cache_key, key + ) else: - self._refs[key] = self._build_connected_attribute(key) + self._refs[cache_key] = self._build_connected_attribute( + cache_key, control_system, key + ) - return self._refs[key] + return self._refs[cache_key] - def get_data_format(self, key: str) -> tango.AttrDataFormat: + def is_disconnected(self) -> bool: + return self._cfg.disconnected + + def get_data_format( + self, key: str, control_system: object | None = None + ) -> tango.AttrDataFormat: """ Return the Tango data format for a resolved attribute. @@ -137,6 +126,8 @@ def get_data_format(self, key: str) -> tango.AttrDataFormat: key : str Catalog key (must have been resolved at least once, or will be resolved now). + control_system : object + Tango control-system context used for Tango host handling. Returns ------- @@ -144,8 +135,22 @@ def get_data_format(self, key: str) -> tango.AttrDataFormat: Data format reported by Tango, or ``FMT_UNKNOWN`` in disconnected mode. """ - self.resolve(key) - return self._data_formats[key] + self.resolve(key, control_system) + return self._data_formats[(id(control_system), key)] + + def _validate_control_system(self, control_system: object | None, key: str) -> None: + from .controlsystem import TangoControlSystem + + if control_system is None: + raise pyaml.PyAMLException( + f"Tango catalog '{self.get_name()}' needs a TangoControlSystem context " + f"before resolving key '{key}'" + ) + + if not isinstance(control_system, TangoControlSystem): + raise pyaml.PyAMLException( + f"Tango catalog '{self.get_name()}' can only resolve through TangoControlSystem" + ) def _parse_key(self, key: str) -> tuple[str, int | None]: """ @@ -163,7 +168,7 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: """ if not isinstance(key, str): raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' expects string keys, got {type(key).__name__}" + f"Tango catalog '{self.get_name()}' expects string keys, got {type(key).__name__}" ) if "@" in key: @@ -172,7 +177,7 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: index = int(idx_str) except ValueError as exc: raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' invalid index '{idx_str}' in key '{key}'." + f"Tango catalog '{self.get_name()}' invalid index '{idx_str}' in key '{key}'." ) from exc else: attr_path = key @@ -181,29 +186,34 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: parts = attr_path.split("/") if len(parts) != 4 or any(part == "" for part in parts): raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' cannot resolve invalid Tango attribute " + f"Tango catalog '{self.get_name()}' cannot resolve invalid Tango attribute " f"reference '{key}'. Expected 'domain/family/member/attribute' or " f"'domain/family/member/attribute@index'." ) return attr_path, index - def _build_disconnected_attribute(self, key: str) -> DeviceAccess: + def _build_disconnected_attribute( + self, cache_key: tuple[int, str], key: str + ) -> DeviceAccess: # In disconnected mode, keep all metadata local. In particular, setting # range avoids Attribute.get_range() from lazily querying Tango later. - self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN + self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN return Attribute(AttributeConfigModel(attribute=key, range=(None, None))) - def _build_disconnected_indexed(self, attr_path: str, index: int) -> DeviceAccess: + def _build_disconnected_indexed( + self, cache_key: tuple[int, str], attr_path: str, index: int + ) -> DeviceAccess: # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. - key = f"{attr_path}@{index}" - self._data_formats[key] = tango.AttrDataFormat.FMT_UNKNOWN + self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN return Attribute( AttributeConfigModel(attribute=attr_path, index=index, range=(None, None)) ) - def _build_connected_attribute(self, key: str) -> DeviceAccess: - tango_attr_name = self._tango_attribute_name(key) + def _build_connected_attribute( + self, cache_key: tuple[int, str], control_system: object, key: str + ) -> DeviceAccess: + tango_attr_name = self._tango_attribute_name(control_system, key) try: # AttributeProxy.get_config() is the most direct way to retrieve # writability, unit, range and data format from Tango. @@ -211,20 +221,26 @@ def _build_connected_attribute(self, key: str) -> DeviceAccess: except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" + f"Tango catalog '{self.get_name()}' cannot resolve '{key}': {pyaml_exception}" ) from df unit, attr_range, data_format, writable = self._read_config_metadata( attr_config, key ) - self._data_formats[key] = data_format + self._data_formats[cache_key] = data_format cfg = AttributeConfigModel(attribute=key, unit=unit, range=attr_range) if writable in self._WRITABLE_TYPES: return Attribute(cfg) return AttributeReadOnly(cfg) - def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: + def _build_connected_indexed( + self, + cache_key: tuple[int, str], + control_system: object, + attr_path: str, + index: int, + ) -> DeviceAccess: """ Build an indexed device access after verifying the attribute is a SPECTRUM. @@ -234,13 +250,13 @@ def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: If the Tango call fails or the attribute is not a SPECTRUM. """ key = f"{attr_path}@{index}" - tango_attr_name = self._tango_attribute_name(attr_path) + tango_attr_name = self._tango_attribute_name(control_system, attr_path) try: attr_config = tango.AttributeProxy(tango_attr_name).get_config() except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': {pyaml_exception}" + f"Tango catalog '{self.get_name()}' cannot resolve '{key}': {pyaml_exception}" ) from df unit, attr_range, data_format, writable = self._read_config_metadata( @@ -248,11 +264,11 @@ def _build_connected_indexed(self, attr_path: str, index: int) -> DeviceAccess: ) if data_format != tango.AttrDataFormat.SPECTRUM: raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' cannot use '{key}' as an indexed " + f"Tango catalog '{self.get_name()}' cannot use '{key}' as an indexed " "key: the Tango attribute is not a SPECTRUM." ) - self._data_formats[key] = tango.AttrDataFormat.SPECTRUM + self._data_formats[cache_key] = tango.AttrDataFormat.SPECTRUM cfg = AttributeConfigModel( attribute=attr_path, index=index, unit=unit, range=attr_range ) @@ -279,14 +295,14 @@ def _read_config_metadata( writable = attr_config.writable except AttributeError as exc: raise pyaml.PyAMLException( - f"Tango catalog '{self._catalog.get_name()}' cannot resolve '{key}': " + f"Tango catalog '{self.get_name()}' cannot resolve '{key}': " f"incomplete Tango attribute config, missing '{exc.name}'." ) from exc return unit, attr_range, data_format, writable - def _tango_attribute_name(self, attr_path: str) -> str: - tango_host = self._control_system.get_tango_host() + def _tango_attribute_name(self, control_system: object, attr_path: str) -> str: + tango_host = control_system.get_tango_host() if tango_host: return f"//{tango_host}/{attr_path}" return attr_path diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 594bef0..45d5142 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,5 +1,7 @@ import logging +import pyaml +import pytest from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel from tango.pyaml.static_catalog import StaticCatalog from tango.pyaml.static_catalog_entry import ( @@ -10,8 +12,15 @@ from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch +from tango.pyaml.attribute_list import AttributeList +from tango.pyaml.attribute_list import ConfigModel as AttributeListConfigModel +from tango.pyaml.attribute_list_read_only import AttributeListReadOnly +from tango.pyaml.attribute_list_read_only import ( + ConfigModel as AttributeListReadOnlyConfigModel, +) from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel from tango.pyaml.attribute_read_only import AttributeReadOnly +from tango.pyaml.attribute_read_only import ConfigModel as AttributeReadOnlyConfigModel from tango.pyaml import __version__ @@ -74,10 +83,164 @@ def test_catalog_can_be_configured_and_resolved(): assert resolved.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" +def test_configured_catalog_instance_is_not_runtime_catalog_until_set(): + device = Attribute(AttributeConfigModel(attribute="sys/tg_test/1/float_scalar")) + catalog = StaticCatalog( + StaticCatalogConfigModel( + name="device-catalog", + entries=[ + StaticCatalogEntry( + StaticCatalogEntryConfigModel(key="BPM_C01-01/x", device=device) + ) + ], + ) + ) + cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) + + assert cs.get_catalog_config() is catalog + assert cs.get_catalog() is None + with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): + cs.get_device("BPM_C01-01/x") + + +def test_get_device_builds_attribute_from_config_model(): + cs = TangoControlSystem( + ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") + ) + + resolved = cs.get_device( + AttributeConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") + ) + + assert isinstance(resolved, Attribute) + assert resolved.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" + assert resolved.unit() == "A" + + +def test_get_device_builds_read_only_attribute_from_config_model(): + cs = TangoControlSystem( + ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") + ) + + resolved = cs.get_device( + AttributeReadOnlyConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") + ) + + assert isinstance(resolved, AttributeReadOnly) + assert resolved.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" + assert resolved.unit() == "A" + + +def test_get_device_builds_attribute_list_from_config_model(): + cs = TangoControlSystem( + ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") + ) + + resolved = cs.get_device( + AttributeListConfigModel( + name="group", + attributes=[ + "sys/tg_test/1/float_scalar", + "sys/tg_test/2/float_scalar", + ], + unit="A", + ) + ) + + assert isinstance(resolved, AttributeList) + assert not isinstance(resolved, AttributeListReadOnly) + assert resolved.name() == "group" + assert resolved.unit() == "A" + assert resolved.get_tango_attributes() == [ + "//tangodb:10000/sys/tg_test/1/float_scalar", + "//tangodb:10000/sys/tg_test/2/float_scalar", + ] + + +def test_get_device_builds_read_only_attribute_list_from_config_model(): + cs = TangoControlSystem( + ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") + ) + + resolved = cs.get_device( + AttributeListReadOnlyConfigModel( + name="group", + attributes=[ + "sys/tg_test/1/float_scalar", + "sys/tg_test/2/float_scalar", + ], + unit="A", + ) + ) + + assert isinstance(resolved, AttributeListReadOnly) + assert resolved.name() == "group" + assert resolved.unit() == "A" + assert resolved.get_tango_attributes() == [ + "//tangodb:10000/sys/tg_test/1/float_scalar", + "//tangodb:10000/sys/tg_test/2/float_scalar", + ] + + +def test_get_device_none_returns_none(): + cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + + assert cs.get_device(None) is None + + +def test_get_device_rejects_preconstructed_device_access(config): + cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + + with pytest.raises(pyaml.PyAMLException, match="Use attach\\(\\)"): + cs.get_device(Attribute(config)) + + +def test_get_device_requires_catalog_for_string_key(): + cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + + with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): + cs.get_device("BPM_C01-01/x") + + +def test_get_device_rejects_unloaded_named_catalog(): + cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog="device-catalog")) + + with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): + cs.get_device("BPM_C01-01/x") + + +def test_get_device_reports_unknown_catalog_key(): + device = Attribute(AttributeConfigModel(attribute="sys/tg_test/1/float_scalar")) + catalog = StaticCatalog( + StaticCatalogConfigModel( + name="device-catalog", + entries=[ + StaticCatalogEntry( + StaticCatalogEntryConfigModel(key="BPM_C01-01/x", device=device) + ) + ], + ) + ) + cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) + cs.set_catalog(catalog) + + with pytest.raises(pyaml.PyAMLException, match="cannot resolve key 'BPM_C01-02/x'"): + cs.get_device("BPM_C01-02/x") + + +def test_get_device_rejects_unknown_reference_type(): + cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + + with pytest.raises(pyaml.PyAMLException, match="type int"): + cs.get_device(42) + + def test_named_catalog_config_is_accepted(): cfg = ConfigModel(name="test_tango_cs", catalog="device-catalog") + cs = TangoControlSystem(cfg) assert cfg.catalog == "device-catalog" + assert cs.get_catalog_config() == "device-catalog" def test_tango_control_system_exposes_tango_host(): diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index d15568b..670a285 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -102,18 +102,6 @@ def test_static_catalog_error_includes_catalog_name(): catalog.resolve("missing") -# --- attach_control_system --- - - -def test_static_catalog_attach_control_system_returns_self(): - catalog = make_catalog() - control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) - - resolver = catalog.attach_control_system(control_system) - - assert resolver is catalog - - def test_static_catalog_is_shared_across_control_systems(): device = make_attribute() catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 5da26f7..050ee9d 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -13,17 +13,18 @@ from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog -def build_resolver(catalog: TangoCatalog, name="live"): +def build_control_system(catalog: TangoCatalog, name="live"): control_system = TangoControlSystem(TangoControlSystemConfigModel(name=name)) - return catalog.attach_control_system(control_system) + control_system.set_catalog(catalog) + return control_system def test_tango_catalog_disconnected_resolves_without_querying_tango(): catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: - device = resolver.resolve("domain/family/member/attribute") + device = catalog.resolve("domain/family/member/attribute", control_system) attr_proxy.assert_not_called() assert isinstance(device, Attribute) @@ -42,13 +43,13 @@ def test_tango_catalog_connected_resolves_writable_attribute(): data_format=tango.AttrDataFormat.SPECTRUM, ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/current", attr_config), ): - device = resolver.resolve("domain/family/member/current") + device = catalog.resolve("domain/family/member/current", control_system) assert isinstance(device, Attribute) assert not isinstance(device, AttributeReadOnly) @@ -56,7 +57,7 @@ def test_tango_catalog_connected_resolves_writable_attribute(): assert device.unit() == "A" assert device.get_range() == [-10.5, 12.0] assert ( - resolver.get_data_format("domain/family/member/current") + catalog.get_data_format("domain/family/member/current", control_system) == tango.AttrDataFormat.SPECTRUM ) @@ -66,13 +67,13 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): name="position", writable=tango.AttrWriteType.READ, unit="mm" ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/position", attr_config), ): - device = resolver.resolve("domain/family/member/position") + device = catalog.resolve("domain/family/member/position", control_system) assert isinstance(device, AttributeReadOnly) assert device.unit() == "mm" @@ -80,14 +81,14 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): def test_tango_catalog_caches_resolved_devices(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/attribute"), ) as attr_proxy: - first = resolver.resolve("domain/family/member/attribute") - second = resolver.resolve("domain/family/member/attribute") + first = catalog.resolve("domain/family/member/attribute", control_system) + second = catalog.resolve("domain/family/member/attribute", control_system) attr_proxy.assert_called_once_with("domain/family/member/attribute") assert first is second @@ -95,16 +96,16 @@ def test_tango_catalog_caches_resolved_devices(): def test_tango_catalog_cache_is_bound_to_control_system_resolver(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - live_resolver = build_resolver(catalog, name="live") - ops_resolver = build_resolver(catalog, name="ops") + live = build_control_system(catalog, name="live") + ops = build_control_system(catalog, name="ops") with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/attribute"), ) as attr_proxy: - live_first = live_resolver.resolve("domain/family/member/attribute") - live_second = live_resolver.resolve("domain/family/member/attribute") - ops_device = ops_resolver.resolve("domain/family/member/attribute") + live_first = catalog.resolve("domain/family/member/attribute", live) + live_second = catalog.resolve("domain/family/member/attribute", live) + ops_device = catalog.resolve("domain/family/member/attribute", ops) assert attr_proxy.call_count == 2 assert live_first is live_second @@ -168,9 +169,9 @@ def test_tango_catalog_rejects_non_tango_control_system(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) with pytest.raises( - pyaml.PyAMLException, match="can only be attached to TangoControlSystem" + pyaml.PyAMLException, match="can only resolve through TangoControlSystem" ): - catalog.attach_control_system(ControlSystemAdapter()) + catalog.resolve("domain/family/member/attribute", ControlSystemAdapter()) def test_tango_catalog_rejects_external_tango_control_system_class(): @@ -180,44 +181,44 @@ class TangoControlSystem: catalog = TangoCatalog(ConfigModel(name="tango-direct")) with pytest.raises( - pyaml.PyAMLException, match="can only be attached to TangoControlSystem" + pyaml.PyAMLException, match="can only resolve through TangoControlSystem" ): - catalog.attach_control_system(TangoControlSystem()) + catalog.resolve("domain/family/member/attribute", TangoControlSystem()) def test_tango_catalog_requires_control_system_attachment(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) with pytest.raises( - pyaml.PyAMLException, match="must be attached to a TangoControlSystem" + pyaml.PyAMLException, match="needs a TangoControlSystem context" ): catalog.resolve("domain/family/member/attribute") def test_tango_catalog_rejects_invalid_tango_reference(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with pytest.raises( pyaml.PyAMLException, match="Expected 'domain/family/member/attribute'" ): - resolver.resolve("domain/family/member") + catalog.resolve("domain/family/member", control_system) def test_tango_catalog_rejects_invalid_index(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with pytest.raises(pyaml.PyAMLException, match="invalid index"): - resolver.resolve("domain/family/member/attribute@notanint") + catalog.resolve("domain/family/member/attribute@notanint", control_system) def test_tango_catalog_disconnected_resolves_indexed_attribute(): catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: - device = resolver.resolve("domain/family/member/attribute@1") + device = catalog.resolve("domain/family/member/attribute@1", control_system) attr_proxy.assert_not_called() assert isinstance(device, Attribute) and device._index is not None @@ -234,20 +235,20 @@ def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): data_format=tango.AttrDataFormat.SPECTRUM, ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/position", attr_config), ): - device = resolver.resolve("domain/family/member/position@0") + device = catalog.resolve("domain/family/member/position@0", control_system) assert isinstance(device, Attribute) and device._index is not None assert not isinstance(device, AttributeReadOnly) assert device.name() == "domain/family/member/position[0]" assert device.unit() == "mm" assert ( - resolver.get_data_format("domain/family/member/position@0") + catalog.get_data_format("domain/family/member/position@0", control_system) == tango.AttrDataFormat.SPECTRUM ) @@ -260,13 +261,13 @@ def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): data_format=tango.AttrDataFormat.SPECTRUM, ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/position", attr_config), ): - device = resolver.resolve("domain/family/member/position@2") + device = catalog.resolve("domain/family/member/position@2", control_system) assert isinstance(device, AttributeReadOnly) and device._index is not None assert device.unit() == "mm" @@ -279,14 +280,14 @@ def test_tango_catalog_connected_rejects_indexed_scalar_attribute(): data_format=tango.AttrDataFormat.SCALAR, ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/current", attr_config), ): with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): - resolver.resolve("domain/family/member/current@0") + catalog.resolve("domain/family/member/current@0", control_system) def test_tango_catalog_indexed_caches_resolved_devices(): @@ -295,14 +296,14 @@ def test_tango_catalog_indexed_caches_resolved_devices(): data_format=tango.AttrDataFormat.SPECTRUM, ) catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", return_value=MockedAttributeProxy("domain/family/member/position", attr_config), ) as attr_proxy: - first = resolver.resolve("domain/family/member/position@1") - second = resolver.resolve("domain/family/member/position@1") + first = catalog.resolve("domain/family/member/position@1", control_system) + second = catalog.resolve("domain/family/member/position@1", control_system) attr_proxy.assert_called_once_with("domain/family/member/position") assert first is second @@ -310,14 +311,14 @@ def test_tango_catalog_indexed_caches_resolved_devices(): def test_tango_catalog_wraps_tango_errors(): catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): with pytest.raises( pyaml.PyAMLException, match="Tango catalog 'tango-direct' cannot resolve 'domain/family/member/attribute'", ): - resolver.resolve("domain/family/member/attribute") + catalog.resolve("domain/family/member/attribute", control_system) def test_tango_catalog_rejects_incomplete_tango_config(): @@ -328,7 +329,7 @@ class IncompleteAttributeConfig: data_format = tango.AttrDataFormat.SCALAR catalog = TangoCatalog(ConfigModel(name="tango-direct")) - resolver = build_resolver(catalog) + control_system = build_control_system(catalog) with patch( "tango.AttributeProxy", @@ -340,4 +341,4 @@ class IncompleteAttributeConfig: pyaml.PyAMLException, match="incomplete Tango attribute config, missing 'writable'", ): - resolver.resolve("domain/family/member/attribute") + catalog.resolve("domain/family/member/attribute", control_system) From bea5df42a7573c7905f9aa470643659b8639f9c5 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Tue, 19 May 2026 15:13:25 +0200 Subject: [PATCH 08/36] Annotation correction for logging level --- tango/pyaml/controlsystem.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 744eb35..c37fc41 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -21,8 +21,8 @@ class ConfigModel(BaseModel): Name of the control system. tango_host : str Tango host URL. Default is the TANGO_HOST variable. - debug_level : int - Debug verbosity level. + debug_level : str | int | None + Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. Or 10, 20, 30, 40, 50. scalar_aggregator : str Aggregator module for scalar values. If none specified, writings and readings of sclar value are serialized. vector_aggregator : str @@ -33,7 +33,7 @@ class ConfigModel(BaseModel): name: str tango_host: str | None = None - debug_level: str = None + debug_level: str | int | None = None lazy_devices: bool = True scalar_aggregator: str | None = "tango.pyaml.multi_attribute" vector_aggregator: str | None = None From fa9f72c614480735c294d5a7866580104ebf07ed Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 11 Jun 2026 15:44:58 +0200 Subject: [PATCH 09/36] Adapting the catalog mechanism. --- tango/pyaml/__init__.py | 2 +- tango/pyaml/catalog.py | 24 +++++++++++++++ tango/pyaml/controlsystem.py | 40 +++++++++++++++---------- tango/pyaml/static_catalog.py | 11 +++---- tango/pyaml/tango_catalog.py | 35 +++++++++------------- tests/test_controlsystem.py | 40 ------------------------- tests/test_static_catalog.py | 28 ++++-------------- tests/test_tango_catalog.py | 55 ++++++++++++++++------------------- 8 files changed, 100 insertions(+), 135 deletions(-) create mode 100644 tango/pyaml/catalog.py diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index ad4a188..a459ed4 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.3" +__version__ = "0.4.0" import logging.config import os diff --git a/tango/pyaml/catalog.py b/tango/pyaml/catalog.py new file mode 100644 index 0000000..91d2e5c --- /dev/null +++ b/tango/pyaml/catalog.py @@ -0,0 +1,24 @@ +"""Configuration helpers for backend-provided catalogs.""" + +from abc import ABCMeta, abstractmethod + +from pydantic import BaseModel + + +class Catalog(metaclass=ABCMeta): + r""" + Abstract class for backend catalog configuration objects. + + Notes + ----- + Concrete catalogs live in each control-system package. They may expose + backend-specific resolution APIs, but those APIs are not called by the + PyAML core. + """ + + @abstractmethod + def resolve(self, key: str) -> BaseModel: + """ + Return a configuration model for a DeviceAccess + """ + pass diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 6b82dce..64aff16 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,8 +1,8 @@ import logging from pydantic import BaseModel, ConfigDict + from pyaml import PyAMLException -from pyaml.configuration.catalog import Catalog from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from . import __version__ @@ -16,6 +16,8 @@ AttributeReadOnly, ConfigModel as AttributeReadOnlyConfigModel, ) +from .catalog import Catalog +from .multi_attribute import MultiAttribute PYAMLCLASS: str = "TangoControlSystem" @@ -32,8 +34,8 @@ class ConfigModel(BaseModel): Name of the control system. tango_host : str Tango host URL. Default is the TANGO_HOST variable. - catalog : Catalog | str | None - Catalog instance or catalog name used to resolve PyAML device keys. + catalog : Catalog | None + Catalog instance used to resolve PyAML device keys. debug_level : int Debug verbosity level. scalar_aggregator : str @@ -48,7 +50,7 @@ class ConfigModel(BaseModel): name: str tango_host: str | None = None - catalog: Catalog | str | None = None + catalog: Catalog | None = None debug_level: str | None = None lazy_devices: bool = True scalar_aggregator: str | None = "tango.pyaml.multi_attribute" @@ -150,13 +152,13 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: "missing backend resolve() method." ) from exc device = resolve(ref, self) - return self.attach([device])[0] + return self._attach([device])[0] if isinstance(ref, AttributeReadOnlyConfigModel): - return self.attach([AttributeReadOnly(ref)])[0] + return self._attach([AttributeReadOnly(ref)])[0] if isinstance(ref, AttributeConfigModel): - return self.attach([Attribute(ref)])[0] + return self._attach([Attribute(ref)])[0] if isinstance(ref, AttributeListReadOnlyConfigModel): return AttributeListReadOnly(self._attach_attribute_list_config(ref)) @@ -175,15 +177,6 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: f"{type(ref).__name__}; expected str, Tango ConfigModel, or None." ) - def get_catalog_config(self) -> Catalog | str | None: - """ - Return the catalog configured for this Tango control system. - - PyAML keeps this value as backend configuration only; runtime catalog - resolution is owned by ``get_device()``. - """ - return self._cfg.catalog - def _attach_attribute_list_config( self, cfg: AttributeListConfigModel ) -> AttributeListConfigModel: @@ -243,5 +236,20 @@ def vector_aggregator(self) -> str | None: """ return self._cfg.vector_aggregator + def get_aggregator(self) -> MultiAttribute | None: + """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" + return MultiAttribute() + + def get_catalog(self) -> Catalog | None: + """ + Returns the catalog that references all control systems devices. + + Returns + ------- + Catalog + The catalog + """ + return self._cfg.catalog + def __repr__(self): return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index 3a97428..2161227 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -1,15 +1,15 @@ -from pydantic import ConfigDict +from pydantic import ConfigDict, BaseModel from pyaml import PyAMLException -from pyaml.configuration.catalog import Catalog, CatalogConfigModel from pyaml.control.deviceaccess import DeviceAccess +from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry PYAMLCLASS = "StaticCatalog" -class ConfigModel(CatalogConfigModel): +class ConfigModel(BaseModel): """ Configuration model for a static catalog. @@ -47,7 +47,8 @@ class StaticCatalog(Catalog): """ def __init__(self, cfg: ConfigModel): - super().__init__(cfg) + super().__init__() + self._cfg = cfg if len(cfg.entries) == 0: raise PyAMLException( "StaticCatalog.entries must contain at least one entry" @@ -87,5 +88,5 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces return self._refs[key] except KeyError as exc: raise PyAMLException( - f"Catalog '{self.get_name()}' cannot resolve key '{key}'" + f"Catalog cannot resolve key '{key}'" ) from exc diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index c13b88c..157c37d 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,18 +1,18 @@ import tango import pyaml -from pydantic import ConfigDict -from pyaml.configuration.catalog import Catalog, CatalogConfigModel +from pydantic import ConfigDict, BaseModel from pyaml.control.deviceaccess import DeviceAccess from .attribute import Attribute, ConfigModel as AttributeConfigModel from .attribute_read_only import AttributeReadOnly +from .catalog import Catalog from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none PYAMLCLASS = "TangoCatalog" -class ConfigModel(CatalogConfigModel): +class ConfigModel(BaseModel): """ Configuration model for a Tango catalog. @@ -45,7 +45,8 @@ class TangoCatalog(Catalog): } def __init__(self, cfg: ConfigModel): - super().__init__(cfg) + super().__init__() + self._cfg = cfg # Resolved DeviceAccess objects are bound to one control-system context # because metadata lookup depends on that control system's Tango host. self._refs: dict[tuple[int, str], DeviceAccess] = {} @@ -143,14 +144,12 @@ def _validate_control_system(self, control_system: object | None, key: str) -> N if control_system is None: raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' needs a TangoControlSystem context " + f"Tango catalog needs a TangoControlSystem context " f"before resolving key '{key}'" ) if not isinstance(control_system, TangoControlSystem): - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' can only resolve through TangoControlSystem" - ) + raise pyaml.PyAMLException("Tango catalog can only resolve through TangoControlSystem") def _parse_key(self, key: str) -> tuple[str, int | None]: """ @@ -167,18 +166,14 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: not a valid integer. """ if not isinstance(key, str): - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' expects string keys, got {type(key).__name__}" - ) + raise pyaml.PyAMLException(f"Tango catalog expects string keys, got {type(key).__name__}") if "@" in key: attr_path, idx_str = key.rsplit("@", 1) try: index = int(idx_str) except ValueError as exc: - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' invalid index '{idx_str}' in key '{key}'." - ) from exc + raise pyaml.PyAMLException(f"Tango catalog invalid index '{idx_str}' in key '{key}'.") from exc else: attr_path = key index = None @@ -186,7 +181,7 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: parts = attr_path.split("/") if len(parts) != 4 or any(part == "" for part in parts): raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' cannot resolve invalid Tango attribute " + f"Tango catalog cannot resolve invalid Tango attribute " f"reference '{key}'. Expected 'domain/family/member/attribute' or " f"'domain/family/member/attribute@index'." ) @@ -220,9 +215,7 @@ def _build_connected_attribute( attr_config = tango.AttributeProxy(tango_attr_name).get_config() except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) - raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' cannot resolve '{key}': {pyaml_exception}" - ) from df + raise pyaml.PyAMLException(f"Tango catalog cannot resolve '{key}': {pyaml_exception}") from df unit, attr_range, data_format, writable = self._read_config_metadata( attr_config, key @@ -256,7 +249,7 @@ def _build_connected_indexed( except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' cannot resolve '{key}': {pyaml_exception}" + f"Tango catalog cannot resolve '{key}': {pyaml_exception}" ) from df unit, attr_range, data_format, writable = self._read_config_metadata( @@ -264,7 +257,7 @@ def _build_connected_indexed( ) if data_format != tango.AttrDataFormat.SPECTRUM: raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' cannot use '{key}' as an indexed " + f"Tango catalog cannot use '{key}' as an indexed " "key: the Tango attribute is not a SPECTRUM." ) @@ -295,7 +288,7 @@ def _read_config_metadata( writable = attr_config.writable except AttributeError as exc: raise pyaml.PyAMLException( - f"Tango catalog '{self.get_name()}' cannot resolve '{key}': " + f"Tango catalog cannot resolve '{key}': " f"incomplete Tango attribute config, missing '{exc.name}'." ) from exc diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 45d5142..1ec828e 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -55,7 +55,6 @@ def test_catalog_can_be_configured_and_resolved(): ) catalog = StaticCatalog( StaticCatalogConfigModel( - name="device-catalog", entries=[ StaticCatalogEntry( StaticCatalogEntryConfigModel( @@ -74,35 +73,13 @@ def test_catalog_can_be_configured_and_resolved(): ) ) - cs.set_catalog(catalog) resolved = cs.get_device("BPM_C01-01/x") - assert cs.get_catalog_config() is catalog assert cs.get_catalog() is catalog assert catalog.resolve("BPM_C01-01/x") is device assert resolved.name() == "//tangodb:10000/sys/tg_test/1/float_scalar" -def test_configured_catalog_instance_is_not_runtime_catalog_until_set(): - device = Attribute(AttributeConfigModel(attribute="sys/tg_test/1/float_scalar")) - catalog = StaticCatalog( - StaticCatalogConfigModel( - name="device-catalog", - entries=[ - StaticCatalogEntry( - StaticCatalogEntryConfigModel(key="BPM_C01-01/x", device=device) - ) - ], - ) - ) - cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) - - assert cs.get_catalog_config() is catalog - assert cs.get_catalog() is None - with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): - cs.get_device("BPM_C01-01/x") - - def test_get_device_builds_attribute_from_config_model(): cs = TangoControlSystem( ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") @@ -202,18 +179,10 @@ def test_get_device_requires_catalog_for_string_key(): cs.get_device("BPM_C01-01/x") -def test_get_device_rejects_unloaded_named_catalog(): - cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog="device-catalog")) - - with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): - cs.get_device("BPM_C01-01/x") - - def test_get_device_reports_unknown_catalog_key(): device = Attribute(AttributeConfigModel(attribute="sys/tg_test/1/float_scalar")) catalog = StaticCatalog( StaticCatalogConfigModel( - name="device-catalog", entries=[ StaticCatalogEntry( StaticCatalogEntryConfigModel(key="BPM_C01-01/x", device=device) @@ -222,7 +191,6 @@ def test_get_device_reports_unknown_catalog_key(): ) ) cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) - cs.set_catalog(catalog) with pytest.raises(pyaml.PyAMLException, match="cannot resolve key 'BPM_C01-02/x'"): cs.get_device("BPM_C01-02/x") @@ -235,14 +203,6 @@ def test_get_device_rejects_unknown_reference_type(): cs.get_device(42) -def test_named_catalog_config_is_accepted(): - cfg = ConfigModel(name="test_tango_cs", catalog="device-catalog") - cs = TangoControlSystem(cfg) - - assert cfg.catalog == "device-catalog" - assert cs.get_catalog_config() == "device-catalog" - - def test_tango_control_system_exposes_tango_host(): cs = TangoControlSystem( ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 670a285..0b903ed 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -26,7 +26,7 @@ def make_entry(key: str, device=None) -> StaticCatalogEntry: def make_catalog(name: str = "static", entries=None) -> StaticCatalog: if entries is None: entries = [make_entry("default/key")] - return StaticCatalog(StaticCatalogConfigModel(name=name, entries=entries)) + return StaticCatalog(StaticCatalogConfigModel(entries=entries)) # --- StaticCatalogEntry --- @@ -48,18 +48,13 @@ def test_static_catalog_entry_returns_device(): def test_static_catalog_rejects_empty_entries(): with pytest.raises(pyaml.PyAMLException, match="must contain at least one entry"): - StaticCatalog(StaticCatalogConfigModel(name="empty", entries=[])) + StaticCatalog(StaticCatalogConfigModel(entries=[])) def test_static_catalog_rejects_duplicate_keys(): entries = [make_entry("BPM/x"), make_entry("BPM/x")] with pytest.raises(pyaml.PyAMLException, match="duplicate key 'BPM/x'"): - StaticCatalog(StaticCatalogConfigModel(name="dup", entries=entries)) - - -def test_static_catalog_get_name(): - catalog = make_catalog(name="my-catalog") - assert catalog.get_name() == "my-catalog" + StaticCatalog(StaticCatalogConfigModel(entries=entries)) # --- StaticCatalog.resolve --- @@ -95,21 +90,11 @@ def test_static_catalog_raises_on_unknown_key(): catalog.resolve("BPM/y") -def test_static_catalog_error_includes_catalog_name(): - catalog = make_catalog(name="my-catalog", entries=[make_entry("BPM/x")]) - - with pytest.raises(pyaml.PyAMLException, match="Catalog 'my-catalog'"): - catalog.resolve("missing") - - def test_static_catalog_is_shared_across_control_systems(): device = make_attribute() catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - live = TangoControlSystem(TangoControlSystemConfigModel(name="live")) - ops = TangoControlSystem(TangoControlSystemConfigModel(name="ops")) - - live.set_catalog(catalog) - ops.set_catalog(catalog) + live = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) + ops = TangoControlSystem(TangoControlSystemConfigModel(name="ops", catalog=catalog)) assert live.get_catalog() is catalog assert ops.get_catalog() is catalog @@ -137,8 +122,7 @@ def test_static_catalog_works_with_attribute_read_only(): def test_static_catalog_can_be_used_through_tango_control_system(): device = make_attribute("sr/bpm/c01-01/x", unit="mm") catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) - control_system.set_catalog(catalog) + control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) resolved = control_system.get_device("BPM/x") diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 050ee9d..272bdc2 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -14,13 +14,12 @@ def build_control_system(catalog: TangoCatalog, name="live"): - control_system = TangoControlSystem(TangoControlSystemConfigModel(name=name)) - control_system.set_catalog(catalog) + control_system = TangoControlSystem(TangoControlSystemConfigModel(name=name, catalog=catalog)) return control_system def test_tango_catalog_disconnected_resolves_without_querying_tango(): - catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) + catalog = TangoCatalog(ConfigModel(disconnected=True)) control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: @@ -42,7 +41,7 @@ def test_tango_catalog_connected_resolves_writable_attribute(): max_value="12.0", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -66,7 +65,7 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): attr_config = MockedAttributeInfoEx( name="position", writable=tango.AttrWriteType.READ, unit="mm" ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -80,7 +79,7 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): def test_tango_catalog_caches_resolved_devices(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -95,7 +94,7 @@ def test_tango_catalog_caches_resolved_devices(): def test_tango_catalog_cache_is_bound_to_control_system_resolver(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) live = build_control_system(catalog, name="live") ops = build_control_system(catalog, name="ops") @@ -114,15 +113,11 @@ def test_tango_catalog_cache_is_bound_to_control_system_resolver(): def test_tango_catalog_connected_metadata_uses_control_system_tango_host(): key = "domain/family/member/current" - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) live = TangoControlSystem( - TangoControlSystemConfigModel(name="live", tango_host="live-db:10000") - ) + TangoControlSystemConfigModel(name="live", tango_host="live-db:10000", catalog=catalog)) ops = TangoControlSystem( - TangoControlSystemConfigModel(name="ops", tango_host="ops-db:10000") - ) - live.set_catalog(catalog) - ops.set_catalog(catalog) + TangoControlSystemConfigModel(name="ops", tango_host="ops-db:10000", catalog=catalog)) attr_configs = { "//live-db:10000/domain/family/member/current": MockedAttributeInfoEx( @@ -155,9 +150,8 @@ def attribute_proxy(attr_full_name): def test_tango_catalog_can_be_used_through_tango_control_system(): - catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) - control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live")) - control_system.set_catalog(catalog) + catalog = TangoCatalog(ConfigModel(disconnected=True)) + control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) device = control_system.get_device("domain/family/member/attribute") @@ -166,7 +160,7 @@ def test_tango_catalog_can_be_used_through_tango_control_system(): def test_tango_catalog_rejects_non_tango_control_system(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) with pytest.raises( pyaml.PyAMLException, match="can only resolve through TangoControlSystem" @@ -178,7 +172,7 @@ def test_tango_catalog_rejects_external_tango_control_system_class(): class TangoControlSystem: pass - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) with pytest.raises( pyaml.PyAMLException, match="can only resolve through TangoControlSystem" @@ -187,7 +181,7 @@ class TangoControlSystem: def test_tango_catalog_requires_control_system_attachment(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) with pytest.raises( pyaml.PyAMLException, match="needs a TangoControlSystem context" @@ -196,7 +190,7 @@ def test_tango_catalog_requires_control_system_attachment(): def test_tango_catalog_rejects_invalid_tango_reference(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with pytest.raises( @@ -206,7 +200,7 @@ def test_tango_catalog_rejects_invalid_tango_reference(): def test_tango_catalog_rejects_invalid_index(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with pytest.raises(pyaml.PyAMLException, match="invalid index"): @@ -214,7 +208,7 @@ def test_tango_catalog_rejects_invalid_index(): def test_tango_catalog_disconnected_resolves_indexed_attribute(): - catalog = TangoCatalog(ConfigModel(name="tango-direct", disconnected=True)) + catalog = TangoCatalog(ConfigModel(disconnected=True)) control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: @@ -234,7 +228,7 @@ def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): unit="mm", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -260,7 +254,7 @@ def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): unit="mm", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -279,7 +273,7 @@ def test_tango_catalog_connected_rejects_indexed_scalar_attribute(): writable=tango.AttrWriteType.READ_WRITE, data_format=tango.AttrDataFormat.SCALAR, ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -295,7 +289,7 @@ def test_tango_catalog_indexed_caches_resolved_devices(): name="position", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( @@ -310,13 +304,14 @@ def test_tango_catalog_indexed_caches_resolved_devices(): def test_tango_catalog_wraps_tango_errors(): - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): with pytest.raises( pyaml.PyAMLException, - match="Tango catalog 'tango-direct' cannot resolve 'domain/family/member/attribute'", + match="Tango catalog" + " cannot resolve 'domain/family/member/attribute'", ): catalog.resolve("domain/family/member/attribute", control_system) @@ -328,7 +323,7 @@ class IncompleteAttributeConfig: max_value = "1" data_format = tango.AttrDataFormat.SCALAR - catalog = TangoCatalog(ConfigModel(name="tango-direct")) + catalog = TangoCatalog(ConfigModel()) control_system = build_control_system(catalog) with patch( From ae4bb789c7bd1d9a32bbab19daa8df9bb1ebec1e Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 11 Jun 2026 16:02:32 +0200 Subject: [PATCH 10/36] Remove unused elements --- tango/pyaml/controlsystem.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 64aff16..c23e2b7 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -53,8 +53,6 @@ class ConfigModel(BaseModel): catalog: Catalog | None = None debug_level: str | None = None lazy_devices: bool = True - scalar_aggregator: str | None = "tango.pyaml.multi_attribute" - vector_aggregator: str | None = None timeout_ms: int = 3000 @@ -214,28 +212,6 @@ def get_tango_host(self) -> str | None: """ return self._cfg.tango_host - def scalar_aggregator(self) -> str | None: - """ - Returns the module name used for handling aggregator of DeviceAccess - - Returns - ------- - str - Aggregator module name - """ - return self._cfg.scalar_aggregator - - def vector_aggregator(self) -> str | None: - """ - Returns the module name used for handling aggregator of DeviceVectorAccess - - Returns - ------- - str - Aggregator module name - """ - return self._cfg.vector_aggregator - def get_aggregator(self) -> MultiAttribute | None: """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" return MultiAttribute() From 3a207a9533b2da196299620aa5e38bd5bc6d8246 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 11 Jun 2026 16:55:17 +0200 Subject: [PATCH 11/36] Put back mandatory methods --- tango/pyaml/controlsystem.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 2a62551..14a4300 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -219,6 +219,28 @@ def get_aggregator(self) -> MultiAttribute | None: """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" return MultiAttribute() + def scalar_aggregator(self) -> str | None: + """ + Returns the module name used for handling aggregator of DeviceAccess + + Returns + ------- + str + Aggregator module name + """ + return None + + def vector_aggregator(self) -> str | None: + """ + Returns the module name used for handling aggregator of DeviceVectorAccess + + Returns + ------- + str + Aggregator module name + """ + return None + def get_catalog(self) -> Catalog | None: """ Returns the catalog that references all control systems devices. From b96d164bccc54aa615f948130f84fd8f7ddd21ef Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 12 Jun 2026 10:40:28 +0200 Subject: [PATCH 12/36] Adapting to the new DeviceAccessList interface. --- tango/pyaml/multi_attribute.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index d6e289d..dc593e2 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -42,6 +42,7 @@ class ConfigModel(BaseModel): class MultiAttribute(DeviceAccessList): def __init__(self, cfg: ConfigModel = None): super().__init__() + self._items:list[Attribute] = [] self._cfg = cfg if self._cfg: for attribute in self._cfg.attributes: @@ -49,7 +50,13 @@ def __init__(self, cfg: ConfigModel = None): attribute=attribute, unit=self._cfg.unit, range=self._cfg.range ) attr = Attribute(attr_config) - self.append(attr) + self._items.append(attr) + + def len(self) -> int: + return len(self._items) + + def get_device_at(self, index: int) -> DeviceAccess: + return self._items[index] def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): if isinstance(devices, list): @@ -65,21 +72,15 @@ def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): ) super().append(devices) - def get_devices(self) -> DeviceAccess | list[DeviceAccess]: - if len(self) == 1: - return self[0] - else: - return self - def set(self, value: npt.NDArray[np.float64]): - if len(value) != len(self): + if len(value) != len(self._items): raise pyaml.PyAMLException( - f"Size of value ({len(value)} do not match the number of managed devices ({len(self)})" + f"Size of value ({len(value)} do not match the number of managed devices ({len(self._items)})" ) asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() # Set part - for index, device in enumerate(self): + for index, device in enumerate(self._items): device._ensure_initialized() asynch_call_id = device._attribute_dev.write_attribute_asynch( device._attr_name, value[index] @@ -98,7 +99,7 @@ def get(self) -> npt.NDArray[np.float64]: asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() # Read asynch - for index, device in enumerate(self): + for index, device in enumerate(self._items): device._ensure_initialized() asynch_call_id = device._attribute_dev.read_attribute_asynch( device._attr_name @@ -120,7 +121,7 @@ def readback(self) -> np.array: asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() # Readback with asynch optim - for index, device in enumerate(self): + for index, device in enumerate(self._items): device._ensure_initialized() asynch_call_id = device._attribute_dev.read_attribute_asynch( device._attr_name @@ -129,20 +130,20 @@ def readback(self) -> np.array: # 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._items[index]._attribute_dev.read_attribute_reply(call_id, timeout) values.append(dev_attr.value) return np.array(values) def get_range(self) -> list[float]: attr_range: list[float] = [] - for device in self: + for device in self._items: attr_range.extend(device.get_range()) return attr_range def check_device_availability(self) -> bool: available = False - for device in self: + for device in self._items: available = device.check_device_availability() if not available: break From f497d92689007ad2d8f40541350c6fba046eec93 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 12 Jun 2026 10:47:37 +0200 Subject: [PATCH 13/36] Adapting to the new DeviceAccessList interface. --- tango/pyaml/multi_attribute.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index dc593e2..9ba759c 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -42,7 +42,7 @@ class ConfigModel(BaseModel): class MultiAttribute(DeviceAccessList): def __init__(self, cfg: ConfigModel = None): super().__init__() - self._items:list[Attribute] = [] + self._items: list[Attribute] = [] self._cfg = cfg if self._cfg: for attribute in self._cfg.attributes: @@ -89,7 +89,7 @@ def set(self, value: npt.NDArray[np.float64]): # Wait part for index, call_id in enumerate(asynch_call_ids): - self[index]._attribute_dev.write_attribute_reply(call_id, timeout) + self._items[index]._attribute_dev.write_attribute_reply(call_id, timeout) def set_and_wait(self, value: npt.NDArray[np.float64]): raise NotImplementedError("Not implemented yet.") @@ -108,8 +108,9 @@ def get(self) -> npt.NDArray[np.float64]: # 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) - if self[index].is_writable(): + device = self._items[index] + dev_attr = device._attribute_dev.read_attribute_reply(call_id, timeout) + if device.is_writable(): values.append(dev_attr.w_value) else: values.append(dev_attr.value) @@ -130,7 +131,9 @@ def readback(self) -> np.array: # Wait to read the value for index, call_id in enumerate(asynch_call_ids): - dev_attr = self._items[index]._attribute_dev.read_attribute_reply(call_id, timeout) + dev_attr = self._items[index]._attribute_dev.read_attribute_reply( + call_id, timeout + ) values.append(dev_attr.value) return np.array(values) From 61f6ddaa8386a3d9dd178992b95cf7475a1a9553 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 12 Jun 2026 11:14:16 +0200 Subject: [PATCH 14/36] Adapting to the new DeviceAccessList interface. Forgot calls to supper()... --- tango/pyaml/multi_attribute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 9ba759c..fdd27db 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -64,13 +64,13 @@ def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): raise pyaml.PyAMLException( "All devices must be instances of Attribute (tango.pyaml.attribute)." ) - super().extend(devices) + self._items.extend(devices) else: if not isinstance(devices, Attribute): raise pyaml.PyAMLException( "Device must be an instance of Attribute (tango.pyaml.attribute)." ) - super().append(devices) + self._items.append(devices) def set(self, value: npt.NDArray[np.float64]): if len(value) != len(self._items): From 020e6a0a2522d92e364b08f76535856b9af64fed Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 15 Jun 2026 15:56:36 +0200 Subject: [PATCH 15/36] Rename get_device() to get_device_access() for clarity --- tango/pyaml/controlsystem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 14a4300..b9fe778 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -115,7 +115,7 @@ def _attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: newDevs.append(None) return newDevs - def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: + def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: """ Resolve a public device reference for this Tango control system. @@ -129,7 +129,7 @@ def get_device(self, ref: str | BaseModel | None) -> DeviceAccess | None: if isinstance(ref, DeviceAccess): raise PyAMLException( - "TangoControlSystem.get_device() expects a catalog key, Tango " + "TangoControlSystem.get_device_access() expects a catalog key, Tango " "ConfigModel, or None. Use attach() for already constructed " "DeviceAccess objects." ) From 36e7c13a85f6ffcc941dfec64bd47a16c535724c Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 15 Jun 2026 16:05:53 +0200 Subject: [PATCH 16/36] Adapting tests and comments --- tango/pyaml/controlsystem.py | 2 +- tests/test_controlsystem.py | 20 ++++++++++---------- tests/test_static_catalog.py | 8 ++++---- tests/test_tango_catalog.py | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index b9fe778..eb77043 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -174,7 +174,7 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: ) raise PyAMLException( - f"TangoControlSystem.get_device() cannot resolve references of type " + f"TangoControlSystem.get_device_access() cannot resolve references of type " f"{type(ref).__name__}; expected str, Tango ConfigModel, or None." ) diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 1ec828e..c2d2d29 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -73,7 +73,7 @@ def test_catalog_can_be_configured_and_resolved(): ) ) - resolved = cs.get_device("BPM_C01-01/x") + resolved = cs.get_device_access("BPM_C01-01/x") assert cs.get_catalog() is catalog assert catalog.resolve("BPM_C01-01/x") is device @@ -85,7 +85,7 @@ def test_get_device_builds_attribute_from_config_model(): ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") ) - resolved = cs.get_device( + resolved = cs.get_device_access( AttributeConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") ) @@ -99,7 +99,7 @@ def test_get_device_builds_read_only_attribute_from_config_model(): ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") ) - resolved = cs.get_device( + resolved = cs.get_device_access( AttributeReadOnlyConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") ) @@ -113,7 +113,7 @@ def test_get_device_builds_attribute_list_from_config_model(): ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") ) - resolved = cs.get_device( + resolved = cs.get_device_access( AttributeListConfigModel( name="group", attributes=[ @@ -139,7 +139,7 @@ def test_get_device_builds_read_only_attribute_list_from_config_model(): ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") ) - resolved = cs.get_device( + resolved = cs.get_device_access( AttributeListReadOnlyConfigModel( name="group", attributes=[ @@ -162,21 +162,21 @@ def test_get_device_builds_read_only_attribute_list_from_config_model(): def test_get_device_none_returns_none(): cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) - assert cs.get_device(None) is None + assert cs.get_device_access(None) is None def test_get_device_rejects_preconstructed_device_access(config): cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) with pytest.raises(pyaml.PyAMLException, match="Use attach\\(\\)"): - cs.get_device(Attribute(config)) + cs.get_device_access(Attribute(config)) def test_get_device_requires_catalog_for_string_key(): cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): - cs.get_device("BPM_C01-01/x") + cs.get_device_access("BPM_C01-01/x") def test_get_device_reports_unknown_catalog_key(): @@ -193,14 +193,14 @@ def test_get_device_reports_unknown_catalog_key(): cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) with pytest.raises(pyaml.PyAMLException, match="cannot resolve key 'BPM_C01-02/x'"): - cs.get_device("BPM_C01-02/x") + cs.get_device_access("BPM_C01-02/x") def test_get_device_rejects_unknown_reference_type(): cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) with pytest.raises(pyaml.PyAMLException, match="type int"): - cs.get_device(42) + cs.get_device_access(42) def test_tango_control_system_exposes_tango_host(): diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 0b903ed..0d1ba19 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -99,9 +99,9 @@ def test_static_catalog_is_shared_across_control_systems(): assert live.get_catalog() is catalog assert ops.get_catalog() is catalog assert catalog.resolve("BPM/x") is device - assert live.get_device("BPM/x") is not device - assert ops.get_device("BPM/x") is not device - assert live.get_device("BPM/x") is not ops.get_device("BPM/x") + assert live.get_device_access("BPM/x") is not device + assert ops.get_device_access("BPM/x") is not device + assert live.get_device_access("BPM/x") is not ops.get_device_access("BPM/x") # --- Integration with DeviceAccess types --- @@ -124,7 +124,7 @@ def test_static_catalog_can_be_used_through_tango_control_system(): catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) - resolved = control_system.get_device("BPM/x") + resolved = control_system.get_device_access("BPM/x") assert resolved is not device assert resolved.name() == "sr/bpm/c01-01/x" diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 272bdc2..bce87cf 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -136,8 +136,8 @@ def attribute_proxy(attr_full_name): return MockedAttributeProxy(attr_full_name, attr_configs[attr_full_name]) with patch("tango.AttributeProxy", side_effect=attribute_proxy) as attr_proxy: - live_device = live.get_device(key) - ops_device = ops.get_device(key) + live_device = live.get_device_access(key) + ops_device = ops.get_device_access(key) assert attr_proxy.call_args_list == [ call("//live-db:10000/domain/family/member/current"), @@ -153,7 +153,7 @@ def test_tango_catalog_can_be_used_through_tango_control_system(): catalog = TangoCatalog(ConfigModel(disconnected=True)) control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) - device = control_system.get_device("domain/family/member/attribute") + device = control_system.get_device_access("domain/family/member/attribute") assert isinstance(device, Attribute) assert control_system.get_catalog() is catalog From 76c34883f58bb96f747e6981293a05ecc7d4a65a Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 22:24:44 +0200 Subject: [PATCH 17/36] Remove config models from attribute list. --- tango/pyaml/attribute_list.py | 51 ++++++++++++++----------- tango/pyaml/attribute_list_read_only.py | 25 ++++++++---- tango/pyaml/controlsystem.py | 21 +++++----- tests/conftest.py | 2 +- tests/test_attribute.py | 8 +++- tests/test_controlsystem.py | 10 ++--- 6 files changed, 67 insertions(+), 50 deletions(-) diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index 44156e5..fb7385b 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -5,6 +5,8 @@ from pydantic import BaseModel from pyaml.control.deviceaccess import DeviceAccess from pyaml.control.readback_value import Value, Quality +from pyaml.common.element import __pyaml_repr__ +from pyaml.validation import register_schema, DynamicValidation import tango from .initializable_element import InitializableElement @@ -15,7 +17,7 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class AttributeListConfig(BaseModel): """ Configuration model for a list of Tango attributes. @@ -34,23 +36,32 @@ class ConfigModel(BaseModel): unit: str = "" -class AttributeList(DeviceAccess, InitializableElement): +@register_schema +class AttributeList(DeviceAccess, InitializableElement, DynamicValidation): """ Handle a list of Tango attributes using Tango Groups. Parameters ---------- - cfg : ConfigModel - Configuration object with attribute list, name and unit. + attributes : list of str + List of Tango attribute paths. + name : str, optional + Group name. + unit : str, optional + Unit of the attributes. """ - def __init__(self, cfg: ConfigModel): + def __init__(self, attributes: list[str], name: str = "", unit: str = ""): super().__init__() - self._cfg = cfg + + self._attributes = attributes + self._name = name + self._unit = unit + self._tango_groups: dict[str, tango.Group] = {} self._attr_dev: dict[str, list[str]] = {} - for attribute in self._cfg.attributes: + for attribute in self._attributes: attribute_dev_name, attr_name = attribute.rsplit("/", 1) if attr_name not in self._attr_dev.keys(): self._attr_dev[attr_name] = [] @@ -60,7 +71,7 @@ def __init__(self, cfg: ConfigModel): def initialize(self): super().initialize() for attr_name, dev_list in self._attr_dev.items(): - self._tango_groups[attr_name] = tango.Group(self._cfg.name) + self._tango_groups[attr_name] = tango.Group(self._name) [self._tango_groups[attr_name].add(dev) for dev in dev_list] def name(self) -> str: @@ -72,7 +83,7 @@ def name(self) -> str: str Group name. """ - return self._cfg.name + return self._name def measure_name(self) -> str: """ @@ -83,7 +94,7 @@ def measure_name(self) -> str: str Group name. """ - return self._cfg.name + return self._name def get_tango_attributes(self) -> list[str]: """ @@ -94,7 +105,7 @@ def get_tango_attributes(self) -> list[str]: list[str] Tango attribute paths in configured order. """ - return self._cfg.attributes + return self._attributes def set(self, value: float): """ @@ -152,7 +163,7 @@ def get(self) -> array: result[val.dev_name + "/" + val.obj_name] = attr_value.w_value else: result[val.dev_name + "/" + val.obj_name] = None - return array([result[attribute] for attribute in self._cfg.attributes]) + return array([result[attribute] for attribute in self._attributes]) def readback(self) -> array: """ @@ -183,7 +194,7 @@ def readback(self) -> array: result[val.dev_name + "/" + val.obj_name] = value else: result[val.dev_name + "/" + val.obj_name] = None - list_res = [result[attribute] for attribute in self._cfg.attributes] + list_res = [result[attribute] for attribute in self._attributes] return array(list_res) def unit(self) -> str: @@ -195,17 +206,13 @@ def unit(self) -> str: str Unit string. """ - return self._cfg.unit + return self._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 - ) + if self._range is not None: + attr_range[0] = self._range[0] if self._range[0] is not None else None + attr_range[1] = self._range[1] if self._range[1] is not None else None else: self._ensure_initialized() devices: list[tango.DeviceProxy] = [] @@ -231,4 +238,4 @@ def check_device_availability(self) -> bool: return available def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index 32828b5..1de9857 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,29 +1,38 @@ import logging import pyaml -from .attribute_list import AttributeList, ConfigModel as AttributeListConfigModel +from .attribute_list import AttributeList, AttributeListConfig +from pyaml.validation import register_schema, DynamicValidation PYAMLCLASS: str = "AttributeListReadOnly" logger = logging.getLogger(__name__) -class ConfigModel(AttributeListConfigModel): - """Configuration model for a read-only Tango attribute list.""" +class AttributeListReadOnlyConfig(AttributeListConfig): ... -class AttributeListReadOnly(AttributeList): +@register_schema +class AttributeListReadOnly(AttributeList, DynamicValidation): """ Handle a list of Tango attributes using Tango Groups. Parameters ---------- - cfg : ConfigModel - Configuration object with attribute list, name and unit. + attributes : list of str + List of Tango attribute paths. + name : str, optional + Group name. + unit : str, optional + Unit of the attributes. """ - def __init__(self, cfg: ConfigModel): - super().__init__(cfg) + def __init__(self, attributes: list[str], name: str = "", unit: str = ""): + super().__init__(attributes, name, unit) + + self._attributes = attributes + self._name = name + self._unit = unit def set(self, value: float): """ diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index eb77043..d909756 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -7,11 +7,8 @@ from pyaml.control.deviceaccess import DeviceAccess from . import __version__ from .attribute import Attribute, ConfigModel as AttributeConfigModel -from .attribute_list import AttributeList, ConfigModel as AttributeListConfigModel -from .attribute_list_read_only import ( - AttributeListReadOnly, - ConfigModel as AttributeListReadOnlyConfigModel, -) +from .attribute_list import AttributeList, AttributeListConfig +from .attribute_list_read_only import AttributeListReadOnly, AttributeListReadOnlyConfig from .attribute_read_only import ( AttributeReadOnly, ConfigModel as AttributeReadOnlyConfigModel, @@ -161,11 +158,13 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: if isinstance(ref, AttributeConfigModel): return self._attach([Attribute(ref)])[0] - if isinstance(ref, AttributeListReadOnlyConfigModel): - return AttributeListReadOnly(self._attach_attribute_list_config(ref)) + if isinstance(ref, AttributeListReadOnlyConfig): + cfg = self._attach_attribute_list_config(ref) + return AttributeListReadOnly(**cfg.model_dump()) - if isinstance(ref, AttributeListConfigModel): - return AttributeList(self._attach_attribute_list_config(ref)) + if isinstance(ref, AttributeListConfig): + cfg = self._attach_attribute_list_config(ref) + return AttributeList(**cfg.model_dump()) if isinstance(ref, BaseModel): raise PyAMLException( @@ -179,8 +178,8 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: ) def _attach_attribute_list_config( - self, cfg: AttributeListConfigModel - ) -> AttributeListConfigModel: + self, cfg: AttributeListConfig + ) -> AttributeListConfig: tango_host = self.get_tango_host() if not tango_host: return cfg diff --git a/tests/conftest.py b/tests/conftest.py index e12bd02..62c6f95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest import yaml -from tango.pyaml.attribute_list import ConfigModel as GrpCM +from tango.pyaml.attribute_list import AttributeListConfig 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 diff --git a/tests/test_attribute.py b/tests/test_attribute.py index a983a34..9b2b3b5 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -4,7 +4,11 @@ from tango.pyaml.attribute_read_only import AttributeReadOnly from .mocked_control_system_initialized import MockedControlSystemInitialized -from .mocked_device_proxy import * +from .mocked_device_proxy import ( + MockedAttributeInfoEx, + MockedDeviceProxy, + tango, +) from .mocked_group import MockedGroup from unittest.mock import patch from tango.pyaml.attribute import Attribute @@ -89,7 +93,7 @@ def test_group_read_write(self, config_group): new=MockedControlSystemInitialized, ), ): - attr_list = AttributeList(config_group) + attr_list = AttributeList(**config_group.model_dump()) attr_list.set_and_wait(10) vals = attr_list.readback() for val in vals: diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index c2d2d29..252e7b6 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -13,11 +13,9 @@ from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch from tango.pyaml.attribute_list import AttributeList -from tango.pyaml.attribute_list import ConfigModel as AttributeListConfigModel +from tango.pyaml.attribute_list import AttributeListConfig from tango.pyaml.attribute_list_read_only import AttributeListReadOnly -from tango.pyaml.attribute_list_read_only import ( - ConfigModel as AttributeListReadOnlyConfigModel, -) +from tango.pyaml.attribute_list_read_only import AttributeListReadOnlyConfig from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.attribute_read_only import ConfigModel as AttributeReadOnlyConfigModel @@ -114,7 +112,7 @@ def test_get_device_builds_attribute_list_from_config_model(): ) resolved = cs.get_device_access( - AttributeListConfigModel( + AttributeListConfig( name="group", attributes=[ "sys/tg_test/1/float_scalar", @@ -140,7 +138,7 @@ def test_get_device_builds_read_only_attribute_list_from_config_model(): ) resolved = cs.get_device_access( - AttributeListReadOnlyConfigModel( + AttributeListReadOnlyConfig( name="group", attributes=[ "sys/tg_test/1/float_scalar", From c46382e842e7b8d0f7b16d3c723b9b1c67302495 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:16:53 +0200 Subject: [PATCH 18/36] Remove config models from attribute. --- tango/pyaml/attribute.py | 91 +++++++++++++++++++----------- tango/pyaml/attribute_read_only.py | 44 +++++++++++---- tango/pyaml/controlsystem.py | 15 ++--- tango/pyaml/multi_attribute.py | 6 +- tango/pyaml/tango_catalog.py | 36 +++++++----- tests/conftest.py | 2 +- tests/test_attribute.py | 12 ++-- tests/test_attribute_indexed.py | 68 ++++++++++++---------- tests/test_attribute_range.py | 18 +++--- tests/test_controlsystem.py | 18 +++--- tests/test_static_catalog.py | 16 +++--- 11 files changed, 194 insertions(+), 132 deletions(-) diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index 88aedbc..a11ceb1 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,22 +1,26 @@ import copy import logging +import tango +import pyaml from typing import Optional, Tuple from pydantic import BaseModel +from pyaml.common.element import __pyaml_repr__ from pyaml.control.deviceaccess import DeviceAccess from pyaml.control.readback_value import Value, Quality +from pyaml.validation import register_schema, DynamicValidation from .initializable_element import InitializableElement from .device_factory import DeviceFactory -from .tango_pyaml_utils import * +from .tango_pyaml_utils import to_float_or_none, tango_to_PyAMLException PYAMLCLASS: str = "Attribute" logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class AttributeConfig(BaseModel): """ Configuration model for Tango attributes. @@ -40,14 +44,25 @@ class ConfigModel(BaseModel): index: Optional[int] = None -class Attribute(DeviceAccess, InitializableElement): +@register_schema +class Attribute(DeviceAccess, InitializableElement, DynamicValidation): """ Tango attribute that can be written to. Parameters ---------- - cfg : ConfigModel - Configuration object containing attribute path and units. + attribute : str + 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 +∞. + index : int, optional + Zero-based index into a SPECTRUM attribute. When set, the instance + behaves as a read-only scalar view of one vector element; writes are + always rejected and a SPECTRUM data_format is enforced on init. + writable : bool, optional + If the attribute should be writable. Default is True. Raises ------ @@ -55,10 +70,21 @@ class Attribute(DeviceAccess, InitializableElement): If the Tango attribute is not writable. """ - def __init__(self, cfg: ConfigModel, writable=True): + def __init__( + self, + attribute: str, + unit: str = "", + range: Optional[Tuple[Optional[float], Optional[float]]] = None, + index: Optional[int] = None, + writable=True, + ): super().__init__() - self._cfg = cfg - self._index = cfg.index + + self._attribute = attribute + self._unit = unit + self._range = range + self._index = index + # Indexed access never writes individual array elements. self._writable = writable and self._index is None self._attribute_dev: tango.DeviceProxy = None @@ -69,9 +95,7 @@ def __init__(self, cfg: ConfigModel, writable=True): 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._attribute.rsplit("/", 1) self._attribute_dev = DeviceFactory().get_device(self._attribute_dev_name) except tango.DevFailed as df: raise tango_to_PyAMLException(df) @@ -83,7 +107,7 @@ def initialize(self): if self._index is not None: if self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM: raise pyaml.PyAMLException( - f"Tango attribute '{self._cfg.attribute}' is not a SPECTRUM; " + f"Tango attribute '{self._attribute}' is not a SPECTRUM; " "indexed access requires a vector attribute." ) @@ -94,7 +118,7 @@ def initialize(self): tango.AttrWriteType.READ_WITH_WRITE, ]: raise pyaml.PyAMLException( - f"Tango attribute {self._cfg.attribute} is not writable." + f"Tango attribute {self._attribute} is not writable." ) def is_writable(self): @@ -116,12 +140,12 @@ def set(self, value: float): """ if self._index is not None: raise pyaml.PyAMLException( - f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + f"Indexed attribute '{self._attribute}[{self._index}]' " "does not support individual element writes." ) self._ensure_initialized() logger.log( - logging.DEBUG, f"Setting asynchronously {self._cfg.attribute} to {value}" + logging.DEBUG, f"Setting asynchronously {self._attribute} to {value}" ) try: self._attribute_dev.write_attribute_asynch(self._attr_name, value) @@ -144,11 +168,11 @@ def set_and_wait(self, value: float): """ if self._index is not None: raise pyaml.PyAMLException( - f"Indexed attribute '{self._cfg.attribute}[{self._index}]' " + f"Indexed attribute '{self._attribute}[{self._index}]' " "does not support individual element writes." ) self._ensure_initialized() - logger.log(logging.DEBUG, f"Setting {self._cfg.attribute} to {value}") + logger.log(logging.DEBUG, f"Setting {self._attribute} to {value}") try: self._attribute_dev.write_attribute(self._attr_name, value) except tango.DevFailed as df: @@ -169,13 +193,17 @@ def readback(self) -> Value: If the Tango read fails. """ self._ensure_initialized() - logger.log(logging.DEBUG, f"Reading {self._cfg.attribute}") + logger.log(logging.DEBUG, f"Reading {self._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 - raw = attr_value.value[self._index] if self._index is not None else attr_value.value + raw = ( + attr_value.value[self._index] + if self._index is not None + else attr_value.value + ) value = Value(raw, quality, attr_value.time.todatetime()) except tango.DevFailed as df: raise tango_to_PyAMLException(df) @@ -190,7 +218,7 @@ def unit(self) -> str: str The unit string. """ - return self._cfg.unit + return self._unit def name(self) -> str: """ @@ -203,8 +231,8 @@ def name(self) -> str: notation when indexed (e.g., 'my/ps/device/current[2]'). """ if self._index is not None: - return f"{self._cfg.attribute}[{self._index}]" - return self._cfg.attribute + return f"{self._attribute}[{self._index}]" + return self._attribute def get_tango_attribute(self) -> str: """ @@ -215,7 +243,7 @@ def get_tango_attribute(self) -> str: str Tango attribute path stored in the configuration. """ - return self._cfg.attribute + return self._attribute def clone_with_tango_attribute(self, attribute: str) -> "Attribute": """ @@ -227,8 +255,7 @@ def clone_with_tango_attribute(self, attribute: str) -> "Attribute": Tango attribute path to store in the cloned instance. """ new_obj = copy.copy(self) - new_obj._cfg = copy.copy(self._cfg) - new_obj._cfg.attribute = attribute + new_obj._attribute = attribute return new_obj def measure_name(self) -> str: @@ -241,7 +268,7 @@ def measure_name(self) -> str: The attribute name (e.g., 'current'), with index notation when indexed (e.g., 'current[2]'). """ - short = self._cfg.attribute.rsplit("/", 1)[1] + short = self._attribute.rsplit("/", 1)[1] if self._index is not None: return f"{short}[{self._index}]" return short @@ -274,13 +301,9 @@ def get(self) -> float: 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 - ) + if self._range is not None: + attr_range[0] = self._range[0] if self._range[0] is not None else None + attr_range[1] = self._range[1] if self._range[1] is not None else None else: self._ensure_initialized() min_value = self._attr_config.min_value @@ -300,4 +323,4 @@ def check_device_availability(self) -> bool: return available def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index 614fe94..c4c4691 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,29 +1,53 @@ import logging +from typing import Optional, Tuple +import pyaml +from pyaml.validation import register_schema, DynamicValidation -from .attribute import Attribute, ConfigModel as AttributeConfigModel -from .tango_pyaml_utils import * +from .attribute import Attribute, AttributeConfig PYAMLCLASS: str = "AttributeReadOnly" logger = logging.getLogger(__name__) -class ConfigModel(AttributeConfigModel): +class AttributeReadOnlyConfig(AttributeConfig): """Configuration model for a read-only Tango attribute.""" -class AttributeReadOnly(Attribute): +@register_schema +class AttributeReadOnly(Attribute, DynamicValidation): """ Read-only Tango attribute. Parameters ---------- - cfg : ConfigModel - Configuration model containing attribute path and unit. + attribute : str + 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 +∞. + index : int, optional + Zero-based index into a SPECTRUM attribute. When set, the instance + behaves as a read-only scalar view of one vector element; writes are + always rejected and a SPECTRUM data_format is enforced on init. """ - def __init__(self, cfg: ConfigModel): - super().__init__(cfg, False) + def __init__( + self, + attribute: str, + unit: str = "", + range: Optional[Tuple[Optional[float], Optional[float]]] = None, + index: Optional[int] = None, + ): + super().__init__( + attribute=attribute, unit=unit, range=range, index=index, writable=False + ) + + self._attribute = attribute + self._unit = unit + self._range = range + self._index = index def set(self, value: float): """ @@ -35,7 +59,7 @@ def set(self, value: float): Always raised because the attribute is read-only. """ raise pyaml.PyAMLException( - f"Tango attribute {self._cfg.attribute} is not writable." + f"Tango attribute {self._attribute} is not writable." ) def set_and_wait(self, value: float): @@ -48,7 +72,7 @@ def set_and_wait(self, value: float): Always raised because the attribute is read-only. """ raise pyaml.PyAMLException( - f"Tango attribute {self._cfg.attribute} is not writable." + f"Tango attribute {self._attribute} is not writable." ) def get(self) -> float: diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index d909756..b0e797b 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -6,13 +6,10 @@ from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from . import __version__ -from .attribute import Attribute, ConfigModel as AttributeConfigModel +from .attribute import Attribute, AttributeConfig from .attribute_list import AttributeList, AttributeListConfig from .attribute_list_read_only import AttributeListReadOnly, AttributeListReadOnlyConfig -from .attribute_read_only import ( - AttributeReadOnly, - ConfigModel as AttributeReadOnlyConfigModel, -) +from .attribute_read_only import AttributeReadOnly, AttributeReadOnlyConfig from .catalog import Catalog from .multi_attribute import MultiAttribute @@ -152,11 +149,11 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: device = resolve(ref, self) return self._attach([device])[0] - if isinstance(ref, AttributeReadOnlyConfigModel): - return self._attach([AttributeReadOnly(ref)])[0] + if isinstance(ref, AttributeReadOnlyConfig): + return self._attach([AttributeReadOnly(**ref.model_dump())])[0] - if isinstance(ref, AttributeConfigModel): - return self._attach([Attribute(ref)])[0] + if isinstance(ref, AttributeConfig): + return self._attach([Attribute(**ref.model_dump())])[0] if isinstance(ref, AttributeListReadOnlyConfig): cfg = self._attach_attribute_list_config(ref) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index fdd27db..475fcf5 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -9,7 +9,7 @@ from pyaml.control.deviceaccesslist import DeviceAccessList -from .attribute import Attribute, ConfigModel as AttrConfig +from .attribute import Attribute, AttributeConfig from .device_factory import DeviceFactory PYAMLCLASS: str = "MultiAttribute" @@ -46,10 +46,10 @@ def __init__(self, cfg: ConfigModel = None): self._cfg = cfg if self._cfg: for attribute in self._cfg.attributes: - attr_config = AttrConfig( + attr_config = AttributeConfig( attribute=attribute, unit=self._cfg.unit, range=self._cfg.range ) - attr = Attribute(attr_config) + attr = Attribute(**attr_config.model_dump()) self._items.append(attr) def len(self) -> int: diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 157c37d..9540d42 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -4,7 +4,7 @@ from pydantic import ConfigDict, BaseModel from pyaml.control.deviceaccess import DeviceAccess -from .attribute import Attribute, ConfigModel as AttributeConfigModel +from .attribute import Attribute, AttributeConfig from .attribute_read_only import AttributeReadOnly from .catalog import Catalog from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none @@ -149,7 +149,9 @@ def _validate_control_system(self, control_system: object | None, key: str) -> N ) if not isinstance(control_system, TangoControlSystem): - raise pyaml.PyAMLException("Tango catalog can only resolve through TangoControlSystem") + raise pyaml.PyAMLException( + "Tango catalog can only resolve through TangoControlSystem" + ) def _parse_key(self, key: str) -> tuple[str, int | None]: """ @@ -166,14 +168,18 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: not a valid integer. """ if not isinstance(key, str): - raise pyaml.PyAMLException(f"Tango catalog expects string keys, got {type(key).__name__}") + raise pyaml.PyAMLException( + f"Tango catalog expects string keys, got {type(key).__name__}" + ) if "@" in key: attr_path, idx_str = key.rsplit("@", 1) try: index = int(idx_str) except ValueError as exc: - raise pyaml.PyAMLException(f"Tango catalog invalid index '{idx_str}' in key '{key}'.") from exc + raise pyaml.PyAMLException( + f"Tango catalog invalid index '{idx_str}' in key '{key}'." + ) from exc else: attr_path = key index = None @@ -194,16 +200,14 @@ def _build_disconnected_attribute( # In disconnected mode, keep all metadata local. In particular, setting # range avoids Attribute.get_range() from lazily querying Tango later. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN - return Attribute(AttributeConfigModel(attribute=key, range=(None, None))) + return Attribute(attribute=key, range=(None, None)) def _build_disconnected_indexed( self, cache_key: tuple[int, str], attr_path: str, index: int ) -> DeviceAccess: # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN - return Attribute( - AttributeConfigModel(attribute=attr_path, index=index, range=(None, None)) - ) + return Attribute(attribute=attr_path, index=index, range=(None, None)) def _build_connected_attribute( self, cache_key: tuple[int, str], control_system: object, key: str @@ -215,17 +219,19 @@ def _build_connected_attribute( attr_config = tango.AttributeProxy(tango_attr_name).get_config() except tango.DevFailed as df: pyaml_exception = tango_to_PyAMLException(df) - raise pyaml.PyAMLException(f"Tango catalog cannot resolve '{key}': {pyaml_exception}") from df + raise pyaml.PyAMLException( + f"Tango catalog cannot resolve '{key}': {pyaml_exception}" + ) from df unit, attr_range, data_format, writable = self._read_config_metadata( attr_config, key ) self._data_formats[cache_key] = data_format - cfg = AttributeConfigModel(attribute=key, unit=unit, range=attr_range) + cfg = AttributeConfig(attribute=key, unit=unit, range=attr_range) if writable in self._WRITABLE_TYPES: - return Attribute(cfg) - return AttributeReadOnly(cfg) + return Attribute(**cfg.model_dump()) + return AttributeReadOnly(**cfg.model_dump()) def _build_connected_indexed( self, @@ -262,13 +268,13 @@ def _build_connected_indexed( ) self._data_formats[cache_key] = tango.AttrDataFormat.SPECTRUM - cfg = AttributeConfigModel( + cfg = AttributeConfig( attribute=attr_path, index=index, unit=unit, range=attr_range ) if writable in self._WRITABLE_TYPES: - return Attribute(cfg) - return AttributeReadOnly(cfg) + return Attribute(**cfg.model_dump()) + return AttributeReadOnly(**cfg.model_dump()) def _read_config_metadata( self, attr_config, key: str diff --git a/tests/conftest.py b/tests/conftest.py index 62c6f95..1d741e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import yaml from tango.pyaml.attribute_list import AttributeListConfig as GrpCM -from tango.pyaml.attribute import ConfigModel as AttrCM +from tango.pyaml.attribute import AttributeConfig as AttrCM from tango.pyaml.multi_attribute import ConfigModel as MultiAttrCM from tango.pyaml.controlsystem import ConfigModel as CsCM, TangoControlSystem from tango.pyaml.device_factory import DeviceFactory diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 9b2b3b5..d0b1716 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -37,7 +37,7 @@ def test_attribute_get_set(self, config): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config) + attr = Attribute(**config.model_dump()) attr.set_and_wait(42.0) assert attr.get() == 42.0 assert attr.readback() == 42.0 @@ -57,7 +57,7 @@ def test_attribute_except(self, config): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config) + attr = Attribute(**config.model_dump()) with pytest.raises(pyaml.PyAMLException) as exc: attr.readback() assert exc is not None @@ -74,13 +74,13 @@ def test_attribute_read_only(self, config): expected_message = ( "Tango attribute sys/tg_test/1/float_scalar is not writable." ) - attr1 = Attribute(config) + attr1 = Attribute(**config.model_dump()) with pytest.raises(pyaml.PyAMLException) as exc: attr1.get() assert exc.value.message == expected_message # Read-only attributes cannot be sets. - attr = AttributeReadOnly(config) + attr = AttributeReadOnly(**config.model_dump()) with pytest.raises(pyaml.PyAMLException) as exc2: attr.set(10) assert exc2.value.message == expected_message @@ -107,6 +107,6 @@ def test_unique_device(self, config): new=MockedControlSystemInitialized, ), ): - attr1 = Attribute(config) - attr2 = Attribute(config) + attr1 = Attribute(**config.model_dump()) + attr2 = Attribute(**config.model_dump()) assert attr1._attribute_dev is attr2._attribute_dev diff --git a/tests/test_attribute_indexed.py b/tests/test_attribute_indexed.py index 3d36aa9..daa4698 100644 --- a/tests/test_attribute_indexed.py +++ b/tests/test_attribute_indexed.py @@ -5,9 +5,13 @@ import pyaml -from tango.pyaml.attribute import Attribute, ConfigModel +from tango.pyaml.attribute import Attribute, AttributeConfig from tango.pyaml.attribute_read_only import AttributeReadOnly -from .mocked_device_proxy import MockedAttributeInfoEx, MockedDeviceProxy, MockedDeviceAttribute +from .mocked_device_proxy import ( + MockedAttributeInfoEx, + MockedDeviceProxy, + MockedDeviceAttribute, +) SPECTRUM_ARRAY = np.array([10.0, 20.0, 30.0]) @@ -57,67 +61,71 @@ def attribute_query(self, name): def test_attribute_indexed_get_returns_w_value_at_index(): - cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") + cfg = AttributeConfig(attribute="domain/family/member/position", index=1, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = Attribute(cfg) + attr = Attribute(**cfg.model_dump()) assert attr.get() == SPECTRUM_ARRAY[1] def test_attribute_indexed_readback_returns_value_at_index(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") + cfg = AttributeConfig(attribute="domain/family/member/position", index=0, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = Attribute(cfg) + attr = Attribute(**cfg.model_dump()) rb = attr.readback() assert rb.value == SPECTRUM_ARRAY[0] def test_attribute_indexed_set_raises(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0) + cfg = AttributeConfig(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = Attribute(cfg) - with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): + attr = Attribute(**cfg.model_dump()) + with pytest.raises( + pyaml.PyAMLException, match="does not support individual element writes" + ): attr.set(99.0) def test_attribute_indexed_set_and_wait_raises(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0) + cfg = AttributeConfig(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): - attr = Attribute(cfg) - with pytest.raises(pyaml.PyAMLException, match="does not support individual element writes"): + attr = Attribute(**cfg.model_dump()) + with pytest.raises( + pyaml.PyAMLException, match="does not support individual element writes" + ): attr.set_and_wait(99.0) def test_attribute_indexed_name_includes_index(): - cfg = ConfigModel(attribute="domain/family/member/position", index=2) - attr = Attribute(cfg) + cfg = AttributeConfig(attribute="domain/family/member/position", index=2) + attr = Attribute(**cfg.model_dump()) assert attr.name() == "domain/family/member/position[2]" def test_attribute_indexed_measure_name_includes_index(): - cfg = ConfigModel(attribute="domain/family/member/position", index=2) - attr = Attribute(cfg) + cfg = AttributeConfig(attribute="domain/family/member/position", index=2) + attr = Attribute(**cfg.model_dump()) assert attr.measure_name() == "position[2]" def test_attribute_indexed_unit(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") - attr = Attribute(cfg) + cfg = AttributeConfig(attribute="domain/family/member/position", index=0, unit="mm") + attr = Attribute(**cfg.model_dump()) assert attr.unit() == "mm" def test_attribute_indexed_raises_when_not_spectrum(): - cfg = ConfigModel(attribute="domain/family/member/current", index=0) + cfg = AttributeConfig(attribute="domain/family/member/current", index=0) with patch("tango.DeviceProxy", new=MockedScalarDeviceProxy): - attr = Attribute(cfg) + attr = Attribute(**cfg.model_dump()) with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): attr.get() def test_attribute_indexed_range_from_config(): - cfg = ConfigModel( + cfg = AttributeConfig( attribute="domain/family/member/position", index=0, unit="mm", range=(-5.0, 5.0) ) - attr = Attribute(cfg) + attr = Attribute(**cfg.model_dump()) assert attr.get_range() == [-5.0, 5.0] @@ -125,29 +133,29 @@ def test_attribute_indexed_range_from_config(): def test_attribute_indexed_read_only_get_returns_measured_value(): - cfg = ConfigModel(attribute="domain/family/member/position", index=2, unit="mm") + cfg = AttributeConfig(attribute="domain/family/member/position", index=2, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeReadOnly(cfg) + attr = AttributeReadOnly(**cfg.model_dump()) assert attr.get() == SPECTRUM_ARRAY[2] def test_attribute_indexed_read_only_readback_returns_value_at_index(): - cfg = ConfigModel(attribute="domain/family/member/position", index=1, unit="mm") + cfg = AttributeConfig(attribute="domain/family/member/position", index=1, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeReadOnly(cfg) + attr = AttributeReadOnly(**cfg.model_dump()) assert attr.readback().value == SPECTRUM_ARRAY[1] def test_attribute_indexed_read_only_set_raises(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0) + cfg = AttributeConfig(attribute="domain/family/member/position", index=0) with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeReadOnly(cfg) + attr = AttributeReadOnly(**cfg.model_dump()) with pytest.raises(pyaml.PyAMLException): attr.set(1.0) def test_attribute_indexed_read_only_get_equals_readback(): - cfg = ConfigModel(attribute="domain/family/member/position", index=0, unit="mm") + cfg = AttributeConfig(attribute="domain/family/member/position", index=0, unit="mm") with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): - attr = AttributeReadOnly(cfg) + attr = AttributeReadOnly(**cfg.model_dump()) assert attr.get() == attr.readback().value diff --git a/tests/test_attribute_range.py b/tests/test_attribute_range.py index a4d9119..67901b6 100644 --- a/tests/test_attribute_range.py +++ b/tests/test_attribute_range.py @@ -1,4 +1,8 @@ -from .mocked_device_proxy import * +from .mocked_device_proxy import ( + MockedAttributeInfoEx, + MockedDeviceProxy, + tango, +) from unittest.mock import patch from tango.pyaml.attribute import Attribute @@ -29,7 +33,7 @@ def test_attribute_range_by_conf(config_range): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config_range) + attr = Attribute(**config_range.model_dump()) attr_range = attr.get_range() assert attr_range is not None @@ -45,12 +49,12 @@ def test_attribute_range_by_conf_with_null(config_range_with_null): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config_range_with_null) + attr = Attribute(**config_range_with_null.model_dump()) 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 + assert attr_range[0] == 0 and attr_range[1] is None def test_attribute_range_by_device(config): @@ -61,7 +65,7 @@ def test_attribute_range_by_device(config): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config) + attr = Attribute(**config.model_dump()) attr_range = attr.get_range() assert attr_range is not None @@ -77,9 +81,9 @@ def test_attribute_range_by_device_min_only(config): new=MockedControlSystemInitialized, ), ): - attr = Attribute(config) + attr = Attribute(**config.model_dump()) 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 + assert attr_range[0] == -10 and attr_range[1] is None diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 252e7b6..86feb07 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -16,9 +16,9 @@ from tango.pyaml.attribute_list import AttributeListConfig from tango.pyaml.attribute_list_read_only import AttributeListReadOnly from tango.pyaml.attribute_list_read_only import AttributeListReadOnlyConfig -from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel +from tango.pyaml.attribute import Attribute, AttributeConfig from tango.pyaml.attribute_read_only import AttributeReadOnly -from tango.pyaml.attribute_read_only import ConfigModel as AttributeReadOnlyConfigModel +from tango.pyaml.attribute_read_only import AttributeReadOnlyConfig from tango.pyaml import __version__ @@ -38,7 +38,7 @@ def test_init_cs(caplog, config_tango_cs): def test_laziness_init_cs_attribute(config_tango_cs_lazy_default, config): with patch("tango.DeviceProxy", side_effect=MockedDeviceProxy) as mock_ctor: - attr = Attribute(config) + attr = Attribute(**config.model_dump()) mock_ctor.assert_not_called() attr.set_and_wait(42.0) mock_ctor.assert_called_once() @@ -48,9 +48,7 @@ def test_laziness_init_cs_attribute(config_tango_cs_lazy_default, config): def test_catalog_can_be_configured_and_resolved(): - device = AttributeReadOnly( - AttributeConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") - ) + device = AttributeReadOnly(attribute="sys/tg_test/1/float_scalar", unit="A") catalog = StaticCatalog( StaticCatalogConfigModel( entries=[ @@ -84,7 +82,7 @@ def test_get_device_builds_attribute_from_config_model(): ) resolved = cs.get_device_access( - AttributeConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") + AttributeConfig(attribute="sys/tg_test/1/float_scalar", unit="A") ) assert isinstance(resolved, Attribute) @@ -98,7 +96,7 @@ def test_get_device_builds_read_only_attribute_from_config_model(): ) resolved = cs.get_device_access( - AttributeReadOnlyConfigModel(attribute="sys/tg_test/1/float_scalar", unit="A") + AttributeReadOnlyConfig(attribute="sys/tg_test/1/float_scalar", unit="A") ) assert isinstance(resolved, AttributeReadOnly) @@ -167,7 +165,7 @@ def test_get_device_rejects_preconstructed_device_access(config): cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) with pytest.raises(pyaml.PyAMLException, match="Use attach\\(\\)"): - cs.get_device_access(Attribute(config)) + cs.get_device_access(Attribute(**config.model_dump())) def test_get_device_requires_catalog_for_string_key(): @@ -178,7 +176,7 @@ def test_get_device_requires_catalog_for_string_key(): def test_get_device_reports_unknown_catalog_key(): - device = Attribute(AttributeConfigModel(attribute="sys/tg_test/1/float_scalar")) + device = Attribute(attribute="sys/tg_test/1/float_scalar") catalog = StaticCatalog( StaticCatalogConfigModel( entries=[ diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 0d1ba19..0cf5281 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -1,7 +1,7 @@ import pytest import pyaml -from tango.pyaml.attribute import Attribute, ConfigModel as AttributeConfigModel +from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel from tango.pyaml.controlsystem import TangoControlSystem @@ -14,7 +14,7 @@ def make_attribute( path: str = "domain/family/member/attr", unit: str = "mm" ) -> Attribute: - return Attribute(AttributeConfigModel(attribute=path, unit=unit)) + return Attribute(attribute=path, unit=unit) def make_entry(key: str, device=None) -> StaticCatalogEntry: @@ -93,7 +93,9 @@ def test_static_catalog_raises_on_unknown_key(): def test_static_catalog_is_shared_across_control_systems(): device = make_attribute() catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - live = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) + live = TangoControlSystem( + TangoControlSystemConfigModel(name="live", catalog=catalog) + ) ops = TangoControlSystem(TangoControlSystemConfigModel(name="ops", catalog=catalog)) assert live.get_catalog() is catalog @@ -108,9 +110,7 @@ def test_static_catalog_is_shared_across_control_systems(): def test_static_catalog_works_with_attribute_read_only(): - device = AttributeReadOnly( - AttributeConfigModel(attribute="sr/bpm/c01-01/pos", unit="mm") - ) + device = AttributeReadOnly(attribute="sr/bpm/c01-01/pos", unit="mm") catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) resolved = catalog.resolve("BPM/x") @@ -122,7 +122,9 @@ def test_static_catalog_works_with_attribute_read_only(): def test_static_catalog_can_be_used_through_tango_control_system(): device = make_attribute("sr/bpm/c01-01/x", unit="mm") catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) + control_system = TangoControlSystem( + TangoControlSystemConfigModel(name="live", catalog=catalog) + ) resolved = control_system.get_device_access("BPM/x") From d52ad4f6c3eb0d3c199ef6fec2e591074834740e Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:31:44 +0200 Subject: [PATCH 19/36] Remove config model from controlsystem. --- tango/pyaml/controlsystem.py | 68 +++++++++++++++++------------------- tests/conftest.py | 8 ++--- tests/test_controlsystem.py | 47 +++++++++---------------- tests/test_static_catalog.py | 11 ++---- tests/test_tango_catalog.py | 14 +++----- 5 files changed, 61 insertions(+), 87 deletions(-) diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index b0e797b..fb8f149 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,10 +1,12 @@ import logging -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel from pyaml import PyAMLException from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess +from pyaml.common.element import __pyaml_repr__ +from pyaml.validation import register_schema, DynamicValidation from . import __version__ from .attribute import Attribute, AttributeConfig from .attribute_list import AttributeList, AttributeListConfig @@ -18,11 +20,12 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +@register_schema +class TangoControlSystem(ControlSystem, DynamicValidation): """ - Configuration model for a Tango Control System. + Tango-specific implementation of a Control System. - Attributes + Parameters ---------- name : str Name of the control system. @@ -40,43 +43,36 @@ class ConfigModel(BaseModel): Device timeout in milli seconds. """ - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - name: str - tango_host: str | None = None - catalog: Catalog | None = None - debug_level: str | int | None = None - lazy_devices: bool = True - timeout_ms: int = 3000 - - -class TangoControlSystem(ControlSystem): - """ - Tango-specific implementation of a Control System. - - Parameters - ---------- - cfg : ConfigModel - Configuration parameters including name, host and debug level. - """ - - def __init__(self, cfg: ConfigModel): + def __init__( + self, + name: str, + tango_host: str | None = None, + catalog: Catalog | None = None, + debug_level: str | int | None = None, + lazy_devices: bool = True, + timeout_ms: int = 3000, + ): super().__init__() - self._cfg = cfg + self._name = name + self._tango_host = tango_host + self._catalog = catalog + self._debug_level = debug_level + self._lazy_devices = lazy_devices + self._timeout_ms = timeout_ms self.__devices = {} # Dict containing all attached DeviceAccess - if self._cfg.debug_level: - if isinstance(self._cfg.debug_level, int): - log_level = self._cfg.debug_level + if self._debug_level: + if isinstance(self._debug_level, int): + log_level = self._debug_level else: - log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) + log_level = getattr(logging, self._debug_level, logging.WARNING) logger.parent.setLevel(log_level) logger.setLevel(log_level) logger.log( logging.WARNING, - f"PyAML Tango control system binding ({__version__}) initialized with name '{self._cfg.name}'" - f" and TANGO_HOST={self._cfg.tango_host}", + f"PyAML Tango control system binding ({__version__}) initialized with name '{self._name}'" + f" and TANGO_HOST={self._tango_host}", ) def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: @@ -198,7 +194,7 @@ def name(self) -> str: str Name of the control system. """ - return self._cfg.name + return self._name def get_tango_host(self) -> str | None: """ @@ -209,7 +205,7 @@ def get_tango_host(self) -> str | None: str | None Tango host URL, or ``None`` when unconfigured. """ - return self._cfg.tango_host + return self._tango_host def get_aggregator(self) -> MultiAttribute | None: """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" @@ -246,7 +242,7 @@ def get_catalog(self) -> Catalog | None: Catalog The catalog """ - return self._cfg.catalog + return self._catalog def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tests/conftest.py b/tests/conftest.py index 1d741e7..cdd4aaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from tango.pyaml.attribute_list import AttributeListConfig as GrpCM from tango.pyaml.attribute import AttributeConfig as AttrCM from tango.pyaml.multi_attribute import ConfigModel as MultiAttrCM -from tango.pyaml.controlsystem import ConfigModel as CsCM, TangoControlSystem +from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.device_factory import DeviceFactory @@ -98,7 +98,7 @@ def config_tango_cs(): lazy_devices: false """ cfg_dict = yaml.safe_load(conf) - return CsCM(**cfg_dict) + return cfg_dict @pytest.fixture @@ -109,7 +109,7 @@ def config_tango_cs_lazy_default(): debug_level: INFO """ cfg_dict = yaml.safe_load(conf) - return CsCM(**cfg_dict) + return cfg_dict @pytest.fixture @@ -120,4 +120,4 @@ def config_tango_cs_false(): debug_level: nope """ cfg_dict = yaml.safe_load(conf) - return CsCM(**cfg_dict) + return cfg_dict diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index 86feb07..d78898e 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -8,7 +8,7 @@ ConfigModel as StaticCatalogEntryConfigModel, ) from tango.pyaml.static_catalog_entry import StaticCatalogEntry -from tango.pyaml.controlsystem import ConfigModel, TangoControlSystem +from tango.pyaml.controlsystem import TangoControlSystem from .mocked_device_proxy import MockedDeviceProxy from unittest.mock import patch @@ -25,11 +25,11 @@ def test_init_cs(caplog, config_tango_cs): # Capture logs with caplog.at_level(logging.INFO): - TangoControlSystem(config_tango_cs) + TangoControlSystem(**config_tango_cs) expected_message = ( - f"PyAML Tango control system binding ({__version__}) initialized with name '{config_tango_cs.name}'" - f" and TANGO_HOST={config_tango_cs.tango_host}" + f"PyAML Tango control system binding ({__version__}) 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 @@ -62,11 +62,9 @@ def test_catalog_can_be_configured_and_resolved(): ) ) cs = TangoControlSystem( - ConfigModel( - name="test_tango_cs", - tango_host="tangodb:10000", - catalog=catalog, - ) + name="test_tango_cs", + tango_host="tangodb:10000", + catalog=catalog, ) resolved = cs.get_device_access("BPM_C01-01/x") @@ -77,9 +75,7 @@ def test_catalog_can_be_configured_and_resolved(): def test_get_device_builds_attribute_from_config_model(): - cs = TangoControlSystem( - ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") - ) + cs = TangoControlSystem(name="test_tango_cs", tango_host="tangodb:10000") resolved = cs.get_device_access( AttributeConfig(attribute="sys/tg_test/1/float_scalar", unit="A") @@ -91,9 +87,7 @@ def test_get_device_builds_attribute_from_config_model(): def test_get_device_builds_read_only_attribute_from_config_model(): - cs = TangoControlSystem( - ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") - ) + cs = TangoControlSystem(name="test_tango_cs", tango_host="tangodb:10000") resolved = cs.get_device_access( AttributeReadOnlyConfig(attribute="sys/tg_test/1/float_scalar", unit="A") @@ -105,10 +99,7 @@ def test_get_device_builds_read_only_attribute_from_config_model(): def test_get_device_builds_attribute_list_from_config_model(): - cs = TangoControlSystem( - ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") - ) - + cs = TangoControlSystem(name="test_tango_cs", tango_host="tangodb:10000") resolved = cs.get_device_access( AttributeListConfig( name="group", @@ -131,9 +122,7 @@ def test_get_device_builds_attribute_list_from_config_model(): def test_get_device_builds_read_only_attribute_list_from_config_model(): - cs = TangoControlSystem( - ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") - ) + cs = TangoControlSystem(name="test_tango_cs", tango_host="tangodb:10000") resolved = cs.get_device_access( AttributeListReadOnlyConfig( @@ -156,20 +145,20 @@ def test_get_device_builds_read_only_attribute_list_from_config_model(): def test_get_device_none_returns_none(): - cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + cs = TangoControlSystem(name="test_tango_cs") assert cs.get_device_access(None) is None def test_get_device_rejects_preconstructed_device_access(config): - cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + cs = TangoControlSystem(name="test_tango_cs") with pytest.raises(pyaml.PyAMLException, match="Use attach\\(\\)"): cs.get_device_access(Attribute(**config.model_dump())) def test_get_device_requires_catalog_for_string_key(): - cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + cs = TangoControlSystem(name="test_tango_cs") with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): cs.get_device_access("BPM_C01-01/x") @@ -186,22 +175,20 @@ def test_get_device_reports_unknown_catalog_key(): ], ) ) - cs = TangoControlSystem(ConfigModel(name="test_tango_cs", catalog=catalog)) + cs = TangoControlSystem(name="test_tango_cs", catalog=catalog) with pytest.raises(pyaml.PyAMLException, match="cannot resolve key 'BPM_C01-02/x'"): cs.get_device_access("BPM_C01-02/x") def test_get_device_rejects_unknown_reference_type(): - cs = TangoControlSystem(ConfigModel(name="test_tango_cs")) + cs = TangoControlSystem(name="test_tango_cs") with pytest.raises(pyaml.PyAMLException, match="type int"): cs.get_device_access(42) def test_tango_control_system_exposes_tango_host(): - cs = TangoControlSystem( - ConfigModel(name="test_tango_cs", tango_host="tangodb:10000") - ) + cs = TangoControlSystem(name="test_tango_cs", tango_host="tangodb:10000") assert cs.get_tango_host() == "tangodb:10000" diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 0cf5281..90f88c2 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -3,7 +3,6 @@ import pyaml from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly -from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel from tango.pyaml.static_catalog import StaticCatalog @@ -93,10 +92,8 @@ def test_static_catalog_raises_on_unknown_key(): def test_static_catalog_is_shared_across_control_systems(): device = make_attribute() catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - live = TangoControlSystem( - TangoControlSystemConfigModel(name="live", catalog=catalog) - ) - ops = TangoControlSystem(TangoControlSystemConfigModel(name="ops", catalog=catalog)) + live = TangoControlSystem(name="live", catalog=catalog) + ops = TangoControlSystem(name="ops", catalog=catalog) assert live.get_catalog() is catalog assert ops.get_catalog() is catalog @@ -122,9 +119,7 @@ def test_static_catalog_works_with_attribute_read_only(): def test_static_catalog_can_be_used_through_tango_control_system(): device = make_attribute("sr/bpm/c01-01/x", unit="mm") catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) - control_system = TangoControlSystem( - TangoControlSystemConfigModel(name="live", catalog=catalog) - ) + control_system = TangoControlSystem(name="live", catalog=catalog) resolved = control_system.get_device_access("BPM/x") diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index bce87cf..2143818 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -8,13 +8,12 @@ from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly -from tango.pyaml.controlsystem import ConfigModel as TangoControlSystemConfigModel from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog def build_control_system(catalog: TangoCatalog, name="live"): - control_system = TangoControlSystem(TangoControlSystemConfigModel(name=name, catalog=catalog)) + control_system = TangoControlSystem(name=name, catalog=catalog) return control_system @@ -114,10 +113,8 @@ def test_tango_catalog_cache_is_bound_to_control_system_resolver(): def test_tango_catalog_connected_metadata_uses_control_system_tango_host(): key = "domain/family/member/current" catalog = TangoCatalog(ConfigModel()) - live = TangoControlSystem( - TangoControlSystemConfigModel(name="live", tango_host="live-db:10000", catalog=catalog)) - ops = TangoControlSystem( - TangoControlSystemConfigModel(name="ops", tango_host="ops-db:10000", catalog=catalog)) + live = TangoControlSystem(name="live", tango_host="live-db:10000", catalog=catalog) + ops = TangoControlSystem(name="ops", tango_host="ops-db:10000", catalog=catalog) attr_configs = { "//live-db:10000/domain/family/member/current": MockedAttributeInfoEx( @@ -151,7 +148,7 @@ def attribute_proxy(attr_full_name): def test_tango_catalog_can_be_used_through_tango_control_system(): catalog = TangoCatalog(ConfigModel(disconnected=True)) - control_system = TangoControlSystem(TangoControlSystemConfigModel(name="live", catalog=catalog)) + control_system = TangoControlSystem(name="live", catalog=catalog) device = control_system.get_device_access("domain/family/member/attribute") @@ -310,8 +307,7 @@ def test_tango_catalog_wraps_tango_errors(): with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): with pytest.raises( pyaml.PyAMLException, - match="Tango catalog" - " cannot resolve 'domain/family/member/attribute'", + match="Tango catalog cannot resolve 'domain/family/member/attribute'", ): catalog.resolve("domain/family/member/attribute", control_system) From 39289f435bdbef6b37749c4900f91e639aec7ca1 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:39:16 +0200 Subject: [PATCH 20/36] Remove config models from multi attribute. --- tango/pyaml/multi_attribute.py | 42 +++++++++++++++++++++------------- tests/conftest.py | 2 +- tests/test_multi_attribute.py | 4 ++-- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 475fcf5..4d61343 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -5,6 +5,8 @@ import pyaml from numpy import typing as npt from pyaml.control.deviceaccess import DeviceAccess +from pyaml.common.element import __pyaml_repr__ +from pyaml.validation import register_schema, DynamicValidation from pydantic import BaseModel from pyaml.control.deviceaccesslist import DeviceAccessList @@ -17,7 +19,7 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class MultiAttributeConfig(BaseModel): """ Configuration model for a list of Tango attributes. @@ -39,18 +41,29 @@ class ConfigModel(BaseModel): range: Optional[Tuple[Optional[float], Optional[float]]] = None -class MultiAttribute(DeviceAccessList): - def __init__(self, cfg: ConfigModel = None): +@register_schema +class MultiAttribute(DeviceAccessList, DynamicValidation): + def __init__( + self, + attributes: list[str] = [], + name: str = "", + unit: str = "", + range: Optional[Tuple[Optional[float], Optional[float]]] = None, + ): super().__init__() + + self._attributes = attributes + self._name = name + self._unit = unit + self._range = range self._items: list[Attribute] = [] - self._cfg = cfg - if self._cfg: - for attribute in self._cfg.attributes: - attr_config = AttributeConfig( - attribute=attribute, unit=self._cfg.unit, range=self._cfg.range - ) - attr = Attribute(**attr_config.model_dump()) - self._items.append(attr) + + for attribute in self._attributes: + attr_config = AttributeConfig( + attribute=attribute, unit=self._unit, range=self._range + ) + attr = Attribute(**attr_config.model_dump()) + self._items.append(attr) def len(self) -> int: return len(self._items) @@ -153,10 +166,7 @@ def check_device_availability(self) -> bool: return available def unit(self) -> str: - if self._cfg: - return self._cfg.unit - else: - return "" + return self._unit def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tests/conftest.py b/tests/conftest.py index cdd4aaa..87f885f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from tango.pyaml.attribute_list import AttributeListConfig as GrpCM from tango.pyaml.attribute import AttributeConfig as AttrCM -from tango.pyaml.multi_attribute import ConfigModel as MultiAttrCM +from tango.pyaml.multi_attribute import MultiAttributeConfig as MultiAttrCM from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.device_factory import DeviceFactory diff --git a/tests/test_multi_attribute.py b/tests/test_multi_attribute.py index 4185f03..2a5b535 100644 --- a/tests/test_multi_attribute.py +++ b/tests/test_multi_attribute.py @@ -15,7 +15,7 @@ def test_multi_read_write(self, config_multi): new=MockedControlSystemInitialized, ), ): - attr_list = MultiAttribute(config_multi) + attr_list = MultiAttribute(**config_multi.model_dump()) rand = random.Random() values = [rand.random() for _ in range(4)] attr_list.set(values) @@ -32,7 +32,7 @@ def test_multiattribute_range(self, config_multi_range): new=MockedControlSystemInitialized, ), ): - ma = MultiAttribute(config_multi_range) + ma = MultiAttribute(**config_multi_range.model_dump()) attr_range = ma.get_range() assert attr_range is not None assert len(attr_range) == 8 # (4*2) From 1b569bec760543779703386217e4882ffbad29d6 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:47:58 +0200 Subject: [PATCH 21/36] Remove config models from static catalog. --- tango/pyaml/static_catalog.py | 44 +++++++++-------------------- tango/pyaml/static_catalog_entry.py | 35 +++++++---------------- tests/test_controlsystem.py | 28 +++++------------- tests/test_static_catalog.py | 10 +++---- 4 files changed, 35 insertions(+), 82 deletions(-) diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index 2161227..a0e8d09 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -1,7 +1,6 @@ -from pydantic import ConfigDict, BaseModel - from pyaml import PyAMLException from pyaml.control.deviceaccess import DeviceAccess +from pyaml.validation import register_schema, DynamicValidation from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry @@ -9,25 +8,8 @@ PYAMLCLASS = "StaticCatalog" -class ConfigModel(BaseModel): - """ - Configuration model for a static catalog. - - Attributes - ---------- - name : str - Catalog identifier. - entries : list[StaticCatalogEntry] - Explicit list of key-to-device mappings. Must contain at least one - entry, and keys must be unique within the catalog. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - entries: list[StaticCatalogEntry] - - -class StaticCatalog(Catalog): +@register_schema +class StaticCatalog(Catalog, DynamicValidation): """ Catalog backed by a fixed list of key-to-device mappings. @@ -37,8 +19,11 @@ class StaticCatalog(Catalog): Parameters ---------- - cfg : ConfigModel - Configuration containing the catalog name and its entries. + name : str + Catalog identifier. + entries : list[StaticCatalogEntry] + Explicit list of key-to-device mappings. Must contain at least one + entry, and keys must be unique within the catalog. Raises ------ @@ -46,15 +31,16 @@ class StaticCatalog(Catalog): If ``cfg.entries`` is empty or contains duplicate keys. """ - def __init__(self, cfg: ConfigModel): + def __init__(self, entries: list[StaticCatalogEntry]): super().__init__() - self._cfg = cfg - if len(cfg.entries) == 0: + + self._entries = entries + if len(self._entries) == 0: raise PyAMLException( "StaticCatalog.entries must contain at least one entry" ) self._refs: dict[str, DeviceAccess] = {} - for entry in cfg.entries: + for entry in self._entries: key = entry.get_key() if key in self._refs: raise PyAMLException( @@ -87,6 +73,4 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces try: return self._refs[key] except KeyError as exc: - raise PyAMLException( - f"Catalog cannot resolve key '{key}'" - ) from exc + raise PyAMLException(f"Catalog cannot resolve key '{key}'") from exc diff --git a/tango/pyaml/static_catalog_entry.py b/tango/pyaml/static_catalog_entry.py index 600fcb1..2429f1b 100644 --- a/tango/pyaml/static_catalog_entry.py +++ b/tango/pyaml/static_catalog_entry.py @@ -1,15 +1,15 @@ -from pydantic import BaseModel, ConfigDict - from pyaml.control.deviceaccess import DeviceAccess +from pyaml.validation import register_schema, DynamicValidation PYAMLCLASS = "StaticCatalogEntry" -class ConfigModel(BaseModel): +@register_schema +class StaticCatalogEntry(DynamicValidation): """ - Configuration model for a static catalog entry. + A single key-to-device mapping in a static catalog. - Attributes + Parameters ---------- key : str Catalog key used to look up the device. @@ -17,29 +17,14 @@ class ConfigModel(BaseModel): Device access object returned when the key is resolved. """ - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - key: str - device: DeviceAccess - - -class StaticCatalogEntry: - """ - A single key-to-device mapping in a static catalog. - - Parameters - ---------- - cfg : ConfigModel - Configuration containing the key and device. - """ - - def __init__(self, cfg: ConfigModel): - self._cfg = cfg + def __init__(self, key: str, device: DeviceAccess): + self.key = key + self.device = device def get_key(self) -> str: """Return the catalog key for this entry.""" - return self._cfg.key + return self.key def get_device(self) -> DeviceAccess: """Return the device access object associated with this entry.""" - return self._cfg.device + return self.device diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index d78898e..d0311d9 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -2,11 +2,7 @@ import pyaml import pytest -from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel from tango.pyaml.static_catalog import StaticCatalog -from tango.pyaml.static_catalog_entry import ( - ConfigModel as StaticCatalogEntryConfigModel, -) from tango.pyaml.static_catalog_entry import StaticCatalogEntry from tango.pyaml.controlsystem import TangoControlSystem @@ -50,16 +46,12 @@ def test_laziness_init_cs_attribute(config_tango_cs_lazy_default, config): def test_catalog_can_be_configured_and_resolved(): device = AttributeReadOnly(attribute="sys/tg_test/1/float_scalar", unit="A") catalog = StaticCatalog( - StaticCatalogConfigModel( - entries=[ - StaticCatalogEntry( - StaticCatalogEntryConfigModel( - key="BPM_C01-01/x", - device=device, - ) - ) - ], - ) + entries=[ + StaticCatalogEntry( + key="BPM_C01-01/x", + device=device, + ) + ], ) cs = TangoControlSystem( name="test_tango_cs", @@ -167,13 +159,7 @@ def test_get_device_requires_catalog_for_string_key(): def test_get_device_reports_unknown_catalog_key(): device = Attribute(attribute="sys/tg_test/1/float_scalar") catalog = StaticCatalog( - StaticCatalogConfigModel( - entries=[ - StaticCatalogEntry( - StaticCatalogEntryConfigModel(key="BPM_C01-01/x", device=device) - ) - ], - ) + entries=[StaticCatalogEntry(key="BPM_C01-01/x", device=device)], ) cs = TangoControlSystem(name="test_tango_cs", catalog=catalog) diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 90f88c2..255eca1 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -4,9 +4,7 @@ from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.controlsystem import TangoControlSystem -from tango.pyaml.static_catalog import ConfigModel as StaticCatalogConfigModel from tango.pyaml.static_catalog import StaticCatalog -from tango.pyaml.static_catalog_entry import ConfigModel as EntryConfigModel from tango.pyaml.static_catalog_entry import StaticCatalogEntry @@ -19,13 +17,13 @@ def make_attribute( def make_entry(key: str, device=None) -> StaticCatalogEntry: if device is None: device = make_attribute() - return StaticCatalogEntry(EntryConfigModel(key=key, device=device)) + return StaticCatalogEntry(key=key, device=device) def make_catalog(name: str = "static", entries=None) -> StaticCatalog: if entries is None: entries = [make_entry("default/key")] - return StaticCatalog(StaticCatalogConfigModel(entries=entries)) + return StaticCatalog(entries=entries) # --- StaticCatalogEntry --- @@ -47,13 +45,13 @@ def test_static_catalog_entry_returns_device(): def test_static_catalog_rejects_empty_entries(): with pytest.raises(pyaml.PyAMLException, match="must contain at least one entry"): - StaticCatalog(StaticCatalogConfigModel(entries=[])) + StaticCatalog(entries=[]) def test_static_catalog_rejects_duplicate_keys(): entries = [make_entry("BPM/x"), make_entry("BPM/x")] with pytest.raises(pyaml.PyAMLException, match="duplicate key 'BPM/x'"): - StaticCatalog(StaticCatalogConfigModel(entries=entries)) + StaticCatalog(entries=entries) # --- StaticCatalog.resolve --- From 9f02866ebcbd3926eb0d5d77725403d447192de9 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:52:52 +0200 Subject: [PATCH 22/36] Remove config models from tango catalog. --- tango/pyaml/tango_catalog.py | 32 +++++++++-------------------- tests/test_tango_catalog.py | 40 ++++++++++++++++++------------------ 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 9540d42..643ed7c 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,8 +1,8 @@ import tango import pyaml -from pydantic import ConfigDict, BaseModel from pyaml.control.deviceaccess import DeviceAccess +from pyaml.validation import register_schema, DynamicValidation from .attribute import Attribute, AttributeConfig from .attribute_read_only import AttributeReadOnly @@ -12,30 +12,17 @@ PYAMLCLASS = "TangoCatalog" -class ConfigModel(BaseModel): - """ - Configuration model for a Tango catalog. - - Attributes - ---------- - name : str - Catalog identifier. - disconnected : bool - If true, resolve Tango attribute names without querying Tango. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - disconnected: bool = False - - -class TangoCatalog(Catalog): +@register_schema +class TangoCatalog(Catalog, DynamicValidation): """ Catalog resolving keys that are direct Tango attribute references. Keys can be plain Tango attribute paths (``domain/family/member/attribute``) or indexed references into a SPECTRUM attribute (``domain/family/member/attribute@index``). + + disconnected : bool + If true, resolve Tango attribute names without querying Tango. """ _WRITABLE_TYPES = { @@ -44,9 +31,10 @@ class TangoCatalog(Catalog): tango.AttrWriteType.READ_WITH_WRITE, } - def __init__(self, cfg: ConfigModel): + def __init__(self, disconnected: bool = False): super().__init__() - self._cfg = cfg + + self._disconnected = disconnected # Resolved DeviceAccess objects are bound to one control-system context # because metadata lookup depends on that control system's Tango host. self._refs: dict[tuple[int, str], DeviceAccess] = {} @@ -114,7 +102,7 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces return self._refs[cache_key] def is_disconnected(self) -> bool: - return self._cfg.disconnected + return self._disconnected def get_data_format( self, key: str, control_system: object | None = None diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index 2143818..b2ed31e 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -9,7 +9,7 @@ from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.controlsystem import TangoControlSystem -from tango.pyaml.tango_catalog import ConfigModel, TangoCatalog +from tango.pyaml.tango_catalog import TangoCatalog def build_control_system(catalog: TangoCatalog, name="live"): @@ -18,7 +18,7 @@ def build_control_system(catalog: TangoCatalog, name="live"): def test_tango_catalog_disconnected_resolves_without_querying_tango(): - catalog = TangoCatalog(ConfigModel(disconnected=True)) + catalog = TangoCatalog(disconnected=True) control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: @@ -40,7 +40,7 @@ def test_tango_catalog_connected_resolves_writable_attribute(): max_value="12.0", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -64,7 +64,7 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): attr_config = MockedAttributeInfoEx( name="position", writable=tango.AttrWriteType.READ, unit="mm" ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -78,7 +78,7 @@ def test_tango_catalog_connected_resolves_read_only_attribute(): def test_tango_catalog_caches_resolved_devices(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -93,7 +93,7 @@ def test_tango_catalog_caches_resolved_devices(): def test_tango_catalog_cache_is_bound_to_control_system_resolver(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() live = build_control_system(catalog, name="live") ops = build_control_system(catalog, name="ops") @@ -112,7 +112,7 @@ def test_tango_catalog_cache_is_bound_to_control_system_resolver(): def test_tango_catalog_connected_metadata_uses_control_system_tango_host(): key = "domain/family/member/current" - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() live = TangoControlSystem(name="live", tango_host="live-db:10000", catalog=catalog) ops = TangoControlSystem(name="ops", tango_host="ops-db:10000", catalog=catalog) @@ -147,7 +147,7 @@ def attribute_proxy(attr_full_name): def test_tango_catalog_can_be_used_through_tango_control_system(): - catalog = TangoCatalog(ConfigModel(disconnected=True)) + catalog = TangoCatalog(disconnected=True) control_system = TangoControlSystem(name="live", catalog=catalog) device = control_system.get_device_access("domain/family/member/attribute") @@ -157,7 +157,7 @@ def test_tango_catalog_can_be_used_through_tango_control_system(): def test_tango_catalog_rejects_non_tango_control_system(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() with pytest.raises( pyaml.PyAMLException, match="can only resolve through TangoControlSystem" @@ -169,7 +169,7 @@ def test_tango_catalog_rejects_external_tango_control_system_class(): class TangoControlSystem: pass - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() with pytest.raises( pyaml.PyAMLException, match="can only resolve through TangoControlSystem" @@ -178,7 +178,7 @@ class TangoControlSystem: def test_tango_catalog_requires_control_system_attachment(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() with pytest.raises( pyaml.PyAMLException, match="needs a TangoControlSystem context" @@ -187,7 +187,7 @@ def test_tango_catalog_requires_control_system_attachment(): def test_tango_catalog_rejects_invalid_tango_reference(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with pytest.raises( @@ -197,7 +197,7 @@ def test_tango_catalog_rejects_invalid_tango_reference(): def test_tango_catalog_rejects_invalid_index(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with pytest.raises(pyaml.PyAMLException, match="invalid index"): @@ -205,7 +205,7 @@ def test_tango_catalog_rejects_invalid_index(): def test_tango_catalog_disconnected_resolves_indexed_attribute(): - catalog = TangoCatalog(ConfigModel(disconnected=True)) + catalog = TangoCatalog(disconnected=True) control_system = build_control_system(catalog) with patch("tango.AttributeProxy") as attr_proxy: @@ -225,7 +225,7 @@ def test_tango_catalog_connected_resolves_indexed_writable_spectrum(): unit="mm", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -251,7 +251,7 @@ def test_tango_catalog_connected_resolves_indexed_read_only_spectrum(): unit="mm", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -270,7 +270,7 @@ def test_tango_catalog_connected_rejects_indexed_scalar_attribute(): writable=tango.AttrWriteType.READ_WRITE, data_format=tango.AttrDataFormat.SCALAR, ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -286,7 +286,7 @@ def test_tango_catalog_indexed_caches_resolved_devices(): name="position", data_format=tango.AttrDataFormat.SPECTRUM, ) - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( @@ -301,7 +301,7 @@ def test_tango_catalog_indexed_caches_resolved_devices(): def test_tango_catalog_wraps_tango_errors(): - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): @@ -319,7 +319,7 @@ class IncompleteAttributeConfig: max_value = "1" data_format = tango.AttrDataFormat.SCALAR - catalog = TangoCatalog(ConfigModel()) + catalog = TangoCatalog() control_system = build_control_system(catalog) with patch( From 8f2aed8b249b8bb88f76a878a0b841e1717bc3f1 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:56:08 +0200 Subject: [PATCH 23/36] Remove config models from readme and error messages. --- README.md | 5 +---- tango/pyaml/controlsystem.py | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a65fd1a..1f098f7 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,12 @@ Python code: ```python from tango.pyaml.attribute import Attribute -from tango.pyaml.tango_attribute import ConfigModel import yaml with open("attribute.yaml") as f: cfg_dict = yaml.safe_load(f) -cfg = ConfigModel(**cfg_dict) -attr = Attribute(cfg) +attr = Attribute(**cfg_dict) attr.set(10.0) value = attr.get() @@ -99,4 +97,3 @@ This project is licensed under the MIT License. ## Links - 🧺 [Repository](https://github.com/python-accelerator-middle-layer/tango-pyaml) - diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index fb8f149..db2d0d0 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -119,8 +119,8 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: if isinstance(ref, DeviceAccess): raise PyAMLException( - "TangoControlSystem.get_device_access() expects a catalog key, Tango " - "ConfigModel, or None. Use attach() for already constructed " + "TangoControlSystem.get_device_access() expects a catalog key " + "or None. Use attach() for already constructed " "DeviceAccess objects." ) @@ -167,7 +167,7 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: raise PyAMLException( f"TangoControlSystem.get_device_access() cannot resolve references of type " - f"{type(ref).__name__}; expected str, Tango ConfigModel, or None." + f"{type(ref).__name__}; expected str or None." ) def _attach_attribute_list_config( From a0fbaa800532d395d92bf50e2d93a95d84416401 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 23:58:32 +0200 Subject: [PATCH 24/36] Add entrypoint so schemas can be discovered by the pyaml core. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 39cb6a6..48d1d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ dev = [ "pre-commit", ] +[project.entry-points."pyaml.schemas"] +tango_pyaml = "tango.pyaml" + [project.urls] Homepage = "https://github.com/python-accelerator-middle-layer/tango-pyaml" Documentation = "https://python-accelerator-middle-layer.github.io/tango-pyaml/" From 124b6817b958d8c3ae5cffb2c0849005a0b824ab Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 00:06:10 +0200 Subject: [PATCH 25/36] Ruff fixes. --- .github/workflows/deploy-pypi.yaml | 4 ++-- .gitignore | 2 +- tests/mocked_group.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-pypi.yaml b/.github/workflows/deploy-pypi.yaml index 4cf8a66..b1c116a 100644 --- a/.github/workflows/deploy-pypi.yaml +++ b/.github/workflows/deploy-pypi.yaml @@ -8,7 +8,7 @@ on: jobs: deploy: - + runs-on: ubuntu-latest #environment: release @@ -27,7 +27,7 @@ jobs: cache-dependency-path: '**/pyproject.toml' - name: Install dependencies run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip pip install hatch - name: Build package run: hatch build diff --git a/.gitignore b/.gitignore index bc0e58b..5a92b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,7 @@ coverage.xml .pytest_cache/ cover/ -# DS +# DS **/.DS_Store # Translations diff --git a/tests/mocked_group.py b/tests/mocked_group.py index e26d2d2..d0d17a0 100644 --- a/tests/mocked_group.py +++ b/tests/mocked_group.py @@ -1,4 +1,4 @@ -from .mocked_device_proxy import * +from .mocked_device_proxy import MockedDeviceAttribute, MockedDeviceProxy, MagicMock class MockedGroupReply: From 45201fda00ac9083825d9b1b4bdef83316203ca1 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 00:31:48 +0200 Subject: [PATCH 26/36] Ruff fixes. --- tango/pyaml/attribute.py | 56 ++++++++++++------------- tango/pyaml/attribute_list.py | 15 +++---- tango/pyaml/attribute_list_read_only.py | 3 +- tango/pyaml/attribute_read_only.py | 8 ++-- tango/pyaml/catalog.py | 1 - tango/pyaml/controlsystem.py | 5 ++- tango/pyaml/device_factory.py | 3 +- tango/pyaml/initializable_element.py | 1 - tango/pyaml/multi_attribute.py | 17 ++++---- tango/pyaml/static_catalog.py | 2 +- tango/pyaml/static_catalog_entry.py | 2 +- tango/pyaml/tango_catalog.py | 9 ++-- tango/pyaml/tango_pyaml_utils.py | 2 +- tests/conftest.py | 4 +- tests/mocked_device_proxy.py | 8 ++-- tests/mocked_group.py | 8 ++-- tests/test_attribute.py | 7 ++-- tests/test_attribute_indexed.py | 10 ++--- tests/test_attribute_range.py | 9 ++-- tests/test_controlsystem.py | 21 +++++----- tests/test_multi_attribute.py | 5 ++- tests/test_static_catalog.py | 2 +- tests/test_tango_catalog.py | 47 ++++++++++++--------- 23 files changed, 131 insertions(+), 114 deletions(-) diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index a11ceb1..36d90fe 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,19 +1,18 @@ import copy import logging -import tango -import pyaml -from typing import Optional, Tuple from pydantic import BaseModel +import pyaml +import tango from pyaml.common.element import __pyaml_repr__ from pyaml.control.deviceaccess import DeviceAccess -from pyaml.control.readback_value import Value, Quality -from pyaml.validation import register_schema, DynamicValidation +from pyaml.control.readback_value import Quality, Value +from pyaml.validation import DynamicValidation, register_schema -from .initializable_element import InitializableElement from .device_factory import DeviceFactory -from .tango_pyaml_utils import to_float_or_none, tango_to_PyAMLException +from .initializable_element import InitializableElement +from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none PYAMLCLASS: str = "Attribute" @@ -40,8 +39,8 @@ class AttributeConfig(BaseModel): attribute: str unit: str = "" - range: Optional[Tuple[Optional[float], Optional[float]]] = None - index: Optional[int] = None + range: tuple[float | None, float | None] | None = None + index: int | None = None @register_schema @@ -74,8 +73,8 @@ def __init__( self, attribute: str, unit: str = "", - range: Optional[Tuple[Optional[float], Optional[float]]] = None, - index: Optional[int] = None, + range: tuple[float | None, float | None] | None = None, + index: int | None = None, writable=True, ): super().__init__() @@ -104,22 +103,23 @@ def initialize(self): self._attribute_dev.get_attribute_config(self._attr_name, wait=True) ) - if self._index is not None: - if self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM: - raise pyaml.PyAMLException( - f"Tango attribute '{self._attribute}' is not a SPECTRUM; " - "indexed access requires a vector attribute." - ) - - if self._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._attribute} is not writable." - ) + if ( + self._index is not None + and self._attr_config.data_format != tango.AttrDataFormat.SPECTRUM + ): + raise pyaml.PyAMLException( + f"Tango attribute '{self._attribute}' is not a SPECTRUM; " + "indexed access requires a vector attribute." + ) + + if self._writable and 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._attribute} is not writable." + ) def is_writable(self): return self._writable @@ -318,7 +318,7 @@ def check_device_availability(self) -> bool: try: self._ensure_initialized() self._attribute_dev.ping() - except tango.DevFailed | pyaml.PyAMLException: + except (tango.DevFailed, pyaml.PyAMLException): available = False return available diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index fb7385b..6e39a62 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -1,13 +1,14 @@ import logging -import pyaml from numpy import array from pydantic import BaseModel -from pyaml.control.deviceaccess import DeviceAccess -from pyaml.control.readback_value import Value, Quality -from pyaml.common.element import __pyaml_repr__ -from pyaml.validation import register_schema, DynamicValidation + +import pyaml import tango +from pyaml.common.element import __pyaml_repr__ +from pyaml.control.deviceaccess import DeviceAccess +from pyaml.control.readback_value import Quality, Value +from pyaml.validation import DynamicValidation, register_schema from .initializable_element import InitializableElement from .tango_pyaml_utils import to_float_or_none @@ -63,7 +64,7 @@ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): for attribute in self._attributes: attribute_dev_name, attr_name = attribute.rsplit("/", 1) - if attr_name not in self._attr_dev.keys(): + if attr_name not in self._attr_dev: self._attr_dev[attr_name] = [] if attribute_dev_name not in self._attr_dev[attr_name]: self._attr_dev[attr_name].append(attribute_dev_name) @@ -233,7 +234,7 @@ def check_device_availability(self) -> bool: try: self._ensure_initialized() [group.ping() for group in self._tango_groups.values()] - except tango.DevFailed | pyaml.PyAMLException: + except (tango.DevFailed, pyaml.PyAMLException): available = False return available diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index 1de9857..fec7d89 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,8 +1,9 @@ import logging import pyaml +from pyaml.validation import DynamicValidation, register_schema + from .attribute_list import AttributeList, AttributeListConfig -from pyaml.validation import register_schema, DynamicValidation PYAMLCLASS: str = "AttributeListReadOnly" diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index c4c4691..a8e6f65 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,7 +1,7 @@ import logging -from typing import Optional, Tuple + import pyaml -from pyaml.validation import register_schema, DynamicValidation +from pyaml.validation import DynamicValidation, register_schema from .attribute import Attribute, AttributeConfig @@ -37,8 +37,8 @@ def __init__( self, attribute: str, unit: str = "", - range: Optional[Tuple[Optional[float], Optional[float]]] = None, - index: Optional[int] = None, + range: tuple[float | None, float | None] | None = None, + index: int | None = None, ): super().__init__( attribute=attribute, unit=unit, range=range, index=index, writable=False diff --git a/tango/pyaml/catalog.py b/tango/pyaml/catalog.py index 91d2e5c..4805952 100644 --- a/tango/pyaml/catalog.py +++ b/tango/pyaml/catalog.py @@ -21,4 +21,3 @@ def resolve(self, key: str) -> BaseModel: """ Return a configuration model for a DeviceAccess """ - pass diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index db2d0d0..729a8f7 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -3,10 +3,11 @@ from pydantic import BaseModel from pyaml import PyAMLException +from pyaml.common.element import __pyaml_repr__ from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess -from pyaml.common.element import __pyaml_repr__ -from pyaml.validation import register_schema, DynamicValidation +from pyaml.validation import DynamicValidation, register_schema + from . import __version__ from .attribute import Attribute, AttributeConfig from .attribute_list import AttributeList, AttributeListConfig diff --git a/tango/pyaml/device_factory.py b/tango/pyaml/device_factory.py index abfa5ef..167fb3f 100644 --- a/tango/pyaml/device_factory.py +++ b/tango/pyaml/device_factory.py @@ -1,5 +1,6 @@ -from threading import Lock from collections import defaultdict +from threading import Lock + import tango diff --git a/tango/pyaml/initializable_element.py b/tango/pyaml/initializable_element.py index 5c2947b..4a9b43e 100644 --- a/tango/pyaml/initializable_element.py +++ b/tango/pyaml/initializable_element.py @@ -19,4 +19,3 @@ def is_initialized(self) -> bool: def _ensure_initialized(self): if not self.is_initialized(): self.initialize() - pass diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 4d61343..c0a4b02 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -1,15 +1,14 @@ import logging -from typing import Tuple, Optional import numpy as np -import pyaml from numpy import typing as npt -from pyaml.control.deviceaccess import DeviceAccess -from pyaml.common.element import __pyaml_repr__ -from pyaml.validation import register_schema, DynamicValidation from pydantic import BaseModel +import pyaml +from pyaml.common.element import __pyaml_repr__ +from pyaml.control.deviceaccess import DeviceAccess from pyaml.control.deviceaccesslist import DeviceAccessList +from pyaml.validation import DynamicValidation, register_schema from .attribute import Attribute, AttributeConfig from .device_factory import DeviceFactory @@ -38,17 +37,17 @@ class MultiAttributeConfig(BaseModel): attributes: list[str] = [] name: str = "" unit: str = "" - range: Optional[Tuple[Optional[float], Optional[float]]] = None + range: tuple[float | None, float | None] | None = None @register_schema class MultiAttribute(DeviceAccessList, DynamicValidation): def __init__( self, - attributes: list[str] = [], + attributes: list[str] | None = None, name: str = "", unit: str = "", - range: Optional[Tuple[Optional[float], Optional[float]]] = None, + range: tuple[float | None, float | None] | None = None, ): super().__init__() @@ -73,7 +72,7 @@ def get_device_at(self, index: int) -> DeviceAccess: def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): if isinstance(devices, list): - if any([not isinstance(device, Attribute) for device in devices]): + if any(not isinstance(device, Attribute) for device in devices): raise pyaml.PyAMLException( "All devices must be instances of Attribute (tango.pyaml.attribute)." ) diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index a0e8d09..70d8b19 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -1,6 +1,6 @@ from pyaml import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pyaml.validation import register_schema, DynamicValidation +from pyaml.validation import DynamicValidation, register_schema from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry diff --git a/tango/pyaml/static_catalog_entry.py b/tango/pyaml/static_catalog_entry.py index 2429f1b..a62509b 100644 --- a/tango/pyaml/static_catalog_entry.py +++ b/tango/pyaml/static_catalog_entry.py @@ -1,5 +1,5 @@ from pyaml.control.deviceaccess import DeviceAccess -from pyaml.validation import register_schema, DynamicValidation +from pyaml.validation import DynamicValidation, register_schema PYAMLCLASS = "StaticCatalogEntry" diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 643ed7c..5e4d9df 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,8 +1,9 @@ -import tango -import pyaml +from typing import ClassVar +import pyaml +import tango from pyaml.control.deviceaccess import DeviceAccess -from pyaml.validation import register_schema, DynamicValidation +from pyaml.validation import DynamicValidation, register_schema from .attribute import Attribute, AttributeConfig from .attribute_read_only import AttributeReadOnly @@ -25,7 +26,7 @@ class TangoCatalog(Catalog, DynamicValidation): If true, resolve Tango attribute names without querying Tango. """ - _WRITABLE_TYPES = { + _WRITABLE_TYPES: ClassVar[set[tango.AttrWriteType]] = { tango.AttrWriteType.READ_WRITE, tango.AttrWriteType.WRITE, tango.AttrWriteType.READ_WITH_WRITE, diff --git a/tango/pyaml/tango_pyaml_utils.py b/tango/pyaml/tango_pyaml_utils.py index fb48686..be20027 100644 --- a/tango/pyaml/tango_pyaml_utils.py +++ b/tango/pyaml/tango_pyaml_utils.py @@ -1,5 +1,5 @@ -import tango import pyaml +import tango def to_float_or_none(s): diff --git a/tests/conftest.py b/tests/conftest.py index 87f885f..309cdfc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ import pytest import yaml -from tango.pyaml.attribute_list import AttributeListConfig as GrpCM from tango.pyaml.attribute import AttributeConfig as AttrCM -from tango.pyaml.multi_attribute import MultiAttributeConfig as MultiAttrCM +from tango.pyaml.attribute_list import AttributeListConfig as GrpCM from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.device_factory import DeviceFactory +from tango.pyaml.multi_attribute import MultiAttributeConfig as MultiAttrCM @pytest.fixture(autouse=True) diff --git a/tests/mocked_device_proxy.py b/tests/mocked_device_proxy.py index f6d7f7f..b16fbd6 100644 --- a/tests/mocked_device_proxy.py +++ b/tests/mocked_device_proxy.py @@ -1,7 +1,9 @@ -import tango -import numpy as np from unittest.mock import MagicMock +import numpy as np + +import tango + class MockedAttributeInfoEx: def __init__( @@ -84,7 +86,7 @@ def command_inout_reply(self, idx, timeout=None): return val def read_attribute(self, attr_name: str): - if attr_name not in self.values.keys(): + if attr_name not in self.values: return MockedDeviceAttribute(attr_name, None) return self.values[attr_name] diff --git a/tests/mocked_group.py b/tests/mocked_group.py index d0d17a0..70460d8 100644 --- a/tests/mocked_group.py +++ b/tests/mocked_group.py @@ -1,4 +1,4 @@ -from .mocked_device_proxy import MockedDeviceAttribute, MockedDeviceProxy, MagicMock +from .mocked_device_proxy import MagicMock, MockedDeviceAttribute, MockedDeviceProxy class MockedGroupReply: @@ -53,7 +53,7 @@ def command_inout(self, command_name, *args, **kwargs): try: idx = dev.command_inout_asynch(command_name) replies_id[name] = idx - except Exception as e: + except Exception as e: # noqa: BLE001 replies.append(MockedGroupCmdReply(name, command_name, None, e)) for name, idx in replies_id.items(): dev = self.devices[name] @@ -68,7 +68,7 @@ def read_attribute(self, attr_name) -> list[MockedGroupAttrReply]: try: idx = dev.read_attribute_asynch(attr_name) replies_id[name] = idx - except Exception as e: + except Exception as e: # noqa: BLE001 replies.append(MockedGroupAttrReply(name, attr_name, None, e)) for name, idx in replies_id.items(): dev = self.devices[name] @@ -94,7 +94,7 @@ def write_attribute(self, attr_name, value): try: dev.write_attribute(attr_name, value) replies.append(MockedGroupReply(name, attr_name)) - except Exception as e: + except Exception as e: # noqa: BLE001 replies.append(MockedGroupReply(name, attr_name, e)) return replies diff --git a/tests/test_attribute.py b/tests/test_attribute.py index d0b1716..f387327 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,6 +1,10 @@ +from unittest.mock import patch + import pyaml.control.readback_value import pytest +from tango.pyaml.attribute import Attribute +from tango.pyaml.attribute_list import AttributeList from tango.pyaml.attribute_read_only import AttributeReadOnly from .mocked_control_system_initialized import MockedControlSystemInitialized @@ -10,9 +14,6 @@ tango, ) from .mocked_group import MockedGroup -from unittest.mock import patch -from tango.pyaml.attribute import Attribute -from tango.pyaml.attribute_list import AttributeList class MockedReadExceptDeviceProxy(MockedDeviceProxy): diff --git a/tests/test_attribute_indexed.py b/tests/test_attribute_indexed.py index daa4698..672b154 100644 --- a/tests/test_attribute_indexed.py +++ b/tests/test_attribute_indexed.py @@ -1,19 +1,19 @@ -import numpy as np -import pytest -import tango from unittest.mock import patch +import numpy as np import pyaml +import pytest +import tango from tango.pyaml.attribute import Attribute, AttributeConfig from tango.pyaml.attribute_read_only import AttributeReadOnly + from .mocked_device_proxy import ( MockedAttributeInfoEx, - MockedDeviceProxy, MockedDeviceAttribute, + MockedDeviceProxy, ) - SPECTRUM_ARRAY = np.array([10.0, 20.0, 30.0]) diff --git a/tests/test_attribute_range.py b/tests/test_attribute_range.py index 67901b6..abee187 100644 --- a/tests/test_attribute_range.py +++ b/tests/test_attribute_range.py @@ -1,13 +1,14 @@ +from unittest.mock import patch + +from tango.pyaml.attribute import Attribute + +from .mocked_control_system_initialized import MockedControlSystemInitialized from .mocked_device_proxy import ( MockedAttributeInfoEx, MockedDeviceProxy, tango, ) -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): diff --git a/tests/test_controlsystem.py b/tests/test_controlsystem.py index d0311d9..abf8815 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,21 +1,22 @@ import logging +from unittest.mock import patch import pyaml import pytest + +from tango.pyaml import __version__ +from tango.pyaml.attribute import Attribute, AttributeConfig +from tango.pyaml.attribute_list import AttributeList, AttributeListConfig +from tango.pyaml.attribute_list_read_only import ( + AttributeListReadOnly, + AttributeListReadOnlyConfig, +) +from tango.pyaml.attribute_read_only import AttributeReadOnly, AttributeReadOnlyConfig +from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.static_catalog import StaticCatalog from tango.pyaml.static_catalog_entry import StaticCatalogEntry -from tango.pyaml.controlsystem import TangoControlSystem from .mocked_device_proxy import MockedDeviceProxy -from unittest.mock import patch -from tango.pyaml.attribute_list import AttributeList -from tango.pyaml.attribute_list import AttributeListConfig -from tango.pyaml.attribute_list_read_only import AttributeListReadOnly -from tango.pyaml.attribute_list_read_only import AttributeListReadOnlyConfig -from tango.pyaml.attribute import Attribute, AttributeConfig -from tango.pyaml.attribute_read_only import AttributeReadOnly -from tango.pyaml.attribute_read_only import AttributeReadOnlyConfig -from tango.pyaml import __version__ def test_init_cs(caplog, config_tango_cs): diff --git a/tests/test_multi_attribute.py b/tests/test_multi_attribute.py index 2a5b535..408db86 100644 --- a/tests/test_multi_attribute.py +++ b/tests/test_multi_attribute.py @@ -1,9 +1,10 @@ import random +from unittest.mock import patch + +from tango.pyaml.multi_attribute import MultiAttribute from .mocked_control_system_initialized import MockedControlSystemInitialized from .mocked_device_proxy import MockedDeviceProxy -from unittest.mock import patch -from tango.pyaml.multi_attribute import MultiAttribute class TestMultiAttributes: diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py index 255eca1..7a38887 100644 --- a/tests/test_static_catalog.py +++ b/tests/test_static_catalog.py @@ -1,6 +1,6 @@ +import pyaml import pytest -import pyaml from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.controlsystem import TangoControlSystem diff --git a/tests/test_tango_catalog.py b/tests/test_tango_catalog.py index b2ed31e..6f7af50 100644 --- a/tests/test_tango_catalog.py +++ b/tests/test_tango_catalog.py @@ -2,15 +2,16 @@ import pyaml import pytest -import tango from pyaml.control.controlsystem import ControlSystemAdapter -from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy +import tango from tango.pyaml.attribute import Attribute from tango.pyaml.attribute_read_only import AttributeReadOnly from tango.pyaml.controlsystem import TangoControlSystem from tango.pyaml.tango_catalog import TangoCatalog +from .mocked_device_proxy import MockedAttributeInfoEx, MockedAttributeProxy + def build_control_system(catalog: TangoCatalog, name="live"): control_system = TangoControlSystem(name=name, catalog=catalog) @@ -273,12 +274,16 @@ def test_tango_catalog_connected_rejects_indexed_scalar_attribute(): catalog = TangoCatalog() control_system = build_control_system(catalog) - with patch( - "tango.AttributeProxy", - return_value=MockedAttributeProxy("domain/family/member/current", attr_config), + with ( + patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy( + "domain/family/member/current", attr_config + ), + ), + pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"), ): - with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): - catalog.resolve("domain/family/member/current@0", control_system) + catalog.resolve("domain/family/member/current@0", control_system) def test_tango_catalog_indexed_caches_resolved_devices(): @@ -304,12 +309,14 @@ def test_tango_catalog_wraps_tango_errors(): catalog = TangoCatalog() control_system = build_control_system(catalog) - with patch("tango.AttributeProxy", side_effect=tango.DevFailed()): - with pytest.raises( + with ( + patch("tango.AttributeProxy", side_effect=tango.DevFailed()), + pytest.raises( pyaml.PyAMLException, match="Tango catalog cannot resolve 'domain/family/member/attribute'", - ): - catalog.resolve("domain/family/member/attribute", control_system) + ), + ): + catalog.resolve("domain/family/member/attribute", control_system) def test_tango_catalog_rejects_incomplete_tango_config(): @@ -322,14 +329,16 @@ class IncompleteAttributeConfig: catalog = TangoCatalog() control_system = build_control_system(catalog) - with patch( - "tango.AttributeProxy", - return_value=MockedAttributeProxy( - "domain/family/member/attribute", IncompleteAttributeConfig() + with ( + patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy( + "domain/family/member/attribute", IncompleteAttributeConfig() + ), ), - ): - with pytest.raises( + pytest.raises( pyaml.PyAMLException, match="incomplete Tango attribute config, missing 'writable'", - ): - catalog.resolve("domain/family/member/attribute", control_system) + ), + ): + catalog.resolve("domain/family/member/attribute", control_system) From 06fe4f596464ba5927f41fb4fc03d1e27603c54a Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:08:32 +0200 Subject: [PATCH 27/36] Temporarily updated the workflow so tests are run against the pyaml main branch. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5215324..8205baa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: pip install accelerator-toolbox pip install matplotlib pip install h5py - pip install accelerator-middle-layer + pip install "git+https://github.com/python-accelerator-middle-layer/pyaml.git" pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 From 9b4a4384b40ca041c11e399b880a67b64f5aa75d Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:39:18 +0200 Subject: [PATCH 28/36] Add copier to dev dependencies. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 39cb6a6..f739ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "mypy", # Typage statique (optionnel) "ipython", # Débogage interactif "pre-commit", + "copier", ] [project.urls] From a28838883ecf653f91395474814c80000b534b91 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:45:28 +0200 Subject: [PATCH 29/36] Add docs. --- .readthedocs.yaml | 25 +++++++ docs/Makefile | 20 ++++++ docs/make.bat | 35 ++++++++++ docs/requirements.txt | 4 ++ docs/source/_static/_images/dark.png | Bin 0 -> 20491 bytes docs/source/_static/_images/logo.png | Bin 0 -> 20405 bytes docs/source/_static/_images/logosmall.png | Bin 0 -> 16262 bytes docs/source/_static/custom.css | 7 ++ docs/source/_templates/.gitkeep | 0 docs/source/api.rst | 14 ++++ docs/source/conf.py | 77 ++++++++++++++++++++++ docs/source/index.rst | 16 +++++ 12 files changed, 198 insertions(+) create mode 100644 .readthedocs.yaml create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/requirements.txt create mode 100644 docs/source/_static/_images/dark.png create mode 100644 docs/source/_static/_images/logo.png create mode 100644 docs/source/_static/_images/logosmall.png create mode 100644 docs/source/_static/custom.css create mode 100644 docs/source/_templates/.gitkeep create mode 100644 docs/source/api.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..4e993c1 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,25 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/source/conf.py + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . + extra_requirements: [] diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..0c89451 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx~= 8.1 +pydata-sphinx-theme +myst_parser +sphinx-copybutton diff --git a/docs/source/_static/_images/dark.png b/docs/source/_static/_images/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..15e19c263707145a9b9b021129048361fb657ee4 GIT binary patch literal 20491 zcmeEu^;=s}vu=yGP$~v3cbp ztLy&a1<|{I?WJ|@4B}^hd?6<(uIc^qIQtcZOe+1^9ZK}_)0ZzFu|NK%#>Lmf(0<<+ z8Xu2|i+a8|KktF+B`!r2Ck3&b_wW#TWzFXP<1Gy?HI2B=lPRqX{wJiNlu+iIfQRnM z!zP!_Qc7)~ z5velxSLiFeyC!F7;MrQIpI?*XO4Ddpz`fw#re4RkKg*5w%|zYD?KYFjkeC*Gt!ksk zgF4Gm7UfI~9$F5ou^&0B?OtM#3Fe7(3X46{U4j!J#L*9SwAA2n_-7uIh|RxT*HlU~ z<_nhHylQ!YFWnd1xVoE5TZZf{<&p8VR`=bsow4-G#JFgdQ7izkVlz)X zq-*HSFWD`M!LA@Oc!N0~`GXe3W6OBBEBX;U3swo@REDRHCAE{>kqKt?1ri_gXL%mQ z={Mz$HQozAtcOGcmK$uF!H$@BF3x|Rn6~^wAG2MrsGyj{tHw@{mUE#fLe}L3E%X!> z>Aa(eM8zz(sE?btKX`0sJcx6G#!I%@j}h|tTcKM&_N!?~T4pUb_MsW{SE63^SM(b< zEAGg)U{BV`Pgbd1(y?Sqt=iQ_+S6<0OMceWnLu{ER=4eMwpQf))%5O0N|)2f+N_;?oAlS1bpF>CPk;ojBW0#p*1iEl1^3fQ!> zrjp*+vTLMu66y52d3rh4ou0xu-srRSw7UiYW4Sq`onwQJ3>v!$rX8v+ECg&4uLtOLRuw% z1eYDIjiqcfp=#sofhWhHJT@8ZSB-xf>q628nq*P7WQ z7qc9}_cJUqYGx#Ev?DpS!XgofH3RwD)Q3jX^2@+1S=2Y|ejqw~LrGSu)sEse2^Cc} zeZy>9!(8EOdrn+WLEI=;bu7y9&4D?N%CF#P zhZO3gE3$j_UPOJ=La|V-r(qO$Bk=`SnK$?Gv%g&{9S+&Rc$bELs6wVy;AN4WvH5OR?g zy|Zh_dC9&h4HkxixtK$6i3f!;v;9Z4%gG$GPBhX;1fmVmbL-?r$Usx#Mo_~YW49Ao z^|%Wl8{+N(5tyBi8Su=r)k0m$~N&sZhoeaAS(CM;SIChAn>~RWm^Cv!e0eRdX zgZ>Ige-`v#GT1PNAS_$w<<7W%oU#75R~+*G?2tHFy*Go7FN10xUr)XclJPO_^?r5j z8|RjEaFisrFo15e=@NI*#BZA~)2btNBtf?4;Z!lEOdp#q4aUatQNW8AXB}tYN{RJ& zOB)uK=*>h_O462d+rHU68SO~xK$|$;T$(JI}kBeRo&prk;mKj&3qTv;qk+rn+5I^7H22)!6ow7S>zi-_IN;j3>kyiTq%24WeES`T7A&frz7E$6m~c!EcROhm=O=L9fZ4B{}M(A6$XyC8JS z(+@dgft=)LabZ{_T-BO%#MPr5#VScbjPNRpIG$oU3lOyx3G2&qvok}tO&Xae|m0T?)ZE6OhLBXP7ZYGLiD@k4+7`XfUW}# z>#Rr z&9mB{T(-SUtolu3a(c>eKqf*y1aV^7QVh}iJY`uUYvA9XDJ9S!^rg$g`zx)V9^kMe zzHxl*9olH4PG7^n_FIfM7Lca2k#M!s=nB`O3w*gzl^e8uNW)lRJuCwzB`p&zK?jyX z=GAD0NG{I7Vt;-zfuxo`DvC+{$mBh5oz{xX>qj;I5FqxQ3z z#HUiRd;fBwsuzNv`~nXe)fUbmVm{s}aZI6#eqK}dn~y!)MyL|sI1YC4Qf|>OQsMh+ zAAc&{Z*;R`__Y|(TE_WyM2%(e+9Q7pm@HK2WRCqXq!_b`vBV-Xqg3vf^iJccuY4_Z zEaZjP@O^z=pHVvhWTE#m4TIW^BL?FSPd55F-2^=rh01NOYX%6% zp`Zd3pEW?8higX=%R5PUyeh08yi5b|Fz%bHuB2jXpH7F2KFKdd_j51aC#E}y_`w>i z!E)LneDg{GtIefMEnLAHiXq}`P3&uDqk1bP3-DMuvAf(&Hf-C~sa;O)y43S&Jd)!}tbD&icKCNz)12>o#45U5$hRkX8 zs$-hER-V?z%3Vs{hS83LPNU=btEsZ@ZI+Y0`*|!gly1;o|ICI-HJyGJP|e?9D-URP zo8^FlX+|tB7(ZB7D&Z`Q6Q4(HtZi`sbbQE1^|xn(q-V9QaqPYLFR+le~H1AGWJJLSN!-PfHcDyd10ZI$*z~Dx&^l1UuVfdh9kXw5()s6r=Up zltY;TG8v=r_nE-wxcqY|5LBbB=^(CN@WwNfvm{}*Gnh*BOT1O1Gk~zA$ck)Fozbf| z?9C08UuT?uv}swt99cVe$<2qjw$q=A2kwJ~Mg*nE2tTaR>uvKC^)uzY^Ww8bw!0S7 za<&*|@&+4eMn=I};r^65D~lZWYSdb~;!QzF<=f*Q$;er!X36!HnynD@wf+I;;+ze^R@F~iak$R!V<&!Zls(BK&I#fe!hu@)8F4Pp#=}sg%*7g659F^ z2Ofdv=a~0+r-d7HcoPzxYtkbr{jaAbOn4zNQJdhWI)uE*y84p-$=gZLE-<;x^LY8* zUtBFNn@)4Q|11+7I_`G!Cy5 z_DgD$tZr?~oNrbWS#T=HD_*S#t$R1fQ5=K6^l`??wQv^`42baHnsqh% zWd<+!CnxALYcDF}aVl2k=GdSY$$+oxc9dXYwCjH}dTS|edS{5nHeov2t`qFnDmQG= zmiDw*td8eQwz7yAc9)5eg%b90J7`om4rR_BW&GE+^1YLy7^A^3HwmAd$EqJ~%k86S z{bPq#D-$s>0lEsA7(WrAO2%vBy5FD{`w!>&4bNtr&TAdlL-2sQW$1(wc=nY9-`Vkx z4N<`dg1twZbFw0h66Wa!b~wTddJPIFXaLq~1jqy+R^PvepV<~jyMm`qvldzH^P?Gy z1C%y(qLMf|tP^g?Tp({?{Ay3?p(d8ysvkPD+=`*-`2 zYb30#J}kZ}juZePE1tr29cH#+FDi8?rG8yf0t}&RQP_jCYx%KzEk^VdP46>K-k@*` z*3EaVaT9h;mWAAvuU|NvtPT?eO?^uFNuDN+? z^icYPt<;F|GfIbG_1&QQJ?mHuDDB>E#L%a^#d7})+Q;D=uqUi71No=YYYbMCIge>A z$BZd)p1vfS7RBjJjU*vF+kTwKLS>rmcGNKmEotJstLqwW%W0>YDqD*l3d_n{Pmz~? z)-!G+>_}<|n^suTkkgXVRW<{Ee^&t@SIOv?+ubR4k1A8}#Q_O6+l0MCpn(=#g4-ln zl}93FlArq%JHUR(!L8jO1@7a+dkEjZbth~z$F=&b!lojdwtvL!#K4H9Qd4+$;<#ug zg&s)`Vt%d+ET)_=T*n_(0zot-MiVpQ2qH zxlzV$41C1;qZh4s_&tdBTSf$y-ANRm?a7XU^8F08uhPv7qv*iU?h-!P*8)%0WS%?m zfto0B6>h^!A{qUeq5T=KO0Ldi*(E{KmvEr(B)uvSAvgD3^Bkqxg-MuC>Ss<|8|gfW zdSa;tQX~)uY+sN)fH7?-PmgZS3v;{bqO3+KbM}Y2$XDy`$?#F`6l=<$GhejtqHE+0 zPL!!Snc>V2cD6G)@|LNg+C zcqg+Oxf9$kK%9ll$zT1sp&knL3*BABF4qY_bJw2 zlt?#EnwC@DGWYYs*MsOjYquvKrAD_Z{Ag((;4^0~ut&a7gVKdbBN5>a$qDo3?sYOH zr_xd6lfEhDJI>aGE?~tDdXrNY$V30YeybWlOz3AjK{K>d`OXk|dtu`(B?Z=fVe5%3 zqN&}u96|C)nxA-;M~k)Q_dk*}V@ceBet~65C|q<1BgQyUhAH%3DhA*`Qb&6ICVCPn zw@!=xVQ`ap@#bBCi@MJr+KV!|3@KqaldZ})Aitw-TLj#Db#}3&2Sgw1)Vkkka3U@8 z+@I-RBT??)%4(w=I*77lRZweOAVt_{Jt3M`SEd#z19`qNs!AeSjmk=SR^nPmc^+Tk zDVkk~UmdAHvfrUe1)((wYKmon;?~K;q_BR{Uv`__xOa^lVcM15J=FK)ZdizjJl@V~ z4b2RL@myale#?sXy_pN9b}mNDT<(5dLsMd~M1cLzie_;{V0-NRq5BWmNw9}jN7l_( zHFaY8@N-ym zREe`u7K8%ae2y?vTIWBA z?g>ldUW0y6!Wh$->$7+Bg5=b?N^ z4iPWK4k}t3RO$U;NV8AK2X-$6STY;mI9u+L3AFZ3ZI8p5?mp248b^OFc$|K$@Rua_ z)$4uJdv|@f8xrWpduHF(OqvX6#}4qVi5$$uu42u;9j|Z;NDpfD{?&YdfX<3ZvX{Y6 z>WC=8H`8=eBNX@z$o9SbYvwQ*EuHnpm#Qv}9cKR6QsbqFo`a9E^X+C^%&GmNFlVR{ z%vYr7#}vyZ38&K*g*!DD^J7t2TFXvgZqZ319g-=+Ej6Vfz@8QNv`RS?mX_T;Cv(%C zIXEf`u<)n072p#apmObWg2zJ%89w*t72GK21TTF`WdWY1BS{OUof&S5S>{VJk^mzI z&bk#Ts?*<3BuTWc3xvcGvoL;EQ2@Id9Itex083z&KQlUOZT8Lc?&_)kdgNPact|KL z;S{!oS{u;5Q@4?6&19TqHf{Cy6l8z0FrV!3mz19zNns|U{{1vszX8ZT>HG(aGdoHV zm(i}D8Bq!(>wkV@Zr8T0#RY!sp2!GdNV#;SHsHG_i)*puSp}peUldUzEJT=5WF2uh z1^xK#Xoo z#jN6u)=HjUz;pkJ$K|N&zL3pS9**0lyEfJUYwEYlAi;%CWsNWUlf%V7JKaXkLqDBc zyVXC1yq$axRU3&|=m?YXoAiqPOl%_SY<5_Q)*e{+3-DI+cyGZbs!X zBDX8?S>bb7jGyNo>ac0Q-_>1x&fjgnNFebuagAd&kz*ztfO!-b2QkYrVUinJ@Lb!> zxY=GHxIIT!2g)zUF2(}MjezfZd?itwCBW?NTxX%e5!bOJ6@0Gli0HEN)D8Zf@VaM- zBG7Ws^CLRVJ$FhXRBzjATCVv|(?arE*2A&d5Y}#YJv)s*JNek83j^-N7XNhfqMvtZ z%)2{DM~y<7`p6IodP?^%!RJ@2JL*%1LBavd4Q_i`cFRPbM6nbiZTh6-Oopj|l1*Ix28C&w?zif14fEj z&1bvz9zT)_B;Rx;$Z9wz)^T(}`572BnAqz)&3;;^j1=+&Awl-Y{~EW6Dvad)*>z>L zAs`=(il*w;c&i)yB70%(^>^L`M4(&D$&}oPs>TYVpdI9xZ(wiB&AsP-v8kH`;8Oy= zo;)xPt%!+=hg&HDo3N8NvX0cy$A=nTah0TRREU^nUBSjqFF`CTOqHwklcIin^GmY4 z^>2JKk45^iP?{vPGIqi9MyvW$7WdYKS2sDZ?XWzJI0_M_$MaC6>;NRP!!bl?SF~@G z8}haui*`aM$k zM^Vdqre3l73JI+;mEGx=Mp-GMDT%$cMz2qaxZ}GM#J&t2wECeP%;jQO{k)qqE}vLKRj$So)T^XMTn*p*9EJ--Z^x7mqrzHG*BINOwKF%69VTU~$mJc2*lX*Cy=Ddjga z@)uIOJF74=v<~4HRD#0x|t^V%Rym(jVf*W6HH5ro7$M}+@suceh2)Z z(K+ZA{74tMI+Q@`cYU&YZ+7y)wjC;h+w80nphz>{HMy2nWawrY)*st!^+rGi(zRmY@+qykL_uL_DckK(-^jCp%L5k zu~@6Q39(2VLQ6Hkv;ye91z<8*@vX=U6XJ&u<6Yv;FZycAt=}|zTo26#qsbeWmm3NL>Rg1#AM8X8F5az2I_=Fw zypG)>J1~cw-+aUY{BRg(%v_0CX>#-nR;%Qk`D23cQt>Fx@ zhLpGykU2Pe0BFI?YRd~hs5+ZGXXhjlxruC>j?K& zx8&C_>NwI+A<<0PA^fkV42gMP{B8Kmhs-kNeAm=+KaG<7f`6yq<{}~vf2|Rc+>aUk zlI-V4S3S3%8NmqF7-J3%;mw5f%G8WkHI@G*ugD`n z3RWo@TTv8IW$M$*$bQ-M$CdAK7fiHBh_CNL=@1fyzW!Vl_-fJYy)@WLwG$`}o~$L_ z59)kf9{;bG1`b3ogrYC5HAPNGmNiem;5*7+J2NpJd{)DK`w~}H3E`6SetLz0VWK+A zB5hZ=zxL&)0q)!2Ke7nNnwO%HbS`~=O(MOnlj@tD3ioQeu#^u0!AryigotQox#0E? z?6xM-`E?fz6Gw+Co#xynY6u|CW>6ubWxVkGd1lkygaErKzs=1Zu9CGYO;v>G2@xXu z^IOekTTn5BXFnub8AoM0n&HQZVM0MiWaJl&?;oQObe!Y4#MB*uY&&s8(Dd|P!*wp2 zT`&fZFye8-#878m4h3L5ckrlNsLoDl=puU5`{!Go$<`!#X#b{xACU`!&e$@E)XgcR z!3dVRy;|?gUjSq)v_13Q_^hz05aD+|FPAoT@M8r&Dtv1sIyZPgk0q@2~b}s~z%o%1b?z^CYsHRmR;h6n}8!JBMSFcPYvINr*k#R90U@ z*tM0*SBq;69w!j^rMHEuyYSXe+Fx)F>USAVi)W|(P!}}b36CGKb@I*lh;)_DwFi|< z!A(gFDo^|aSGIPK_Ts#j19hv!q}C9=bK?+=PGo4>nnrZjogQ0!6> z=Q2ci(iw|!?W{h~$^IuhE-&HLP#*ANT6^W6S^S8?g}}KoNaSC=`;Xe$SRna^W%Nv2 z&#wg|Xo6$&0zr?123JH%j{UgUzbt~s`#Aa}-^_FcrmQw8L!_10)4kCzjTB^66Bux6|hk==RA?1_PCgy{_ zdvR>me|ib;+sN_MC{`3m_00MmNVDWgvupW)Gx39Svv#AOMq_zqA~;#B3Gv1BmmCk1 zNtdoG$bkuT?g?z}EmgU^_J8bCCH=L3(Z0wz{_!D3`-vcGi zx@7@rr;t{6IHE1|YcYurG5$)ID1LqKNqnYhDwP4~ zuvn8f$86R$xO4^1X;?5lu%~CneuZt=YdP;%LPF+72!JRJa?MD(nz9vbiiWgMzI{MB z&!#E!4)K5PNA;JuMJk49GrD(bSP_!%NivY_^O{F;8WX&yd8USm&FK1berMi6IJf2G zX)L;RD>J1*xYxVJnM(+z3LFv%$G)=4#^UPY;^ zQfI6tPvK^;)BOz&?=y@!vQL;FP1(Jrgw&c3CJVUlbZqtc_{c;PPj@0{_XTyRHLhw! z7yZ&73c)#h1SHm)XJFX@7Kkv-9NVD~Bj1zpFW zCZP%KS#qLC48>KK%sRHWo1qgsSW`&yDR0<&*6DMj42h7!q}wyh=aaGS}pdllKtwAuLtn!4>5 zs`yM5gQS9G&F#M0jJ}#{OvVH{fA1xj3Y+)|RbDIlNx)ky;3F}7^OmgJls2NK!hjzN zyzu-86NUg}nfnhOJG9&V5xEW41PLU}q@5EzKSY%RilV)Y2;pJBT|~D<6XLLZevI#Z zyt`TF&-__1Fc(RE>c1r-|0GAvsno+_nf~#h6$!b4%$d>LPFH1Q{~GlHzG?nSuKD$! zr4;*Zrz=K|;$ljJ`+8>#kW{#0Z2hT+POv?_O-5_2K+Cr+cEmE?$dlVwY7h^u*_BBq zZtAAl!iLzkpwiM_WUnI9Bz&CcAgf|ZYvEZfY{Y9(*)E*b_Nrc-G;K+bycV5`!3(RO z`i^UFAx{s%=oBrJN;q(KoNtbIOKaAv1d)8bHwwkgzM&hLUmMn;=l=XOj=Zind86)5 zqz-~LL6&EKd&E38qi)AP#(K`i-_ZEY^3(w0f`SAeDZ@6*&tjsX7l^o05$tMn5zYx&HGB9O&(l4at*SI?2 zh2z{arbRnnVDX^wqGR79O51Ud0sM^p5p zn@4`+S{E4+qxl#NQbg2eu#iR?68vM8FR?o-ij)DUuawX*&1ZAk=btT}!#^@Hz?MLw zNke?J-wPq*eYq5&=f;uGY2y4xTpPZWK4^mHn4Du)iVl6A$QL-&t626mkCAuu=)wGK zf{!@xV)cpWej@D3)4M5Wmh)VXL-95B9P5A|`wK*0y)!q=uUr^> zpm=@j&%}4niN2S!F5-HNQpFxe&*KdUGH%kZcZQ^Oxqir*);c=rIlC`cY1bzgk7#sM z_84F>1|YKNmJauMHQRdAhUDg7MSn6nx5}f}yN|=&UiyJQwc!+b2yVfqpG5pSj*C~o z2a&>Aj@+6h21II5dva}UVY_v_dzhnALCj3)?Y56it$WuP^&hld0xK~#WMW7gjVE(N zzy(pGw+hMKOCnw3PwGyM2S3R@t5|wE7rg6MXfySZR_lZ(gxh5O$+h4aj3d;4sre{g z#;9((%4bDkUgvTk85f%bUdb>tghw5^{|p!;80vp;8K!S79rR_($;>)LGRY2p zG$y<^!pQo_miD1rf^9L1?`3gP&|^hs8HNbs1m7JsT@ z^pHOhFeR6Ib4B*o01bu7^V;3@bzIN0hbe+f=Pi4#w$`EcY4~FmXQTH-?si|?fPFoR z5};psUE0Fs?ZCJq;p`?K6*SwmCG8HnOR7=6KseqLz{1i+v?tzLmHh1VHug}fTsiic zchA3yLm~d<#8Q(OJ1C8v0I+?Q{_V?f z{-LTxvmanfjkiC}bEZb$Dh2a-`_{o&Z)yCbJA;M~GaLQu$ zEYg&)my6O5d=j?dp}#Fd8jdXvTxGxtoW%;G_@KB#5%A&WPIF3HU+bjeaoD!PUdQY> z(QQN!cJFmPl>=Mh9X)Ms5li&Qbi*~RvIs!e<;wM-Il*wQ38%BFC|U@QEBMk^R&%GM z#cn<#HqNE$K}Ht6{oJt)UfJi0J^z_Yr{BK#eR!fg6X!n;=6xzC!Jr2EVfuVg)*RU< zaO=+}upkqD&Oz9)v&Oe^AY0e8`^tx99;TRg;tcka8kYdyofvDj z3uJg-w@;VMy)kA#0g9ZlgygIZwQQc@AnFs%$#@^1$9J`T;{CIc*(x=-eE;+-v9Xb( zD#gz$<)W0Iz}@UA&>0Rc_RtFus2B3V@Z(}9w)INM@;DwWQ{3_y2Xu@75IzgcjCD>b zVku5lDF}e8aeIvKwmk4wL;>hmoeZ43HqFSI{b6?Va{5P0&eQH~?*;{+L*6-M2He~H zeHx!z4UjCs(~z-?LE2y39eyL*KPCK=vHQlgOgQ#5Pj`J5FF~?0I`j~{mJqQV$~ zZ-df{&;jH}bH8%GMLi7459@aNUMqA=rcHi&a)SSY3ZrHtA3r&M5aF_c|8-Z2{|+fc z4oX(oJ@eP>z)LgkOpjvba8T|_T!Yvv2T>;-k2fK5ck3rAR!puMH+|^umz?OYKrvuC zay z8^VDG`*=_vN6aI&9+WlV=@XyxEo$uHvh9OUbeLB=8ZXVHoDui?4ytpPe#ABA7P{U)vNr9gPm+y zJ7K;4vdrGte|K1SCv~(2-LCuPWWdU<7rGP|T169x%ra+ou*RRkR)YX1-&=mIgNz7- z&!zBmfr3bi-i~6D!9_yHk6{09_1_5P)r+(QUt!Kl-SYg4l3KUKVFJ1KI6idoGs4HO9w zs|xsf?z%=9AnffinB#XzzI}*HojeYGzqRpNCtjFp=y2VN9#h0Y@N6|0o5f!+iC`QD zWnI37Rn1ychzXQcSQZ%_BC%?~o$#h&Qt+6pQ^UV@bsRa_=ezTY+py zo8@TglJNaKwU3`};KkA*ZEkTAn|DlT>;s`fb5r=z7evIGTj}d=eA-V#amuFR6Kb`0&gye8FbC+^IL6#j{ECPs2Kz6Hg7t3SqNSaztfjM+TD z+h~(|{L_2x)a&SSq9^x~&3i5D(il`>SmV#hn(jy^c8A`}q$u8&7a6-Z)>pXq5pdNI(7F^$SVKJQT)`-jZ7vz-D%dU``CHd2b zFzRgkU>3`BSLA!lPFuMA+1n?z6M}bqf~UJ#-3ebrR&bh*_q0HQ!bAA~W`L^K(D(y? z=0nSZX|^FZb;C6|NND@c$u>n_27DtTm`gd!iB{d)K>I+E6R>>aGKn#lpR_#Eut*f* z!kOleS~y2Vd9E^pD?%7rXdvjxUE-JBpfSD8aFjOnDzK%`_@{u>L+9Qt&Jn!b9c#$P ze|?{GF^gm_rW;RV$=U;vxUlS&st>R{-szi%Dph4rcJ^jfhZxUVKO4~5jFwiYH}xwp zw4Dn)2d?vr4xE2pZlV4ThPX^5$s{})S)lR}Iv#r`oElwtyt;Vz#2d!*Oa@fY%}Udz z4RTi{@_l0#L?$9?Mp=WgF@5f`;k1**&L79<2WX@7g{{8xKs&}TSk_6{%-RwRH8eEf zemN`cdX3fiRVD*-mJlZ(V`4|?VW3UBMUX8ksCd?gjf@i89UKJU>Kdj1)hOv=sJz*I z6Q0nGPM$-5S}RT(m>Jnnx(W3EVJg7vtPps@_F;F^?Cd$zwMNZn)?F+qIEcz2_3Xw$ zXc0;^@ijl-|q#zU9>__h3{ zDY+TYDh~kBce@{sc!Zgn+cER7@BW78XEy(U3X;wcw(loHm2N%xs($2lKv`3z(Jv;< zY<2r*C)iad!PB(PvWh^5u@6hoQ37I(i(~5UN4x=W{S0*_*>cV6>?iD1*f)zMFq)Vo z1og;DH4|vh3>S;MucwOjH*8HH281FLEkPNe7QdZQU3U_@0r#ffnIsw;gKkD!GEL&G zES5dbV$=3y_sbsxCmMH)VBrv%x7atdqqkH;s8K}hYl)+oL$(ouhN#RwS?k32f|7As zLE|k3fF8=Ox1{;w1kSYC!zh9?)h_2eegt9snRi*rcZ?u14Ll}|GsUR7?sXr9mcl6f z;eK0<0oa1;IEZE~U?R;JV$oJ$9asibKZDYE3E09flns)I$OC_WKS0@9ljB7CN7a+cPAS>vI=zp5u{5%Z9=Q#s@I&*$qg*DI zebP7ej>}BD!gb<5a~B;m8G(8KzFee*>j-Z@qlyl?9z{(sS+Ll%ISRzKIa>y0cP=)4 zZBiDm);MNgjkIuUZGZZDw3n-!eKxWX5_E%rp+`hTOXggqogjxb5$JI4b}N5=d@_90 zk#|Gv(TtfIB$4k-4(xHg4tCK=bZXm;Ylgm;3Uh0nnxD$&STE&9+oK@+1dq;bq@4vZ zV9b^pjWNHf4{=d!W))nbWTO0q+LWLIdrZ-Gm-k5I?5CgjlUh1StCMGewuN!!^CrOP zFVrAs>@mR5EO?&nmuquEAvrX7Z!H*D@i(bQ1!YjQNL0x`-iJn`I~)MmdL#*Hj8wDD z$|P&PW3gPyZK4bnl_>p73}fAE$N&V-KXg})7vQ(Qbi0;2a*hy0reg+W1yuDS1{6i{ za6UzR{O?dIR|(1Y$*8;>0#Hql`;^C|@a5Q-=;tz4<22!abP5&HFow+tA4xUgXbh*w zAZIa<*S9>{Hi{hkGt0lTOZ{Y(f%^Mi*1A? z_62_h6G5*4ZV#KwF=+@8p!`1~!4)av;vZFE@KUR*D{>uVe2&4j^voalV?LQ371!;* zKvlbtkN3-}g&y{<5ZA#*Yf_ki-noqD{pG%&R)tQ&)qI#R%E$>o5VZGV>x!z5Z{S)D zV*K_=W^GbGWAa1X|09ThPAIs!yT#~~_4B=+$jc|5zx5Bczx*nx{YC89gKC}_pB(%$ zoqO5Z_6i>1Ru?z7NS|jtzKr?ZzMFUKx~MQ9OK;d5Z7AFiax8Th@-Rv-o?sdMQ|F}H zq){iC_YURC^+8@WWi0aTFN~OvXEt~_^})t{hmNX3pgortZfv|hP3wHRrC4@k>2vPX z*I9|>?(9zE#B+Lo#=ULvg7xV0;x-p9rW2h0OARW+5K&~gfTp_Ue}UMkuWUw|Q{!Sm z%v2_m{1J;L35rU~n)_Cl_W$UUV7-5I6sC;0U?0t|jqGRo!sGuGhv)4ew5fRqI|Bc| z(e4-&o==W3GL_#zIUJ_H{|oV+uix+#sjld-dP<;`fgP;2H&+^v4{06+D~?y02gP=D z)2qzlS8iiAA?p1o=Mf8j!d3t}{D{my?2+C6Plb;QqDrVe#NX8GYHu1HQ7maI7G=$Q zyhg%pRq=Y)wYOLsfw)XcEQt55P&QRBBkDsb&)8+ytHr)lhbn6CnguT2NI&BtxP%~@ zv9P~ExLu=OCy-WuL_3U3DVG**Babv1%$uFw0uKLIy?wjfF-Q8%?^K~Ko4d9RKP&{?- z^mE8h$NWka1axiMgD58R#GjQozg9t@j8Ac{R;)i~J5VKnTnuMU5Ve0Sgf--F?_)bE z!Md|=WxpWln*D<|7SxM%9LwzHWHl(#i^V5n?UBw+uHnTC9IAi+1u!O{B!xiz{%(T; zwP#+k+eczNs=|o2DDfm|Pd^?)zPkLSDMa@OO6kZob3VWb;kjz|(&1A{=i1hbKHQ+{ z%F=(8zJ1UD;wFPgLZWY?se*n*1Dp0|iWC6Lby-aYXw3{?g;PBL2emF3wJt5!R?XeV zcPIvb6OhU2J2Mb+-x)&{&uGn3$=&oepzNET(7k0qTQ_rxm3y*^+=RdNff7yR5(p7b zO2X3VDK|wZTeb5K5wJAMabuI4Mr&A;a$n1eQf3p{`!m=Q^h=C(5+2@A5JsqS1vxo9 z2K1h`T4!zQaFbZu)v||+vxN(Z<53k^8U{> z)zh>gV-rQ>;J%w*&&UUNMsKx8DnrNAuTgD56WUX*>#RUy?KO{iQg+%kXT(#0@mW<(=2<**f_LKX%Ko*6k8&z`ev#+`DdO+0jO74C%I% zcoJQ;4~Yqn^Lsa#1%85ZO?7bgBjAkRSJ)P~a`+#1i#5EE^^op|0EoRfuE#7VDMtJEH-G1E2N#a@zSK1&$_Uq>Ko!+hp{nZzy0;*DB)>KcTyhp1`QWw2n8jA174-XL;6;Er7RlMi91D-K$Bnj2rw81aR}4 z-4tKB7D-oUoG7;dpBci@J9%H`jW5EWemCn40EG;tF|z6dvG z9aQG@Z*5OHasNxi%=2L!+H7mMhQLdyV;EQ1pO9z+m2UKAoS@+R#v=^5*Sr}9Np2guw0PnF?4 zW&eodQr))iMKaG~O1!ROE)_+g2aj*7Mh2ku72JFA2O8YDLgp>+f`L zPAdkDI3YnpQcFdNlyT2q4o5vYioR{E>P#@X^m!uHs)u9Sj|(^eRVeTuAWOVeN+@&? zfMQN6+U)Q?hcX6TaM+Ork+~Y<(ZTJ=N*+Zr-l_Jf*=hA|!9{qs(!JMJ{738zFNW3I zdo){D1HpUDPw=imX8sikS2$I9h@*VlR>4pCp)qFFzFzLXNhS%D$G=QP2O3Benp;-m z-!|C}K7@VWya69ZMEvL|G8q&CWn-{`~2K%Ao3^Q%-T4>k!)yLhKph z;n;y(zMzD_CuUTBYR(!MC4kD*iB ze9Y*5LgP1VE=t%LmdEH+!t?YSqVpAdZYWct)igqu1#QJYd-AdLNgQ*CBcomuDx&vt zu`rY=ZnQxBQkytNU{-)?E)R2n;-l5nq=neZc%Q42C*CB>;G<%Xm`XY=3;W5dXkgs# z|7qvU-=SW(04|Lw$&KtWH5o}+vd4&N!X#;s7#e%_N!H;GBbQ{k##Rx@8X~f1EZ15Y zSB-5DW68b`V@Wi|yx*StZ@kZQem}pQbDr~jp3ga`XLqK-o%{S4u z7hK>onW6n9ZE^yK1q^CHTEeAd3r+G(Flow3xaL$~dOO*C6>7`+3pJnv)4@D);+zSk zb?!UHKi}%DSE;Py`m*9>OyOvOgZIDta~LmcBQ=G}kLx@d&c?5%I)J#jsq3OoKn>Dn ziCmC)#cOd*^KTbsM2(&$_0+WOsmvevH*AqeYw>8E`1#VPug-YwO&(NxoaqW6p~xy? zRS~d-2P#G66i*G(<4BxEtNS#9q+6oGzw)ZPs_T1{lNm$Xt0mkS9(*Ey;z-+IZtkqr zQ^+Fw@>?{)5P&G*9SaZU1S8TxK~-cNYQW06fVo9H21vsp_8x5F-%1KqkjAe?k( z*;-ZqtHE60-x(U_nrcPLp~>uleRhz(8sukVWR&y~`wvx@zF$CZaoMM6s~vxy zma}fSuny4)8W$|!QQEY0A``Y3@_QGK+RbUoMrdWBA3QTJ#flt((=-AbchNXnCwE%# zluZgB3f@q$wL>zIsRptSiF~4gGttCgfrZW~6RQC|fhg?22T#mSQ%tjCyZGyD{R)=f zIdT@OjI!Utlq@RM<-;Sc7>(w`th*;owrr4+5`}dAJywdJ`g$ew?9cV$4Ka&4w^8lJ zFIWmN$yfKLG_7gF^^8I_C7fC4j}?49R<^@|H$!kp*g$tSTKV*bw5tX_!)yJyY97(&Xm?-!<1Sr zf6pQ>1hARzSKO>?ryE{m$w8Pgk#4hOjDjf7w4B(RP}Du50ERasV$E5pcQG4TK~y~q zI8H>IGY8t0sv;8>&9K?D%GJ$eqo4|pxrMim0o;!iTul^DxuW?khxLSejxj7{ zt)hEnX1Z-%ep9?zEfNx)yumL%Ap)``r;Rp*bUU}k&Bl6*G8}5T6so@N?$26iB7DOIztIDg^2epw8JY5|z5urAtg|C1 zoTz+IPQKbxboWxTQ&W?vq(i}u;v(aq3wJ#Z(cBZc-#;PG9 zX2avgU4ipA_e-{2>3$H;tJ=$99+dYA^bBJ(01uID4%fEcSCO0yc z3x9Z#Ax>%0eDSdNHY2Gl393hAE6rY+P?Gx*M@cR-K%!zw9e0D5UgYp}@m8Ah(<3YS zMyR=(-Y#P%heg3X-Ri9_vH9E|Z-yA0HX^AebnRnvYGmceY6NP&F*YT^ipB$J{8P{6#!lpChr49G< zKkY1MR0Z@39yEOev?boR%T3_bCP39IA)NJISH5E1%Vg9Gt`H8P7;61(mZ(nIQk?|S$5U*D-lq&QRo zZqmgCYNk7FwM3Kt>jH>)QJ{l=;PXFVVf%j}GB5GC1Us9jsd<*b-tCG}^~K`Sq~I(MWf>2zWCd(|R*Nabpo zjcPGI==zY9pa&I{{z~@=KhBQPsaG2 zMOBH7c#G_slhE5CGdUW-tEZv;;{=1;Xut*=X11qzNtq5lIPLF?lL zJhFgW+U(d&%cncM3kG{Rum$AyPTyjy-&Pa^ zC<#9Z03B66OMQ7q)9H+emS&Z5psFI7h^-LXz;GXUS?)K!`G435>~VINUgio5C-(n- Os&~r}ZBTy0G4g-zoh~u} literal 0 HcmV?d00001 diff --git a/docs/source/_static/_images/logo.png b/docs/source/_static/_images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ddba8ad8155f35392eb84a63e60aa36745eb8c16 GIT binary patch literal 20405 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%0D1=FR{q{2jKLIA9m^ja;hP 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Ÿ%zbxl=~Jrsq|1 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. + See the `main pyAML documentation + `__ + for tutorials, how-to guides, and an overview of the ecosystem. + +.. toctree:: + :hidden: + + api From 01f6831123ac00d7a8f8eddcc31860d5b2e6957e Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:45:45 +0200 Subject: [PATCH 30/36] Update readme. --- README.md | 90 ++++++++++++------------------------------------------- 1 file changed, 19 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a65fd1a..438b351 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,50 @@ # tango-pyaml -**Bridge between **[**Tango Controls**](https://www.tango-controls.org/)** and PyAML** - +**Short one sentence description of tango-pyaml** +[![Documentation Status](https://readthedocs.org/projects/tango-pyaml/badge/?version=latest)](https://tango-pyaml.readthedocs.io/en/latest/?badge=latest) +[![Current release](https://img.shields.io/github/v/tag/python-accelerator-middle-layer/tango-pyaml)](https://github.com/python-accelerator-middle-layer/tango-pyaml/tags) ## Overview -`tango-pyaml` is a Python bridge between the [Tango control system](https://www.tango-controls.org/) and the [PyAML](https://github.com/python-accelerator-middle-layer/pyaml) abstraction layer for control systems. It provides a set of classes that allow Tango attributes and devices to be accessed and controlled using PyAML concepts. - -This library is part of the **Python Accelerator Middle Layer (PyAML)** ecosystem. - -## Features + -- ✅ Read and write Tango attributes via a unified PyAML interface -- 🔁 Support for read-only and read/write attributes -- 📊 Grouped attribute operations using `tango.Group` -- 💥 Exception mapping from Tango exceptions to PyAML exceptions -- 🧹 Designed to integrate seamlessly with PyAML `ControlSystem` components -- 🧪 Mocked devices for unit testing without Tango runtime +Describe the purpose, scope, and main features of tango-pyaml here. ## Installation +Install the package from PyPI: + ```bash pip install tango-pyaml ``` -## Requirements - -- Python >= 3.9 -- [PyTango](https://pytango.readthedocs.io/en/latest/) >= 9.5.1 -- [PyAML](https://github.com/python-accelerator-middle-layer/pyaml) -- [pydantic](https://docs.pydantic.dev/) >= 2.0 +## Development -For development and testing: +Install the development dependencies with: ```bash pip install tango-pyaml[dev] ``` -## Usage Example - -This is an example of an explicit call to a Tango attribute using PyAML. For more details about implicit declaration and broader configuration options, please refer to the [PyAML documentation](https://github.com/python-accelerator-middle-layer/pyaml). - -Configuration file `attribute.yaml`: - -```yaml -attribute: "sys/tg_test/1/float_scalar" -unit: "A" -``` - -Python code: - -```python -from tango.pyaml.attribute import Attribute -from tango.pyaml.tango_attribute import ConfigModel -import yaml - -with open("attribute.yaml") as f: - cfg_dict = yaml.safe_load(f) - -cfg = ConfigModel(**cfg_dict) -attr = Attribute(cfg) - -attr.set(10.0) -value = attr.get() -readback = attr.readback() - -print(f"Value: {value}, Readback: {readback.value} [{readback.quality}]") -``` - -## Available Classes - -- `Attribute` — Read/write access to a Tango attribute -- `AttributeReadOnly` — Read-only attribute wrapper -- `AttributeList` — Manage a group of attributes from multiple devices -- `TangoControlSystem` — Adapter to configure global Tango control system context - -## Testing - -Tests rely on mocked Tango devices and attributes using `unittest.mock`. To run tests: +Run the test suite with: ```bash pytest ``` -## Project Structure +Install the pre-commit hooks with: -- `tango.pyaml.attribute` – Main attribute interface -- `tango.pyaml.attribute_read_only` – Read-only attribute implementation -- `tango.pyaml.attribute_list` – Attribute groups with `tango.Group` -- `tango.pyaml.tango_attribute` – Base class wrapping attribute logic -- `mocked_device_proxy.py` – In-memory mock for Tango `DeviceProxy` and `AttributeProxy` +```bash +pre-commit install +``` -## License +## Documentation -This project is licensed under the MIT License. +The documentation is available at: -## Links + -- 🧺 [Repository](https://github.com/python-accelerator-middle-layer/tango-pyaml) +## Contributing +Please use the issue tracker or submit a pull request. From c1135d870dd2549f7f1ce72ed8fa734d93342b0e Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:45:57 +0200 Subject: [PATCH 31/36] Add copier answers. --- .copier-answers.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .copier-answers.yml diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..69f2df1 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,9 @@ +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: 0.1.0 +_src_path: https://github.com/python-accelerator-middle-layer/pyaml-repository-template.git +distribution_name: tango-pyaml +html_title: tango-pyaml +import_name: tango.pyaml +package_name: tango-pyaml +repository_url: https://github.com/python-accelerator-middle-layer/tango-pyaml +use_docs_extra: false From e98b50133920ef078e3f293bac1891427bacdc49 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 16:53:10 +0200 Subject: [PATCH 32/36] Modified readme. --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 438b351..379b3dd 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,22 @@ # tango-pyaml -**Short one sentence description of tango-pyaml** +**Bridge between **[**Tango Controls**](https://www.tango-controls.org/)** and pyAML** [![Documentation Status](https://readthedocs.org/projects/tango-pyaml/badge/?version=latest)](https://tango-pyaml.readthedocs.io/en/latest/?badge=latest) -[![Current release](https://img.shields.io/github/v/tag/python-accelerator-middle-layer/tango-pyaml)](https://github.com/python-accelerator-middle-layer/tango-pyaml/tags) +[![Current release](https://img.shields.io/github/v/release/python-accelerator-middle-layer/tango-pyaml)](https://github.com/python-accelerator-middle-layer/tango-pyaml/releases) ## Overview - +`tango-pyaml` is a Python bridge between the [Tango control system](https://www.tango-controls.org/) and the [pyAML](https://github.com/python-accelerator-middle-layer/pyaml) abstraction layer for control systems. It provides a set of classes that allow Tango attributes and devices to be accessed and controlled using pyAML concepts. -Describe the purpose, scope, and main features of tango-pyaml here. +## Features + +- ✅ Read and write Tango attributes via a unified PyAML interface +- 🔁 Support for read-only and read/write attributes +- 📊 Grouped attribute operations using `tango.Group` +- 💥 Exception mapping from Tango exceptions to PyAML exceptions +- 🧹 Designed to integrate seamlessly with PyAML `ControlSystem` components +- 🧪 Mocked devices for unit testing without Tango runtime ## Installation @@ -43,7 +50,7 @@ pre-commit install The documentation is available at: - + ## Contributing From d0ab2a0757e91cab38df5b3292874501f10cad89 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 17:09:16 +0200 Subject: [PATCH 33/36] Fix bug with attributes in multi attribute. --- tango/pyaml/multi_attribute.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index c0a4b02..807b5e4 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -50,8 +50,7 @@ def __init__( range: tuple[float | None, float | None] | None = None, ): super().__init__() - - self._attributes = attributes + self._attributes = [] if attributes is None else attributes self._name = name self._unit = unit self._range = range From af47ecc3c9a30d8a8c892396dcd36a009a6cfb5d Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 17:23:27 +0200 Subject: [PATCH 34/36] Changes for dynamic versioning. --- .gitignore | 1 + pyproject.toml | 7 +++++-- tango/pyaml/__init__.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 5a92b6b..808420e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +**/_version.py # PyInstaller # Usually these files are written by a python script from a template diff --git a/pyproject.toml b/pyproject.toml index 48d1d9a..0a1e6d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,12 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [tool.hatch.version] -path = "tango/pyaml/__init__.py" +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "tango/pyaml/_version.py" [tool.hatch.build.targets.sdist] exclude = [ diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index a459ed4..a73e0e9 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,8 +1,8 @@ -__version__ = "0.4.0" - import logging.config import os +from ._version import __version__ as __version__ + config_file = os.getenv("TANGO_PYAML_LOG_CONFIG", "tango_pyaml_logging.conf") if os.path.exists(config_file): From c19b37ad06b7971eb6da37d856fe72f3ccf36e4e Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Thu, 10 Sep 2026 11:00:29 +0200 Subject: [PATCH 35/36] Add workflow for github release. --- .github/workflows/github-release.yml | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/github-release.yml diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 0000000..d92832a --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,49 @@ +name: GitHub Release + +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + +permissions: + contents: write + +jobs: + release: + name: Build and create release on GitHub + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install Hatch + run: python -m pip install --upgrade hatch + + - name: Build distribution + run: hatch build + + - name: Determine whether this is a pre-release + id: release_type + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + + if [[ "$tag" =~ (\.dev[0-9]+|a[0-9]+|b[0-9]+|rc[0-9]+)$ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub release + uses: softprops/action-gh-release@v3 + with: + draft: false + prerelease: ${{ steps.release_type.outputs.prerelease }} + generate_release_notes: true + files: dist/* From c31a3148d7fd40e75f3d20f3b45220df112f1b89 Mon Sep 17 00:00:00 2001 From: Alexis Gamelin Date: Fri, 11 Sep 2026 09:53:56 +0200 Subject: [PATCH 36/36] Add numpy-style docstrings to all modules Every module, class and public method of tango.pyaml now carries a numpy-format docstring. Class docstrings include Parameters, Attributes and Methods sections, following the pyaml core style. Also fixes docstrings that no longer matched the code (TangoControlSystem parameters, StaticCatalog raises, TangoCatalog disconnected parameter). No code change: only docstrings were added or rewritten. Fixes #39 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AJzpQexixFAPP5hzc12inQ --- tango/pyaml/__init__.py | 23 +++ tango/pyaml/attribute.py | 115 +++++++++++++++ tango/pyaml/attribute_list.py | 91 +++++++++++- tango/pyaml/attribute_list_read_only.py | 35 ++++- tango/pyaml/attribute_read_only.py | 53 +++++++ tango/pyaml/catalog.py | 18 ++- tango/pyaml/controlsystem.py | 183 +++++++++++++++++++++--- tango/pyaml/device_factory.py | 81 ++++++++++- tango/pyaml/initializable_element.py | 53 +++++++ tango/pyaml/multi_attribute.py | 183 ++++++++++++++++++++++++ tango/pyaml/static_catalog.py | 22 ++- tango/pyaml/static_catalog_entry.py | 34 ++++- tango/pyaml/tango_catalog.py | 83 ++++++++++- tango/pyaml/tango_pyaml_utils.py | 18 +++ 14 files changed, 947 insertions(+), 45 deletions(-) diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index a73e0e9..885fbfd 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,3 +1,26 @@ +""" +Tango backend for pyAML. + +This package bridges `Tango Controls `_ and +the pyAML abstraction layer. It exposes Tango attributes and attribute groups +as pyAML :class:`~pyaml.control.deviceaccess.DeviceAccess` objects, provides a +:class:`~tango.pyaml.controlsystem.TangoControlSystem` implementation and +catalogs that resolve pyAML device keys into Tango attribute references. + +The package registers its configuration schemas with pyAML through the +``pyaml.schemas`` entry point, so the classes below can be used directly in +pyAML YAML configuration files. + +Logging is configured at import time from two optional environment variables: + +``TANGO_PYAML_LOG_CONFIG`` + Path to a :mod:`logging.config` file (default ``tango_pyaml_logging.conf`` + in the current directory). Loaded only if the file exists. +``TANGO_PYAML_LOG_LEVEL`` + Level name (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``, ``CRITICAL``) + applied to the ``tango.pyaml`` logger. +""" + import logging.config import os diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index 36d90fe..34727fa 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,3 +1,11 @@ +""" +Scalar Tango attribute access. + +This module maps a single Tango attribute (or one element of a SPECTRUM +attribute) onto the pyAML :class:`~pyaml.control.deviceaccess.DeviceAccess` +interface. +""" + import copy import logging @@ -48,6 +56,10 @@ class Attribute(DeviceAccess, InitializableElement, DynamicValidation): """ Tango attribute that can be written to. + The Tango device proxy is obtained lazily from + :class:`~tango.pyaml.device_factory.DeviceFactory` on first access, so + building an ``Attribute`` never contacts the control system. + Parameters ---------- attribute : str @@ -63,6 +75,56 @@ class Attribute(DeviceAccess, InitializableElement, DynamicValidation): writable : bool, optional If the attribute should be writable. Default is True. + Attributes + ---------- + _attribute : str + Full path of the Tango attribute. + _unit : str + Unit of the attribute. + _range : tuple of (float or None, float or None) or None + Configured range, or ``None`` to query Tango on first use. + _index : int or None + Index into a SPECTRUM attribute, or ``None`` for scalar access. + _writable : bool + ``True`` if writes are allowed (always ``False`` when indexed). + _attribute_dev : tango.DeviceProxy or None + Proxy of the device owning the attribute, set on initialization. + _attr_config : tango.AttributeInfoEx or None + Tango attribute configuration, set on initialization. + _attribute_dev_name : str or None + Device part of the attribute path, set on initialization. + _attr_name : str or None + Attribute part of the attribute path, set on initialization. + + Methods + ------- + initialize() + Connect to the Tango device and check the attribute configuration. + is_writable() + Tell whether the attribute accepts writes. + set(value) + Write a value asynchronously to the Tango attribute. + set_and_wait(value) + Write a value synchronously to the Tango attribute. + get() + Get the last written value of the attribute. + readback() + Return the readback value with metadata. + unit() + Return the unit of the attribute. + name() + Return the full attribute name. + measure_name() + Return the short attribute name (last component). + get_tango_attribute() + Return the raw Tango attribute path without index decoration. + clone_with_tango_attribute(attribute) + Return a shallow copy configured with another Tango attribute path. + get_range() + Return the valid range of the attribute. + check_device_availability() + Check whether the Tango device answers to a ping. + Raises ------ pyaml.PyAMLException @@ -92,6 +154,20 @@ def __init__( self._attr_name: str = None def initialize(self): + """ + Connect to the Tango device and check the attribute configuration. + + Splits the attribute path into device and attribute names, obtains + the device proxy from :class:`~tango.pyaml.device_factory.DeviceFactory` + and reads the attribute configuration. + + Raises + ------ + pyaml.PyAMLException + If the device proxy cannot be created, if an indexed attribute is + not a SPECTRUM, or if a writable attribute is not writable in + Tango. + """ super().initialize() try: self._attribute_dev_name, self._attr_name = self._attribute.rsplit("/", 1) @@ -122,6 +198,14 @@ def initialize(self): ) def is_writable(self): + """ + Tell whether the attribute accepts writes. + + Returns + ------- + bool + ``True`` if :meth:`set` and :meth:`set_and_wait` are allowed. + """ return self._writable def set(self, value: float): @@ -253,6 +337,11 @@ def clone_with_tango_attribute(self, attribute: str) -> "Attribute": ---------- attribute : str Tango attribute path to store in the cloned instance. + + Returns + ------- + Attribute + Copy of this instance pointing to ``attribute``. """ new_obj = copy.copy(self) new_obj._attribute = attribute @@ -300,6 +389,23 @@ def get(self) -> float: raise tango_to_PyAMLException(df) def get_range(self) -> list[float]: + """ + Return the valid range of the attribute. + + The configured ``range`` takes precedence; otherwise the ``min_value`` + and ``max_value`` limits of the Tango attribute configuration are + used, which requires initialization. + + Returns + ------- + list of float or None + ``[min, max]`` where an unbounded limit is ``None``. + + Raises + ------ + pyaml.PyAMLException + If no range is configured and initialization fails. + """ attr_range: list[float] = [None, None] if self._range is not None: attr_range[0] = self._range[0] if self._range[0] is not None else None @@ -314,6 +420,15 @@ def get_range(self) -> list[float]: return attr_range def check_device_availability(self) -> bool: + """ + Check whether the Tango device answers to a ping. + + Returns + ------- + bool + ``True`` if the device is reachable, ``False`` if initialization + or the ping fails. + """ available = True try: self._ensure_initialized() diff --git a/tango/pyaml/attribute_list.py b/tango/pyaml/attribute_list.py index 6e39a62..ed61b7f 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -1,3 +1,11 @@ +""" +Grouped Tango attribute access. + +This module handles a list of Tango attributes through :class:`tango.Group` +objects, one per distinct attribute name, so that a value can be written to or +read from many devices in a single call. +""" + import logging from numpy import array @@ -42,6 +50,10 @@ class AttributeList(DeviceAccess, InitializableElement, DynamicValidation): """ Handle a list of Tango attributes using Tango Groups. + Attributes are grouped by attribute name: one :class:`tango.Group` is + created per distinct attribute name and holds every device exposing it. + Groups are created lazily on first access. + Parameters ---------- attributes : list of str @@ -50,6 +62,44 @@ class AttributeList(DeviceAccess, InitializableElement, DynamicValidation): Group name. unit : str, optional Unit of the attributes. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths in configured order. + _name : str + Group name. + _unit : str + Unit of the attributes. + _tango_groups : dict of str to tango.Group + Tango groups indexed by attribute name, created on initialization. + _attr_dev : dict of str to list of str + Device names indexed by attribute name. + + Methods + ------- + initialize() + Create one Tango group per attribute name. + name() + Return the group name. + measure_name() + Return the group name (alias for measurement name). + get_tango_attributes() + Return the raw Tango attribute paths stored in the configuration. + set(value) + Write a value asynchronously to all Tango attributes. + set_and_wait(value) + Write a value synchronously to all Tango attributes. + get() + Return the last written values of all attributes. + readback() + Return readback values with metadata for all attributes. + unit() + Return the unit for the attribute list. + get_range() + Return the valid ranges of the attributes. + check_device_availability() + Check whether every device of the groups answers to a ping. """ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): @@ -70,6 +120,12 @@ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): self._attr_dev[attr_name].append(attribute_dev_name) def initialize(self): + """ + Create one Tango group per attribute name. + + Each group is named after the list and populated with the devices + exposing that attribute. + """ super().initialize() for attr_name, dev_list in self._attr_dev.items(): self._tango_groups[attr_name] = tango.Group(self._name) @@ -103,7 +159,7 @@ def get_tango_attributes(self) -> list[str]: Returns ------- - list[str] + list of str Tango attribute paths in configured order. """ return self._attributes @@ -148,8 +204,9 @@ def get(self) -> array: Returns ------- - numpy.array - Array of last written values ordered as in configuration. + numpy.ndarray + Array of last written values ordered as in configuration. Entries + whose read failed are ``None``. """ self._ensure_initialized() result = {} @@ -172,8 +229,10 @@ def readback(self) -> array: Returns ------- - numpy.array - Array of Value objects ordered as in configuration. + numpy.ndarray + Array of :class:`~pyaml.control.readback_value.Value` objects + ordered as in configuration. Entries whose read failed are + ``None``. """ self._ensure_initialized() logger.log(logging.DEBUG, f"Reading list {self.name()}") @@ -210,6 +269,19 @@ def unit(self) -> str: return self._unit def get_range(self) -> list[float]: + """ + Return the valid ranges of the attributes. + + If a ``_range`` is configured it is returned as ``[min, max]``. + Otherwise the limits are read from the Tango attribute configuration + of every device and returned flattened as + ``[min0, max0, min1, max1, ...]``, in group order. + + Returns + ------- + list of float or None + Range limits, where an unbounded limit is ``None``. + """ attr_range: list[float] = [None, None] if self._range is not None: attr_range[0] = self._range[0] if self._range[0] is not None else None @@ -230,6 +302,15 @@ def get_range(self) -> list[float]: return attr_range def check_device_availability(self) -> bool: + """ + Check whether every device of the groups answers to a ping. + + Returns + ------- + bool + ``True`` if all devices are reachable, ``False`` if initialization + or any ping fails. + """ available = True try: self._ensure_initialized() diff --git a/tango/pyaml/attribute_list_read_only.py b/tango/pyaml/attribute_list_read_only.py index fec7d89..3b12a1e 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,3 +1,5 @@ +"""Read-only list of Tango attributes handled through Tango groups.""" + import logging import pyaml @@ -10,13 +12,17 @@ logger = logging.getLogger(__name__) -class AttributeListReadOnlyConfig(AttributeListConfig): ... +class AttributeListReadOnlyConfig(AttributeListConfig): + """Configuration model for a read-only list of Tango attributes.""" @register_schema class AttributeListReadOnly(AttributeList, DynamicValidation): """ - Handle a list of Tango attributes using Tango Groups. + Handle a read-only list of Tango attributes using Tango Groups. + + Same as :class:`~tango.pyaml.attribute_list.AttributeList`, except that + asynchronous writes through :meth:`set` are rejected. Parameters ---------- @@ -26,6 +32,22 @@ class AttributeListReadOnly(AttributeList, DynamicValidation): Group name. unit : str, optional Unit of the attributes. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths in configured order. + _name : str + Group name. + _unit : str + Unit of the attributes. + + Methods + ------- + set(value) + Disallowed asynchronous write operation. + set_and_wait(value) + Write a value synchronously to all Tango attributes. """ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): @@ -37,12 +59,17 @@ def __init__(self, attributes: list[str], name: str = "", unit: str = ""): def set(self, value: float): """ - Write a value asynchronously to all Tango attributes. + Disallowed asynchronous write operation. Parameters ---------- value : float - Value to write. + Ignored. + + Raises + ------ + pyaml.PyAMLException + Always raised because the attribute list is read-only. """ raise pyaml.PyAMLException( f"Tango attribute list {self.name()} is not writable." diff --git a/tango/pyaml/attribute_read_only.py b/tango/pyaml/attribute_read_only.py index a8e6f65..40d3a9a 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,3 +1,5 @@ +"""Read-only scalar Tango attribute.""" + import logging import pyaml @@ -19,6 +21,11 @@ class AttributeReadOnly(Attribute, DynamicValidation): """ Read-only Tango attribute. + Behaves like :class:`~tango.pyaml.attribute.Attribute` but rejects every + write and never checks the Tango attribute writability on + initialization. :meth:`get` returns the readback value instead of the + last written value. + Parameters ---------- attribute : str @@ -31,6 +38,26 @@ class AttributeReadOnly(Attribute, DynamicValidation): Zero-based index into a SPECTRUM attribute. When set, the instance behaves as a read-only scalar view of one vector element; writes are always rejected and a SPECTRUM data_format is enforced on init. + + Attributes + ---------- + _attribute : str + Full path of the Tango attribute. + _unit : str + Unit of the attribute. + _range : tuple of (float or None, float or None) or None + Configured range, or ``None`` to query Tango. + _index : int or None + Index into a SPECTRUM attribute, or ``None`` for scalar access. + + Methods + ------- + set(value) + Disallowed write operation. + set_and_wait(value) + Disallowed synchronous write operation. + get() + Return the current readback value of the attribute. """ def __init__( @@ -53,6 +80,11 @@ def set(self, value: float): """ Disallowed write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException @@ -66,6 +98,11 @@ def set_and_wait(self, value: float): """ Disallowed synchronous write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException @@ -76,4 +113,20 @@ def set_and_wait(self, value: float): ) def get(self) -> float: + """ + Return the current readback value of the attribute. + + A read-only attribute has no setpoint, so this is equivalent to + ``readback().value``. + + Returns + ------- + float + The readback value. + + Raises + ------ + pyaml.PyAMLException + If the Tango read fails. + """ return self.readback().value diff --git a/tango/pyaml/catalog.py b/tango/pyaml/catalog.py index 4805952..9e37d2a 100644 --- a/tango/pyaml/catalog.py +++ b/tango/pyaml/catalog.py @@ -9,6 +9,11 @@ class Catalog(metaclass=ABCMeta): r""" Abstract class for backend catalog configuration objects. + Methods + ------- + resolve(key) + Return a configuration model for a DeviceAccess. + Notes ----- Concrete catalogs live in each control-system package. They may expose @@ -19,5 +24,16 @@ class Catalog(metaclass=ABCMeta): @abstractmethod def resolve(self, key: str) -> BaseModel: """ - Return a configuration model for a DeviceAccess + Return a configuration model for a DeviceAccess. + + Parameters + ---------- + key : str + Catalog key to resolve. + + Returns + ------- + pydantic.BaseModel + Configuration model describing the device registered under + ``key``. """ diff --git a/tango/pyaml/controlsystem.py b/tango/pyaml/controlsystem.py index 729a8f7..79ae6b9 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,3 +1,11 @@ +""" +Tango implementation of the pyAML control system. + +:class:`TangoControlSystem` resolves pyAML device references through a +:class:`~tango.pyaml.catalog.Catalog`, prefixes Tango attribute paths with the +configured Tango host and caches the resulting device access objects. +""" + import logging from pydantic import BaseModel @@ -30,18 +38,55 @@ class TangoControlSystem(ControlSystem, DynamicValidation): ---------- name : str Name of the control system. - tango_host : str - Tango host URL. Default is the TANGO_HOST variable. - catalog : Catalog | None + tango_host : str, optional + Tango host URL (``host:port``). Default is ``None``, meaning the + ``TANGO_HOST`` environment variable is used by PyTango. + catalog : Catalog, optional Catalog instance used to resolve PyAML device keys. - debug_level : str | int | None - Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. Or 10, 20, 30, 40, 50. - scalar_aggregator : str - 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. - timeout_ms : int - Device timeout in milli seconds. + debug_level : str or int, optional + Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. + Or 10, 20, 30, 40, 50. + lazy_devices : bool, optional + Reserved for lazy device creation. Default is True. + timeout_ms : int, optional + Device timeout in milliseconds. Default is 3000. + + Attributes + ---------- + _name : str + Name of the control system. + _tango_host : str or None + Configured Tango host. + _catalog : Catalog or None + Catalog used to resolve device keys. + _debug_level : str or int or None + Requested log level. + _lazy_devices : bool + Lazy device creation flag. + _timeout_ms : int + Device timeout in milliseconds. + + Methods + ------- + attach(devs) + Attach a list of device accesses to this control system. + attach_array(devs) + Attach a list of device accesses to this control system. + get_device_access(ref) + Resolve a public device reference for this Tango control system. + name() + Return the name of the control system. + get_tango_host() + Return the Tango host configured for this control system. + get_aggregator() + Return a new empty aggregator of device accesses. + scalar_aggregator() + Return the module name used for handling aggregator of DeviceAccess. + vector_aggregator() + Return the module name used for handling aggregator of + DeviceVectorAccess. + get_catalog() + Return the catalog that references all control system devices. """ def __init__( @@ -77,12 +122,60 @@ def __init__( ) def attach_array(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Attach a list of device accesses to this control system. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + """ return self._attach(devs) def attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Attach a list of device accesses to this control system. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + """ return self._attach(devs) def _attach(self, devs: list[DeviceAccess]) -> list[DeviceAccess]: + """ + Prefix attribute paths with the Tango host and cache the results. + + Each device is cloned with its full attribute name + (``//tango_host/attribute``) the first time it is seen; subsequent + calls return the cached clone. + + Parameters + ---------- + devs : list of DeviceAccess + Tango attributes to attach. ``None`` entries are preserved. + + Returns + ------- + list of DeviceAccess + Attached device accesses, in the same order as ``devs``. + + Raises + ------ + pyaml.PyAMLException + If a device does not expose ``get_tango_attribute()``. + """ # Concatenate the tango_host prefix newDevs = [] for d in devs: @@ -114,6 +207,28 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: catalog. Public Python APIs may pass Tango backend configuration models. Already constructed DeviceAccess instances are intentionally rejected: attach() remains the internal compatibility API for those. + + Parameters + ---------- + ref : str or pydantic.BaseModel or None + Catalog key, Tango configuration model + (:class:`~tango.pyaml.attribute.AttributeConfig`, + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnlyConfig`, + :class:`~tango.pyaml.attribute_list.AttributeListConfig` or + :class:`~tango.pyaml.attribute_list_read_only.AttributeListReadOnlyConfig`), + or ``None``. + + Returns + ------- + DeviceAccess or None + Attached device access, or ``None`` if ``ref`` is ``None``. + + Raises + ------ + pyaml.PyAMLException + If ``ref`` is an already constructed DeviceAccess, if no usable + catalog is configured for a string key, or if ``ref`` has an + unsupported type. """ if ref is None: return None @@ -174,6 +289,20 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: def _attach_attribute_list_config( self, cfg: AttributeListConfig ) -> AttributeListConfig: + """ + Return a copy of ``cfg`` with attribute paths prefixed by the Tango host. + + Parameters + ---------- + cfg : AttributeListConfig + Configuration to adapt. + + Returns + ------- + AttributeListConfig + ``cfg`` itself when no Tango host is configured, otherwise a copy + whose ``attributes`` are ``//tango_host/attribute`` paths. + """ tango_host = self.get_tango_host() if not tango_host: return cfg @@ -203,45 +332,55 @@ def get_tango_host(self) -> str | None: Returns ------- - str | None + str or None Tango host URL, or ``None`` when unconfigured. """ return self._tango_host def get_aggregator(self) -> MultiAttribute | None: - """Returns a new empty DeviceAccessList. If None is returned serialized readings/writtings are performed""" + """ + Return a new empty aggregator of device accesses. + + If ``None`` were returned, serialized readings/writings would be + performed by the pyAML core instead. + + Returns + ------- + MultiAttribute + New empty :class:`~tango.pyaml.multi_attribute.MultiAttribute`. + """ return MultiAttribute() def scalar_aggregator(self) -> str | None: """ - Returns the module name used for handling aggregator of DeviceAccess + Return the module name used for handling aggregator of DeviceAccess. Returns ------- - str - Aggregator module name + str or None + Aggregator module name. Always ``None`` for Tango. """ return None def vector_aggregator(self) -> str | None: """ - Returns the module name used for handling aggregator of DeviceVectorAccess + Return the module name used for handling aggregator of DeviceVectorAccess. Returns ------- - str - Aggregator module name + str or None + Aggregator module name. Always ``None`` for Tango. """ return None def get_catalog(self) -> Catalog | None: """ - Returns the catalog that references all control systems devices. + Return the catalog that references all control system devices. Returns ------- - Catalog - The catalog + Catalog or None + The catalog, or ``None`` if none was configured. """ return self._catalog diff --git a/tango/pyaml/device_factory.py b/tango/pyaml/device_factory.py index 167fb3f..93cfd10 100644 --- a/tango/pyaml/device_factory.py +++ b/tango/pyaml/device_factory.py @@ -1,3 +1,5 @@ +"""Shared cache of Tango device proxies.""" + from collections import defaultdict from threading import Lock @@ -5,14 +7,51 @@ class DeviceFactory: - """Singleton factory to build PyAML elements with future compatibility logic.""" + """ + Singleton factory to build PyAML elements with future compatibility logic. + + The factory caches one :class:`tango.DeviceProxy` per device name so that + every :class:`~tango.pyaml.attribute.Attribute` of the same device shares + the same connection. It also holds the client timeout applied to every + proxy it creates. + + Attributes + ---------- + _instance : DeviceFactory or None + The unique instance, created on first call. + _lock : threading.Lock + Lock protecting the instance creation. + _elements : dict of str to tango.DeviceProxy + Cache of device proxies indexed by device name. + _timeout : int + Client timeout in milliseconds applied to new proxies. + + Methods + ------- + set_timeout_ms(timeout) + Set the timeout applied to newly created device proxies. + get_timeout_ms() + Return the timeout applied to device proxies. + get_device(device_name) + Return the cached device proxy for a device, creating it if needed. + clear() + Drop all cached device proxies. + """ _instance = None _lock = Lock() def __new__(cls): """ - No matter how many times you call DeviceFactory(), it will be created only once. + Return the unique factory instance. + + No matter how many times you call ``DeviceFactory()``, it will be + created only once. + + Returns + ------- + DeviceFactory + The singleton instance. """ with cls._lock: if cls._instance is None: @@ -22,12 +61,49 @@ def __new__(cls): return cls._instance def set_timeout_ms(self, timeout: int): + """ + Set the timeout applied to newly created device proxies. + + Proxies already in the cache keep their current timeout. + + Parameters + ---------- + timeout : int + Timeout in milliseconds. + """ self._timeout = timeout def get_timeout_ms(self) -> int: + """ + Return the timeout applied to device proxies. + + Returns + ------- + int + Timeout in milliseconds. + """ return self._timeout def get_device(self, device_name: str) -> tango.DeviceProxy: + """ + Return the cached device proxy for a device, creating it if needed. + + Parameters + ---------- + device_name : str + Tango device name (``domain/family/member``), optionally prefixed + by ``//host:port/``. + + Returns + ------- + tango.DeviceProxy + Device proxy configured with the factory timeout. + + Raises + ------ + tango.DevFailed + If the device proxy cannot be created. + """ if device_name not in self._elements: dp = tango.DeviceProxy(device_name) dp.set_timeout_millis(self._timeout) @@ -35,4 +111,5 @@ def get_device(self, device_name: str) -> tango.DeviceProxy: return self._elements[device_name] def clear(self): + """Drop all cached device proxies.""" self._elements.clear() diff --git a/tango/pyaml/initializable_element.py b/tango/pyaml/initializable_element.py index 4a9b43e..44c9911 100644 --- a/tango/pyaml/initializable_element.py +++ b/tango/pyaml/initializable_element.py @@ -1,21 +1,74 @@ +""" +Lazy initialization support for Tango-backed elements. + +Tango connections are expensive and may fail when the control system is not +reachable. Elements derived from :class:`InitializableElement` postpone any +Tango call until the first access, through :meth:`_ensure_initialized`. +""" + from abc import ABCMeta, abstractmethod class InitializableElement(metaclass=ABCMeta): + """ + Base class for elements whose Tango resources are created lazily. + + Subclasses implement :meth:`initialize` to open the Tango connections + they need and call :meth:`_ensure_initialized` at the beginning of every + method that requires them. + + Attributes + ---------- + _initialized : bool + ``True`` once :meth:`initialize` has been called. + + Methods + ------- + initialize() + Create the Tango resources needed by the element. + name() + Return the element name. + is_initialized() + Tell whether the element has already been initialized. + """ + def __init__(self): self._initialized = False @abstractmethod def initialize(self): + """ + Create the Tango resources needed by the element. + + Subclasses must call ``super().initialize()`` so that the + initialization flag is set. + """ self._initialized = True @abstractmethod def name(self) -> str: + """ + Return the element name. + + Returns + ------- + str + Element name. + """ return "" def is_initialized(self) -> bool: + """ + Tell whether the element has already been initialized. + + Returns + ------- + bool + ``True`` if :meth:`initialize` has been called. + """ return self._initialized def _ensure_initialized(self): + """Call :meth:`initialize` if it has not been called yet.""" if not self.is_initialized(): self.initialize() diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index 807b5e4..274f965 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -1,3 +1,12 @@ +""" +Aggregated access to several scalar Tango attributes. + +:class:`MultiAttribute` is the Tango implementation of the pyAML +:class:`~pyaml.control.deviceaccesslist.DeviceAccessList` aggregator: reads and +writes on the managed :class:`~tango.pyaml.attribute.Attribute` objects are +issued asynchronously and collected afterwards. +""" + import logging import numpy as np @@ -42,6 +51,71 @@ class MultiAttributeConfig(BaseModel): @register_schema class MultiAttribute(DeviceAccessList, DynamicValidation): + """ + Aggregate several scalar Tango attributes into one vector access. + + Each managed item is an :class:`~tango.pyaml.attribute.Attribute`. + Items can be created from the ``attributes`` paths at construction time + or appended later with :meth:`add_devices`; the latter is how + :meth:`~tango.pyaml.controlsystem.TangoControlSystem.get_aggregator` + uses this class. + + Parameters + ---------- + attributes : list of str, optional + List of Tango attribute paths. Default is an empty list. + name : str, optional + Group name. + unit : str, optional + Unit shared by all attributes. + range : tuple(min, max), optional + Range of valid values applied to every attribute. Use null for -∞ + or +∞. + + Attributes + ---------- + _attributes : list of str + Tango attribute paths given at construction time. + _name : str + Group name. + _unit : str + Unit shared by all attributes. + _range : tuple of (float or None, float or None) or None + Range applied to every attribute built from ``_attributes``. + _items : list of Attribute + Managed attributes, in order. + + Methods + ------- + len() + Return the number of managed attributes. + get_device_at(index) + Return the managed attribute at a given position. + add_devices(devices) + Append one or several attributes to the aggregate. + set(value) + Write one value per attribute, asynchronously. + set_and_wait(value) + Not implemented. + get() + Return the last written value of every attribute. + readback() + Return the readback value of every attribute. + get_range() + Return the valid ranges of all attributes. + check_device_availability() + Check whether every managed device is reachable. + unit() + Return the unit shared by the attributes. + + Notes + ----- + Reads and writes are issued with the PyTango asynchronous API + (``read_attribute_asynch`` / ``write_attribute_asynch``) on every item + first, and the replies are then collected in order. The reply timeout is + the one of :class:`~tango.pyaml.device_factory.DeviceFactory`. + """ + def __init__( self, attributes: list[str] | None = None, @@ -64,12 +138,47 @@ def __init__( self._items.append(attr) def len(self) -> int: + """ + Return the number of managed attributes. + + Returns + ------- + int + Number of items. + """ return len(self._items) def get_device_at(self, index: int) -> DeviceAccess: + """ + Return the managed attribute at a given position. + + Parameters + ---------- + index : int + Zero-based position in the aggregate. + + Returns + ------- + DeviceAccess + The :class:`~tango.pyaml.attribute.Attribute` at ``index``. + """ return self._items[index] def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): + """ + Append one or several attributes to the aggregate. + + Parameters + ---------- + devices : DeviceAccess or list of DeviceAccess + Attribute(s) to append. Each one must be an instance of + :class:`~tango.pyaml.attribute.Attribute`. + + Raises + ------ + pyaml.PyAMLException + If any device is not an ``Attribute``. + """ if isinstance(devices, list): if any(not isinstance(device, Attribute) for device in devices): raise pyaml.PyAMLException( @@ -84,6 +193,22 @@ def add_devices(self, devices: DeviceAccess | list[DeviceAccess]): self._items.append(devices) def set(self, value: npt.NDArray[np.float64]): + """ + Write one value per attribute, asynchronously. + + All writes are issued first, then every reply is awaited so that the + call returns once all devices acknowledged the write. + + Parameters + ---------- + value : numpy.ndarray of float + Values to write, one per managed attribute, in order. + + Raises + ------ + pyaml.PyAMLException + If the size of ``value`` does not match the number of items. + """ if len(value) != len(self._items): raise pyaml.PyAMLException( f"Size of value ({len(value)} do not match the number of managed devices ({len(self._items)})" @@ -103,9 +228,33 @@ def set(self, value: npt.NDArray[np.float64]): self._items[index]._attribute_dev.write_attribute_reply(call_id, timeout) def set_and_wait(self, value: npt.NDArray[np.float64]): + """ + Not implemented. + + Parameters + ---------- + value : numpy.ndarray of float + Values to write, one per managed attribute. + + Raises + ------ + NotImplementedError + Always. + """ raise NotImplementedError("Not implemented yet.") def get(self) -> npt.NDArray[np.float64]: + """ + Return the last written value of every attribute. + + For writable attributes the Tango ``w_value`` (setpoint) is returned; + for read-only ones the readback ``value`` is used instead. + + Returns + ------- + numpy.ndarray of float + Setpoints, one per managed attribute, in order. + """ values = [] asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() @@ -129,6 +278,14 @@ def get(self) -> npt.NDArray[np.float64]: return np.array(values) def readback(self) -> np.array: + """ + Return the readback value of every attribute. + + Returns + ------- + numpy.ndarray of float + Readback values, one per managed attribute, in order. + """ values = [] asynch_call_ids = [] timeout = DeviceFactory().get_timeout_ms() @@ -150,12 +307,30 @@ def readback(self) -> np.array: return np.array(values) def get_range(self) -> list[float]: + """ + Return the valid ranges of all attributes. + + Returns + ------- + list of float or None + Flattened ``[min0, max0, min1, max1, ...]`` list, one pair per + managed attribute, where an unbounded limit is ``None``. + """ attr_range: list[float] = [] for device in self._items: attr_range.extend(device.get_range()) return attr_range def check_device_availability(self) -> bool: + """ + Check whether every managed device is reachable. + + Returns + ------- + bool + ``True`` if all devices answer, ``False`` at the first + unreachable one (or if there is no item). + """ available = False for device in self._items: available = device.check_device_availability() @@ -164,6 +339,14 @@ def check_device_availability(self) -> bool: return available def unit(self) -> str: + """ + Return the unit shared by the attributes. + + Returns + ------- + str + Unit string. + """ return self._unit def __repr__(self): diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py index 70d8b19..7eaf3cb 100644 --- a/tango/pyaml/static_catalog.py +++ b/tango/pyaml/static_catalog.py @@ -1,3 +1,5 @@ +"""Catalog backed by an explicit list of key-to-device mappings.""" + from pyaml import PyAMLException from pyaml.control.deviceaccess import DeviceAccess from pyaml.validation import DynamicValidation, register_schema @@ -19,16 +21,26 @@ class StaticCatalog(Catalog, DynamicValidation): Parameters ---------- - name : str - Catalog identifier. - entries : list[StaticCatalogEntry] + entries : list of StaticCatalogEntry Explicit list of key-to-device mappings. Must contain at least one entry, and keys must be unique within the catalog. + Attributes + ---------- + _entries : list of StaticCatalogEntry + Entries given at construction time, in configured order. + _refs : dict of str to DeviceAccess + Lookup table built from ``_entries``, indexed by catalog key. + + Methods + ------- + resolve(key, control_system=None) + Return the device associated with ``key``. + Raises ------ pyaml.PyAMLException - If ``cfg.entries`` is empty or contains duplicate keys. + If ``entries`` is empty or contains duplicate keys. """ def __init__(self, entries: list[StaticCatalogEntry]): @@ -56,7 +68,7 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces ---------- key : str Catalog key to resolve. - control_system : object | None + control_system : object, optional Optional backend context. Static catalogs do not need it, but the argument keeps the backend catalog API uniform. diff --git a/tango/pyaml/static_catalog_entry.py b/tango/pyaml/static_catalog_entry.py index a62509b..3c3ed9d 100644 --- a/tango/pyaml/static_catalog_entry.py +++ b/tango/pyaml/static_catalog_entry.py @@ -1,3 +1,5 @@ +"""Single entry of a :class:`~tango.pyaml.static_catalog.StaticCatalog`.""" + from pyaml.control.deviceaccess import DeviceAccess from pyaml.validation import DynamicValidation, register_schema @@ -15,6 +17,20 @@ class StaticCatalogEntry(DynamicValidation): Catalog key used to look up the device. device : DeviceAccess Device access object returned when the key is resolved. + + Attributes + ---------- + key : str + Catalog key used to look up the device. + device : DeviceAccess + Device access object returned when the key is resolved. + + Methods + ------- + get_key() + Return the catalog key for this entry. + get_device() + Return the device access object associated with this entry. """ def __init__(self, key: str, device: DeviceAccess): @@ -22,9 +38,23 @@ def __init__(self, key: str, device: DeviceAccess): self.device = device def get_key(self) -> str: - """Return the catalog key for this entry.""" + """ + Return the catalog key for this entry. + + Returns + ------- + str + Catalog key. + """ return self.key def get_device(self) -> DeviceAccess: - """Return the device access object associated with this entry.""" + """ + Return the device access object associated with this entry. + + Returns + ------- + DeviceAccess + Device access object. + """ return self.device diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py index 5e4d9df..bf8b30e 100644 --- a/tango/pyaml/tango_catalog.py +++ b/tango/pyaml/tango_catalog.py @@ -1,3 +1,12 @@ +""" +Catalog resolving keys that are direct Tango attribute references. + +Unlike :class:`~tango.pyaml.static_catalog.StaticCatalog`, no explicit mapping +is needed: the catalog key *is* the Tango attribute path. In connected mode the +attribute configuration (unit, limits, writability, data format) is fetched +from Tango to build the appropriate device access object. +""" + from typing import ClassVar import pyaml @@ -22,8 +31,38 @@ class TangoCatalog(Catalog, DynamicValidation): or indexed references into a SPECTRUM attribute (``domain/family/member/attribute@index``). - disconnected : bool + Parameters + ---------- + disconnected : bool, optional If true, resolve Tango attribute names without querying Tango. + Default is False. + + Attributes + ---------- + _WRITABLE_TYPES : set of tango.AttrWriteType + Tango write types for which a writable + :class:`~tango.pyaml.attribute.Attribute` is built. + _disconnected : bool + ``True`` when Tango is never queried. + _refs : dict of (int, str) to DeviceAccess + Cache of resolved devices indexed by ``(id(control_system), key)``. + _data_formats : dict of (int, str) to tango.AttrDataFormat + Data format of each resolved key, with the same indexing as + ``_refs``. + + Methods + ------- + resolve(key, control_system=None) + Resolve a Tango attribute reference into a DeviceAccess. + is_disconnected() + Tell whether the catalog works without querying Tango. + get_data_format(key, control_system=None) + Return the Tango data format for a resolved attribute. + + Notes + ----- + Resolved DeviceAccess objects are bound to one control-system context + because metadata lookup depends on that control system's Tango host. """ _WRITABLE_TYPES: ClassVar[set[tango.AttrWriteType]] = { @@ -51,9 +90,9 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces :class:`~tango.pyaml.attribute.Attribute` or :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`. - ``domain/family/member/attribute@index`` — resolves to a scalar view - of one element in a SPECTRUM attribute - (:class:`~tango.pyaml.attribute_indexed.AttributeIndexed` or - :class:`~tango.pyaml.attribute_indexed_read_only.AttributeIndexedReadOnly`). + of one element in a SPECTRUM attribute (an indexed + :class:`~tango.pyaml.attribute.Attribute` or + :class:`~tango.pyaml.attribute_read_only.AttributeReadOnly`). In connected mode (``disconnected=False``) indexed keys additionally verify that the Tango attribute is a SPECTRUM. @@ -103,6 +142,14 @@ def resolve(self, key: str, control_system: object | None = None) -> DeviceAcces return self._refs[cache_key] def is_disconnected(self) -> bool: + """ + Tell whether the catalog works without querying Tango. + + Returns + ------- + bool + ``True`` in disconnected mode. + """ return self._disconnected def get_data_format( @@ -129,6 +176,15 @@ def get_data_format( return self._data_formats[(id(control_system), key)] def _validate_control_system(self, control_system: object | None, key: str) -> None: + """ + Check that ``control_system`` is a usable TangoControlSystem. + + Raises + ------ + pyaml.PyAMLException + If ``control_system`` is ``None`` or not a + :class:`~tango.pyaml.controlsystem.TangoControlSystem`. + """ from .controlsystem import TangoControlSystem if control_system is None: @@ -186,6 +242,7 @@ def _parse_key(self, key: str) -> tuple[str, int | None]: def _build_disconnected_attribute( self, cache_key: tuple[int, str], key: str ) -> DeviceAccess: + """Build a writable attribute without querying Tango.""" # In disconnected mode, keep all metadata local. In particular, setting # range avoids Attribute.get_range() from lazily querying Tango later. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN @@ -194,6 +251,7 @@ def _build_disconnected_attribute( def _build_disconnected_indexed( self, cache_key: tuple[int, str], attr_path: str, index: int ) -> DeviceAccess: + """Build an indexed attribute without querying Tango.""" # Cannot verify SPECTRUM in disconnected mode; store FMT_UNKNOWN. self._data_formats[cache_key] = tango.AttrDataFormat.FMT_UNKNOWN return Attribute(attribute=attr_path, index=index, range=(None, None)) @@ -201,6 +259,14 @@ def _build_disconnected_indexed( def _build_connected_attribute( self, cache_key: tuple[int, str], control_system: object, key: str ) -> DeviceAccess: + """ + Build a scalar attribute from the Tango attribute configuration. + + Raises + ------ + pyaml.PyAMLException + If the Tango call fails. + """ tango_attr_name = self._tango_attribute_name(control_system, key) try: # AttributeProxy.get_config() is the most direct way to retrieve @@ -273,6 +339,14 @@ def _read_config_metadata( tango.AttrDataFormat, tango.AttrWriteType, ]: + """ + Extract ``(unit, range, data_format, writable)`` from a Tango config. + + Raises + ------ + pyaml.PyAMLException + If ``attr_config`` lacks one of the expected fields. + """ try: unit = attr_config.unit or "" attr_range = ( @@ -290,6 +364,7 @@ def _read_config_metadata( return unit, attr_range, data_format, writable def _tango_attribute_name(self, control_system: object, attr_path: str) -> str: + """Prefix ``attr_path`` with the control-system Tango host, if any.""" tango_host = control_system.get_tango_host() if tango_host: return f"//{tango_host}/{attr_path}" diff --git a/tango/pyaml/tango_pyaml_utils.py b/tango/pyaml/tango_pyaml_utils.py index be20027..1770e38 100644 --- a/tango/pyaml/tango_pyaml_utils.py +++ b/tango/pyaml/tango_pyaml_utils.py @@ -1,8 +1,26 @@ +"""Small helpers shared by the Tango pyAML classes.""" + import pyaml import tango def to_float_or_none(s): + """ + Convert a value to ``float``, returning ``None`` when impossible. + + Tango reports unset attribute limits as the string ``"Not specified"``; + this helper maps such values to ``None``. + + Parameters + ---------- + s : object + Value to convert (typically a string or a number). + + Returns + ------- + float or None + The converted value, or ``None`` if ``s`` cannot be converted. + """ try: return float(s) except (TypeError, ValueError):