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**
+[](https://tango-pyaml.readthedocs.io/en/latest/?badge=latest)
+[](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)