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 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/.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/* 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 diff --git a/.gitignore b/.gitignore index bc0e58b..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 @@ -55,7 +56,7 @@ coverage.xml .pytest_cache/ cover/ -# DS +# DS **/.DS_Store # Translations 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/README.md b/README.md index a65fd1a..379b3dd 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # tango-pyaml -**Bridge between **[**Tango Controls**](https://www.tango-controls.org/)** and 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/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. - -This library is part of the **Python Accelerator Middle Layer (PyAML)** ecosystem. +`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. ## Features @@ -21,82 +20,38 @@ This library is part of the **Python Accelerator Middle Layer (PyAML)** ecosyste ## Installation +Install the package from PyPI: + ```bash pip install tango-pyaml ``` -## Requirements +## Development -- 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 - -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. 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 0000000..15e19c2 Binary files /dev/null and b/docs/source/_static/_images/dark.png differ diff --git a/docs/source/_static/_images/logo.png b/docs/source/_static/_images/logo.png new file mode 100644 index 0000000..ddba8ad Binary files /dev/null and b/docs/source/_static/_images/logo.png differ diff --git a/docs/source/_static/_images/logosmall.png b/docs/source/_static/_images/logosmall.png new file mode 100644 index 0000000..d93ed8a Binary files /dev/null and b/docs/source/_static/_images/logosmall.png differ diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 0000000..2997030 --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,7 @@ +.api-root h1 { + display: none; +} + +.api-generation { + display: none; +} diff --git a/docs/source/_templates/.gitkeep b/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..92406ff --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,14 @@ +API Reference +============= + +.. container:: api-generation + + .. autosummary:: + :toctree: api + :recursive: + + tango.pyaml + +.. container:: api-root + + .. include:: api/tango.pyaml.rst diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..c48710a --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,77 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "tango-pyaml" +copyright = "2026, pyAML Collaboration" +author = "pyAML Collaboration" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "myst_parser", + "sphinx_copybutton", +] + +autosummary_generate = True + +# Maybe add "undoc-members": True here later +autodoc_default_options = { + "members": True, + "show-inheritance": True, + "member-order": "groupwise", +} + +autodoc_typehints = "description" +autodoc_typehints_description_target = "documented" +autodoc_typehints_format = "short" +# autosummary_generate_overwrite = False +# autosummary_ignore_module_all = False +autoclass_content = "both" # include both class docstring and __init__ + +napoleon_use_rtype = False # More legible +# napoleon_numpy_docstring = False # Force consistency, leave only Google +# napoleon_custom_sections = [("Returns", "params_style")] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "pydata_sphinx_theme" +html_static_path = ["_static"] +html_title = "tango-pyaml" +html_show_sourcelink = False +html_css_files = ["custom.css"] +html_logo = "_static/_images/logo.png" + +html_theme_options = { + "navigation_depth": 4, + "show_nav_level": 2, + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/python-accelerator-middle-layer/tango-pyaml", + "icon": "fa-brands fa-github", + "type": "fontawesome", + }, + ], +} + +html_sidebars = { + "**": [ + "sidebar-collapse", + "sidebar-nav-bs", + ], +} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..90a87b0 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,16 @@ + +.. include:: ../../README.md + :parser: myst_parser.sphinx_ + +.. seealso:: + + This package is part of the + `pyAML ecosystem `__. + See the `main pyAML documentation + `__ + for tutorials, how-to guides, and an overview of the ecosystem. + +.. toctree:: + :hidden: + + api diff --git a/pyproject.toml b/pyproject.toml index 39cb6a6..58bea97 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 = [ @@ -54,8 +57,12 @@ dev = [ "mypy", # Typage statique (optionnel) "ipython", # Débogage interactif "pre-commit", + "copier", ] +[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/" diff --git a/tango/pyaml/__init__.py b/tango/pyaml/__init__.py index ad4a188..885fbfd 100644 --- a/tango/pyaml/__init__.py +++ b/tango/pyaml/__init__.py @@ -1,8 +1,31 @@ -__version__ = "0.3.3" +""" +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 +from ._version import __version__ as __version__ + config_file = os.getenv("TANGO_PYAML_LOG_CONFIG", "tango_pyaml_logging.conf") if os.path.exists(config_file): diff --git a/tango/pyaml/attribute.py b/tango/pyaml/attribute.py index 9ddd267..34727fa 100644 --- a/tango/pyaml/attribute.py +++ b/tango/pyaml/attribute.py @@ -1,21 +1,33 @@ +""" +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 -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.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 * +from .initializable_element import InitializableElement +from .tango_pyaml_utils import tango_to_PyAMLException, to_float_or_none PYAMLCLASS: str = "Attribute" logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class AttributeConfig(BaseModel): """ Configuration model for Tango attributes. @@ -27,21 +39,91 @@ 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 + range: tuple[float | None, float | None] | None = None + index: int | None = None -class Attribute(DeviceAccess, InitializableElement): +@register_schema +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 ---------- - 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. + + 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 ------ @@ -49,21 +131,46 @@ 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: tuple[float | None, float | None] | None = None, + index: int | None = None, + writable=True, + ): super().__init__() - self._cfg = cfg - self._writable = writable + + 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 self._attr_config: tango.AttributeConfig = None self._attribute_dev_name: str = None 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._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) @@ -72,17 +179,33 @@ def initialize(self): self._attribute_dev.get_attribute_config(self._attr_name, wait=True) ) - 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._cfg.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): + """ + 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): @@ -97,11 +220,16 @@ 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._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) @@ -120,10 +248,15 @@ 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._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: @@ -144,13 +277,18 @@ 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 - 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 @@ -164,7 +302,7 @@ def unit(self) -> str: str The unit string. """ - return self._cfg.unit + return self._unit def name(self) -> str: """ @@ -173,9 +311,41 @@ 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._attribute}[{self._index}]" + return self._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._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. + + Returns + ------- + Attribute + Copy of this instance pointing to ``attribute``. """ - return self._cfg.attribute + new_obj = copy.copy(self) + new_obj._attribute = attribute + return new_obj def measure_name(self) -> str: """ @@ -184,14 +354,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._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,19 +381,35 @@ 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) 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._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 @@ -227,13 +420,22 @@ 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() self._attribute_dev.ping() - except tango.DevFailed | pyaml.PyAMLException: + except (tango.DevFailed, pyaml.PyAMLException): available = False 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.py b/tango/pyaml/attribute_list.py index 4d1eddf..ed61b7f 100644 --- a/tango/pyaml/attribute_list.py +++ b/tango/pyaml/attribute_list.py @@ -1,11 +1,22 @@ +""" +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 -import pyaml from numpy import array from pydantic import BaseModel -from pyaml.control.deviceaccess import DeviceAccess -from pyaml.control.readback_value import Value, Quality + +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 @@ -15,7 +26,7 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class AttributeListConfig(BaseModel): """ Configuration model for a list of Tango attributes. @@ -34,33 +45,90 @@ 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. + 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 ---------- - 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. + + 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, 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(): + 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) 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._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 +140,7 @@ def name(self) -> str: str Group name. """ - return self._cfg.name + return self._name def measure_name(self) -> str: """ @@ -83,7 +151,18 @@ def measure_name(self) -> str: str Group name. """ - return self._cfg.name + return self._name + + def get_tango_attributes(self) -> list[str]: + """ + Return the raw Tango attribute paths stored in the configuration. + + Returns + ------- + list of str + Tango attribute paths in configured order. + """ + return self._attributes def set(self, value: float): """ @@ -125,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 = {} @@ -141,7 +221,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: """ @@ -149,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()}") @@ -172,7 +254,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: @@ -184,17 +266,26 @@ def unit(self) -> str: str Unit string. """ - return self._cfg.unit + 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._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] = [] @@ -211,13 +302,22 @@ 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() [group.ping() for group in self._tango_groups.values()] - except tango.DevFailed | pyaml.PyAMLException: + except (tango.DevFailed, pyaml.PyAMLException): available = False 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 104e561..3b12a1e 100644 --- a/tango/pyaml/attribute_list_read_only.py +++ b/tango/pyaml/attribute_list_read_only.py @@ -1,34 +1,75 @@ +"""Read-only list of Tango attributes handled through Tango groups.""" + import logging import pyaml -from .attribute_list import AttributeList, ConfigModel +from pyaml.validation import DynamicValidation, register_schema + +from .attribute_list import AttributeList, AttributeListConfig PYAMLCLASS: str = "AttributeListReadOnly" logger = logging.getLogger(__name__) -class AttributeListReadOnly(AttributeList): +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 ---------- - 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. + + 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, 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): """ - 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 1dccc03..40d3a9a 100644 --- a/tango/pyaml/attribute_read_only.py +++ b/tango/pyaml/attribute_read_only.py @@ -1,51 +1,132 @@ +"""Read-only scalar Tango attribute.""" + import logging -from .attribute import Attribute, ConfigModel -from .tango_pyaml_utils import * +import pyaml +from pyaml.validation import DynamicValidation, register_schema + +from .attribute import Attribute, AttributeConfig PYAMLCLASS: str = "AttributeReadOnly" logger = logging.getLogger(__name__) -class AttributeReadOnly(Attribute): +class AttributeReadOnlyConfig(AttributeConfig): + """Configuration model for a read-only Tango attribute.""" + + +@register_schema +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 ---------- - 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. + + 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__(self, cfg: ConfigModel): - super().__init__(cfg, False) + def __init__( + self, + attribute: str, + unit: str = "", + range: tuple[float | None, float | None] | None = None, + index: int | None = 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): """ Disallowed write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException 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): """ Disallowed synchronous write operation. + Parameters + ---------- + value : float + Ignored. + Raises ------ pyaml.PyAMLException 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: + """ + 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 new file mode 100644 index 0000000..9e37d2a --- /dev/null +++ b/tango/pyaml/catalog.py @@ -0,0 +1,39 @@ +"""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. + + Methods + ------- + resolve(key) + Return a configuration model for a DeviceAccess. + + 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. + + 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 744eb35..79ae6b9 100644 --- a/tango/pyaml/controlsystem.py +++ b/tango/pyaml/controlsystem.py @@ -1,102 +1,320 @@ +""" +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 -import copy 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.validation import DynamicValidation, register_schema + from . import __version__ +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, AttributeReadOnlyConfig +from .catalog import Catalog +from .multi_attribute import MultiAttribute PYAMLCLASS: str = "TangoControlSystem" 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. - tango_host : str - Tango host URL. Default is the TANGO_HOST variable. - debug_level : int - Debug verbosity level. - scalar_aggregator : str - Aggregator module for scalar values. If none specified, writings and readings of sclar value are serialized. - 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. - """ - - name: str - tango_host: str | None = None - debug_level: str = None - lazy_devices: bool = True - scalar_aggregator: str | None = "tango.pyaml.multi_attribute" - vector_aggregator: str | None = None - timeout_ms: int = 3000 + 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 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. - -class TangoControlSystem(ControlSystem): - """ - Tango-specific implementation of a Control System. - - Parameters + Attributes ---------- - cfg : ConfigModel - Configuration parameters including name, host and debug level. + _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__(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: - log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) + if self._debug_level: + if isinstance(self._debug_level, int): + log_level = self._debug_level + else: + 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 __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]: + """ + 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: 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) return newDevs + def get_device_access(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. + + 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 + + if isinstance(ref, DeviceAccess): + raise PyAMLException( + "TangoControlSystem.get_device_access() expects a catalog key " + "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, AttributeReadOnlyConfig): + return self._attach([AttributeReadOnly(**ref.model_dump())])[0] + + if isinstance(ref, AttributeConfig): + return self._attach([Attribute(**ref.model_dump())])[0] + + if isinstance(ref, AttributeListReadOnlyConfig): + cfg = self._attach_attribute_list_config(ref) + return AttributeListReadOnly(**cfg.model_dump()) + + if isinstance(ref, AttributeListConfig): + cfg = self._attach_attribute_list_config(ref) + return AttributeList(**cfg.model_dump()) + + 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_access() cannot resolve references of type " + f"{type(ref).__name__}; expected str or 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 + + 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. @@ -106,29 +324,65 @@ def name(self) -> str: str Name of the control system. """ - return self._cfg.name + return self._name + + def get_tango_host(self) -> str | None: + """ + Return the Tango host configured for this control system. + + Returns + ------- + str or None + Tango host URL, or ``None`` when unconfigured. + """ + return self._tango_host + + def get_aggregator(self) -> MultiAttribute | None: + """ + 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 self._cfg.scalar_aggregator + 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: + """ + Return the catalog that references all control system devices. + + Returns + ------- + Catalog or None + The catalog, or ``None`` if none was configured. """ - return self._cfg.vector_aggregator + return self._catalog def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tango/pyaml/device_factory.py b/tango/pyaml/device_factory.py index abfa5ef..93cfd10 100644 --- a/tango/pyaml/device_factory.py +++ b/tango/pyaml/device_factory.py @@ -1,17 +1,57 @@ -from threading import Lock +"""Shared cache of Tango device proxies.""" + from collections import defaultdict +from threading import Lock + import tango 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: @@ -21,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) @@ -34,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 5c2947b..44c9911 100644 --- a/tango/pyaml/initializable_element.py +++ b/tango/pyaml/initializable_element.py @@ -1,22 +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() - pass diff --git a/tango/pyaml/multi_attribute.py b/tango/pyaml/multi_attribute.py index d6e289d..274f965 100644 --- a/tango/pyaml/multi_attribute.py +++ b/tango/pyaml/multi_attribute.py @@ -1,15 +1,25 @@ +""" +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 -from typing import Tuple, Optional import numpy as np -import pyaml from numpy import typing as npt -from pyaml.control.deviceaccess import DeviceAccess 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, ConfigModel as AttrConfig +from .attribute import Attribute, AttributeConfig from .device_factory import DeviceFactory PYAMLCLASS: str = "MultiAttribute" @@ -17,7 +27,7 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): +class MultiAttributeConfig(BaseModel): """ Configuration model for a list of Tango attributes. @@ -36,50 +46,177 @@ class ConfigModel(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): + """ + 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`. + """ -class MultiAttribute(DeviceAccessList): - def __init__(self, cfg: ConfigModel = None): + def __init__( + self, + attributes: list[str] | None = None, + name: str = "", + unit: str = "", + range: tuple[float | None, float | None] | None = None, + ): super().__init__() - self._cfg = cfg - if self._cfg: - for attribute in self._cfg.attributes: - attr_config = AttrConfig( - attribute=attribute, unit=self._cfg.unit, range=self._cfg.range - ) - attr = Attribute(attr_config) - self.append(attr) + self._attributes = [] if attributes is None else attributes + self._name = name + self._unit = unit + self._range = range + self._items: list[Attribute] = [] + + 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 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]): + if any(not isinstance(device, Attribute) for device in devices): 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) - - def get_devices(self) -> DeviceAccess | list[DeviceAccess]: - if len(self) == 1: - return self[0] - else: - return self + self._items.append(devices) def set(self, value: npt.NDArray[np.float64]): - if len(value) != len(self): + """ + 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)})" + 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] @@ -88,17 +225,41 @@ 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]): + """ + 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() # 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 @@ -107,8 +268,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) @@ -116,11 +278,19 @@ 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() # 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,30 +299,55 @@ 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]: + """ + 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: + 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: + for device in self._items: available = device.check_device_availability() if not available: break return available def unit(self) -> str: - if self._cfg: - return self._cfg.unit - else: - return "" + """ + Return the unit shared by the attributes. + + Returns + ------- + str + Unit string. + """ + return self._unit def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__(self) diff --git a/tango/pyaml/static_catalog.py b/tango/pyaml/static_catalog.py new file mode 100644 index 0000000..7eaf3cb --- /dev/null +++ b/tango/pyaml/static_catalog.py @@ -0,0 +1,88 @@ +"""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 + +from .catalog import Catalog +from .static_catalog_entry import StaticCatalogEntry + +PYAMLCLASS = "StaticCatalog" + + +@register_schema +class StaticCatalog(Catalog, DynamicValidation): + """ + 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 + ---------- + 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 ``entries`` is empty or contains duplicate keys. + """ + + def __init__(self, entries: list[StaticCatalogEntry]): + super().__init__() + + 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 self._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, control_system: object | None = None) -> DeviceAccess: + """ + Return the device associated with ``key``. + + Parameters + ---------- + key : str + Catalog key to resolve. + control_system : object, optional + Optional backend context. Static catalogs do not need it, but the + argument keeps the backend catalog API uniform. + + 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 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..3c3ed9d --- /dev/null +++ b/tango/pyaml/static_catalog_entry.py @@ -0,0 +1,60 @@ +"""Single entry of a :class:`~tango.pyaml.static_catalog.StaticCatalog`.""" + +from pyaml.control.deviceaccess import DeviceAccess +from pyaml.validation import DynamicValidation, register_schema + +PYAMLCLASS = "StaticCatalogEntry" + + +@register_schema +class StaticCatalogEntry(DynamicValidation): + """ + A single key-to-device mapping in a static catalog. + + Parameters + ---------- + key : str + 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): + self.key = key + self.device = device + + def get_key(self) -> str: + """ + 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. + + Returns + ------- + DeviceAccess + Device access object. + """ + return self.device diff --git a/tango/pyaml/tango_catalog.py b/tango/pyaml/tango_catalog.py new file mode 100644 index 0000000..bf8b30e --- /dev/null +++ b/tango/pyaml/tango_catalog.py @@ -0,0 +1,371 @@ +""" +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 +import tango +from pyaml.control.deviceaccess import DeviceAccess +from pyaml.validation import DynamicValidation, register_schema + +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 + +PYAMLCLASS = "TangoCatalog" + + +@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``). + + 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]] = { + tango.AttrWriteType.READ_WRITE, + tango.AttrWriteType.WRITE, + tango.AttrWriteType.READ_WITH_WRITE, + } + + def __init__(self, disconnected: bool = False): + super().__init__() + + 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] = {} + self._data_formats: dict[tuple[int, str], tango.AttrDataFormat] = {} + + 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 (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. + + Parameters + ---------- + key : str + Plain attribute path or indexed path (``attribute@index``). + control_system : object + Tango control-system context used for Tango host handling. + + 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_control_system(control_system, key) + attr_path, index = self._parse_key(key) + cache_key = (id(control_system), key) + + if cache_key not in self._refs: + if index is not None: + if self.is_disconnected(): + self._refs[cache_key] = self._build_disconnected_indexed( + cache_key, attr_path, index + ) + else: + self._refs[cache_key] = self._build_connected_indexed( + cache_key, control_system, attr_path, index + ) + else: + if self.is_disconnected(): + self._refs[cache_key] = self._build_disconnected_attribute( + cache_key, key + ) + else: + self._refs[cache_key] = self._build_connected_attribute( + cache_key, control_system, key + ) + + 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( + self, key: str, control_system: object | None = None + ) -> 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). + control_system : object + Tango control-system context used for Tango host handling. + + Returns + ------- + tango.AttrDataFormat + Data format reported by Tango, or ``FMT_UNKNOWN`` in disconnected + mode. + """ + 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: + """ + 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: + raise pyaml.PyAMLException( + f"Tango catalog needs a TangoControlSystem context " + f"before resolving key '{key}'" + ) + + if not isinstance(control_system, TangoControlSystem): + raise pyaml.PyAMLException( + "Tango catalog can only resolve through TangoControlSystem" + ) + + 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 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 + 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 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, 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 + return Attribute(attribute=key, range=(None, None)) + + 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)) + + 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 + # writability, unit, range and data format from Tango. + 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 + + unit, attr_range, data_format, writable = self._read_config_metadata( + attr_config, key + ) + self._data_formats[cache_key] = data_format + cfg = AttributeConfig(attribute=key, unit=unit, range=attr_range) + + if writable in self._WRITABLE_TYPES: + return Attribute(**cfg.model_dump()) + return AttributeReadOnly(**cfg.model_dump()) + + 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. + + Raises + ------ + pyaml.PyAMLException + If the Tango call fails or the attribute is not a SPECTRUM. + """ + key = f"{attr_path}@{index}" + 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 cannot resolve '{key}': {pyaml_exception}" + ) from df + + 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 cannot use '{key}' as an indexed " + "key: the Tango attribute is not a SPECTRUM." + ) + + self._data_formats[cache_key] = tango.AttrDataFormat.SPECTRUM + cfg = AttributeConfig( + attribute=attr_path, index=index, unit=unit, range=attr_range + ) + + if writable in self._WRITABLE_TYPES: + return Attribute(**cfg.model_dump()) + return AttributeReadOnly(**cfg.model_dump()) + + def _read_config_metadata( + self, attr_config, key: str + ) -> tuple[ + str, + tuple[float | None, float | None], + 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 = ( + 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 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, 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}" + return attr_path diff --git a/tango/pyaml/tango_pyaml_utils.py b/tango/pyaml/tango_pyaml_utils.py index fb48686..1770e38 100644 --- a/tango/pyaml/tango_pyaml_utils.py +++ b/tango/pyaml/tango_pyaml_utils.py @@ -1,8 +1,26 @@ -import tango +"""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): diff --git a/tests/conftest.py b/tests/conftest.py index e12bd02..309cdfc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ import pytest import yaml -from tango.pyaml.attribute_list import ConfigModel as GrpCM -from tango.pyaml.attribute import ConfigModel as AttrCM -from tango.pyaml.multi_attribute import ConfigModel as MultiAttrCM -from tango.pyaml.controlsystem import ConfigModel as CsCM, TangoControlSystem +from tango.pyaml.attribute import AttributeConfig as AttrCM +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) @@ -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/mocked_device_proxy.py b/tests/mocked_device_proxy.py index 7e2c220..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__( @@ -10,11 +12,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: @@ -80,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] @@ -134,13 +140,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/mocked_group.py b/tests/mocked_group.py index e26d2d2..70460d8 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 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 a983a34..f387327 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,14 +1,19 @@ +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 -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 -from tango.pyaml.attribute_list import AttributeList class MockedReadExceptDeviceProxy(MockedDeviceProxy): @@ -33,7 +38,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 @@ -53,7 +58,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 @@ -70,13 +75,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 @@ -89,7 +94,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: @@ -103,6 +108,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 new file mode 100644 index 0000000..672b154 --- /dev/null +++ b/tests/test_attribute_indexed.py @@ -0,0 +1,161 @@ +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, + MockedDeviceAttribute, + MockedDeviceProxy, +) + +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, + ) + + +# --- Attribute with index --- + + +def test_attribute_indexed_get_returns_w_value_at_index(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=1, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = Attribute(**cfg.model_dump()) + assert attr.get() == SPECTRUM_ARRAY[1] + + +def test_attribute_indexed_readback_returns_value_at_index(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=0, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + attr = Attribute(**cfg.model_dump()) + rb = attr.readback() + assert rb.value == SPECTRUM_ARRAY[0] + + +def test_attribute_indexed_set_raises(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + 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 = AttributeConfig(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumDeviceProxy): + 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 = 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 = 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 = 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 = AttributeConfig(attribute="domain/family/member/current", index=0) + with patch("tango.DeviceProxy", new=MockedScalarDeviceProxy): + attr = Attribute(**cfg.model_dump()) + with pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"): + attr.get() + + +def test_attribute_indexed_range_from_config(): + cfg = AttributeConfig( + attribute="domain/family/member/position", index=0, unit="mm", range=(-5.0, 5.0) + ) + attr = Attribute(**cfg.model_dump()) + assert attr.get_range() == [-5.0, 5.0] + + +# --- AttributeReadOnly with index --- + + +def test_attribute_indexed_read_only_get_returns_measured_value(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=2, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeReadOnly(**cfg.model_dump()) + assert attr.get() == SPECTRUM_ARRAY[2] + + +def test_attribute_indexed_read_only_readback_returns_value_at_index(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=1, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeReadOnly(**cfg.model_dump()) + assert attr.readback().value == SPECTRUM_ARRAY[1] + + +def test_attribute_indexed_read_only_set_raises(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=0) + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + attr = AttributeReadOnly(**cfg.model_dump()) + with pytest.raises(pyaml.PyAMLException): + attr.set(1.0) + + +def test_attribute_indexed_read_only_get_equals_readback(): + cfg = AttributeConfig(attribute="domain/family/member/position", index=0, unit="mm") + with patch("tango.DeviceProxy", new=MockedSpectrumRODeviceProxy): + 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..abee187 100644 --- a/tests/test_attribute_range.py +++ b/tests/test_attribute_range.py @@ -1,8 +1,13 @@ -from .mocked_device_proxy import * - 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, +) class MockedMinMaxAttrDeviceProxy(MockedDeviceProxy): @@ -29,7 +34,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 +50,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 +66,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 +82,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 f8f7255..abf8815 100644 --- a/tests/test_controlsystem.py +++ b/tests/test_controlsystem.py @@ -1,22 +1,32 @@ import logging +from unittest.mock import patch -from tango.pyaml.controlsystem import TangoControlSystem +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 .mocked_device_proxy import MockedDeviceProxy -from unittest.mock import patch -from tango.pyaml.attribute import Attribute -from tango.pyaml import __version__ 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 @@ -25,10 +35,147 @@ 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() 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(attribute="sys/tg_test/1/float_scalar", unit="A") + catalog = StaticCatalog( + entries=[ + StaticCatalogEntry( + key="BPM_C01-01/x", + device=device, + ) + ], + ) + cs = TangoControlSystem( + name="test_tango_cs", + tango_host="tangodb:10000", + catalog=catalog, + ) + + resolved = cs.get_device_access("BPM_C01-01/x") + + 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_get_device_builds_attribute_from_config_model(): + 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") + ) + + 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(name="test_tango_cs", tango_host="tangodb:10000") + + resolved = cs.get_device_access( + AttributeReadOnlyConfig(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(name="test_tango_cs", tango_host="tangodb:10000") + resolved = cs.get_device_access( + AttributeListConfig( + 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(name="test_tango_cs", tango_host="tangodb:10000") + + resolved = cs.get_device_access( + AttributeListReadOnlyConfig( + 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(name="test_tango_cs") + + assert cs.get_device_access(None) is None + + +def test_get_device_rejects_preconstructed_device_access(config): + 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(name="test_tango_cs") + + with pytest.raises(pyaml.PyAMLException, match="has no catalog configured"): + cs.get_device_access("BPM_C01-01/x") + + +def test_get_device_reports_unknown_catalog_key(): + device = Attribute(attribute="sys/tg_test/1/float_scalar") + catalog = StaticCatalog( + entries=[StaticCatalogEntry(key="BPM_C01-01/x", device=device)], + ) + 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(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(name="test_tango_cs", tango_host="tangodb:10000") + + assert cs.get_tango_host() == "tangodb:10000" diff --git a/tests/test_multi_attribute.py b/tests/test_multi_attribute.py index 4185f03..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: @@ -15,7 +16,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 +33,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) diff --git a/tests/test_static_catalog.py b/tests/test_static_catalog.py new file mode 100644 index 0000000..7a38887 --- /dev/null +++ b/tests/test_static_catalog.py @@ -0,0 +1,125 @@ +import pyaml +import pytest + +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 StaticCatalog +from tango.pyaml.static_catalog_entry import StaticCatalogEntry + + +def make_attribute( + path: str = "domain/family/member/attr", unit: str = "mm" +) -> Attribute: + return Attribute(attribute=path, unit=unit) + + +def make_entry(key: str, device=None) -> StaticCatalogEntry: + if device is None: + device = make_attribute() + 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(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(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(entries=entries) + + +# --- 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_is_shared_across_control_systems(): + device = make_attribute() + catalog = make_catalog(entries=[make_entry("BPM/x", device=device)]) + live = TangoControlSystem(name="live", catalog=catalog) + ops = TangoControlSystem(name="ops", catalog=catalog) + + assert live.get_catalog() is catalog + assert ops.get_catalog() is catalog + assert catalog.resolve("BPM/x") is device + 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 --- + + +def test_static_catalog_works_with_attribute_read_only(): + 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") + + 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(name="live", catalog=catalog) + + 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 new file mode 100644 index 0000000..6f7af50 --- /dev/null +++ b/tests/test_tango_catalog.py @@ -0,0 +1,344 @@ +from unittest.mock import call, patch + +import pyaml +import pytest +from pyaml.control.controlsystem import ControlSystemAdapter + +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) + return control_system + + +def test_tango_catalog_disconnected_resolves_without_querying_tango(): + catalog = TangoCatalog(disconnected=True) + control_system = build_control_system(catalog) + + with patch("tango.AttributeProxy") as attr_proxy: + device = catalog.resolve("domain/family/member/attribute", control_system) + + 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() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/current", attr_config), + ): + device = catalog.resolve("domain/family/member/current", control_system) + + 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 ( + catalog.get_data_format("domain/family/member/current", control_system) + == 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() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + device = catalog.resolve("domain/family/member/position", control_system) + + assert isinstance(device, AttributeReadOnly) + assert device.unit() == "mm" + + +def test_tango_catalog_caches_resolved_devices(): + catalog = TangoCatalog() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/attribute"), + ) as attr_proxy: + 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 + + +def test_tango_catalog_cache_is_bound_to_control_system_resolver(): + catalog = TangoCatalog() + 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 = 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 + 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() + 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( + 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_access(key) + ops_device = ops.get_device_access(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(disconnected=True) + control_system = TangoControlSystem(name="live", catalog=catalog) + + device = control_system.get_device_access("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() + + with pytest.raises( + pyaml.PyAMLException, match="can only resolve through TangoControlSystem" + ): + catalog.resolve("domain/family/member/attribute", ControlSystemAdapter()) + + +def test_tango_catalog_rejects_external_tango_control_system_class(): + class TangoControlSystem: + pass + + catalog = TangoCatalog() + + with pytest.raises( + pyaml.PyAMLException, match="can only resolve through TangoControlSystem" + ): + catalog.resolve("domain/family/member/attribute", TangoControlSystem()) + + +def test_tango_catalog_requires_control_system_attachment(): + catalog = TangoCatalog() + + with pytest.raises( + pyaml.PyAMLException, match="needs a TangoControlSystem context" + ): + catalog.resolve("domain/family/member/attribute") + + +def test_tango_catalog_rejects_invalid_tango_reference(): + catalog = TangoCatalog() + control_system = build_control_system(catalog) + + with pytest.raises( + pyaml.PyAMLException, match="Expected 'domain/family/member/attribute'" + ): + catalog.resolve("domain/family/member", control_system) + + +def test_tango_catalog_rejects_invalid_index(): + catalog = TangoCatalog() + control_system = build_control_system(catalog) + + with pytest.raises(pyaml.PyAMLException, match="invalid index"): + catalog.resolve("domain/family/member/attribute@notanint", control_system) + + +def test_tango_catalog_disconnected_resolves_indexed_attribute(): + catalog = TangoCatalog(disconnected=True) + control_system = build_control_system(catalog) + + with patch("tango.AttributeProxy") as attr_proxy: + 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 + 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() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + 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 ( + catalog.get_data_format("domain/family/member/position@0", control_system) + == 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() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ): + device = catalog.resolve("domain/family/member/position@2", control_system) + + assert isinstance(device, AttributeReadOnly) and device._index is not None + 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() + control_system = build_control_system(catalog) + + with ( + patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy( + "domain/family/member/current", attr_config + ), + ), + pytest.raises(pyaml.PyAMLException, match="not a SPECTRUM"), + ): + catalog.resolve("domain/family/member/current@0", control_system) + + +def test_tango_catalog_indexed_caches_resolved_devices(): + attr_config = MockedAttributeInfoEx( + name="position", + data_format=tango.AttrDataFormat.SPECTRUM, + ) + catalog = TangoCatalog() + control_system = build_control_system(catalog) + + with patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy("domain/family/member/position", attr_config), + ) as attr_proxy: + 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 + + +def test_tango_catalog_wraps_tango_errors(): + catalog = TangoCatalog() + control_system = build_control_system(catalog) + + 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) + + +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() + control_system = build_control_system(catalog) + + with ( + patch( + "tango.AttributeProxy", + return_value=MockedAttributeProxy( + "domain/family/member/attribute", IncompleteAttributeConfig() + ), + ), + pytest.raises( + pyaml.PyAMLException, + match="incomplete Tango attribute config, missing 'writable'", + ), + ): + catalog.resolve("domain/family/member/attribute", control_system)