From c31a3148d7fd40e75f3d20f3b45220df112f1b89 Mon Sep 17 00:00:00 2001 From: Alexis Gamelin Date: Fri, 11 Sep 2026 09:53:56 +0200 Subject: [PATCH] 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):