From b47dc9707e7d802480dbad89ef9264fda3609221 Mon Sep 17 00:00:00 2001 From: PONS Date: Tue, 8 Sep 2026 13:20:00 +0200 Subject: [PATCH 01/10] Fix signal name method --- .pre-commit-config.yaml | 14 +++++++------- pyaml_cs_oa/signal.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75f5e97..38805b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,10 +12,10 @@ repos: args: [--fix] - id: ruff-format -# - repo: https://github.com/pre-commit/mirrors-mypy -# rev: v1.18.2 -# hooks: -# - id: mypy -# additional_dependencies: [ -# "pydantic>=2.0", -# ] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.2 + hooks: + - id: mypy + additional_dependencies: [ + "pydantic>=2.0", + ] diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index 3cb7de2..e39450d 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -34,6 +34,8 @@ def build(self): self.SP, self.RB = get_SP_RB(self._cfg) + self._readable = self.SP + if self.SP: self.SP.__peer__ = self if self.RB: @@ -45,7 +47,14 @@ def get_cs(self) -> str: def name(self) -> str: """Return the backend signal name.""" - return self._signal.name + if isinstance(self._cfg, EpicsConfigR): + return self._cfg.read_pvname + elif isinstance(self._cfg, (EpicsConfigW, EpicsConfigRW)): + return self._cfg.write_pvname + elif isinstance(self._cfg, TangoConfigAtt): + return self._cfg.attribute + else: + raise ValueError(f"Unsupported control system config type: {type(self._cfg)!r}") def measure_name(self) -> str: """Return the configured process-variable or attribute name.""" From f7acdee9a027902c63baf3a8ee19fc718e2fec99 Mon Sep 17 00:00:00 2001 From: PONS Date: Tue, 8 Sep 2026 14:56:07 +0200 Subject: [PATCH 02/10] Fix with RO Tango attribute --- pyaml_cs_oa/container.py | 1 - pyaml_cs_oa/signal.py | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pyaml_cs_oa/container.py b/pyaml_cs_oa/container.py index 4a4872a..c28b76e 100644 --- a/pyaml_cs_oa/container.py +++ b/pyaml_cs_oa/container.py @@ -61,7 +61,6 @@ async def _run_get(self) -> SignalDatatypeT: """Connect and fetch the backend's current value.""" await self._r_sig.connect() backend = self._r_sig._connector.backend - print(f"Read {self._r_sig.name}") return await backend.get_value() async def async_get(self) -> SignalDatatypeT: diff --git a/pyaml_cs_oa/signal.py b/pyaml_cs_oa/signal.py index e39450d..6f6c64d 100644 --- a/pyaml_cs_oa/signal.py +++ b/pyaml_cs_oa/signal.py @@ -34,7 +34,16 @@ def build(self): self.SP, self.RB = get_SP_RB(self._cfg) - self._readable = self.SP + # FIXME: Find a way using Ophyd to get attribute config to see if the TangoAtt is R or RW + # rather than creating a DeviceProxy for nothing + # Work around the issue by checking that the setpoint is None + if isinstance(self._cfg, TangoConfigAtt): + try: + setpoint = self.SP.get() + if setpoint is None: + self._writable = False + except Exception as ex: + ... if self.SP: self.SP.__peer__ = self From 71e24c1aef30caf81ad8b7e5e74f214b670e1aa3 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 19:02:50 +0200 Subject: [PATCH 03/10] Remove config models from control system. --- pyaml_cs_oa/controlsystem.py | 96 ++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 9e565df..4cd4c37 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -2,11 +2,13 @@ import logging +from pyaml.common.element import __pyaml_repr__ from pyaml.common.exception import PyAMLException from pyaml.control.controlsystem import ControlSystem from pyaml.control.deviceaccess import DeviceAccess from pyaml.control.deviceaccesslist import DeviceAccessList -from pydantic import BaseModel, ConfigDict +from pyaml.validation import DynamicValidation, register_schema +from pydantic import BaseModel from . import __version__ from .aggregator import OAAggregator @@ -23,48 +25,48 @@ logger = logging.getLogger(__name__) -class ConfigModel(BaseModel): - """ - Configuration model for an OA Control System. - - Attributes - ---------- - name : str - Name of the control system. - prefix : str - Prefix added to the PV or attribute name. It can be a - for instance, TANGO_HOST, or a PV prefix. - catalog : Catalog | None - Catalog instance or catalog name used to resolve PyAML device keys. - If None specified a dynamic catalog is used. - debug_level : str - Debug verbosity level. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - name: str - prefix: str = "" - catalog: Catalog | None = None - debug_level: str | None = None - - -class OphydAsyncControlSystem(ControlSystem): +@register_schema +class OphydAsyncControlSystem(ControlSystem, DynamicValidation): """Generic PyAML control system using an ophyd-async backend.""" - def __init__(self, cfg: ConfigModel): + def __init__( + self, + name: str, + prefix: str = "", + catalog: Catalog | None = None, + debug_level: str | None = None, + ): + """Create an ophyd-async control-system interface. + + Parameters + ---------- + name : str + Name used to identify the control system. + prefix : str, optional + Prefix added to the PV or attribute name. It can be a + for instance, TANGO_HOST, or a PV prefix. + catalog : Catalog or None, optional + Catalog instance or catalog name used to resolve PyAML device keys. + If None specified a dynamic catalog is used. + debug_level : str or None, optional + Debug verbosity level. + """ + super().__init__() - self._cfg = cfg + self._name = name + self._prefix = prefix + self._catalog = catalog + self._debug_level = debug_level self._devices: dict[str, DeviceAccess] = {} # Dict containing all attached DeviceAccess - if self._cfg.debug_level: - log_level = getattr(logging, self._cfg.debug_level, logging.WARNING) + if self._debug_level: + log_level = getattr(logging, self._debug_level, logging.WARNING) logger.setLevel(log_level) logger.log( logging.WARNING, - f"PyAML OA control system binding ({__version__}) initialized with name '{self._cfg.name}'" - f" and prefix='{self._cfg.prefix}'", + f"PyAML OA control system binding ({__version__}) initialized with name '{self._name}'" + f" and prefix='{self._prefix}'", ) def attach(self, devs: list[OASignal | None]) -> list[OASignal | None]: @@ -84,10 +86,10 @@ def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: if isinstance(ref, str): # Retrieve a config from a key using using a Catalog - if self._cfg.catalog is None: + if self._catalog is None: raise PyAMLException(f"Control system '{self.name()}' has no catalog when trying to resolve '{ref}'") try: - ref = self._cfg.catalog.resolve(ref) + ref = self._catalog.resolve(ref) except AttributeError as exc: raise PyAMLException(f"Control system '{self.name()}' catalog cannot resolve key '{ref}'") from exc @@ -111,24 +113,24 @@ def _attach(self, configs: list[ControlSysConfig | None]) -> list[OASignal | Non index_str = "" if sig_cfg.index is None else str(sig_cfg.index) if isinstance(sig_cfg, EpicsConfigR): - key = self._cfg.prefix + sig_cfg.read_pvname + index_str + key = self._prefix + sig_cfg.read_pvname + index_str sig_cls = EpicsR - config = dict(read_pvname=self._cfg.prefix + sig_cfg.read_pvname) + config = dict(read_pvname=self._prefix + sig_cfg.read_pvname) elif isinstance(sig_cfg, EpicsConfigW): - key = self._cfg.prefix + sig_cfg.write_pvname + index_str + key = self._prefix + sig_cfg.write_pvname + index_str sig_cls = EpicsW - config = dict(write_pvname=self._cfg.prefix + sig_cfg.write_pvname) + config = dict(write_pvname=self._prefix + sig_cfg.write_pvname) elif isinstance(sig_cfg, EpicsConfigRW): - key = self._cfg.prefix + sig_cfg.read_pvname + sig_cfg.write_pvname + index_str + key = self._prefix + sig_cfg.read_pvname + sig_cfg.write_pvname + index_str sig_cls = EpicsRW config = dict( - read_pvname=self._cfg.prefix + sig_cfg.read_pvname, - write_pvname=self._cfg.prefix + sig_cfg.write_pvname, + read_pvname=self._prefix + sig_cfg.read_pvname, + write_pvname=self._prefix + sig_cfg.write_pvname, ) elif isinstance(sig_cfg, TangoConfigAtt): - key = self._cfg.prefix + sig_cfg.attribute + index_str + key = self._prefix + sig_cfg.attribute + index_str sig_cls = TangoAtt - config = dict(attribute=self._cfg.prefix + sig_cfg.attribute) + config = dict(attribute=self._prefix + sig_cfg.attribute) else: raise PyAMLException(f"OphydAsyncControlSystem: Unsupported type {type(sig_cfg)}") @@ -152,11 +154,11 @@ def name(self) -> str: str Name of the control system. """ - return self._cfg.name + return self._name def get_aggregator(self) -> DeviceAccessList | None: """Return a new empty aggregator for batched device operations.""" return OAAggregator() def __repr__(self): - return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) + return __pyaml_repr__() From b2b8c57f691f081fd47c06b94ad71b910e5e56fe Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 19:43:21 +0200 Subject: [PATCH 04/10] Remove config models from signal types. --- pyaml_cs_oa/epicsR.py | 12 ++++++------ pyaml_cs_oa/epicsRW.py | 22 ++++++++++++++++------ pyaml_cs_oa/epicsW.py | 25 +++++++++++++++++++------ pyaml_cs_oa/tangoAtt.py | 19 +++++++++++++------ 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/pyaml_cs_oa/epicsR.py b/pyaml_cs_oa/epicsR.py index d48a6ce..2fce056 100644 --- a/pyaml_cs_oa/epicsR.py +++ b/pyaml_cs_oa/epicsR.py @@ -1,19 +1,19 @@ """Read-only EPICS signal implementation.""" +from pyaml.validation import DynamicValidation, register_schema + from .float_signal import FloatSignalContainer from .types import EpicsConfigR PYAMLCLASS: str = "EpicsR" -class ConfigModel(EpicsConfigR): - """Configuration model registered for the ``EpicsR`` device class.""" - - -class EpicsR(FloatSignalContainer): +@register_schema +class EpicsR(FloatSignalContainer, DynamicValidation): """PyAML read-only signal backed by an EPICS read signal.""" - def __init__(self, cfg: ConfigModel): + def __init__(self, read_pvname: str, timeout_ms: int = 3000, index: int | None = None, unit: str = ""): + cfg = EpicsConfigR(read_pvname=read_pvname, timeout_ms=timeout_ms, index=index, unit=unit) super().__init__(cfg) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/epicsRW.py b/pyaml_cs_oa/epicsRW.py index 9c448c6..ddb1a4e 100644 --- a/pyaml_cs_oa/epicsRW.py +++ b/pyaml_cs_oa/epicsRW.py @@ -1,19 +1,29 @@ """Read/write EPICS signal implementation.""" +from pyaml.validation import DynamicValidation, register_schema + from .float_signal import FloatSignalContainer from .types import EpicsConfigRW PYAMLCLASS: str = "EpicsRW" -class ConfigModel(EpicsConfigRW): - """Configuration model registered for the ``EpicsRW`` device class.""" - - -class EpicsRW(FloatSignalContainer): +@register_schema +class EpicsRW(FloatSignalContainer, DynamicValidation): """PyAML read/write signal backed by an EPICS signal.""" - def __init__(self, cfg: ConfigModel): + def __init__( + self, + read_pvname: str, + write_pvname: str, + timeout_ms: int = 3000, + range: list[float] | None = None, + index: int | None = None, + unit: str = "", + ): + cfg = EpicsConfigRW( + read_pvname=read_pvname, write_pvname=write_pvname, timeout_ms=timeout_ms, range=range, index=index, unit=unit + ) super().__init__(cfg) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/epicsW.py b/pyaml_cs_oa/epicsW.py index 30861d7..5ad00ab 100644 --- a/pyaml_cs_oa/epicsW.py +++ b/pyaml_cs_oa/epicsW.py @@ -1,19 +1,32 @@ """Write-only EPICS signal implementation.""" +from pyaml.validation import DynamicValidation, register_schema + from .float_signal import FloatSignalContainer from .types import EpicsConfigW PYAMLCLASS: str = "EpicsW" -class ConfigModel(EpicsConfigW): - """Configuration model registered for the ``EpicsW`` device class.""" - - -class EpicsW(FloatSignalContainer): +@register_schema +class EpicsW(FloatSignalContainer, DynamicValidation): """PyAML write-only signal backed by an EPICS write signal.""" - def __init__(self, cfg: ConfigModel): + def __init__( + self, + write_pvname: str, + timeout_ms: int = 3000, + range: list[float] | None = None, + index: int | None = None, + unit: str = "", + ): + cfg = EpicsConfigW( + write_pvname=write_pvname, + timeout_ms=timeout_ms, + range=range, + index=index, + unit=unit, + ) super().__init__(cfg) def get_cs(self) -> str: diff --git a/pyaml_cs_oa/tangoAtt.py b/pyaml_cs_oa/tangoAtt.py index e84c05c..aede5c3 100644 --- a/pyaml_cs_oa/tangoAtt.py +++ b/pyaml_cs_oa/tangoAtt.py @@ -1,19 +1,26 @@ """Tango attribute signal implementation.""" +from pyaml.validation import DynamicValidation, register_schema + from .float_signal import FloatSignalContainer from .types import TangoConfigAtt PYAMLCLASS: str = "TangoRW" -class ConfigModel(TangoConfigAtt): - """Configuration model registered for the ``TangoAtt`` device class.""" - - -class TangoAtt(FloatSignalContainer): +@register_schema +class TangoAtt(FloatSignalContainer, DynamicValidation): """PyAML signal backed by a Tango attribute.""" - def __init__(self, cfg: ConfigModel): + def __init__( + self, + attribute: str, + timeout_ms: int = 3000, + range: list[float] | None = None, + index: int | None = None, + unit: str = "", + ): + cfg = TangoConfigAtt(attribute=attribute, timeout_ms=timeout_ms, range=range, index=index, unit=unit) super().__init__(cfg) def get_cs(self) -> str: From 66c68a63da0602faf3dcb63c783a16bd9b26cf8f Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 19:43:53 +0200 Subject: [PATCH 05/10] Remove config models from dynamic catalog. --- pyaml_cs_oa/dynamic_catalog.py | 48 ++++++++++++++++------------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/pyaml_cs_oa/dynamic_catalog.py b/pyaml_cs_oa/dynamic_catalog.py index 2291f9e..f6a064c 100644 --- a/pyaml_cs_oa/dynamic_catalog.py +++ b/pyaml_cs_oa/dynamic_catalog.py @@ -4,14 +4,16 @@ from pyaml.common.exception import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel, ConfigDict +from pyaml.validation import DynamicValidation, register_schema +from pydantic import BaseModel from .catalog import Catalog PYAMLCLASS = "DynamicCatalog" -class ConfigModel(BaseModel): +@register_schema +class DynamicCatalog(Catalog, DynamicValidation): """ Default dynamic catalog. @@ -41,19 +43,17 @@ class ConfigModel(BaseModel): timeout_ms: 3000 """ - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - timeout_ms: int = 3000 - backend: str = "" + def __init__( + self, + timeout_ms: int = 3000, + backend: str = "", + ): + self._timeout_ms = timeout_ms + self._backend = backend - -class DynamicCatalog(Catalog): - """Resolve compact backend specifications into configuration models.""" - - def __init__(self, cfg: ConfigModel): - self._cfg = cfg self._dp = {} # Device proxy cache (Tango Only) - if cfg.backend.lower() != "tango" and cfg.backend.lower() != "epics": - raise PyAMLException(f"backend must be `epics` or `tango` but got '{cfg.backend}'") from None + if self._backend.lower() != "tango" and self._backend.lower() != "epics": + raise PyAMLException(f"backend must be `epics` or `tango` but got '{self._backend}'") from None def resolve(self, key: str) -> BaseModel: """Resolve a backend specification string. @@ -68,10 +68,10 @@ def resolve(self, key: str) -> BaseModel: pydantic.BaseModel Backend-specific configuration model. """ - if self._cfg.backend.lower() == "epics": - return _build_epics_config(key, self._cfg.timeout_ms) - elif self._cfg.backend.lower() == "tango": - return _build_tango_config(key, self._cfg.timeout_ms) + if self._backend.lower() == "epics": + return _build_epics_config(key, self._timeout_ms) + elif self._backend.lower() == "tango": + return _build_tango_config(key, self._timeout_ms) else: return None @@ -120,18 +120,16 @@ def _parse_pv(token: str) -> tuple[list[str], int | None, str, bool]: def _build_epics_config(pv_str: str, timeout_ms: int) -> DeviceAccess: """Build an EPICS configuration model from a specification string.""" - from .epicsR import ConfigModel as EpicsRConfig - from .epicsRW import ConfigModel as EpicsRWConfig - from .epicsW import ConfigModel as EpicsWConfig + from .types import EpicsConfigR, EpicsConfigRW, EpicsConfigW pv_names, index, unit, hasw = _parse_pv(pv_str) if len(pv_names) == 1: if hasw: - return EpicsWConfig(write_pvname=pv_names[0], timeout_ms=timeout_ms, index=index, unit=unit) + return EpicsConfigW(write_pvname=pv_names[0], timeout_ms=timeout_ms, index=index, unit=unit) else: - return EpicsRConfig(read_pvname=pv_names[0], timeout_ms=timeout_ms, index=index, unit=unit) + return EpicsConfigR(read_pvname=pv_names[0], timeout_ms=timeout_ms, index=index, unit=unit) if len(pv_names) == 2: - return EpicsRWConfig(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index, unit=unit) + return EpicsConfigRW(read_pvname=pv_names[0], write_pvname=pv_names[1], timeout_ms=timeout_ms, index=index, unit=unit) raise PyAMLException(f"Too many comma-separated tokens in key '{pv_str}' (max 2)") @@ -159,7 +157,7 @@ def _parse_attribute(token: str) -> tuple[list[str], int | None, str]: def _build_tango_config(att_name: str, timeout_ms: int) -> BaseModel: """Build a Tango configuration model from a specification string.""" - from .tangoAtt import ConfigModel as TangoAtt + from .types import TangoConfigAtt att_name, index, unit = _parse_attribute(att_name) - return TangoAtt(attribute=att_name, timeout_ms=timeout_ms, index=index, unit=unit) + return TangoConfigAtt(attribute=att_name, timeout_ms=timeout_ms, index=index, unit=unit) From 2cf626a6d25714f746e6a25f5182aefeca70f45c Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 20:47:54 +0200 Subject: [PATCH 06/10] Remove config models from static catalog. --- pyaml_cs_oa/static_catalog.py | 21 ++++++++------------- pyaml_cs_oa/static_catalog_entry.py | 23 ++++++++--------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/pyaml_cs_oa/static_catalog.py b/pyaml_cs_oa/static_catalog.py index 2421576..ced0a41 100644 --- a/pyaml_cs_oa/static_catalog.py +++ b/pyaml_cs_oa/static_catalog.py @@ -2,7 +2,8 @@ from pyaml.common.exception import PyAMLException from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel, ConfigDict +from pyaml.validation import DynamicValidation, register_schema +from pydantic import BaseModel from .catalog import Catalog from .static_catalog_entry import StaticCatalogEntry @@ -10,27 +11,21 @@ PYAMLCLASS = "StaticCatalog" -class ConfigModel(BaseModel): +@register_schema +class StaticCatalog(Catalog, DynamicValidation): """ Static catalog: a fixed mapping of keys to DeviceAccess instances. Keys are resolved at construction time; no control-system connection is required. The catalog instance is shared across all control systems that reference it. """ - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + def __init__(self, entries: list[StaticCatalogEntry]): + self._entries = entries - entries: list[StaticCatalogEntry] - - -class StaticCatalog(Catalog): - """Resolve keys from a fixed set of configured device references.""" - - def __init__(self, cfg: ConfigModel): - self._cfg = cfg - if not cfg.entries: + if not self._entries: raise PyAMLException("StaticCatalog.entries must contain at least one entry") self._refs: dict[str, DeviceAccess] = {} - for entry in cfg.entries: + for entry in self._entries: key = entry.get_key() if key in self._refs: raise PyAMLException(f"StaticCatalog.entries contains duplicate key '{key}'") diff --git a/pyaml_cs_oa/static_catalog_entry.py b/pyaml_cs_oa/static_catalog_entry.py index cd53c44..575daae 100644 --- a/pyaml_cs_oa/static_catalog_entry.py +++ b/pyaml_cs_oa/static_catalog_entry.py @@ -1,30 +1,23 @@ """Configuration entries used by the static catalog.""" from pyaml.control.deviceaccess import DeviceAccess -from pydantic import BaseModel, ConfigDict +from pyaml.validation import DynamicValidation, register_schema PYAMLCLASS = "StaticCatalogEntry" -class ConfigModel(BaseModel): - """Configuration for one named device reference.""" - - model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") - - key: str - device: DeviceAccess - - -class StaticCatalogEntry: +@register_schema +class StaticCatalogEntry(DynamicValidation): """Resolved key-to-device entry used by :class:`StaticCatalog`.""" - def __init__(self, cfg: ConfigModel): - self._cfg = cfg + def __init__(self, key: str, device: DeviceAccess): + self.key = key + self.device = device def get_key(self) -> str: """Return the catalog key.""" - return self._cfg.key + return self.key def get_device(self) -> DeviceAccess: """Return the configured device.""" - return self._cfg.device + return self.device From 51fd24b48a8d89a59e60d880e872007d96775e7e Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 20:48:31 +0200 Subject: [PATCH 07/10] Update tests to not use config models. --- tests/test_bpm_orbit.py | 14 ++++++------- tests/test_dynamic_catalog.py | 39 ++++++++++++++++------------------- tests/test_signal.py | 19 +++++++---------- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/tests/test_bpm_orbit.py b/tests/test_bpm_orbit.py index 0857c3b..35d6ba6 100644 --- a/tests/test_bpm_orbit.py +++ b/tests/test_bpm_orbit.py @@ -11,11 +11,9 @@ from pyaml.control.deviceaccess import DeviceAccess from pydantic import BaseModel, ConfigDict -from pyaml_cs_oa.controlsystem import ConfigModel, OphydAsyncControlSystem +from pyaml_cs_oa.controlsystem import OphydAsyncControlSystem from pyaml_cs_oa.float_signal import FloatSignalContainer -from pyaml_cs_oa.static_catalog import ConfigModel as StaticCatalogConfig from pyaml_cs_oa.static_catalog import StaticCatalog -from pyaml_cs_oa.static_catalog_entry import ConfigModel as StaticCatalogEntryConfig from pyaml_cs_oa.static_catalog_entry import StaticCatalogEntry from pyaml_cs_oa.types import EpicsConfigR @@ -94,7 +92,7 @@ class IdentityAttachControlSystem(OphydAsyncControlSystem): # attach public methods are depecrated def get_device_access(self, ref: str | BaseModel | None) -> DeviceAccess | None: - config = self._cfg.catalog.resolve(ref) + config = self._catalog.resolve(ref) return IndexedVectorSignal(config) @@ -132,13 +130,13 @@ def _control_system_with_indexed_orbit(orbit_device: VectorDevice, bpm_count: in ) entries.extend( [ - StaticCatalogEntry(StaticCatalogEntryConfig(key=f"BPM{bpm_index}:X", device=IndexedVectorSignal(x_config))), - StaticCatalogEntry(StaticCatalogEntryConfig(key=f"BPM{bpm_index}:Y", device=IndexedVectorSignal(y_config))), + StaticCatalogEntry(key=f"BPM{bpm_index}:X", device=IndexedVectorSignal(x_config)), + StaticCatalogEntry(key=f"BPM{bpm_index}:Y", device=IndexedVectorSignal(y_config)), ], ) - catalog = StaticCatalog(StaticCatalogConfig(entries=entries)) - control_system = IdentityAttachControlSystem(ConfigModel(name="live", catalog=catalog)) + catalog = StaticCatalog(entries=entries) + control_system = IdentityAttachControlSystem(name="live", catalog=catalog) return control_system diff --git a/tests/test_dynamic_catalog.py b/tests/test_dynamic_catalog.py index da41233..7517b00 100644 --- a/tests/test_dynamic_catalog.py +++ b/tests/test_dynamic_catalog.py @@ -1,19 +1,16 @@ import pytest from pyaml.common.exception import PyAMLException -from pyaml_cs_oa.dynamic_catalog import ConfigModel as DynamicCatalogConfig from pyaml_cs_oa.dynamic_catalog import DynamicCatalog -from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig -from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig -from pyaml_cs_oa.tangoAtt import ConfigModel as TangoAttConfig +from pyaml_cs_oa.types import EpicsConfigR, EpicsConfigRW, TangoConfigAtt def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics", timeout_ms=1234)) + catalog = DynamicCatalog(backend="epics", timeout_ms=1234) device = catalog.resolve("PV:RB[m]") - assert isinstance(device, EpicsRConfig) + assert isinstance(device, EpicsConfigR) assert device.read_pvname == "PV:RB" assert device.timeout_ms == 1234 assert device.unit == "m" @@ -21,22 +18,22 @@ def test_dynamic_epics_catalog_resolves_scalar_read_key_without_index() -> None: def test_dynamic_epics_catalog_resolves_indexed_read_key() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") device = catalog.resolve("PV:ARRAY@3[m]") - assert isinstance(device, EpicsRConfig) + assert isinstance(device, EpicsConfigR) assert device.read_pvname == "PV:ARRAY" assert device.unit == "m" assert device.index == 3 def test_dynamic_epics_catalog_resolves_read_write_key() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") device = catalog.resolve("(PV:RB, PV:SP)[m]") - assert isinstance(device, EpicsRWConfig) + assert isinstance(device, EpicsConfigRW) assert device.read_pvname == "PV:RB" assert device.write_pvname == "PV:SP" assert device.unit == "m" @@ -44,11 +41,11 @@ def test_dynamic_epics_catalog_resolves_read_write_key() -> None: def test_dynamic_epics_catalog_resolves_indexed_read_write_key() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") device = catalog.resolve("(PV:RB, PV:SP)@5[m]") - assert isinstance(device, EpicsRWConfig) + assert isinstance(device, EpicsConfigRW) assert device.read_pvname == "PV:RB" assert device.write_pvname == "PV:SP" assert device.unit == "m" @@ -56,11 +53,11 @@ def test_dynamic_epics_catalog_resolves_indexed_read_write_key() -> None: def test_dynamic_epics_catalog_strips_whitespace_from_pv_names_and_index() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") device = catalog.resolve(" ( PV:RB , PV:SP ) @ 7 [m]") - assert isinstance(device, EpicsRWConfig) + assert isinstance(device, EpicsConfigRW) assert device.read_pvname == "PV:RB" assert device.write_pvname == "PV:SP" assert device.index == 7 @@ -68,25 +65,25 @@ def test_dynamic_epics_catalog_strips_whitespace_from_pv_names_and_index() -> No def test_dynamic_epics_catalog_rejects_invalid_index() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") with pytest.raises(PyAMLException, match="Invalid index"): catalog.resolve("PV:ARRAY@not-an-index[m]") def test_dynamic_epics_catalog_rejects_too_many_read_write_tokens() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="epics")) + catalog = DynamicCatalog(backend="epics") with pytest.raises(PyAMLException, match="Too many comma-separated tokens"): catalog.resolve("(PV:ONE, PV:TWO, PV:THREE)[m]") def test_tango_catalog_resolves_scalar_attribute() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango", timeout_ms=1234)) + catalog = DynamicCatalog(backend="tango", timeout_ms=1234) device = catalog.resolve("sys/tg_test/1/value[m]") - assert isinstance(device, TangoAttConfig) + assert isinstance(device, TangoConfigAtt) assert device.attribute == "sys/tg_test/1/value" assert device.timeout_ms == 1234 assert device.unit == "m" @@ -94,17 +91,17 @@ def test_tango_catalog_resolves_scalar_attribute() -> None: def test_disconnected_tango_catalog_resolves_indexed_attribute() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango")) + catalog = DynamicCatalog(backend="tango") device = catalog.resolve("sys/tg_test/1/spectrum@4[m]") - assert isinstance(device, TangoAttConfig) + assert isinstance(device, TangoConfigAtt) assert device.attribute == "sys/tg_test/1/spectrum" assert device.index == 4 def test_tango_catalog_rejects_invalid_index() -> None: - catalog = DynamicCatalog(DynamicCatalogConfig(backend="tango")) + catalog = DynamicCatalog(backend="tango") with pytest.raises(PyAMLException, match="Invalid index"): catalog.resolve("sys/tg_test/1/spectrum@bad[m]") diff --git a/tests/test_signal.py b/tests/test_signal.py index 5eaf44b..c0210b6 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -1,11 +1,8 @@ import pytest -from pyaml_cs_oa.epicsR import ConfigModel as EpicsRConfig from pyaml_cs_oa.epicsR import EpicsR -from pyaml_cs_oa.epicsRW import ConfigModel as EpicsRWConfig -from pyaml_cs_oa.epicsW import ConfigModel as EpicsWConfig +from pyaml_cs_oa.epicsRW import EpicsRW from pyaml_cs_oa.epicsW import EpicsW -from pyaml_cs_oa.tangoAtt import ConfigModel as TangoAttConfig from pyaml_cs_oa.tangoAtt import TangoAtt from pyaml_cs_oa.types import EpicsConfigW @@ -27,7 +24,7 @@ def fake_get_sp_rb(cfg): monkeypatch.setattr("pyaml_cs_oa.epics.get_SP_RB", fake_get_sp_rb) - signal = EpicsR(EpicsRWConfig(read_pvname="PV:RB", write_pvname="PV:SP")) + signal = EpicsRW(read_pvname="PV:RB", write_pvname="PV:SP") signal.build() assert signal.SP is setpoint @@ -45,7 +42,7 @@ def fake_get_sp_rb(cfg): monkeypatch.setattr("pyaml_cs_oa.tango.get_SP_RB", fake_get_sp_rb) - signal = TangoAtt(TangoAttConfig(attribute="sys/tg_test/1/value")) + signal = TangoAtt(attribute="sys/tg_test/1/value") signal.build() assert signal.SP is None @@ -54,32 +51,32 @@ def fake_get_sp_rb(cfg): def test_measure_name_uses_epics_read_pv_for_readable_signal() -> None: - signal = EpicsR(EpicsRConfig(read_pvname="PV:RB")) + signal = EpicsR(read_pvname="PV:RB") assert signal.measure_name() == "PV:RB" def test_measure_name_uses_tango_attribute() -> None: - signal = TangoAtt(TangoAttConfig(attribute="sys/tg_test/1/value")) + signal = TangoAtt(attribute="sys/tg_test/1/value") assert signal.measure_name() == "sys/tg_test/1/value" def test_measure_name_uses_epics_write_pv_for_write_only_signal() -> None: - signal = EpicsW(EpicsWConfig(write_pvname="PV:SP")) + signal = EpicsW(write_pvname="PV:SP") assert signal.measure_name() == "PV:SP" def test_get_range_returns_configured_range_for_writable_signal() -> None: - signal = EpicsW(EpicsWConfig(write_pvname="PV:SP", range=[0.0, 10.0])) + signal = EpicsW(write_pvname="PV:SP", range=[0.0, 10.0]) signal._writable = True assert signal.get_range() == [0.0, 10.0] def test_get_range_returns_configured_range_from_write_config() -> None: - signal = EpicsW(EpicsWConfig(write_pvname="PV:SP", range=[0.0, 10.0])) + signal = EpicsW(write_pvname="PV:SP", range=[0.0, 10.0]) signal._writable = False assert signal.get_range() == [0.0, 10.0] From 2c129692a23eb23a7e0c106cef5bb741e840ec02 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 8 Sep 2026 20:52:50 +0200 Subject: [PATCH 08/10] Add entrypoint so core can discover the schemas. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index cee974e..6a8ba8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ docs-extra = [ "ophyd-async[tango]", ] +[project.entry-points."pyaml.schemas"] +pyaml_cs_oa = "pyaml_cs_oa" + [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" From eb727a6195cae48070bb3a2733733d83906d751b Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Tue, 19 May 2026 17:12:59 +0200 Subject: [PATCH 09/10] Updated configmodel for controlsystem. --- pyaml_cs_oa/controlsystem.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 4cd4c37..d55630d 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -34,7 +34,7 @@ def __init__( name: str, prefix: str = "", catalog: Catalog | None = None, - debug_level: str | None = None, + debug_level: str | int | None = None, ): """Create an ophyd-async control-system interface. @@ -48,8 +48,8 @@ def __init__( catalog : Catalog or None, optional Catalog instance or catalog name used to resolve PyAML device keys. If None specified a dynamic catalog is used. - debug_level : str or None, optional - Debug verbosity level. + debug_level : str | int | None, optional + Debug verbosity level. Such as INFO, DEBUG, WARNING, ERROR, CRITICAL. Or 10, 20, 30, 40, 50. """ super().__init__() From ef6d40ae4cdfa22e2a2bb3b644c414755e412e37 Mon Sep 17 00:00:00 2001 From: Teresia Olsson Date: Wed, 9 Sep 2026 11:25:52 +0200 Subject: [PATCH 10/10] Fix bug with wrong types discovered during dynamic validation. --- pyaml_cs_oa/controlsystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyaml_cs_oa/controlsystem.py b/pyaml_cs_oa/controlsystem.py index 4cd4c37..d24f55a 100644 --- a/pyaml_cs_oa/controlsystem.py +++ b/pyaml_cs_oa/controlsystem.py @@ -136,7 +136,7 @@ def _attach(self, configs: list[ControlSysConfig | None]) -> list[OASignal | Non if key not in self._devices: n_conf = dict(sig_cfg) | config - nr = sig_cls(sig_cfg_cls(**n_conf)) + nr = sig_cls(**n_conf) nr.build() self._devices[key] = nr