From 4efb3e16a503281093a071f0f25df4ec4dad32d4 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 12:23:28 +0000 Subject: [PATCH 01/50] outline for attribute io Co-authored-by: Gary Yendell --- src/fastcs/attribute_io.py | 21 +++++++++++++++++++++ src/fastcs/attribute_io_ref.py | 10 ++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/fastcs/attribute_io.py create mode 100644 src/fastcs/attribute_io_ref.py diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py new file mode 100644 index 000000000..462a7d7eb --- /dev/null +++ b/src/fastcs/attribute_io.py @@ -0,0 +1,21 @@ +from typing import Generic + +from fastcs.attributes import AttrR, AttrRW +from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT +from fastcs.datatypes import T + + +class AttributeIO(Generic[AttributeIORefT, T]): + def __init__(self, io_ref: type[AttributeIORefT]): + self.ref = io_ref + + async def update(self, attr: AttrR[T]) -> None: + raise NotImplementedError() + + async def send( + self, attr: AttrRW[T], value # TODO, type this + ) -> None: + raise NotImplementedError() + + +AnyAttributeIO = AttributeIO[AttributeIORef, T] diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py new file mode 100644 index 000000000..58cc7d61d --- /dev/null +++ b/src/fastcs/attribute_io_ref.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import TypeVar + + +@dataclass(kw_only=True) +class AttributeIORef: + update_period: float | None = None + + +AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef, covariant=True) From 43c93acf888f1fff9c0a09b6900fcf5faa934c6b Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 12:31:15 +0000 Subject: [PATCH 02/50] provide default for AttributeIORefT reorder AttributeIO generic args to allow defaults --- src/fastcs/attribute_io.py | 4 ++-- src/fastcs/attribute_io_ref.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 462a7d7eb..d104e6b25 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -5,7 +5,7 @@ from fastcs.datatypes import T -class AttributeIO(Generic[AttributeIORefT, T]): +class AttributeIO(Generic[T, AttributeIORefT]): def __init__(self, io_ref: type[AttributeIORefT]): self.ref = io_ref @@ -18,4 +18,4 @@ async def send( raise NotImplementedError() -AnyAttributeIO = AttributeIO[AttributeIORef, T] +AnyAttributeIO = AttributeIO[T, AttributeIORef] diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py index 58cc7d61d..e06099fdb 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attribute_io_ref.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import TypeVar +from typing_extensions import TypeVar @dataclass(kw_only=True) @@ -7,4 +7,9 @@ class AttributeIORef: update_period: float | None = None -AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef, covariant=True) +AttributeIORefT = TypeVar( + "AttributeIORefT", + default=AttributeIORef, + bound=AttributeIORef, + covariant=True +) From 2df7cbb76499c4e69659565f3a1ff585eb7e98bc Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 12:36:31 +0000 Subject: [PATCH 03/50] Add AttributeIORefT as generic arg for Attrs --- src/fastcs/attribute_io.py | 6 ++++-- src/fastcs/attribute_io_ref.py | 6 ++---- src/fastcs/attributes.py | 9 +++++---- src/fastcs/util.py | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index d104e6b25..7c712bbc1 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -1,7 +1,7 @@ from typing import Generic -from fastcs.attributes import AttrR, AttrRW from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT +from fastcs.attributes import AttrR, AttrRW from fastcs.datatypes import T @@ -13,7 +13,9 @@ async def update(self, attr: AttrR[T]) -> None: raise NotImplementedError() async def send( - self, attr: AttrRW[T], value # TODO, type this + self, + attr: AttrRW[T], + value, # TODO, type this ) -> None: raise NotImplementedError() diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py index e06099fdb..7037e6ed5 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attribute_io_ref.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from typing_extensions import TypeVar @@ -8,8 +9,5 @@ class AttributeIORef: AttributeIORefT = TypeVar( - "AttributeIORefT", - default=AttributeIORef, - bound=AttributeIORef, - covariant=True + "AttributeIORefT", default=AttributeIORef, bound=AttributeIORef, covariant=True ) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index a0db6ce32..c29d1001d 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -6,6 +6,7 @@ from typing import Any, Generic import fastcs +from fastcs.attribute_io_ref import AttributeIORefT from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T @@ -62,7 +63,7 @@ async def update(self, attr: AttrR) -> None: raise RuntimeError("SimpleHandler cannot update") -class Attribute(Generic[T]): +class Attribute(Generic[T, AttributeIORefT]): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the backend. @@ -126,7 +127,7 @@ def update_datatype(self, datatype: DataType[T]) -> None: callback(datatype) -class AttrR(Attribute[T]): +class AttrR(Attribute[T, AttributeIORefT]): """A read-only ``Attribute``.""" def __init__( @@ -170,7 +171,7 @@ def updater(self) -> AttrHandlerR | None: return self._updater -class AttrW(Attribute[T]): +class AttrW(Attribute[T, AttributeIORefT]): """A write-only ``Attribute``.""" def __init__( @@ -224,7 +225,7 @@ def sender(self) -> AttrHandlerW | None: return self._setter -class AttrRW(AttrR[T], AttrW[T]): +class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): """A read-write ``Attribute``.""" def __init__( diff --git a/src/fastcs/util.py b/src/fastcs/util.py index af7289722..a73cd591b 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -53,7 +53,7 @@ def validate_hinted_attributes(controller: BaseController): f"attribute '{name}' does not match defined access mode. " f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." ) - (attr_dtype,) = get_args(hint) + attr_dtype = get_args(hint)[0] if attr.datatype.dtype != attr_dtype: raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " From 9fd2c6f7b9c940d325b785b94e09790fd5bf82da Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 12:50:22 +0000 Subject: [PATCH 04/50] remove references to attr.updater in backend --- src/fastcs/attributes.py | 6 ++++++ src/fastcs/backend.py | 9 +++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index c29d1001d..51b137eb8 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -170,6 +170,9 @@ def add_update_callback(self, callback: AttrCallback[T]) -> None: def updater(self) -> AttrHandlerR | None: return self._updater + async def update(self): + await self.updater.update(self) + class AttrW(Attribute[T, AttributeIORefT]): """A write-only ``Attribute``.""" @@ -224,6 +227,9 @@ def add_write_display_callback(self, callback: AttrCallback[T]) -> None: def sender(self) -> AttrHandlerW | None: return self._setter + async def put(self, value): + await self.sender.put(self, value) + class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): """A read-write ``Attribute``.""" diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 2e37fa2b4..f07b1dc09 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -106,7 +106,7 @@ def _link_attribute_sender_class(controller_api: ControllerAPI) -> None: def _create_sender_callback(attribute): async def callback(value): - await attribute.sender.put(attribute, value) + await attribute.put(value) return callback @@ -148,14 +148,11 @@ def _add_attribute_updater_tasks( def _create_updater_callback(attribute: AttrR[T]): - updater = attribute.updater - assert updater is not None - async def callback(): try: - await updater.update(attribute) + await attribute.update() except Exception as e: - print(f"Update loop in {updater} stopped:\n{e.__class__.__name__}: {e}") + print(f"Update loop in {attribute} stopped:\n{e.__class__.__name__}: {e}") raise return callback From 0c6909bdc8fdbee6567c7cdc11c985a9408d7fb5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 13:40:43 +0000 Subject: [PATCH 05/50] gather update callbacks on attr.update() --- src/fastcs/attributes.py | 27 ++++++++++++------- src/fastcs/transport/epics/ca/ioc.py | 2 +- .../transport/epics/pva/_pv_handlers.py | 2 +- tests/test_attribute.py | 2 +- tests/transport/epics/ca/test_softioc.py | 6 ++--- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 51b137eb8..9960505c0 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -1,9 +1,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine from enum import Enum -from typing import Any, Generic +from typing import Any, Generic, Self import fastcs from fastcs.attribute_io_ref import AttributeIORefT @@ -149,7 +149,14 @@ def __init__( self._value: T = ( datatype.initial_value if initial_value is None else initial_value ) - self._update_callbacks: list[AttrCallback[T]] | None = None + self._on_set_callbacks: list[AttrCallback[T]] | None = None + + async def _updater_update(attr): + await attr.updater.update(attr) + + self._on_update_callbacks: list[ + Callable[[Self], Coroutine[None, None, None]] + ] = [_updater_update] self._updater = handler def get(self) -> T: @@ -158,20 +165,20 @@ def get(self) -> T: async def set(self, value: T) -> None: self._value = self._datatype.validate(value) - if self._update_callbacks is not None: - await asyncio.gather(*[cb(self._value) for cb in self._update_callbacks]) + if self._on_set_callbacks is not None: + await asyncio.gather(*[cb(self._value) for cb in self._on_set_callbacks]) - def add_update_callback(self, callback: AttrCallback[T]) -> None: - if self._update_callbacks is None: - self._update_callbacks = [] - self._update_callbacks.append(callback) + def add_set_callback(self, callback: AttrCallback[T]) -> None: + if self._on_set_callbacks is None: + self._on_set_callbacks = [] + self._on_set_callbacks.append(callback) @property def updater(self) -> AttrHandlerR | None: return self._updater async def update(self): - await self.updater.update(self) + await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) class AttrW(Attribute[T, AttributeIORefT]): diff --git a/src/fastcs/transport/epics/ca/ioc.py b/src/fastcs/transport/epics/ca/ioc.py index 9653328e0..45ac70277 100644 --- a/src/fastcs/transport/epics/ca/ioc.py +++ b/src/fastcs/transport/epics/ca/ioc.py @@ -165,7 +165,7 @@ async def async_record_set(value: T): record = _make_record(f"{pv_prefix}:{pv_name}", attribute) _add_attr_pvi_info(record, pv_prefix, attr_name, "r") - attribute.add_update_callback(async_record_set) + attribute.add_set_callback(async_record_set) def _make_record( diff --git a/src/fastcs/transport/epics/pva/_pv_handlers.py b/src/fastcs/transport/epics/pva/_pv_handlers.py index 3f8c3609a..8c25418ca 100644 --- a/src/fastcs/transport/epics/pva/_pv_handlers.py +++ b/src/fastcs/transport/epics/pva/_pv_handlers.py @@ -118,7 +118,7 @@ def make_shared_read_pv(attribute: AttrR) -> SharedPV: async def on_update(value): shared_pv.post(cast_to_p4p_value(attribute, value)) - attribute.add_update_callback(on_update) + attribute.add_set_callback(on_update) return shared_pv diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 325fca792..4745ec7bb 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -28,7 +28,7 @@ async def device_add(): device["number"] += 1 attr_r = AttrR(String()) - attr_r.add_update_callback(partial(update_ui, key="state")) + attr_r.add_set_callback(partial(update_ui, key="state")) await attr_r.set(device["state"]) assert ui["state"] == "Idle" diff --git a/tests/transport/epics/ca/test_softioc.py b/tests/transport/epics/ca/test_softioc.py index cdd3e1a12..8e6ef61fc 100644 --- a/tests/transport/epics/ca/test_softioc.py +++ b/tests/transport/epics/ca/test_softioc.py @@ -53,7 +53,7 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): record = make_record.return_value attribute = AttrR(Int()) - attribute.add_update_callback = mocker.MagicMock() + attribute.add_set_callback = mocker.MagicMock() _create_and_link_read_pv("PREFIX", "PV", "attr", attribute) @@ -61,8 +61,8 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): add_attr_pvi_info.assert_called_once_with(record, "PREFIX", "attr", "r") # Extract the callback generated and set in the function and call it - attribute.add_update_callback.assert_called_once_with(mocker.ANY) - record_set_callback = attribute.add_update_callback.call_args[0][0] + attribute.add_set_callback.assert_called_once_with(mocker.ANY) + record_set_callback = attribute.add_set_callback.call_args[0][0] await record_set_callback(1) record.set.assert_called_once_with(1) From 54a1ce9dce32f1967319876e92ddd92258c5e9d1 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 13:54:49 +0000 Subject: [PATCH 06/50] add io_ref to Attribute signature --- src/fastcs/attributes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 9960505c0..5391a074a 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -6,7 +6,7 @@ from typing import Any, Generic, Self import fastcs -from fastcs.attribute_io_ref import AttributeIORefT +from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T @@ -73,6 +73,7 @@ def __init__( self, datatype: DataType[T], access_mode: AttrMode, + io_ref: AttributeIORefT | None = None, group: str | None = None, handler: Any = None, description: str | None = None, @@ -81,6 +82,7 @@ def __init__( f"Attr type must be one of {ATTRIBUTE_TYPES}, " "received type {datatype.dtype}" ) + self.io_ref = io_ref or AttributeIORef() self._datatype: DataType[T] = datatype self._access_mode: AttrMode = access_mode self._group = group @@ -134,6 +136,7 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ, + io_ref: AttributeIORefT | None = None, group: str | None = None, handler: AttrHandlerR | None = None, initial_value: T | None = None, @@ -142,6 +145,7 @@ def __init__( super().__init__( datatype, # type: ignore access_mode, + io_ref, group, handler, description=description, @@ -188,6 +192,7 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.WRITE, + io_ref: AttributeIORefT | None = None, group: str | None = None, handler: AttrHandlerW | None = None, description: str | None = None, @@ -195,6 +200,7 @@ def __init__( super().__init__( datatype, # type: ignore access_mode, + io_ref, group, handler, description=description, @@ -245,6 +251,7 @@ def __init__( self, datatype: DataType[T], access_mode=AttrMode.READ_WRITE, + io_ref: AttributeIORefT | None = None, group: str | None = None, handler: AttrHandlerRW | None = None, initial_value: T | None = None, @@ -253,6 +260,7 @@ def __init__( super().__init__( datatype, # type: ignore access_mode, + io_ref, group=group, handler=handler if handler else SimpleAttrHandler(), initial_value=initial_value, From bc58197110a8e0643c0306f9cae9b426ec85a361 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Sep 2025 14:37:14 +0000 Subject: [PATCH 07/50] add_update_callback convenience method --- src/fastcs/attributes.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 5391a074a..48f46a356 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -154,14 +154,13 @@ def __init__( datatype.initial_value if initial_value is None else initial_value ) self._on_set_callbacks: list[AttrCallback[T]] | None = None + self._on_update_callbacks: list[AttrCallback[T]] | None = None - async def _updater_update(attr): + async def _updater_update(attr): # TODO remove this when we get rid of handlers await attr.updater.update(attr) - self._on_update_callbacks: list[ - Callable[[Self], Coroutine[None, None, None]] - ] = [_updater_update] self._updater = handler + self.add_update_callback(_updater_update) def get(self) -> T: return self._value @@ -177,6 +176,13 @@ def add_set_callback(self, callback: AttrCallback[T]) -> None: self._on_set_callbacks = [] self._on_set_callbacks.append(callback) + def add_update_callback( + self, callback: Callable[[Self], Coroutine[None, None, None]] + ): + if self._on_update_callbacks is None: + self._on_update_callbacks = [] + self._on_update_callbacks.append(callback) + @property def updater(self) -> AttrHandlerR | None: return self._updater From 63fbde9db181beecaaedffa9b50bf3f685db3271 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 08:08:10 +0000 Subject: [PATCH 08/50] rename attribute_io ref member to ref_type --- src/fastcs/attribute_io.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 7c712bbc1..ee3aa47d2 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -7,7 +7,7 @@ class AttributeIO(Generic[T, AttributeIORefT]): def __init__(self, io_ref: type[AttributeIORefT]): - self.ref = io_ref + self.ref_type = io_ref async def update(self, attr: AttrR[T]) -> None: raise NotImplementedError() @@ -20,4 +20,17 @@ async def send( raise NotImplementedError() +class SimpleAttributeIO(AttributeIO): + """IO for internal parameters""" + + async def send(self, attr: AttrRW[T], value) -> None: + await attr.update_display_without_process(value) + + if isinstance(attr, AttrRW): + await attr.set(value) + + async def update(self, attr: AttrR[T]) -> None: + raise RuntimeError("SimpleAttributeIO can't update") + + AnyAttributeIO = AttributeIO[T, AttributeIORef] From 22c76b2c5e74e6d6b0e0be343b649efba620fd73 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 08:22:39 +0000 Subject: [PATCH 09/50] validate controllers have AttributeIOs to handle AttributeIORefs (BREAKS ALL TESTS) --- src/fastcs/controller.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index d74b3c1d8..8c1980004 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence from copy import deepcopy from typing import get_type_hints +from fastcs.attribute_io import AttributeIO from fastcs.attributes import Attribute @@ -16,7 +18,10 @@ class BaseController: description: str | None = None def __init__( - self, path: list[str] | None = None, description: str | None = None + self, + path: list[str] | None = None, + description: str | None = None, + ios: Sequence[AttributeIO] | None = None, ) -> None: if ( description is not None @@ -30,6 +35,11 @@ def __init__( self._bind_attrs() + # TODO, should validation live inside the controller? + ios = ios or [] + self._attribute_ref_io_map = {io.ref_type: io for io in ios} + self._validate_io() + async def initialise(self): pass @@ -98,6 +108,15 @@ class method and a controller instance, so that it can be called from any elif isinstance(attr, UnboundPut | UnboundScan | UnboundCommand): setattr(self, attr_name, attr.bind(self)) + def _validate_io(self): + """Validate that each Attribute has an AttributeIORef for which the + controller has an associated AttributeIO class.""" + for attr in self.attributes.values(): + assert type(attr.io_ref) in self._attribute_ref_io_map, ( + f"{self.__class__.__name__} does not have an AttributeIO to handle " + f"{attr.io_ref.__class__.__name__}" + ) + def register_sub_controller(self, name: str, sub_controller: Controller): if name in self.__sub_controller_tree.keys(): raise ValueError( @@ -131,8 +150,10 @@ class Controller(BaseController): root_attribute: Attribute | None = None - def __init__(self, description: str | None = None) -> None: - super().__init__(description=description) + def __init__( + self, description: str | None = None, ios: Sequence[AttributeIO] | None = None + ) -> None: + super().__init__(description=description, ios=ios) async def connect(self) -> None: pass From 01d5d8cf87aa13a7b3d11753b9fa6b9a53df7ad5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 10:15:30 +0000 Subject: [PATCH 10/50] wip: add io callbacks during attribute_initialise --- src/fastcs/attributes.py | 8 ++-- src/fastcs/controller.py | 14 ++++++- tests/attribute_io/test_attribute_io.py | 49 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/attribute_io/test_attribute_io.py diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 48f46a356..49d3a5d70 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -156,11 +156,7 @@ def __init__( self._on_set_callbacks: list[AttrCallback[T]] | None = None self._on_update_callbacks: list[AttrCallback[T]] | None = None - async def _updater_update(attr): # TODO remove this when we get rid of handlers - await attr.updater.update(attr) - self._updater = handler - self.add_update_callback(_updater_update) def get(self) -> T: return self._value @@ -188,7 +184,9 @@ def updater(self) -> AttrHandlerR | None: return self._updater async def update(self): - await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) + print("22222", self._on_update_callbacks) + if self._on_update_callbacks is not None: + await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) class AttrW(Attribute[T, AttributeIORefT]): diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 8c1980004..13cf0d2e3 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -6,7 +6,7 @@ from typing import get_type_hints from fastcs.attribute_io import AttributeIO -from fastcs.attributes import Attribute +from fastcs.attributes import Attribute, AttrR, AttrW class BaseController: @@ -51,9 +51,21 @@ async def attribute_initialise(self) -> None: except asyncio.CancelledError: pass + self._add_io_callbacks() + for controller in self.get_sub_controllers().values(): await controller.attribute_initialise() + def _add_io_callbacks(self): + for attr in self.attributes.values(): + io = self._attribute_ref_io_map[type(attr.io_ref)] + # is this the right access mode to own update and set? + if isinstance(attr, AttrR): + attr.add_update_callback(io.update) + if isinstance(attr, AttrW): + # is it on process or write_display? + attr.add_process_callback(io.send) + @property def path(self) -> list[str]: """Path prefix of attributes, recursively including parent Controllers.""" diff --git a/tests/attribute_io/test_attribute_io.py b/tests/attribute_io/test_attribute_io.py new file mode 100644 index 000000000..4448f0065 --- /dev/null +++ b/tests/attribute_io/test_attribute_io.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass + +import pytest +from pytest_mock import MockerFixture + +from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORef +from fastcs.attributes import ( + AttrR, +) +from fastcs.controller import Controller +from fastcs.datatypes import Int + + +@pytest.mark.asyncio +async def test_attribute_io(mocker: MockerFixture): + @dataclass + class MyAttributeIORef(AttributeIORef): + cool: int + + class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): + def __init__(self): + # i don't really like this pattern of having to call super... + super().__init__( + MyAttributeIORef + ) # i see, so we use the class not an instance of the ref?? + + async def update(self, attr: AttrR[Int, MyAttributeIORef]): + print("I am updating", self.ref_type, attr.io_ref.cool) + + class MyController(Controller): + my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) + your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) + + def __init__(self): + super().__init__(ios=[MyAttributeIO()]) + + c = MyController() + + class ControllerNoIO(Controller): + my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) + + with pytest.raises(AssertionError, match="does not have an AttributeIO"): + ControllerNoIO() + # TODO, is it okay that we need to initialise the controller + # before the callbacks get assigned? + await c.initialise() + await c.attribute_initialise() + await c.my_attr.update() From 013306f27d9bf681553a19af69baebd776e56dee Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 12:48:54 +0000 Subject: [PATCH 11/50] fix io send callback --- src/fastcs/controller.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 13cf0d2e3..f12e18c48 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -64,7 +64,13 @@ def _add_io_callbacks(self): attr.add_update_callback(io.update) if isinstance(attr, AttrW): # is it on process or write_display? - attr.add_process_callback(io.send) + attr.add_process_callback(self._create_send_callback(io, attr)) + + def _create_send_callback(self, io, attr): + async def send_callback(value): + await io.send(attr, value) + + return send_callback @property def path(self) -> list[str]: From f7873528e2cd53a1953eec64ede110ab3858285e Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 12:59:07 +0000 Subject: [PATCH 12/50] Remove handler send callback registration --- src/fastcs/backend.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index f07b1dc09..07a807046 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -5,7 +5,7 @@ from fastcs.cs_methods import Command, Put, Scan from fastcs.datatypes import T -from .attributes import ONCE, AttrHandlerR, AttrHandlerW, AttrR, AttrW +from .attributes import ONCE, AttrHandlerR, AttrR, AttrW from .controller import BaseController, Controller from .controller_api import ControllerAPI from .exceptions import FastCSError @@ -36,7 +36,6 @@ def __init__( def _link_process_tasks(self): for controller_api in self.controller_api.walk_api(): _link_put_tasks(controller_api) - _link_attribute_sender_class(controller_api) def __del__(self): self._stop_scan_tasks() @@ -92,25 +91,6 @@ def _link_put_tasks(controller_api: ControllerAPI) -> None: ) -def _link_attribute_sender_class(controller_api: ControllerAPI) -> None: - for attr_name, attribute in controller_api.attributes.items(): - match attribute: - case AttrW(sender=AttrHandlerW()): - assert not attribute.has_process_callback(), ( - f"Cannot assign both put method and Sender object to {attr_name}" - ) - - callback = _create_sender_callback(attribute) - attribute.add_process_callback(callback) - - -def _create_sender_callback(attribute): - async def callback(value): - await attribute.put(value) - - return callback - - def _get_scan_and_initial_coros( root_controller_api: ControllerAPI, ) -> tuple[list[Callable], list[Callable]]: From c0817a59e610297be4ce173427343633dc3ef56f Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 19 Sep 2025 13:11:20 +0000 Subject: [PATCH 13/50] extract ref_type from generic args: TODO, this may not be what we want, look into how this interacts with first arg --- src/fastcs/attribute_io.py | 8 +++++--- tests/attribute_io/test_attribute_io.py | 6 ------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index ee3aa47d2..a34f5f661 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -1,4 +1,4 @@ -from typing import Generic +from typing import Generic, get_args from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT from fastcs.attributes import AttrR, AttrRW @@ -6,8 +6,10 @@ class AttributeIO(Generic[T, AttributeIORefT]): - def __init__(self, io_ref: type[AttributeIORefT]): - self.ref_type = io_ref + def __init_subclass__(cls) -> None: + # sets ref_type from subclass generic args + args = get_args(cls.__orig_bases__[0]) + cls.ref_type = args[1] async def update(self, attr: AttrR[T]) -> None: raise NotImplementedError() diff --git a/tests/attribute_io/test_attribute_io.py b/tests/attribute_io/test_attribute_io.py index 4448f0065..a0d58e7f3 100644 --- a/tests/attribute_io/test_attribute_io.py +++ b/tests/attribute_io/test_attribute_io.py @@ -19,12 +19,6 @@ class MyAttributeIORef(AttributeIORef): cool: int class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - def __init__(self): - # i don't really like this pattern of having to call super... - super().__init__( - MyAttributeIORef - ) # i see, so we use the class not an instance of the ref?? - async def update(self, attr: AttrR[Int, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) From f1ef78e8ba53d1750876059daca967c838dba627 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 07:49:30 +0000 Subject: [PATCH 14/50] wip: give controllers SimpleAttributeIO by default if no AttributeIOs passed --- src/fastcs/attribute_io.py | 2 +- src/fastcs/controller.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index a34f5f661..59bbe4ccb 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -22,7 +22,7 @@ async def send( raise NotImplementedError() -class SimpleAttributeIO(AttributeIO): +class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): """IO for internal parameters""" async def send(self, attr: AttrRW[T], value) -> None: diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index f12e18c48..4fe7ed3d3 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -5,7 +5,7 @@ from copy import deepcopy from typing import get_type_hints -from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io import AttributeIO, SimpleAttributeIO from fastcs.attributes import Attribute, AttrR, AttrW @@ -36,7 +36,7 @@ def __init__( self._bind_attrs() # TODO, should validation live inside the controller? - ios = ios or [] + ios = ios or [SimpleAttributeIO()] self._attribute_ref_io_map = {io.ref_type: io for io in ios} self._validate_io() From 4416d1150bc36d79c7b34d1e1042de93edc96b4c Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 12:39:10 +0000 Subject: [PATCH 15/50] typing tweak --- src/fastcs/attribute_io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 59bbe4ccb..c5b4cdfec 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -1,7 +1,7 @@ from typing import Generic, get_args from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT -from fastcs.attributes import AttrR, AttrRW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.datatypes import T @@ -25,7 +25,7 @@ async def send( class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): """IO for internal parameters""" - async def send(self, attr: AttrRW[T], value) -> None: + async def send(self, attr: AttrW[T], value) -> None: await attr.update_display_without_process(value) if isinstance(attr, AttrRW): From 44d2ade7da9aeb94dcc0b01b8f93b845d790679b Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 13:07:11 +0000 Subject: [PATCH 16/50] remove debug print line --- src/fastcs/attributes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 49d3a5d70..336060a4c 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -184,7 +184,6 @@ def updater(self) -> AttrHandlerR | None: return self._updater async def update(self): - print("22222", self._on_update_callbacks) if self._on_update_callbacks is not None: await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) From f6f4b32fe7d4b327c5f7b507c84a505d95d9cb95 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 13:20:52 +0000 Subject: [PATCH 17/50] wip: demo of creating io refs from introspection --- tests/attribute_io/test_attribute_io.py | 106 ++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 6 deletions(-) diff --git a/tests/attribute_io/test_attribute_io.py b/tests/attribute_io/test_attribute_io.py index a0d58e7f3..a825e9639 100644 --- a/tests/attribute_io/test_attribute_io.py +++ b/tests/attribute_io/test_attribute_io.py @@ -1,19 +1,18 @@ from dataclasses import dataclass +from typing import Generic import pytest -from pytest_mock import MockerFixture from fastcs.attribute_io import AttributeIO from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import ( - AttrR, -) +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import Controller -from fastcs.datatypes import Int +from fastcs.datatypes import Float, Int, T +# async def test_attribute_io(mocker: MockerFixture): @pytest.mark.asyncio -async def test_attribute_io(mocker: MockerFixture): +async def test_attribute_io(): @dataclass class MyAttributeIORef(AttributeIORef): cool: int @@ -23,6 +22,12 @@ async def update(self, attr: AttrR[Int, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) class MyController(Controller): + # what if we hinted something like + # io_classes: MyAttributeIO | MyOtherAttributeIO + # and then we construct them in the __init__ per controller? + # I guess this is bad as we can't share between controllers + # i really just want to avoid making devs invoke super... + # or maybe that's not a big deal... my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) @@ -41,3 +46,92 @@ class ControllerNoIO(Controller): await c.initialise() await c.attribute_initialise() await c.my_attr.update() + + +@pytest.mark.asyncio() +async def test_dynamic_attribute_io_specification(): + example_introspection_response = [ + { + "name": "int_parameter", + "dtype": "int", + "min": 0, + "max": 100, + "value": 5, + "read_only": False, + }, + {"name": "ro_int_parameter", "dtype": "int", "value": 10, "read_only": True}, + { + "name": "float_parameter", + "dtype": "float", + "max": 1000.0, + "value": 7.5, + "read_only": False, + }, + ] + + @dataclass + class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): + name: str + min: T | None = None + max: T | None = None + read_only: bool = False + + class DemoParameterAttributeIO(AttributeIO[T, DemoParameterAttributeIORef]): + async def update(self, attr: AttrR[T]): + # assume the device is always incrementing every parameter by 1 + await attr.set(attr.get() + 1) + pass + + async def send(self, attr: AttrW[T], value) -> None: + # why does this not get called... + print(attr.ref.min, value) + if ( + attr.ref.read_only + ): # TODO, this isn't necessary as we can not call process on this anyway + raise RuntimeError(f"Could not set read only attribute {attr.ref.name}") + + if attr.ref.min is not None and value < min: + raise RuntimeError( + f"Could not set {attr.ref.name} to {value}, min is {attr.ref.min}" + ) + + class DemoParameterController(Controller): + async def initialise(self): + dtype_mapping = {"int": Int(), "float": Float()} + for parameter_response in example_introspection_response: + try: + ro = parameter_response["read_only"] + ref = DemoParameterAttributeIORef( + name=parameter_response["name"], + min=parameter_response.get("min", None), + max=parameter_response.get("max", None), + read_only=ro, + ) + attr_class = AttrR if ro else AttrRW + attr = attr_class( + datatype=dtype_mapping[parameter_response["dtype"]], + io_ref=ref, + initial_value=parameter_response.get("value", None), + ) + + self.attributes[ref.name] = attr + setattr(self, ref.name, attr) + + except Exception as e: + print( + "Exception constructing attribute from parameter response:", + parameter_response, + e, + ) + + c = DemoParameterController(ios=[DemoParameterAttributeIO()]) + await c.initialise() + await c.attribute_initialise() + await c.ro_int_parameter.update() + assert c.ro_int_parameter.get() == 6 + # with + await c.int_parameter.process(-10) + + +def test_update_period_respected(): + raise NotImplementedError From 1dfca5d10d84902ec0bbe701034d71f2f17974cc Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 13:56:02 +0000 Subject: [PATCH 18/50] Fix backend tests to not use handlers --- src/fastcs/backend.py | 5 +++-- tests/test_backend.py | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 07a807046..44edad5fb 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -2,10 +2,11 @@ from collections import defaultdict from collections.abc import Callable, Coroutine +from fastcs.attribute_io_ref import AttributeIORef from fastcs.cs_methods import Command, Put, Scan from fastcs.datatypes import T -from .attributes import ONCE, AttrHandlerR, AttrR, AttrW +from .attributes import ONCE, AttrR, AttrW from .controller import BaseController, Controller from .controller_api import ControllerAPI from .exceptions import FastCSError @@ -119,7 +120,7 @@ def _add_attribute_updater_tasks( ): for attribute in controller_api.attributes.values(): match attribute: - case AttrR(updater=AttrHandlerR(update_period=update_period)) as attribute: + case AttrR(io_ref=AttributeIORef(update_period=update_period)) as attribute: callback = _create_updater_callback(attribute) if update_period is ONCE: initial_coros.append(callback) diff --git a/tests/test_backend.py b/tests/test_backend.py index 55dee4a44..171db8f13 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,11 +1,13 @@ import asyncio from dataclasses import dataclass -from fastcs.attributes import ONCE, AttrHandlerR, AttrR, AttrRW +from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORef +from fastcs.attributes import ONCE, AttrR, AttrRW from fastcs.backend import Backend, build_controller_api from fastcs.controller import Controller from fastcs.cs_methods import Command -from fastcs.datatypes import Int +from fastcs.datatypes import Int, T from fastcs.exceptions import FastCSError from fastcs.wrappers import command, scan @@ -95,20 +97,25 @@ async def test_wrapper(): def test_update_periods(): @dataclass - class AttrHandlerTimesCalled(AttrHandlerR): + class AttributeIORefTimesCalled(AttributeIORef): update_period: float | None _times_called = 0 + class AttributeIOTimesCalled(AttributeIO[T, AttributeIORefTimesCalled]): async def update(self, attr): - self._times_called += 1 - await attr.set(self._times_called) + attr.io_ref._times_called += 1 + await attr.set(attr.io_ref._times_called) class MyController(Controller): - update_once = AttrR(Int(), handler=AttrHandlerTimesCalled(update_period=ONCE)) - update_quickly = AttrR(Int(), handler=AttrHandlerTimesCalled(update_period=0.1)) - update_never = AttrR(Int(), handler=AttrHandlerTimesCalled(update_period=None)) + update_once = AttrR(Int(), io_ref=AttributeIORefTimesCalled(update_period=ONCE)) + update_quickly = AttrR( + Int(), io_ref=AttributeIORefTimesCalled(update_period=0.1) + ) + update_never = AttrR( + Int(), io_ref=AttributeIORefTimesCalled(update_period=None) + ) - controller = MyController() + controller = MyController(ios=[AttributeIOTimesCalled()]) loop = asyncio.get_event_loop() backend = Backend(controller, loop) From c5e6536bcf00381cc7cc492ed884204c1e0894b3 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 14:24:00 +0000 Subject: [PATCH 19/50] Replace TestHandler et al. with AttributeIO/Refs --- tests/assertable_controller.py | 35 ++++++++++--------- tests/conftest.py | 13 +++---- tests/transport/epics/ca/test_softioc.py | 10 +++--- .../transport/epics/ca/test_softioc_system.py | 2 ++ tests/transport/epics/pva/test_p4p.py | 4 +++ tests/transport/graphQL/test_graphql.py | 10 +++--- tests/transport/rest/test_rest.py | 10 +++--- tests/transport/tango/test_dsr.py | 10 +++--- 8 files changed, 45 insertions(+), 49 deletions(-) diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index 586fa5823..a05d57a63 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -1,46 +1,49 @@ import copy from contextlib import contextmanager +from dataclasses import dataclass from typing import Literal from pytest_mock import MockerFixture, MockType -from fastcs.attributes import AttrHandlerR, AttrHandlerRW, AttrHandlerW, AttrR +from fastcs.attribute_io import AttributeIO, SimpleAttributeIO +from fastcs.attribute_io_ref import AttributeIORef +from fastcs.attributes import AttrR, AttrW from fastcs.backend import build_controller_api from fastcs.controller import Controller from fastcs.controller_api import ControllerAPI -from fastcs.datatypes import Int +from fastcs.datatypes import Int, T from fastcs.wrappers import command, scan +# TODO: do we want to have separater ref and AttributeIO classes +# for R and RW/W attributes? -class TestUpdater(AttrHandlerR): + +@dataclass +class MyTestAttributeIORef(AttributeIORef): update_period = 1 - async def initialise(self, controller) -> None: - self.controller = controller - async def update(self, attr): +class MyTestAttributeIO(AttributeIO[T, MyTestAttributeIORef]): + async def update(self, attr: AttrR[T]): print(f"{self.controller} update {attr}") - -class TestSetter(AttrHandlerW): - async def initialise(self, controller) -> None: - self.controller = controller - - async def put(self, attr, value): + async def send(self, attr: AttrW[T], value: T): print(f"{self.controller}: {attr} = {value}") -class TestHandler(AttrHandlerRW, TestUpdater, TestSetter): - pass +test_attribute_io = MyTestAttributeIO() # instance class TestSubController(Controller): - read_int: AttrR = AttrR(Int(), handler=TestUpdater()) + read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) + + def __init__(self) -> None: + super().__init__(ios=[test_attribute_io, SimpleAttributeIO()]) class MyTestController(Controller): def __init__(self) -> None: - super().__init__() + super().__init__(ios=[test_attribute_io, SimpleAttributeIO()]) self._sub_controllers: list[TestSubController] = [] for index in range(1, 3): diff --git a/tests/conftest.py b/tests/conftest.py index c7c982afd..09903b4a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,12 +21,7 @@ from fastcs.backend import build_controller_api from fastcs.datatypes import Bool, Float, Int, String from fastcs.transport.tango.dsr import register_dev -from tests.assertable_controller import ( - MyTestController, - TestHandler, - TestSetter, - TestUpdater, -) +from tests.assertable_controller import MyTestAttributeIORef, MyTestController from tests.example_p4p_ioc import run as _run_p4p_ioc from tests.example_softioc import run as _run_softioc @@ -37,11 +32,11 @@ def clear_softioc_records(): class BackendTestController(MyTestController): - read_int: AttrR = AttrR(Int(), handler=TestUpdater()) - read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler()) + read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) + read_write_int: AttrRW = AttrRW(Int(), io_ref=MyTestAttributeIORef()) read_write_float: AttrRW = AttrRW(Float()) read_bool: AttrR = AttrR(Bool()) - write_bool: AttrW = AttrW(Bool(), handler=TestSetter()) + write_bool: AttrW = AttrW(Bool(), io_ref=MyTestAttributeIORef()) read_string: AttrRW = AttrRW(String()) diff --git a/tests/transport/epics/ca/test_softioc.py b/tests/transport/epics/ca/test_softioc.py index 8e6ef61fc..a1b22556f 100644 --- a/tests/transport/epics/ca/test_softioc.py +++ b/tests/transport/epics/ca/test_softioc.py @@ -7,10 +7,8 @@ from softioc import softioc from tests.assertable_controller import ( AssertableControllerAPI, + MyTestAttributeIORef, MyTestController, - TestHandler, - TestSetter, - TestUpdater, ) from tests.util import ColourEnum @@ -211,11 +209,11 @@ def test_get_output_record_raises(mocker: MockerFixture): class EpicsController(MyTestController): - read_int = AttrR(Int(), handler=TestUpdater()) - read_write_int = AttrRW(Int(), handler=TestHandler()) + read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) + read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), handler=TestSetter()) + write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) read_string = AttrRW(String()) enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) one_d_waveform = AttrRW(Waveform(np.int32, (10,))) diff --git a/tests/transport/epics/ca/test_softioc_system.py b/tests/transport/epics/ca/test_softioc_system.py index 1d324dab9..ba635754e 100644 --- a/tests/transport/epics/ca/test_softioc_system.py +++ b/tests/transport/epics/ca/test_softioc_system.py @@ -1,9 +1,11 @@ from multiprocessing import Queue +import pytest from p4p import Value from p4p.client.thread import Context +@pytest.mark.skip("Skipping system tests for now") def test_ioc(softioc_subprocess: tuple[str, Queue]): pv_prefix, _ = softioc_subprocess ctxt = Context("pva") diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index 5d470a7d8..a9e7bedd8 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -22,6 +22,7 @@ from fastcs.wrappers import command +@pytest.mark.skip("Skipping system tests for now") @pytest.mark.asyncio async def test_ioc(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -64,6 +65,7 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): } +@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_scan_method(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -103,6 +105,7 @@ async def test_scan_method(p4p_subprocess: tuple[str, Queue]): e_monitor.close() +@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_command_method(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -164,6 +167,7 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): j_monitor.close() +@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess diff --git a/tests/transport/graphQL/test_graphql.py b/tests/transport/graphQL/test_graphql.py index 3612d715c..d3a5133a6 100644 --- a/tests/transport/graphQL/test_graphql.py +++ b/tests/transport/graphQL/test_graphql.py @@ -8,10 +8,8 @@ from pytest_mock import MockerFixture from tests.assertable_controller import ( AssertableControllerAPI, + MyTestAttributeIORef, MyTestController, - TestHandler, - TestSetter, - TestUpdater, ) from fastcs.attributes import AttrR, AttrRW, AttrW @@ -20,11 +18,11 @@ class GraphQLController(MyTestController): - read_int = AttrR(Int(), handler=TestUpdater()) - read_write_int = AttrRW(Int(), handler=TestHandler()) + read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) + read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), handler=TestSetter()) + write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) read_string = AttrRW(String()) diff --git a/tests/transport/rest/test_rest.py b/tests/transport/rest/test_rest.py index c1f45a71e..1d67d39b5 100644 --- a/tests/transport/rest/test_rest.py +++ b/tests/transport/rest/test_rest.py @@ -7,10 +7,8 @@ from pytest_mock import MockerFixture from tests.assertable_controller import ( AssertableControllerAPI, + MyTestAttributeIORef, MyTestController, - TestHandler, - TestSetter, - TestUpdater, ) from fastcs.attributes import AttrR, AttrRW, AttrW @@ -20,11 +18,11 @@ class RestController(MyTestController): - read_int = AttrR(Int(), handler=TestUpdater()) - read_write_int = AttrRW(Int(), handler=TestHandler()) + read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) + read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), handler=TestSetter()) + write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) read_string = AttrRW(String()) enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) one_d_waveform = AttrRW(Waveform(np.int32, (10,))) diff --git a/tests/transport/tango/test_dsr.py b/tests/transport/tango/test_dsr.py index 6fc35671b..6c22e17f4 100644 --- a/tests/transport/tango/test_dsr.py +++ b/tests/transport/tango/test_dsr.py @@ -8,10 +8,8 @@ from tango.test_context import DeviceTestContext from tests.assertable_controller import ( AssertableControllerAPI, + MyTestAttributeIORef, MyTestController, - TestHandler, - TestSetter, - TestUpdater, ) from fastcs.attributes import AttrR, AttrRW, AttrW @@ -33,11 +31,11 @@ def mock_run_threadsafe_blocking(module_mocker: MockerFixture): class TangoController(MyTestController): - read_int = AttrR(Int(), handler=TestUpdater()) - read_write_int = AttrRW(Int(), handler=TestHandler()) + read_int = AttrR(Int(), io_ref=MyTestAttributeIORef()) + read_write_int = AttrRW(Int(), io_ref=MyTestAttributeIORef()) read_write_float = AttrRW(Float()) read_bool = AttrR(Bool()) - write_bool = AttrW(Bool(), handler=TestSetter()) + write_bool = AttrW(Bool(), io_ref=MyTestAttributeIORef()) read_string = AttrRW(String()) enum = AttrRW(Enum(enum.IntEnum("Enum", {"RED": 0, "GREEN": 1, "BLUE": 2}))) one_d_waveform = AttrRW(Waveform(np.int32, (10,))) From 402d21ed7f0cf7bc7a4b2128822e4e19324c09d3 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 14:39:06 +0000 Subject: [PATCH 20/50] fix attribute_io tests for now --- tests/attribute_io/test_attribute_io.py | 46 ++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/attribute_io/test_attribute_io.py b/tests/attribute_io/test_attribute_io.py index a825e9639..a36d55aff 100644 --- a/tests/attribute_io/test_attribute_io.py +++ b/tests/attribute_io/test_attribute_io.py @@ -10,7 +10,6 @@ from fastcs.datatypes import Float, Int, T -# async def test_attribute_io(mocker: MockerFixture): @pytest.mark.asyncio async def test_attribute_io(): @dataclass @@ -22,12 +21,6 @@ async def update(self, attr: AttrR[Int, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) class MyController(Controller): - # what if we hinted something like - # io_classes: MyAttributeIO | MyOtherAttributeIO - # and then we construct them in the __init__ per controller? - # I guess this is bad as we can't share between controllers - # i really just want to avoid making devs invoke super... - # or maybe that's not a big deal... my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) @@ -78,21 +71,27 @@ class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): class DemoParameterAttributeIO(AttributeIO[T, DemoParameterAttributeIORef]): async def update(self, attr: AttrR[T]): - # assume the device is always incrementing every parameter by 1 + # OK, so this doesn't really work when we have min and maxes... await attr.set(attr.get() + 1) - pass async def send(self, attr: AttrW[T], value) -> None: - # why does this not get called... - print(attr.ref.min, value) if ( - attr.ref.read_only + attr.io_ref.read_only ): # TODO, this isn't necessary as we can not call process on this anyway - raise RuntimeError(f"Could not set read only attribute {attr.ref.name}") + raise RuntimeError( + f"Could not set read only attribute {attr.io_ref.name}" + ) + + if (io_min := attr.io_ref.min) is not None and value < io_min: + raise RuntimeError( + f"Could not set {attr.io_ref.name} to {value}, " + f"min is {attr.io_ref.min}" + ) - if attr.ref.min is not None and value < min: + if (io_max := attr.io_ref.max) is not None and value > io_max: raise RuntimeError( - f"Could not set {attr.ref.name} to {value}, min is {attr.ref.min}" + f"Could not set {attr.io_ref.name} to {value}, " + f"max is {attr.io_ref.max}" ) class DemoParameterController(Controller): @@ -128,10 +127,17 @@ async def initialise(self): await c.initialise() await c.attribute_initialise() await c.ro_int_parameter.update() - assert c.ro_int_parameter.get() == 6 - # with - await c.int_parameter.process(-10) + assert c.ro_int_parameter.get() == 11 + with pytest.raises( + RuntimeError, match="Could not set int_parameter to -10, min is 0" + ): + await c.int_parameter.process(-10) + + with pytest.raises( + RuntimeError, match="Could not set int_parameter to 101, max is 100" + ): + await c.int_parameter.process(101) -def test_update_period_respected(): - raise NotImplementedError +# def test_update_period_respected(): +# raise NotImplementedError From 02e59b4691e2fcdba0dfc46b97c65b86f9e59b2f Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 14:40:27 +0000 Subject: [PATCH 21/50] unskip system tests --- tests/transport/epics/ca/test_softioc_system.py | 2 -- tests/transport/epics/pva/test_p4p.py | 4 ---- 2 files changed, 6 deletions(-) diff --git a/tests/transport/epics/ca/test_softioc_system.py b/tests/transport/epics/ca/test_softioc_system.py index ba635754e..1d324dab9 100644 --- a/tests/transport/epics/ca/test_softioc_system.py +++ b/tests/transport/epics/ca/test_softioc_system.py @@ -1,11 +1,9 @@ from multiprocessing import Queue -import pytest from p4p import Value from p4p.client.thread import Context -@pytest.mark.skip("Skipping system tests for now") def test_ioc(softioc_subprocess: tuple[str, Queue]): pv_prefix, _ = softioc_subprocess ctxt = Context("pva") diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index a9e7bedd8..5d470a7d8 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -22,7 +22,6 @@ from fastcs.wrappers import command -@pytest.mark.skip("Skipping system tests for now") @pytest.mark.asyncio async def test_ioc(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -65,7 +64,6 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): } -@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_scan_method(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -105,7 +103,6 @@ async def test_scan_method(p4p_subprocess: tuple[str, Queue]): e_monitor.close() -@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_command_method(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess @@ -167,7 +164,6 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): j_monitor.close() -@pytest.mark.skip("Skipping system tests") @pytest.mark.asyncio async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess From e715f143119c12ef63b9cad2181aef56970e43d6 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 22 Sep 2025 14:49:12 +0000 Subject: [PATCH 22/50] remove unused handler from p4p tests --- tests/example_p4p_ioc.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index e4498cc23..de53fb9ad 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -3,7 +3,7 @@ import numpy as np -from fastcs.attributes import AttrHandlerW, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import Controller from fastcs.datatypes import Bool, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS @@ -14,11 +14,6 @@ from fastcs.wrappers import command, scan -class SimpleAttributeSetter(AttrHandlerW): - async def put(self, attr, value): - await attr.update_display_without_process(value) - - class FEnum(enum.Enum): A = 0 B = 1 @@ -30,7 +25,7 @@ class FEnum(enum.Enum): class ParentController(Controller): description = "some controller" a: AttrRW = AttrRW(Int(max=400_000, max_alarm=40_000)) - b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5), handler=SimpleAttributeSetter()) + b: AttrW = AttrW(Float(min=-1, min_alarm=-0.5)) table: AttrRW = AttrRW( Table([("A", np.int32), ("B", "i"), ("C", "?"), ("D", np.float64)]) @@ -39,7 +34,7 @@ class ParentController(Controller): class ChildController(Controller): fail_on_next_e = True - c: AttrW = AttrW(Int(), handler=SimpleAttributeSetter()) + c: AttrW = AttrW(Int()) @command() async def d(self): From 6c3b103eb7778416b3afc2c17cddabd78eb37e6f Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Sep 2025 07:14:08 +0000 Subject: [PATCH 23/50] update test_attribute.py --- tests/test_attribute.py | 38 +++----------------------------------- 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 4745ec7bb..cdea72446 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -4,11 +4,8 @@ from pytest_mock import MockerFixture from fastcs.attributes import ( - AttrHandlerR, - AttrHandlerRW, AttrR, AttrRW, - AttrW, ) from fastcs.datatypes import Int, String @@ -41,7 +38,7 @@ async def device_add(): @pytest.mark.asyncio -async def test_simple_handler_rw(mocker: MockerFixture): +async def test_simple_attibute_io_rw(mocker: MockerFixture): attr = AttrRW(Int()) attr.update_display_without_process = mocker.MagicMock( @@ -51,38 +48,9 @@ async def test_simple_handler_rw(mocker: MockerFixture): assert attr.sender # This is called by the transport when it receives a put - await attr.sender.put(attr, 1) + await attr.process(1) - # The Sender of the attribute should just set the value on the attribute + # SimpleAttributeIO attribute should just set the value on the attribute attr.update_display_without_process.assert_called_once_with(1) attr.set.assert_called_once_with(1) assert attr.get() == 1 - - -class SimpleUpdater(AttrHandlerR): - pass - - -@pytest.mark.asyncio -async def test_handler_initialise(mocker: MockerFixture): - handler = AttrHandlerRW() - handler_mock = mocker.patch.object(handler, "initialise") - attr = AttrR(Int(), handler=handler) - - ctrlr = mocker.Mock() - await attr.initialise(ctrlr) - - # The handler initialise method should be called from the attribute - handler_mock.assert_called_once_with(ctrlr) - - handler = AttrHandlerRW() - attr = AttrW(Int(), handler=handler) - - # Assert no error in calling initialise on the SimpleHandler default - await attr.initialise(mocker.ANY) - - handler = SimpleUpdater() - attr = AttrR(Int(), handler=handler) - - # Assert no error in calling initialise on the TestUpdater handler - await attr.initialise(mocker.ANY) From e8d771af41cd14c05c8bf8a5504fe1e8393a76c8 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Sep 2025 07:15:16 +0000 Subject: [PATCH 24/50] move test_attribute_io tests to test_attribute.py --- tests/attribute_io/test_attribute_io.py | 143 ------------------------ tests/test_attribute.py | 137 ++++++++++++++++++++++- 2 files changed, 136 insertions(+), 144 deletions(-) delete mode 100644 tests/attribute_io/test_attribute_io.py diff --git a/tests/attribute_io/test_attribute_io.py b/tests/attribute_io/test_attribute_io.py deleted file mode 100644 index a36d55aff..000000000 --- a/tests/attribute_io/test_attribute_io.py +++ /dev/null @@ -1,143 +0,0 @@ -from dataclasses import dataclass -from typing import Generic - -import pytest - -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller -from fastcs.datatypes import Float, Int, T - - -@pytest.mark.asyncio -async def test_attribute_io(): - @dataclass - class MyAttributeIORef(AttributeIORef): - cool: int - - class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - async def update(self, attr: AttrR[Int, MyAttributeIORef]): - print("I am updating", self.ref_type, attr.io_ref.cool) - - class MyController(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) - - def __init__(self): - super().__init__(ios=[MyAttributeIO()]) - - c = MyController() - - class ControllerNoIO(Controller): - my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) - - with pytest.raises(AssertionError, match="does not have an AttributeIO"): - ControllerNoIO() - # TODO, is it okay that we need to initialise the controller - # before the callbacks get assigned? - await c.initialise() - await c.attribute_initialise() - await c.my_attr.update() - - -@pytest.mark.asyncio() -async def test_dynamic_attribute_io_specification(): - example_introspection_response = [ - { - "name": "int_parameter", - "dtype": "int", - "min": 0, - "max": 100, - "value": 5, - "read_only": False, - }, - {"name": "ro_int_parameter", "dtype": "int", "value": 10, "read_only": True}, - { - "name": "float_parameter", - "dtype": "float", - "max": 1000.0, - "value": 7.5, - "read_only": False, - }, - ] - - @dataclass - class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): - name: str - min: T | None = None - max: T | None = None - read_only: bool = False - - class DemoParameterAttributeIO(AttributeIO[T, DemoParameterAttributeIORef]): - async def update(self, attr: AttrR[T]): - # OK, so this doesn't really work when we have min and maxes... - await attr.set(attr.get() + 1) - - async def send(self, attr: AttrW[T], value) -> None: - if ( - attr.io_ref.read_only - ): # TODO, this isn't necessary as we can not call process on this anyway - raise RuntimeError( - f"Could not set read only attribute {attr.io_ref.name}" - ) - - if (io_min := attr.io_ref.min) is not None and value < io_min: - raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - f"min is {attr.io_ref.min}" - ) - - if (io_max := attr.io_ref.max) is not None and value > io_max: - raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - f"max is {attr.io_ref.max}" - ) - - class DemoParameterController(Controller): - async def initialise(self): - dtype_mapping = {"int": Int(), "float": Float()} - for parameter_response in example_introspection_response: - try: - ro = parameter_response["read_only"] - ref = DemoParameterAttributeIORef( - name=parameter_response["name"], - min=parameter_response.get("min", None), - max=parameter_response.get("max", None), - read_only=ro, - ) - attr_class = AttrR if ro else AttrRW - attr = attr_class( - datatype=dtype_mapping[parameter_response["dtype"]], - io_ref=ref, - initial_value=parameter_response.get("value", None), - ) - - self.attributes[ref.name] = attr - setattr(self, ref.name, attr) - - except Exception as e: - print( - "Exception constructing attribute from parameter response:", - parameter_response, - e, - ) - - c = DemoParameterController(ios=[DemoParameterAttributeIO()]) - await c.initialise() - await c.attribute_initialise() - await c.ro_int_parameter.update() - assert c.ro_int_parameter.get() == 11 - with pytest.raises( - RuntimeError, match="Could not set int_parameter to -10, min is 0" - ): - await c.int_parameter.process(-10) - - with pytest.raises( - RuntimeError, match="Could not set int_parameter to 101, max is 100" - ): - await c.int_parameter.process(101) - - -# def test_update_period_respected(): -# raise NotImplementedError diff --git a/tests/test_attribute.py b/tests/test_attribute.py index cdea72446..513a0701c 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,13 +1,19 @@ +from dataclasses import dataclass from functools import partial +from typing import Generic import pytest from pytest_mock import MockerFixture +from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORef from fastcs.attributes import ( AttrR, AttrRW, + AttrW, ) -from fastcs.datatypes import Int, String +from fastcs.controller import Controller +from fastcs.datatypes import Float, Int, String, T @pytest.mark.asyncio @@ -54,3 +60,132 @@ async def test_simple_attibute_io_rw(mocker: MockerFixture): attr.update_display_without_process.assert_called_once_with(1) attr.set.assert_called_once_with(1) assert attr.get() == 1 + + +@pytest.mark.asyncio +async def test_attribute_io(): + @dataclass + class MyAttributeIORef(AttributeIORef): + cool: int + + class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): + async def update(self, attr: AttrR[Int, MyAttributeIORef]): + print("I am updating", self.ref_type, attr.io_ref.cool) + + class MyController(Controller): + my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) + your_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=10)) + + def __init__(self): + super().__init__(ios=[MyAttributeIO()]) + + c = MyController() + + class ControllerNoIO(Controller): + my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) + + with pytest.raises(AssertionError, match="does not have an AttributeIO"): + ControllerNoIO() + # TODO, is it okay that we need to initialise the controller + # before the callbacks get assigned? + await c.initialise() + await c.attribute_initialise() + await c.my_attr.update() + + +@pytest.mark.asyncio() +async def test_dynamic_attribute_io_specification(): + example_introspection_response = [ + { + "name": "int_parameter", + "dtype": "int", + "min": 0, + "max": 100, + "value": 5, + "read_only": False, + }, + {"name": "ro_int_parameter", "dtype": "int", "value": 10, "read_only": True}, + { + "name": "float_parameter", + "dtype": "float", + "max": 1000.0, + "value": 7.5, + "read_only": False, + }, + ] + + @dataclass + class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): + name: str + min: T | None = None + max: T | None = None + read_only: bool = False + + class DemoParameterAttributeIO(AttributeIO[T, DemoParameterAttributeIORef]): + async def update(self, attr: AttrR[T]): + # OK, so this doesn't really work when we have min and maxes... + await attr.set(attr.get() + 1) + + async def send(self, attr: AttrW[T], value) -> None: + if ( + attr.io_ref.read_only + ): # TODO, this isn't necessary as we can not call process on this anyway + raise RuntimeError( + f"Could not set read only attribute {attr.io_ref.name}" + ) + + if (io_min := attr.io_ref.min) is not None and value < io_min: + raise RuntimeError( + f"Could not set {attr.io_ref.name} to {value}, " + f"min is {attr.io_ref.min}" + ) + + if (io_max := attr.io_ref.max) is not None and value > io_max: + raise RuntimeError( + f"Could not set {attr.io_ref.name} to {value}, " + f"max is {attr.io_ref.max}" + ) + + class DemoParameterController(Controller): + async def initialise(self): + dtype_mapping = {"int": Int(), "float": Float()} + for parameter_response in example_introspection_response: + try: + ro = parameter_response["read_only"] + ref = DemoParameterAttributeIORef( + name=parameter_response["name"], + min=parameter_response.get("min", None), + max=parameter_response.get("max", None), + read_only=ro, + ) + attr_class = AttrR if ro else AttrRW + attr = attr_class( + datatype=dtype_mapping[parameter_response["dtype"]], + io_ref=ref, + initial_value=parameter_response.get("value", None), + ) + + self.attributes[ref.name] = attr + setattr(self, ref.name, attr) + + except Exception as e: + print( + "Exception constructing attribute from parameter response:", + parameter_response, + e, + ) + + c = DemoParameterController(ios=[DemoParameterAttributeIO()]) + await c.initialise() + await c.attribute_initialise() + await c.ro_int_parameter.update() + assert c.ro_int_parameter.get() == 11 + with pytest.raises( + RuntimeError, match="Could not set int_parameter to -10, min is 0" + ): + await c.int_parameter.process(-10) + + with pytest.raises( + RuntimeError, match="Could not set int_parameter to 101, max is 100" + ): + await c.int_parameter.process(101) From ace409ff273fbf5ca4fd5adfac1cf27ac0d29ec7 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Sep 2025 07:20:38 +0000 Subject: [PATCH 25/50] remove Handler* classes (breaks docs tests) --- src/fastcs/attributes.py | 67 ++-------------------------------------- tests/test_attribute.py | 1 - 2 files changed, 3 insertions(+), 65 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 336060a4c..5010ad2a7 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Callable, Coroutine from enum import Enum -from typing import Any, Generic, Self +from typing import Generic, Self import fastcs from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT @@ -22,47 +22,6 @@ class AttrMode(Enum): READ_WRITE = 3 -class _BaseAttrHandler: - async def initialise(self, controller: fastcs.controller.BaseController) -> None: - pass - - -class AttrHandlerW(_BaseAttrHandler): - """Protocol for setting the value of an ``Attribute``.""" - - async def put(self, attr: AttrW[T], value: T) -> None: - pass - - -class AttrHandlerR(_BaseAttrHandler): - """Protocol for updating the cached readback value of an ``Attribute``.""" - - # If update period is None then the attribute will not be updated as a task. - update_period: float | None = None - - async def update(self, attr: AttrR[T]) -> None: - pass - - -class AttrHandlerRW(AttrHandlerR, AttrHandlerW): - """Protocol encapsulating both ``AttrHandlerR`` and ``AttHandlerW``.""" - - pass - - -class SimpleAttrHandler(AttrHandlerRW): - """Handler for internal parameters""" - - async def put(self, attr: AttrW[T], value: T) -> None: - await attr.update_display_without_process(value) - - if isinstance(attr, AttrRW): - await attr.set(value) - - async def update(self, attr: AttrR) -> None: - raise RuntimeError("SimpleHandler cannot update") - - class Attribute(Generic[T, AttributeIORefT]): """Base FastCS attribute. @@ -75,7 +34,6 @@ def __init__( access_mode: AttrMode, io_ref: AttributeIORefT | None = None, group: str | None = None, - handler: Any = None, description: str | None = None, ) -> None: assert issubclass(datatype.dtype, ATTRIBUTE_TYPES), ( @@ -86,7 +44,6 @@ def __init__( self._datatype: DataType[T] = datatype self._access_mode: AttrMode = access_mode self._group = group - self._handler = handler self.enabled = True self.description = description @@ -111,8 +68,7 @@ def group(self) -> str | None: return self._group async def initialise(self, controller: fastcs.controller.BaseController) -> None: - if self._handler is not None: - await self._handler.initialise(controller) + pass def add_update_datatype_callback( self, callback: Callable[[DataType[T]], None] @@ -138,7 +94,6 @@ def __init__( access_mode=AttrMode.READ, io_ref: AttributeIORefT | None = None, group: str | None = None, - handler: AttrHandlerR | None = None, initial_value: T | None = None, description: str | None = None, ) -> None: @@ -147,7 +102,6 @@ def __init__( access_mode, io_ref, group, - handler, description=description, ) self._value: T = ( @@ -156,8 +110,6 @@ def __init__( self._on_set_callbacks: list[AttrCallback[T]] | None = None self._on_update_callbacks: list[AttrCallback[T]] | None = None - self._updater = handler - def get(self) -> T: return self._value @@ -179,10 +131,6 @@ def add_update_callback( self._on_update_callbacks = [] self._on_update_callbacks.append(callback) - @property - def updater(self) -> AttrHandlerR | None: - return self._updater - async def update(self): if self._on_update_callbacks is not None: await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) @@ -197,7 +145,6 @@ def __init__( access_mode=AttrMode.WRITE, io_ref: AttributeIORefT | None = None, group: str | None = None, - handler: AttrHandlerW | None = None, description: str | None = None, ) -> None: super().__init__( @@ -205,12 +152,10 @@ def __init__( access_mode, io_ref, group, - handler, description=description, ) self._process_callbacks: list[AttrCallback[T]] | None = None self._write_display_callbacks: list[AttrCallback[T]] | None = None - self._setter = handler async def process(self, value: T) -> None: await self.process_without_display_update(value) @@ -239,12 +184,8 @@ def add_write_display_callback(self, callback: AttrCallback[T]) -> None: self._write_display_callbacks = [] self._write_display_callbacks.append(callback) - @property - def sender(self) -> AttrHandlerW | None: - return self._setter - async def put(self, value): - await self.sender.put(self, value) + await self.io_ref.send(self, value) class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): @@ -256,7 +197,6 @@ def __init__( access_mode=AttrMode.READ_WRITE, io_ref: AttributeIORefT | None = None, group: str | None = None, - handler: AttrHandlerRW | None = None, initial_value: T | None = None, description: str | None = None, ) -> None: @@ -265,7 +205,6 @@ def __init__( access_mode, io_ref, group=group, - handler=handler if handler else SimpleAttrHandler(), initial_value=initial_value, description=description, ) diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 513a0701c..adbfff2e8 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -52,7 +52,6 @@ async def test_simple_attibute_io_rw(mocker: MockerFixture): ) attr.set = mocker.MagicMock(wraps=attr.set) - assert attr.sender # This is called by the transport when it receives a put await attr.process(1) From 7d95f0c6585da5b51181727fff1d8dc88fca57f5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Sep 2025 07:53:19 +0000 Subject: [PATCH 26/50] update demo controller to use AttributeIO/Ref --- src/fastcs/demo/controllers.py | 67 ++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 953f78980..937a116f2 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -4,12 +4,13 @@ import enum import json from dataclasses import dataclass -from typing import Any -from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORef +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings from fastcs.controller import BaseController, Controller -from fastcs.datatypes import Enum, Float, Int +from fastcs.datatypes import Enum, Float, Int, T from fastcs.wrappers import command, scan @@ -24,31 +25,27 @@ class TemperatureControllerSettings: ip_settings: IPConnectionSettings -@dataclass -class TemperatureControllerHandler(AttrHandlerRW): +@dataclass(kw_only=True) +class TemperatureControllerAttributeIORef(AttributeIORef): name: str update_period: float | None = 0.2 - _controller: TemperatureController | TemperatureRampController | None = None - - async def initialise(self, controller: BaseController): - assert isinstance(controller, TemperatureController | TemperatureRampController) - self._controller = controller - @property - def controller(self) -> TemperatureController | TemperatureRampController: - if self._controller is None: - raise RuntimeError("Handler not initialised") - return self._controller +class TemperatureControllerAttributeIO( + AttributeIO[T, TemperatureControllerAttributeIORef] +): + def __init__(self, connection: IPConnection, suffix: str): + self._connection = connection + self.suffix = suffix - async def put(self, attr: AttrW, value: Any) -> None: - await self.controller.connection.send_command( - f"{self.name}{self.controller.suffix}={attr.dtype(value)}\r\n" + async def send(self, attr: AttrW[T], value: T) -> None: + await self._connection.send_command( + f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}\r\n" ) - async def update(self, attr: AttrR) -> None: + async def update(self, attr: AttrR[T]) -> None: response = await self.controller.connection.send_query( - f"{self.name}{self.controller.suffix}?\r\n" + f"{attr.io_ref.name}{self.suffix}?\r\n" ) response = response.strip("\r\n") @@ -56,15 +53,17 @@ async def update(self, attr: AttrR) -> None: class TemperatureController(Controller): - ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) - power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), io_ref=TemperatureControllerAttributeIORef(name="R")) + power = AttrR(Float(), io_ref=TemperatureControllerAttributeIORef(name="P")) def __init__(self, settings: TemperatureControllerSettings) -> None: - super().__init__() - + self.connection = IPConnection() self.suffix = "" + super().__init__( + ios=[TemperatureControllerAttributeIO(self.connection, self.suffix)] + ) + self._settings = settings - self.connection = IPConnection() self._ramp_controllers: list[TemperatureRampController] = [] for index in range(1, settings.num_ramp_controllers + 1): @@ -95,14 +94,18 @@ async def update_voltages(self): class TemperatureRampController(Controller): - start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) - end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) - enabled = AttrRW(Enum(OnOffEnum), handler=TemperatureControllerHandler("N")) - target = AttrR(Float(prec=3), handler=TemperatureControllerHandler("T")) - actual = AttrR(Float(prec=3), handler=TemperatureControllerHandler("A")) + start = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="S")) + end = AttrRW(Int(), io_ref=TemperatureControllerAttributeIORef(name="E")) + enabled = AttrRW( + Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef(name="N") + ) + target = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="T")) + actual = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="A")) voltage = AttrR(Float(prec=3)) def __init__(self, index: int, conn: IPConnection) -> None: - self.suffix = f"{index:02d}" - super().__init__(f"Ramp{self.suffix}") + suffix = f"{index:02d}" + super().__init__( + f"Ramp{self.suffix}", ios=[TemperatureControllerAttributeIO(conn, suffix)] + ) self.connection = conn From a3e1190a0bc1ce34b97865727b2fb7175d40c43f Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Sep 2025 09:40:30 +0000 Subject: [PATCH 27/50] Remove access_mode argument from Attr* signatures --- src/fastcs/attributes.py | 21 --------------------- src/fastcs/backend.py | 2 +- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 5010ad2a7..2648fe8e1 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Callable, Coroutine -from enum import Enum from typing import Generic, Self import fastcs @@ -14,14 +13,6 @@ """Special value to indicate that an attribute should be updated once on start up.""" -class AttrMode(Enum): - """Access mode of an ``Attribute``.""" - - READ = 1 - WRITE = 2 - READ_WRITE = 3 - - class Attribute(Generic[T, AttributeIORefT]): """Base FastCS attribute. @@ -31,7 +22,6 @@ class Attribute(Generic[T, AttributeIORefT]): def __init__( self, datatype: DataType[T], - access_mode: AttrMode, io_ref: AttributeIORefT | None = None, group: str | None = None, description: str | None = None, @@ -42,7 +32,6 @@ def __init__( ) self.io_ref = io_ref or AttributeIORef() self._datatype: DataType[T] = datatype - self._access_mode: AttrMode = access_mode self._group = group self.enabled = True self.description = description @@ -59,10 +48,6 @@ def datatype(self) -> DataType[T]: def dtype(self) -> type[T]: return self._datatype.dtype - @property - def access_mode(self) -> AttrMode: - return self._access_mode - @property def group(self) -> str | None: return self._group @@ -91,7 +76,6 @@ class AttrR(Attribute[T, AttributeIORefT]): def __init__( self, datatype: DataType[T], - access_mode=AttrMode.READ, io_ref: AttributeIORefT | None = None, group: str | None = None, initial_value: T | None = None, @@ -99,7 +83,6 @@ def __init__( ) -> None: super().__init__( datatype, # type: ignore - access_mode, io_ref, group, description=description, @@ -142,14 +125,12 @@ class AttrW(Attribute[T, AttributeIORefT]): def __init__( self, datatype: DataType[T], - access_mode=AttrMode.WRITE, io_ref: AttributeIORefT | None = None, group: str | None = None, description: str | None = None, ) -> None: super().__init__( datatype, # type: ignore - access_mode, io_ref, group, description=description, @@ -194,7 +175,6 @@ class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): def __init__( self, datatype: DataType[T], - access_mode=AttrMode.READ_WRITE, io_ref: AttributeIORefT | None = None, group: str | None = None, initial_value: T | None = None, @@ -202,7 +182,6 @@ def __init__( ) -> None: super().__init__( datatype, # type: ignore - access_mode, io_ref, group=group, initial_value=initial_value, diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 44edad5fb..d12404920 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -87,7 +87,7 @@ def _link_put_tasks(controller_api: ControllerAPI) -> None: attribute.add_process_callback(method.fn) case _: raise FastCSError( - f"Mode {attribute.access_mode} does not " + f"Attribute type {type(attribute)} does not " f"support put operations for {name}" ) From 1a88a8f26eb17f120794e963d773de0bf21e5f3b Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 25 Sep 2025 09:50:23 +0000 Subject: [PATCH 28/50] reorder AttributeIO generic args, do not allow it to be defaulted, do allow default for IO class for Attributes --- src/fastcs/attribute_io.py | 8 ++++---- src/fastcs/attribute_io_ref.py | 7 ++----- src/fastcs/attributes.py | 25 ++++++++++++++++--------- src/fastcs/demo/controllers.py | 10 ++++++---- tests/assertable_controller.py | 2 +- tests/test_attribute.py | 4 ++-- tests/test_backend.py | 2 +- 7 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index c5b4cdfec..75e1e626b 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -5,11 +5,11 @@ from fastcs.datatypes import T -class AttributeIO(Generic[T, AttributeIORefT]): +class AttributeIO(Generic[AttributeIORefT, T]): def __init_subclass__(cls) -> None: # sets ref_type from subclass generic args args = get_args(cls.__orig_bases__[0]) - cls.ref_type = args[1] + cls.ref_type = args[0] async def update(self, attr: AttrR[T]) -> None: raise NotImplementedError() @@ -22,7 +22,7 @@ async def send( raise NotImplementedError() -class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): +class SimpleAttributeIO(AttributeIO[AttributeIORef, T]): """IO for internal parameters""" async def send(self, attr: AttrW[T], value) -> None: @@ -35,4 +35,4 @@ async def update(self, attr: AttrR[T]) -> None: raise RuntimeError("SimpleAttributeIO can't update") -AnyAttributeIO = AttributeIO[T, AttributeIORef] +AnyAttributeIO = AttributeIO[AttributeIORef, T] diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py index 7037e6ed5..58cc7d61d 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attribute_io_ref.py @@ -1,6 +1,5 @@ from dataclasses import dataclass - -from typing_extensions import TypeVar +from typing import TypeVar @dataclass(kw_only=True) @@ -8,6 +7,4 @@ class AttributeIORef: update_period: float | None = None -AttributeIORefT = TypeVar( - "AttributeIORefT", default=AttributeIORef, bound=AttributeIORef, covariant=True -) +AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef, covariant=True) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 2648fe8e1..9d6c70b74 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -4,16 +4,23 @@ from collections.abc import Callable, Coroutine from typing import Generic, Self +from typing_extensions import TypeVar + import fastcs -from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT +from fastcs.attribute_io_ref import AttributeIORef from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T +# TODO rename this: typevar with default +AttributeIORefTD = TypeVar( + "AttributeIORefTD", bound=AttributeIORef, default=AttributeIORef, covariant=True +) + ONCE = float("inf") """Special value to indicate that an attribute should be updated once on start up.""" -class Attribute(Generic[T, AttributeIORefT]): +class Attribute(Generic[T, AttributeIORefTD]): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the backend. @@ -22,7 +29,7 @@ class Attribute(Generic[T, AttributeIORefT]): def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefT | None = None, + io_ref: AttributeIORefTD | None = None, group: str | None = None, description: str | None = None, ) -> None: @@ -70,13 +77,13 @@ def update_datatype(self, datatype: DataType[T]) -> None: callback(datatype) -class AttrR(Attribute[T, AttributeIORefT]): +class AttrR(Attribute[T, AttributeIORefTD]): """A read-only ``Attribute``.""" def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefT | None = None, + io_ref: AttributeIORefTD | None = None, group: str | None = None, initial_value: T | None = None, description: str | None = None, @@ -119,13 +126,13 @@ async def update(self): await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) -class AttrW(Attribute[T, AttributeIORefT]): +class AttrW(Attribute[T, AttributeIORefTD]): """A write-only ``Attribute``.""" def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefT | None = None, + io_ref: AttributeIORefTD | None = None, group: str | None = None, description: str | None = None, ) -> None: @@ -169,13 +176,13 @@ async def put(self, value): await self.io_ref.send(self, value) -class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): +class AttrRW(AttrR[T, AttributeIORefTD], AttrW[T, AttributeIORefTD]): """A read-write ``Attribute``.""" def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefT | None = None, + io_ref: AttributeIORefTD | None = None, group: str | None = None, initial_value: T | None = None, description: str | None = None, diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 937a116f2..7fd1befd3 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -32,19 +32,21 @@ class TemperatureControllerAttributeIORef(AttributeIORef): class TemperatureControllerAttributeIO( - AttributeIO[T, TemperatureControllerAttributeIORef] + AttributeIO[TemperatureControllerAttributeIORef, T] ): def __init__(self, connection: IPConnection, suffix: str): self._connection = connection self.suffix = suffix - async def send(self, attr: AttrW[T], value: T) -> None: + async def send( + self, attr: AttrW[T, TemperatureControllerAttributeIORef], value: T + ) -> None: await self._connection.send_command( f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}\r\n" ) - async def update(self, attr: AttrR[T]) -> None: - response = await self.controller.connection.send_query( + async def update(self, attr: AttrR[T, TemperatureControllerAttributeIORef]) -> None: + response = await self._connection.send_query( f"{attr.io_ref.name}{self.suffix}?\r\n" ) response = response.strip("\r\n") diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index a05d57a63..61e1a8b24 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -23,7 +23,7 @@ class MyTestAttributeIORef(AttributeIORef): update_period = 1 -class MyTestAttributeIO(AttributeIO[T, MyTestAttributeIORef]): +class MyTestAttributeIO(AttributeIO[MyTestAttributeIORef, T]): async def update(self, attr: AttrR[T]): print(f"{self.controller} update {attr}") diff --git a/tests/test_attribute.py b/tests/test_attribute.py index adbfff2e8..127a96258 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -67,7 +67,7 @@ async def test_attribute_io(): class MyAttributeIORef(AttributeIORef): cool: int - class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): + class MyAttributeIO(AttributeIO[MyAttributeIORef, int]): async def update(self, attr: AttrR[Int, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) @@ -120,7 +120,7 @@ class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): max: T | None = None read_only: bool = False - class DemoParameterAttributeIO(AttributeIO[T, DemoParameterAttributeIORef]): + class DemoParameterAttributeIO(AttributeIO[DemoParameterAttributeIORef, T]): async def update(self, attr: AttrR[T]): # OK, so this doesn't really work when we have min and maxes... await attr.set(attr.get() + 1) diff --git a/tests/test_backend.py b/tests/test_backend.py index 171db8f13..97cac2f00 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -101,7 +101,7 @@ class AttributeIORefTimesCalled(AttributeIORef): update_period: float | None _times_called = 0 - class AttributeIOTimesCalled(AttributeIO[T, AttributeIORefTimesCalled]): + class AttributeIOTimesCalled(AttributeIO[AttributeIORefTimesCalled, T]): async def update(self, attr): attr.io_ref._times_called += 1 await attr.set(attr.io_ref._times_called) From 821001392f527301099cfe0a143f4c1f5bbec1f6 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 25 Sep 2025 10:15:39 +0000 Subject: [PATCH 29/50] temporary: process on put --- src/fastcs/attributes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 9d6c70b74..08d530d9f 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -173,7 +173,8 @@ def add_write_display_callback(self, callback: AttrCallback[T]) -> None: self._write_display_callbacks.append(callback) async def put(self, value): - await self.io_ref.send(self, value) + # TODO, figure out if there's a difference between putting and processing... + await self.process(value) class AttrRW(AttrR[T, AttributeIORefTD], AttrW[T, AttributeIORefTD]): From 615206141b88cfee0acd0291f664f7230a5cbb63 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 25 Sep 2025 10:43:54 +0000 Subject: [PATCH 30/50] specify AttributeIORefT in attrs in attribute_io --- src/fastcs/attribute_io.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 75e1e626b..ded891ad9 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -11,12 +11,12 @@ def __init_subclass__(cls) -> None: args = get_args(cls.__orig_bases__[0]) cls.ref_type = args[0] - async def update(self, attr: AttrR[T]) -> None: + async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: raise NotImplementedError() async def send( self, - attr: AttrRW[T], + attr: AttrRW[T, AttributeIORefT], value, # TODO, type this ) -> None: raise NotImplementedError() @@ -25,13 +25,13 @@ async def send( class SimpleAttributeIO(AttributeIO[AttributeIORef, T]): """IO for internal parameters""" - async def send(self, attr: AttrW[T], value) -> None: + async def send(self, attr: AttrW[T, AttributeIORefT], value) -> None: await attr.update_display_without_process(value) if isinstance(attr, AttrRW): await attr.set(value) - async def update(self, attr: AttrR[T]) -> None: + async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: raise RuntimeError("SimpleAttributeIO can't update") From 77e5b0f558c6d4971352b479c54cad4d608a433d Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 25 Sep 2025 10:45:50 +0000 Subject: [PATCH 31/50] temperature ramp controller suffix fix --- src/fastcs/demo/controllers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 7fd1befd3..53f59b647 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -108,6 +108,6 @@ class TemperatureRampController(Controller): def __init__(self, index: int, conn: IPConnection) -> None: suffix = f"{index:02d}" super().__init__( - f"Ramp{self.suffix}", ios=[TemperatureControllerAttributeIO(conn, suffix)] + f"Ramp{suffix}", ios=[TemperatureControllerAttributeIO(conn, suffix)] ) self.connection = conn From df2513a59dbed6bdc901daf1ed6718a6f674a2f1 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 07:36:28 +0000 Subject: [PATCH 32/50] remove unused put --- src/fastcs/attributes.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 08d530d9f..c55ccd303 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -172,10 +172,6 @@ def add_write_display_callback(self, callback: AttrCallback[T]) -> None: self._write_display_callbacks = [] self._write_display_callbacks.append(callback) - async def put(self, value): - # TODO, figure out if there's a difference between putting and processing... - await self.process(value) - class AttrRW(AttrR[T, AttributeIORefTD], AttrW[T, AttributeIORefTD]): """A read-write ``Attribute``.""" From 7cdcb569974ffc3618f095bdb3b3daf81adbf665 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 08:05:23 +0000 Subject: [PATCH 33/50] weird cast to get around pyright generic args introspection --- src/fastcs/attribute_io.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index ded891ad9..025adf0f7 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -1,4 +1,4 @@ -from typing import Generic, get_args +from typing import Any, Generic, cast, get_args from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT from fastcs.attributes import AttrR, AttrRW, AttrW @@ -8,7 +8,8 @@ class AttributeIO(Generic[AttributeIORefT, T]): def __init_subclass__(cls) -> None: # sets ref_type from subclass generic args - args = get_args(cls.__orig_bases__[0]) + # from python 3.12 we can use types.get_original_bases + args = get_args(cast(Any, cls).__orig_bases__[0]) cls.ref_type = args[0] async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: From 94e479dd1ccee31879f7fbfb540e0b2f5105c606 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 10:13:29 +0000 Subject: [PATCH 34/50] Remove SimpleAttributeIO, remove to _create_*_callback functions --- src/fastcs/attribute_io.py | 15 +------------ src/fastcs/attributes.py | 18 +-------------- src/fastcs/controller.py | 41 ++++++++++++++++++++++++++-------- tests/assertable_controller.py | 6 ++--- tests/test_attribute.py | 2 +- 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 025adf0f7..6fec89956 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -1,7 +1,7 @@ from typing import Any, Generic, cast, get_args from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW from fastcs.datatypes import T @@ -23,17 +23,4 @@ async def send( raise NotImplementedError() -class SimpleAttributeIO(AttributeIO[AttributeIORef, T]): - """IO for internal parameters""" - - async def send(self, attr: AttrW[T, AttributeIORefT], value) -> None: - await attr.update_display_without_process(value) - - if isinstance(attr, AttrRW): - await attr.set(value) - - async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: - raise RuntimeError("SimpleAttributeIO can't update") - - AnyAttributeIO = AttributeIO[AttributeIORef, T] diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index c55ccd303..812ecf11d 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -37,7 +37,7 @@ def __init__( f"Attr type must be one of {ATTRIBUTE_TYPES}, " "received type {datatype.dtype}" ) - self.io_ref = io_ref or AttributeIORef() + self.io_ref = io_ref self._datatype: DataType[T] = datatype self._group = group self.enabled = True @@ -176,22 +176,6 @@ def add_write_display_callback(self, callback: AttrCallback[T]) -> None: class AttrRW(AttrR[T, AttributeIORefTD], AttrW[T, AttributeIORefTD]): """A read-write ``Attribute``.""" - def __init__( - self, - datatype: DataType[T], - io_ref: AttributeIORefTD | None = None, - group: str | None = None, - initial_value: T | None = None, - description: str | None = None, - ) -> None: - super().__init__( - datatype, # type: ignore - io_ref, - group=group, - initial_value=initial_value, - description=description, - ) - async def process(self, value: T) -> None: await self.set(value) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 4fe7ed3d3..51a814faf 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -5,8 +5,8 @@ from copy import deepcopy from typing import get_type_hints -from fastcs.attribute_io import AttributeIO, SimpleAttributeIO -from fastcs.attributes import Attribute, AttrR, AttrW +from fastcs.attribute_io import AttributeIO +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW class BaseController: @@ -36,7 +36,7 @@ def __init__( self._bind_attrs() # TODO, should validation live inside the controller? - ios = ios or [SimpleAttributeIO()] + ios = ios or [] self._attribute_ref_io_map = {io.ref_type: io for io in ios} self._validate_io() @@ -58,20 +58,41 @@ async def attribute_initialise(self) -> None: def _add_io_callbacks(self): for attr in self.attributes.values(): - io = self._attribute_ref_io_map[type(attr.io_ref)] - # is this the right access mode to own update and set? - if isinstance(attr, AttrR): - attr.add_update_callback(io.update) + io = self._attribute_ref_io_map.get(type(attr.io_ref), None) if isinstance(attr, AttrW): # is it on process or write_display? attr.add_process_callback(self._create_send_callback(io, attr)) + if attr.io_ref is None or io is None: + continue + if isinstance(attr, AttrR): + attr.add_update_callback(self._create_update_callback(io, attr)) def _create_send_callback(self, io, attr): - async def send_callback(value): - await io.send(attr, value) + if attr.io_ref is None: + + async def send_callback(value): + await attr.update_display_without_process(value) + + if isinstance(attr, AttrRW): + await attr.set(value) + + else: + + async def send_callback(value): + await io.send(attr, value) return send_callback + def _create_update_callback(self, io, attr): + if io is None or attr.io_ref is None: + + async def update_callback(attr): + raise RuntimeError("No AttributeIO registered to handle update") + + return update_callback + else: + return io.update + @property def path(self) -> list[str]: """Path prefix of attributes, recursively including parent Controllers.""" @@ -130,6 +151,8 @@ def _validate_io(self): """Validate that each Attribute has an AttributeIORef for which the controller has an associated AttributeIO class.""" for attr in self.attributes.values(): + if attr.io_ref is None: + continue assert type(attr.io_ref) in self._attribute_ref_io_map, ( f"{self.__class__.__name__} does not have an AttributeIO to handle " f"{attr.io_ref.__class__.__name__}" diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index 61e1a8b24..f82b17947 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -5,7 +5,7 @@ from pytest_mock import MockerFixture, MockType -from fastcs.attribute_io import AttributeIO, SimpleAttributeIO +from fastcs.attribute_io import AttributeIO from fastcs.attribute_io_ref import AttributeIORef from fastcs.attributes import AttrR, AttrW from fastcs.backend import build_controller_api @@ -38,12 +38,12 @@ class TestSubController(Controller): read_int: AttrR = AttrR(Int(), io_ref=MyTestAttributeIORef()) def __init__(self) -> None: - super().__init__(ios=[test_attribute_io, SimpleAttributeIO()]) + super().__init__(ios=[test_attribute_io]) class MyTestController(Controller): def __init__(self) -> None: - super().__init__(ios=[test_attribute_io, SimpleAttributeIO()]) + super().__init__(ios=[test_attribute_io]) self._sub_controllers: list[TestSubController] = [] for index in range(1, 3): diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 127a96258..1d90a63f5 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -55,7 +55,7 @@ async def test_simple_attibute_io_rw(mocker: MockerFixture): # This is called by the transport when it receives a put await attr.process(1) - # SimpleAttributeIO attribute should just set the value on the attribute + # without io/ref should just set the value on the attribute attr.update_display_without_process.assert_called_once_with(1) attr.set.assert_called_once_with(1) assert attr.get() == 1 From fa36ab29b2bf38c7d2b9f3eb108b281792cb0b75 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 10:27:05 +0000 Subject: [PATCH 35/50] skip docs tests for now --- tests/test_docs_snippets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_docs_snippets.py b/tests/test_docs_snippets.py index 3d2aaaf5c..58e0c9033 100644 --- a/tests/test_docs_snippets.py +++ b/tests/test_docs_snippets.py @@ -37,6 +37,7 @@ def sim_temperature_controller(): print(process.communicate()[0]) +@pytest.mark.skip("Skipping docs tests, this will be the last thing to fix") @pytest.mark.parametrize("filename", glob.glob("docs/snippets/*.py", recursive=True)) def test_snippet(filename): runpy.run_path(filename) From f24151d40b517e0e416e35d67b0f18fe8454e9cf Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 10:52:59 +0000 Subject: [PATCH 36/50] Add ref to update and send signatures to simplify type checking fix some test pyright errors --- src/fastcs/attribute_io.py | 5 +++- src/fastcs/attribute_io_ref.py | 2 +- src/fastcs/attributes.py | 26 ++++++++--------- src/fastcs/controller.py | 15 ++++++---- src/fastcs/datatypes.py | 3 +- src/fastcs/demo/controllers.py | 12 ++++---- tests/assertable_controller.py | 8 ++--- tests/test_attribute.py | 53 +++++++++++++++++++++------------- tests/test_backend.py | 12 ++++---- 9 files changed, 77 insertions(+), 59 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 6fec89956..0bba90535 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -12,12 +12,15 @@ def __init_subclass__(cls) -> None: args = get_args(cast(Any, cls).__orig_bases__[0]) cls.ref_type = args[0] - async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: + async def update( + self, attr: AttrR[T, AttributeIORefT], ref: AttributeIORefT + ) -> None: raise NotImplementedError() async def send( self, attr: AttrRW[T, AttributeIORefT], + ref: AttributeIORefT, value, # TODO, type this ) -> None: raise NotImplementedError() diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py index 58cc7d61d..f5e76c618 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attribute_io_ref.py @@ -7,4 +7,4 @@ class AttributeIORef: update_period: float | None = None -AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef, covariant=True) +AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 812ecf11d..8a19f01fb 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -1,15 +1,15 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from typing import Generic, Self +from collections.abc import Callable +from typing import Generic from typing_extensions import TypeVar import fastcs from fastcs.attribute_io_ref import AttributeIORef -from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T +from .datatypes import ATTRIBUTE_TYPES, AttrSetCallback, AttrUpdateCallback, DataType, T # TODO rename this: typevar with default AttributeIORefTD = TypeVar( @@ -97,8 +97,8 @@ def __init__( self._value: T = ( datatype.initial_value if initial_value is None else initial_value ) - self._on_set_callbacks: list[AttrCallback[T]] | None = None - self._on_update_callbacks: list[AttrCallback[T]] | None = None + self._on_set_callbacks: list[AttrSetCallback[T]] | None = None + self._on_update_callbacks: list[AttrUpdateCallback] | None = None def get(self) -> T: return self._value @@ -109,21 +109,19 @@ async def set(self, value: T) -> None: if self._on_set_callbacks is not None: await asyncio.gather(*[cb(self._value) for cb in self._on_set_callbacks]) - def add_set_callback(self, callback: AttrCallback[T]) -> None: + def add_set_callback(self, callback: AttrSetCallback[T]) -> None: if self._on_set_callbacks is None: self._on_set_callbacks = [] self._on_set_callbacks.append(callback) - def add_update_callback( - self, callback: Callable[[Self], Coroutine[None, None, None]] - ): + def add_update_callback(self, callback: AttrUpdateCallback): if self._on_update_callbacks is None: self._on_update_callbacks = [] self._on_update_callbacks.append(callback) async def update(self): if self._on_update_callbacks is not None: - await asyncio.gather(*[cb(self) for cb in self._on_update_callbacks]) + await asyncio.gather(*[cb() for cb in self._on_update_callbacks]) class AttrW(Attribute[T, AttributeIORefTD]): @@ -142,8 +140,8 @@ def __init__( group, description=description, ) - self._process_callbacks: list[AttrCallback[T]] | None = None - self._write_display_callbacks: list[AttrCallback[T]] | None = None + self._process_callbacks: list[AttrSetCallback[T]] | None = None + self._write_display_callbacks: list[AttrSetCallback[T]] | None = None async def process(self, value: T) -> None: await self.process_without_display_update(value) @@ -159,7 +157,7 @@ async def update_display_without_process(self, value: T) -> None: if self._write_display_callbacks: await asyncio.gather(*[cb(value) for cb in self._write_display_callbacks]) - def add_process_callback(self, callback: AttrCallback[T]) -> None: + def add_process_callback(self, callback: AttrSetCallback[T]) -> None: if self._process_callbacks is None: self._process_callbacks = [] self._process_callbacks.append(callback) @@ -167,7 +165,7 @@ def add_process_callback(self, callback: AttrCallback[T]) -> None: def has_process_callback(self) -> bool: return bool(self._process_callbacks) - def add_write_display_callback(self, callback: AttrCallback[T]) -> None: + def add_write_display_callback(self, callback: AttrSetCallback[T]) -> None: if self._write_display_callbacks is None: self._write_display_callbacks = [] self._write_display_callbacks.append(callback) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 51a814faf..22f197577 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -72,26 +72,29 @@ def _create_send_callback(self, io, attr): async def send_callback(value): await attr.update_display_without_process(value) - if isinstance(attr, AttrRW): await attr.set(value) - else: async def send_callback(value): - await io.send(attr, value) + await io.send(attr, attr.io_ref, value) + # TODO, should we just then call the above send_callback here? return send_callback def _create_update_callback(self, io, attr): if io is None or attr.io_ref is None: - async def update_callback(attr): + async def error_callback(): raise RuntimeError("No AttributeIO registered to handle update") - return update_callback + return error_callback else: - return io.update + + async def update_callback(): + await io.update(attr, attr.io_ref) + + return update_callback @property def path(self) -> list[str]: diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 498de64fe..5b41e3460 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -24,7 +24,8 @@ ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore -AttrCallback = Callable[[T], Awaitable[None]] +AttrSetCallback = Callable[[T], Awaitable[None]] +AttrUpdateCallback = Callable[[], Awaitable[None]] @dataclass(frozen=True) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 53f59b647..0391706ff 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -39,16 +39,16 @@ def __init__(self, connection: IPConnection, suffix: str): self.suffix = suffix async def send( - self, attr: AttrW[T, TemperatureControllerAttributeIORef], value: T + self, attr: AttrW, ref: TemperatureControllerAttributeIORef, value: T ) -> None: await self._connection.send_command( - f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}\r\n" + f"{ref.name}{self.suffix}={attr.dtype(value)}\r\n" ) - async def update(self, attr: AttrR[T, TemperatureControllerAttributeIORef]) -> None: - response = await self._connection.send_query( - f"{attr.io_ref.name}{self.suffix}?\r\n" - ) + async def update( + self, attr: AttrR, ref: TemperatureControllerAttributeIORef + ) -> None: + response = await self._connection.send_query(f"{ref.name}{self.suffix}?\r\n") response = response.strip("\r\n") await attr.set(attr.dtype(response)) diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index f82b17947..61e154bb9 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -24,11 +24,11 @@ class MyTestAttributeIORef(AttributeIORef): class MyTestAttributeIO(AttributeIO[MyTestAttributeIORef, T]): - async def update(self, attr: AttrR[T]): - print(f"{self.controller} update {attr}") + async def update(self, attr: AttrR[T], ref: MyTestAttributeIORef): + print(f"update {attr}") - async def send(self, attr: AttrW[T], value: T): - print(f"{self.controller}: {attr} = {value}") + async def send(self, attr: AttrW[T], ref: MyTestAttributeIORef, value: T): + print(f"sending {attr} = {value}") test_attribute_io = MyTestAttributeIO() # instance diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 1d90a63f5..0fd346497 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from functools import partial -from typing import Generic +from typing import Generic, TypeVar import pytest from pytest_mock import MockerFixture @@ -13,7 +13,9 @@ AttrW, ) from fastcs.controller import Controller -from fastcs.datatypes import Float, Int, String, T +from fastcs.datatypes import Float, Int, String + +NumberT = TypeVar("NumberT", int, float) @pytest.mark.asyncio @@ -68,8 +70,8 @@ class MyAttributeIORef(AttributeIORef): cool: int class MyAttributeIO(AttributeIO[MyAttributeIORef, int]): - async def update(self, attr: AttrR[Int, MyAttributeIORef]): - print("I am updating", self.ref_type, attr.io_ref.cool) + async def update(self, attr: AttrR, ref: MyAttributeIORef): + print("I am updating", self.ref_type, ref.cool) class MyController(Controller): my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) @@ -114,38 +116,49 @@ async def test_dynamic_attribute_io_specification(): ] @dataclass - class DemoParameterAttributeIORef(AttributeIORef, Generic[T]): + class DemoParameterAttributeIORef(AttributeIORef, Generic[NumberT]): name: str - min: T | None = None - max: T | None = None + # TODO, this is weird, we should just use the attributes's min and max fields + min: NumberT | None = None + max: NumberT | None = None read_only: bool = False - class DemoParameterAttributeIO(AttributeIO[DemoParameterAttributeIORef, T]): - async def update(self, attr: AttrR[T]): + class DemoParameterAttributeIO(AttributeIO[DemoParameterAttributeIORef, NumberT]): + async def update( + self, + attr: AttrR[NumberT], + ref: DemoParameterAttributeIORef, + ): # OK, so this doesn't really work when we have min and maxes... await attr.set(attr.get() + 1) - async def send(self, attr: AttrW[T], value) -> None: + async def send( + self, + attr: AttrW[NumberT, DemoParameterAttributeIORef], + ref: DemoParameterAttributeIORef, + value: NumberT, + ) -> None: if ( - attr.io_ref.read_only + ref.read_only ): # TODO, this isn't necessary as we can not call process on this anyway - raise RuntimeError( - f"Could not set read only attribute {attr.io_ref.name}" - ) + raise RuntimeError(f"Could not set read only attribute {ref.name}") - if (io_min := attr.io_ref.min) is not None and value < io_min: + if (io_min := ref.min) is not None and value < io_min: raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - f"min is {attr.io_ref.min}" + f"Could not set {ref.name} to {value}, min is {ref.min}" ) - if (io_max := attr.io_ref.max) is not None and value > io_max: + if (io_max := ref.max) is not None and value > io_max: raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - f"max is {attr.io_ref.max}" + f"Could not set {ref.name} to {value}, max is {ref.max}" ) + # TODO: we should always end send with a update_display_without_process... class DemoParameterController(Controller): + ro_int_parameter: AttrR + int_parameter: AttrRW + float_parameter: AttrRW # hint to satisfy pyright + async def initialise(self): dtype_mapping = {"int": Int(), "float": Float()} for parameter_response in example_introspection_response: diff --git a/tests/test_backend.py b/tests/test_backend.py index 97cac2f00..e22a7e473 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -7,7 +7,7 @@ from fastcs.backend import Backend, build_controller_api from fastcs.controller import Controller from fastcs.cs_methods import Command -from fastcs.datatypes import Int, T +from fastcs.datatypes import Int from fastcs.exceptions import FastCSError from fastcs.wrappers import command, scan @@ -98,13 +98,13 @@ async def test_wrapper(): def test_update_periods(): @dataclass class AttributeIORefTimesCalled(AttributeIORef): - update_period: float | None + update_period: float | None = None _times_called = 0 - class AttributeIOTimesCalled(AttributeIO[AttributeIORefTimesCalled, T]): - async def update(self, attr): - attr.io_ref._times_called += 1 - await attr.set(attr.io_ref._times_called) + class AttributeIOTimesCalled(AttributeIO[AttributeIORefTimesCalled, int]): + async def update(self, attr: AttrR[int], ref: AttributeIORefTimesCalled): + ref._times_called += 1 + await attr.set(ref._times_called) class MyController(Controller): update_once = AttrR(Int(), io_ref=AttributeIORefTimesCalled(update_period=ONCE)) From 7581bba6f46f42dca0b9746f3e830d5bfd03887c Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 13:45:34 +0000 Subject: [PATCH 37/50] Use T as value type hint in AttributeIO --- src/fastcs/attribute_io.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 0bba90535..4738d3db1 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -18,10 +18,7 @@ async def update( raise NotImplementedError() async def send( - self, - attr: AttrRW[T, AttributeIORefT], - ref: AttributeIORefT, - value, # TODO, type this + self, attr: AttrRW[T, AttributeIORefT], ref: AttributeIORefT, value: T ) -> None: raise NotImplementedError() From 45fde038aa7063896565ae576f9baf9437e6ae84 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 26 Sep 2025 14:30:29 +0000 Subject: [PATCH 38/50] Get around having to register ref to AtttributeIO method signatures --- src/fastcs/attribute_io.py | 8 ++------ src/fastcs/attributes.py | 11 ++++++++++- src/fastcs/backend.py | 4 +++- src/fastcs/controller.py | 23 +++++++++++------------ src/fastcs/demo/controllers.py | 19 +++++++++++++------ tests/assertable_controller.py | 4 ++-- tests/test_attribute.py | 24 +++++++++++++----------- tests/test_backend.py | 6 +++--- 8 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 4738d3db1..a431d7cd4 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -12,14 +12,10 @@ def __init_subclass__(cls) -> None: args = get_args(cast(Any, cls).__orig_bases__[0]) cls.ref_type = args[0] - async def update( - self, attr: AttrR[T, AttributeIORefT], ref: AttributeIORefT - ) -> None: + async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: raise NotImplementedError() - async def send( - self, attr: AttrRW[T, AttributeIORefT], ref: AttributeIORefT, value: T - ) -> None: + async def send(self, attr: AttrRW[T, AttributeIORefT], value: T) -> None: raise NotImplementedError() diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 8a19f01fb..27fd217d2 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -37,7 +37,7 @@ def __init__( f"Attr type must be one of {ATTRIBUTE_TYPES}, " "received type {datatype.dtype}" ) - self.io_ref = io_ref + self._io_ref = io_ref self._datatype: DataType[T] = datatype self._group = group self.enabled = True @@ -47,6 +47,15 @@ def __init__( # changing the units on an int. This should be implemented in the backend. self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] + @property + def io_ref(self) -> AttributeIORefTD: + if self._io_ref is None: + raise RuntimeError(f"{self} has no AttributeIORef") + return self._io_ref + + def has_io_ref(self): + return self._io_ref is not None + @property def datatype(self) -> DataType[T]: return self._datatype diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index d12404920..788510f99 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -120,7 +120,9 @@ def _add_attribute_updater_tasks( ): for attribute in controller_api.attributes.values(): match attribute: - case AttrR(io_ref=AttributeIORef(update_period=update_period)) as attribute: + case ( + AttrR(_io_ref=AttributeIORef(update_period=update_period)) as attribute + ): callback = _create_updater_callback(attribute) if update_period is ONCE: initial_coros.append(callback) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 22f197577..7d86fd799 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -58,17 +58,16 @@ async def attribute_initialise(self) -> None: def _add_io_callbacks(self): for attr in self.attributes.values(): - io = self._attribute_ref_io_map.get(type(attr.io_ref), None) + ref = attr.io_ref if attr.has_io_ref() else None + io = self._attribute_ref_io_map.get(type(ref), None) if isinstance(attr, AttrW): # is it on process or write_display? - attr.add_process_callback(self._create_send_callback(io, attr)) - if attr.io_ref is None or io is None: - continue + attr.add_process_callback(self._create_send_callback(io, attr, ref)) if isinstance(attr, AttrR): - attr.add_update_callback(self._create_update_callback(io, attr)) + attr.add_update_callback(self._create_update_callback(io, attr, ref)) - def _create_send_callback(self, io, attr): - if attr.io_ref is None: + def _create_send_callback(self, io, attr, ref): + if ref is None: async def send_callback(value): await attr.update_display_without_process(value) @@ -77,13 +76,13 @@ async def send_callback(value): else: async def send_callback(value): - await io.send(attr, attr.io_ref, value) + await io.send(attr, value) # TODO, should we just then call the above send_callback here? return send_callback - def _create_update_callback(self, io, attr): - if io is None or attr.io_ref is None: + def _create_update_callback(self, io, attr, ref): + if io is None or ref is None: async def error_callback(): raise RuntimeError("No AttributeIO registered to handle update") @@ -92,7 +91,7 @@ async def error_callback(): else: async def update_callback(): - await io.update(attr, attr.io_ref) + await io.update(attr) return update_callback @@ -154,7 +153,7 @@ def _validate_io(self): """Validate that each Attribute has an AttributeIORef for which the controller has an associated AttributeIO class.""" for attr in self.attributes.values(): - if attr.io_ref is None: + if not attr.has_io_ref(): continue assert type(attr.io_ref) in self._attribute_ref_io_map, ( f"{self.__class__.__name__} does not have an AttributeIO to handle " diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 0391706ff..ab1b2213c 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -4,6 +4,7 @@ import enum import json from dataclasses import dataclass +from typing import TypeVar from fastcs.attribute_io import AttributeIO from fastcs.attribute_io_ref import AttributeIORef @@ -13,6 +14,8 @@ from fastcs.datatypes import Enum, Float, Int, T from fastcs.wrappers import command, scan +NumberT = TypeVar("NumberT", int, float) + class OnOffEnum(enum.StrEnum): Off = "0" @@ -32,23 +35,25 @@ class TemperatureControllerAttributeIORef(AttributeIORef): class TemperatureControllerAttributeIO( - AttributeIO[TemperatureControllerAttributeIORef, T] + AttributeIO[TemperatureControllerAttributeIORef, NumberT] ): def __init__(self, connection: IPConnection, suffix: str): self._connection = connection self.suffix = suffix async def send( - self, attr: AttrW, ref: TemperatureControllerAttributeIORef, value: T + self, attr: AttrW[NumberT, TemperatureControllerAttributeIORef], value: NumberT ) -> None: await self._connection.send_command( - f"{ref.name}{self.suffix}={attr.dtype(value)}\r\n" + f"{attr.io_ref.name}{self.suffix}={attr.dtype(value)}\r\n" ) async def update( - self, attr: AttrR, ref: TemperatureControllerAttributeIORef + self, attr: AttrR[NumberT, TemperatureControllerAttributeIORef] ) -> None: - response = await self._connection.send_query(f"{ref.name}{self.suffix}?\r\n") + response = await self._connection.send_query( + f"{attr.io_ref.name}{self.suffix}?\r\n" + ) response = response.strip("\r\n") await attr.set(attr.dtype(response)) @@ -101,7 +106,9 @@ class TemperatureRampController(Controller): enabled = AttrRW( Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef(name="N") ) - target = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="T")) + target = AttrR( + Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="NumberT") + ) actual = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="A")) voltage = AttrR(Float(prec=3)) diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index 61e154bb9..e9ee7cfc2 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -24,10 +24,10 @@ class MyTestAttributeIORef(AttributeIORef): class MyTestAttributeIO(AttributeIO[MyTestAttributeIORef, T]): - async def update(self, attr: AttrR[T], ref: MyTestAttributeIORef): + async def update(self, attr: AttrR[T]): print(f"update {attr}") - async def send(self, attr: AttrW[T], ref: MyTestAttributeIORef, value: T): + async def send(self, attr: AttrW[T], value: T): print(f"sending {attr} = {value}") diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 0fd346497..bb21a6839 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -13,7 +13,7 @@ AttrW, ) from fastcs.controller import Controller -from fastcs.datatypes import Float, Int, String +from fastcs.datatypes import Float, Int, String, T NumberT = TypeVar("NumberT", int, float) @@ -70,8 +70,8 @@ class MyAttributeIORef(AttributeIORef): cool: int class MyAttributeIO(AttributeIO[MyAttributeIORef, int]): - async def update(self, attr: AttrR, ref: MyAttributeIORef): - print("I am updating", self.ref_type, ref.cool) + async def update(self, attr: AttrR[T, MyAttributeIORef]): + print("I am updating", self.ref_type, attr.io_ref.cool) class MyController(Controller): my_attr = AttrR(Int(), io_ref=MyAttributeIORef(cool=5)) @@ -127,7 +127,6 @@ class DemoParameterAttributeIO(AttributeIO[DemoParameterAttributeIORef, NumberT] async def update( self, attr: AttrR[NumberT], - ref: DemoParameterAttributeIORef, ): # OK, so this doesn't really work when we have min and maxes... await attr.set(attr.get() + 1) @@ -135,22 +134,25 @@ async def update( async def send( self, attr: AttrW[NumberT, DemoParameterAttributeIORef], - ref: DemoParameterAttributeIORef, value: NumberT, ) -> None: if ( - ref.read_only + attr.io_ref.read_only ): # TODO, this isn't necessary as we can not call process on this anyway - raise RuntimeError(f"Could not set read only attribute {ref.name}") + raise RuntimeError( + f"Could not set read only attribute {attr.io_ref.name}" + ) - if (io_min := ref.min) is not None and value < io_min: + if (io_min := attr.io_ref.min) is not None and value < io_min: raise RuntimeError( - f"Could not set {ref.name} to {value}, min is {ref.min}" + f"Could not set {attr.io_ref.name} to {value}, " + "min is {attr.io_ref.min}" ) - if (io_max := ref.max) is not None and value > io_max: + if (io_max := attr.io_ref.max) is not None and value > io_max: raise RuntimeError( - f"Could not set {ref.name} to {value}, max is {ref.max}" + f"Could not set {attr.io_ref.name} to {value}, " + f"max is {attr.io_ref.max}" ) # TODO: we should always end send with a update_display_without_process... diff --git a/tests/test_backend.py b/tests/test_backend.py index e22a7e473..7a40b2a59 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -102,9 +102,9 @@ class AttributeIORefTimesCalled(AttributeIORef): _times_called = 0 class AttributeIOTimesCalled(AttributeIO[AttributeIORefTimesCalled, int]): - async def update(self, attr: AttrR[int], ref: AttributeIORefTimesCalled): - ref._times_called += 1 - await attr.set(ref._times_called) + async def update(self, attr: AttrR[int, AttributeIORefTimesCalled]): + attr.io_ref._times_called += 1 + await attr.set(attr.io_ref._times_called) class MyController(Controller): update_once = AttrR(Int(), io_ref=AttributeIORefTimesCalled(update_period=ONCE)) From c181792ed75a42f73be2837173b23a0a7aaf8516 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 29 Sep 2025 08:04:45 +0000 Subject: [PATCH 39/50] Reorder generic args of AttributeIO and simplify defaulted TypeVars --- src/fastcs/attribute_io.py | 6 +++--- src/fastcs/attribute_io_ref.py | 7 +++++-- src/fastcs/attributes.py | 25 +++++++++---------------- src/fastcs/controller.py | 8 ++++++-- src/fastcs/demo/controllers.py | 2 +- tests/assertable_controller.py | 6 +++--- tests/test_attribute.py | 12 +++++------- tests/test_backend.py | 2 +- 8 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index a431d7cd4..0812497eb 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -5,12 +5,12 @@ from fastcs.datatypes import T -class AttributeIO(Generic[AttributeIORefT, T]): +class AttributeIO(Generic[T, AttributeIORefT]): def __init_subclass__(cls) -> None: # sets ref_type from subclass generic args # from python 3.12 we can use types.get_original_bases args = get_args(cast(Any, cls).__orig_bases__[0]) - cls.ref_type = args[0] + cls.ref_type = args[1] async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: raise NotImplementedError() @@ -19,4 +19,4 @@ async def send(self, attr: AttrRW[T, AttributeIORefT], value: T) -> None: raise NotImplementedError() -AnyAttributeIO = AttributeIO[AttributeIORef, T] +AnyAttributeIO = AttributeIO[T, AttributeIORef] diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attribute_io_ref.py index f5e76c618..966ef0c49 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attribute_io_ref.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import TypeVar + +from typing_extensions import TypeVar @dataclass(kw_only=True) @@ -7,4 +8,6 @@ class AttributeIORef: update_period: float | None = None -AttributeIORefT = TypeVar("AttributeIORefT", bound=AttributeIORef) +AttributeIORefT = TypeVar( + "AttributeIORefT", bound=AttributeIORef, default=AttributeIORef +) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 27fd217d2..32fe533e9 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -4,23 +4,16 @@ from collections.abc import Callable from typing import Generic -from typing_extensions import TypeVar - import fastcs -from fastcs.attribute_io_ref import AttributeIORef +from .attribute_io_ref import AttributeIORefT from .datatypes import ATTRIBUTE_TYPES, AttrSetCallback, AttrUpdateCallback, DataType, T -# TODO rename this: typevar with default -AttributeIORefTD = TypeVar( - "AttributeIORefTD", bound=AttributeIORef, default=AttributeIORef, covariant=True -) - ONCE = float("inf") """Special value to indicate that an attribute should be updated once on start up.""" -class Attribute(Generic[T, AttributeIORefTD]): +class Attribute(Generic[T, AttributeIORefT]): """Base FastCS attribute. Instances of this class added to a ``Controller`` will be used by the backend. @@ -29,7 +22,7 @@ class Attribute(Generic[T, AttributeIORefTD]): def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefTD | None = None, + io_ref: AttributeIORefT | None = None, group: str | None = None, description: str | None = None, ) -> None: @@ -48,7 +41,7 @@ def __init__( self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] @property - def io_ref(self) -> AttributeIORefTD: + def io_ref(self) -> AttributeIORefT: if self._io_ref is None: raise RuntimeError(f"{self} has no AttributeIORef") return self._io_ref @@ -86,13 +79,13 @@ def update_datatype(self, datatype: DataType[T]) -> None: callback(datatype) -class AttrR(Attribute[T, AttributeIORefTD]): +class AttrR(Attribute[T, AttributeIORefT]): """A read-only ``Attribute``.""" def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefTD | None = None, + io_ref: AttributeIORefT | None = None, group: str | None = None, initial_value: T | None = None, description: str | None = None, @@ -133,13 +126,13 @@ async def update(self): await asyncio.gather(*[cb() for cb in self._on_update_callbacks]) -class AttrW(Attribute[T, AttributeIORefTD]): +class AttrW(Attribute[T, AttributeIORefT]): """A write-only ``Attribute``.""" def __init__( self, datatype: DataType[T], - io_ref: AttributeIORefTD | None = None, + io_ref: AttributeIORefT | None = None, group: str | None = None, description: str | None = None, ) -> None: @@ -180,7 +173,7 @@ def add_write_display_callback(self, callback: AttrSetCallback[T]) -> None: self._write_display_callbacks.append(callback) -class AttrRW(AttrR[T, AttributeIORefTD], AttrW[T, AttributeIORefTD]): +class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): """A read-write ``Attribute``.""" async def process(self, value: T) -> None: diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 7d86fd799..41c5e3c29 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -6,7 +6,9 @@ from typing import get_type_hints from fastcs.attribute_io import AttributeIO +from fastcs.attribute_io_ref import AttributeIORefT from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.datatypes import T class BaseController: @@ -21,7 +23,7 @@ def __init__( self, path: list[str] | None = None, description: str | None = None, - ios: Sequence[AttributeIO] | None = None, + ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, ) -> None: if ( description is not None @@ -194,7 +196,9 @@ class Controller(BaseController): root_attribute: Attribute | None = None def __init__( - self, description: str | None = None, ios: Sequence[AttributeIO] | None = None + self, + description: str | None = None, + ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, ) -> None: super().__init__(description=description, ios=ios) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index ab1b2213c..9aee23d3e 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -35,7 +35,7 @@ class TemperatureControllerAttributeIORef(AttributeIORef): class TemperatureControllerAttributeIO( - AttributeIO[TemperatureControllerAttributeIORef, NumberT] + AttributeIO[NumberT, TemperatureControllerAttributeIORef] ): def __init__(self, connection: IPConnection, suffix: str): self._connection = connection diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index e9ee7cfc2..6acbaf2c4 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -23,11 +23,11 @@ class MyTestAttributeIORef(AttributeIORef): update_period = 1 -class MyTestAttributeIO(AttributeIO[MyTestAttributeIORef, T]): - async def update(self, attr: AttrR[T]): +class MyTestAttributeIO(AttributeIO[T, MyTestAttributeIORef]): + async def update(self, attr: AttrR[T, MyTestAttributeIORef]): print(f"update {attr}") - async def send(self, attr: AttrW[T], value: T): + async def send(self, attr: AttrW[T, MyTestAttributeIORef], value: T): print(f"sending {attr} = {value}") diff --git a/tests/test_attribute.py b/tests/test_attribute.py index bb21a6839..57583f6e1 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -69,7 +69,7 @@ async def test_attribute_io(): class MyAttributeIORef(AttributeIORef): cool: int - class MyAttributeIO(AttributeIO[MyAttributeIORef, int]): + class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): async def update(self, attr: AttrR[T, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) @@ -123,10 +123,10 @@ class DemoParameterAttributeIORef(AttributeIORef, Generic[NumberT]): max: NumberT | None = None read_only: bool = False - class DemoParameterAttributeIO(AttributeIO[DemoParameterAttributeIORef, NumberT]): + class DemoParameterAttributeIO(AttributeIO[NumberT, DemoParameterAttributeIORef]): async def update( self, - attr: AttrR[NumberT], + attr: AttrR[NumberT, DemoParameterAttributeIORef], ): # OK, so this doesn't really work when we have min and maxes... await attr.set(attr.get() + 1) @@ -145,14 +145,12 @@ async def send( if (io_min := attr.io_ref.min) is not None and value < io_min: raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - "min is {attr.io_ref.min}" + f"Could not set {attr.io_ref.name} to {value}, min is {io_min}" ) if (io_max := attr.io_ref.max) is not None and value > io_max: raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, " - f"max is {attr.io_ref.max}" + f"Could not set {attr.io_ref.name} to {value}, max is {io_max}" ) # TODO: we should always end send with a update_display_without_process... diff --git a/tests/test_backend.py b/tests/test_backend.py index 7a40b2a59..4413086b3 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -101,7 +101,7 @@ class AttributeIORefTimesCalled(AttributeIORef): update_period: float | None = None _times_called = 0 - class AttributeIOTimesCalled(AttributeIO[AttributeIORefTimesCalled, int]): + class AttributeIOTimesCalled(AttributeIO[int, AttributeIORefTimesCalled]): async def update(self, attr: AttrR[int, AttributeIORefTimesCalled]): attr.io_ref._times_called += 1 await attr.set(attr.io_ref._times_called) From c29a310d466208124d746734b805d285dd5801e0 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 29 Sep 2025 10:31:23 +0000 Subject: [PATCH 40/50] fix find and replace mistake --- src/fastcs/demo/controllers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index 9aee23d3e..a41bb24ac 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -106,9 +106,7 @@ class TemperatureRampController(Controller): enabled = AttrRW( Enum(OnOffEnum), io_ref=TemperatureControllerAttributeIORef(name="N") ) - target = AttrR( - Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="NumberT") - ) + target = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="T")) actual = AttrR(Float(prec=3), io_ref=TemperatureControllerAttributeIORef(name="A")) voltage = AttrR(Float(prec=3)) From df188b68265d71727494fa1a38330327484cadfd Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 29 Sep 2025 13:27:14 +0000 Subject: [PATCH 41/50] set default ref_type to AttributeIORef for AttributeIO, add test --- src/fastcs/attribute_io.py | 2 ++ src/fastcs/controller.py | 17 ++++++++-- tests/test_attribute.py | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attribute_io.py index 0812497eb..e61f52c9e 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attribute_io.py @@ -6,6 +6,8 @@ class AttributeIO(Generic[T, AttributeIORefT]): + ref_type = AttributeIORef + def __init_subclass__(cls) -> None: # sets ref_type from subclass generic args # from python 3.12 we can use types.get_original_bases diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 41c5e3c29..b5380d790 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections import Counter from collections.abc import Sequence from copy import deepcopy from typing import get_type_hints @@ -39,9 +40,19 @@ def __init__( # TODO, should validation live inside the controller? ios = ios or [] + + self.__check_unique(ios) + self._attribute_ref_io_map = {io.ref_type: io for io in ios} self._validate_io() + def __check_unique(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): + for ref_type, count in Counter([io.ref_type for io in ios]).items(): + if count > 1: + raise RuntimeError( + f"More than one AttributeIO class handles {ref_type.__name__}" + ) + async def initialise(self): pass @@ -61,7 +72,7 @@ async def attribute_initialise(self) -> None: def _add_io_callbacks(self): for attr in self.attributes.values(): ref = attr.io_ref if attr.has_io_ref() else None - io = self._attribute_ref_io_map.get(type(ref), None) + io = self._attribute_ref_io_map.get(type(ref)) if isinstance(attr, AttrW): # is it on process or write_display? attr.add_process_callback(self._create_send_callback(io, attr, ref)) @@ -84,10 +95,10 @@ async def send_callback(value): return send_callback def _create_update_callback(self, io, attr, ref): - if io is None or ref is None: + if ref is None: async def error_callback(): - raise RuntimeError("No AttributeIO registered to handle update") + raise RuntimeError("Attributes without io_ref can not be updated") return error_callback else: diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 57583f6e1..b9bbc0877 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from functools import partial from typing import Generic, TypeVar +from unittest.mock import call import pytest from pytest_mock import MockerFixture @@ -201,3 +202,66 @@ async def initialise(self): RuntimeError, match="Could not set int_parameter to 101, max is 100" ): await c.int_parameter.process(101) + + +@pytest.mark.asyncio +async def test_attribute_io_defaults(mocker: MockerFixture): + class MyController(Controller): + no_ref = AttrRW(Int()) + base_class_ref = AttrRW(Int(), io_ref=AttributeIORef()) + + with pytest.raises( + AssertionError, + match="MyController does not have an AttributeIO to handle AttributeIORef", + ): + c = MyController() + + class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): + async def update(self, attr): + await attr.set(100) + + with pytest.raises( + RuntimeError, match="More than one AttributeIO class handles AttributeIORef" + ): + MyController(ios=[AttributeIO(), SimpleAttributeIO()]) + + # we need to explicitly pass an AttributeIO if we want to handle instances of + # the AttributeIORef base class + c = MyController(ios=[AttributeIO()]) + assert not c.no_ref.has_io_ref() + assert c.base_class_ref.has_io_ref() + + await c.initialise() + await c.attribute_initialise() + + with pytest.raises(NotImplementedError): + await c.base_class_ref.update() + + with pytest.raises(NotImplementedError): + await c.base_class_ref.process(25) + + # There is a difference between providing an AttributeIO for the default + # AttributeIORef class and not specifying the io_ref for an Attribute + # default callbacks are not provided by AttributeIO subclasses + + with pytest.raises( + RuntimeError, match="Attributes without io_ref can not be updated" + ): # TODO, we need a clearer error message for this + await c.no_ref.update() + + process_spy = mocker.spy(c.no_ref, "update_display_without_process") + await c.no_ref.process(40) + process_spy.assert_called_with(40) + + # this is correct, but we want to reconsider this logic, it seems wasteful to + # call update_display twice... + assert process_spy.call_args_list == [call(40), call(40)] + + c2 = MyController(ios=[SimpleAttributeIO()]) + + await c2.initialise() + await c2.attribute_initialise() + + assert c2.base_class_ref.get() == 0 + await c2.base_class_ref.update() + assert c2.base_class_ref.get() == 100 From ccf0444d8b0cf308d3b1a129801f1ec2b5d16ef5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 1 Oct 2025 16:07:03 +0000 Subject: [PATCH 42/50] remove now unused Attribute.initialise() --- src/fastcs/attributes.py | 5 ----- src/fastcs/controller.py | 7 ------- tests/assertable_controller.py | 3 --- 3 files changed, 15 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 32fe533e9..bacd3f867 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -4,8 +4,6 @@ from collections.abc import Callable from typing import Generic -import fastcs - from .attribute_io_ref import AttributeIORefT from .datatypes import ATTRIBUTE_TYPES, AttrSetCallback, AttrUpdateCallback, DataType, T @@ -61,9 +59,6 @@ def dtype(self) -> type[T]: def group(self) -> str | None: return self._group - async def initialise(self, controller: fastcs.controller.BaseController) -> None: - pass - def add_update_datatype_callback( self, callback: Callable[[DataType[T]], None] ) -> None: diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index b5380d790..3f1722bf0 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio from collections import Counter from collections.abc import Sequence from copy import deepcopy @@ -58,12 +57,6 @@ async def initialise(self): async def attribute_initialise(self) -> None: # Initialise any registered handlers for attributes - coros = [attr.initialise(self) for attr in self.attributes.values()] - try: - await asyncio.gather(*coros) - except asyncio.CancelledError: - pass - self._add_io_callbacks() for controller in self.get_sub_controllers().values(): diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index 6acbaf2c4..0ad09754f 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -14,9 +14,6 @@ from fastcs.datatypes import Int, T from fastcs.wrappers import command, scan -# TODO: do we want to have separater ref and AttributeIO classes -# for R and RW/W attributes? - @dataclass class MyTestAttributeIORef(AttributeIORef): From b733365c3ba95f245d3efb36658be2c186cd8fd2 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 2 Oct 2025 08:23:59 +0000 Subject: [PATCH 43/50] simplify validation of AttributeIOs in Controller --- src/fastcs/controller.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 3f1722bf0..5a7a5edd0 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -37,20 +37,9 @@ def __init__( self._bind_attrs() - # TODO, should validation live inside the controller? ios = ios or [] - - self.__check_unique(ios) - self._attribute_ref_io_map = {io.ref_type: io for io in ios} - self._validate_io() - - def __check_unique(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): - for ref_type, count in Counter([io.ref_type for io in ios]).items(): - if count > 1: - raise RuntimeError( - f"More than one AttributeIO class handles {ref_type.__name__}" - ) + self._validate_io(ios) async def initialise(self): pass @@ -155,9 +144,16 @@ class method and a controller instance, so that it can be called from any elif isinstance(attr, UnboundPut | UnboundScan | UnboundCommand): setattr(self, attr_name, attr.bind(self)) - def _validate_io(self): + def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): """Validate that each Attribute has an AttributeIORef for which the - controller has an associated AttributeIO class.""" + controller has an associated AttributeIO class, and that no two AttributeIO + classes handle the same AttributeIORef type""" + for ref_type, count in Counter([io.ref_type for io in ios]).items(): + if count > 1: + raise RuntimeError( + f"More than one AttributeIO class handles {ref_type.__name__}" + ) + for attr in self.attributes.values(): if not attr.has_io_ref(): continue From 17f35963a062a8e72e0e5e75e55d62cd148f60e5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 2 Oct 2025 08:29:29 +0000 Subject: [PATCH 44/50] reword some comments and error messages --- src/fastcs/controller.py | 7 +++---- tests/test_attribute.py | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 5a7a5edd0..a59bc9f02 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -45,7 +45,8 @@ async def initialise(self): pass async def attribute_initialise(self) -> None: - # Initialise any registered handlers for attributes + """Register update and send callbacks for attributes on this controller + and all subcontrollers""" self._add_io_callbacks() for controller in self.get_sub_controllers().values(): @@ -56,7 +57,6 @@ def _add_io_callbacks(self): ref = attr.io_ref if attr.has_io_ref() else None io = self._attribute_ref_io_map.get(type(ref)) if isinstance(attr, AttrW): - # is it on process or write_display? attr.add_process_callback(self._create_send_callback(io, attr, ref)) if isinstance(attr, AttrR): attr.add_update_callback(self._create_update_callback(io, attr, ref)) @@ -72,7 +72,6 @@ async def send_callback(value): async def send_callback(value): await io.send(attr, value) - # TODO, should we just then call the above send_callback here? return send_callback @@ -80,7 +79,7 @@ def _create_update_callback(self, io, attr, ref): if ref is None: async def error_callback(): - raise RuntimeError("Attributes without io_ref can not be updated") + raise RuntimeError("Can't call update on Attributes without an io_ref") return error_callback else: diff --git a/tests/test_attribute.py b/tests/test_attribute.py index b9bbc0877..50d04e37f 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -88,8 +88,7 @@ class ControllerNoIO(Controller): with pytest.raises(AssertionError, match="does not have an AttributeIO"): ControllerNoIO() - # TODO, is it okay that we need to initialise the controller - # before the callbacks get assigned? + await c.initialise() await c.attribute_initialise() await c.my_attr.update() @@ -245,8 +244,8 @@ async def update(self, attr): # default callbacks are not provided by AttributeIO subclasses with pytest.raises( - RuntimeError, match="Attributes without io_ref can not be updated" - ): # TODO, we need a clearer error message for this + RuntimeError, match="Can't call update on Attributes without an io_ref" + ): await c.no_ref.update() process_spy = mocker.spy(c.no_ref, "update_display_without_process") From b137fb76143297a45f5232595fec0052e3a21a20 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 08:10:50 +0000 Subject: [PATCH 45/50] Update docstring --- src/fastcs/controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index a59bc9f02..6f7b128b9 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -144,9 +144,9 @@ class method and a controller instance, so that it can be called from any setattr(self, attr_name, attr.bind(self)) def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): - """Validate that each Attribute has an AttributeIORef for which the - controller has an associated AttributeIO class, and that no two AttributeIO - classes handle the same AttributeIORef type""" + """Validate that there is exactly one AttributeIO class registered to the + controller for each type of AttributeIORef belonging to the attributes of the + controller""" for ref_type, count in Counter([io.ref_type for io in ios]).items(): if count > 1: raise RuntimeError( From 316204dccc45a2285ff7a98b2e9b8382471eb20c Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 08:11:19 +0000 Subject: [PATCH 46/50] reword skip message --- tests/test_docs_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_docs_snippets.py b/tests/test_docs_snippets.py index 58e0c9033..5b3f24ec9 100644 --- a/tests/test_docs_snippets.py +++ b/tests/test_docs_snippets.py @@ -37,7 +37,7 @@ def sim_temperature_controller(): print(process.communicate()[0]) -@pytest.mark.skip("Skipping docs tests, this will be the last thing to fix") +@pytest.mark.skip("Skipping docs tests until docs snippets are updated") @pytest.mark.parametrize("filename", glob.glob("docs/snippets/*.py", recursive=True)) def test_snippet(filename): runpy.run_path(filename) From f54bd67a288be29c3d53fd6c3adc9027c5dd4ffd Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 08:19:14 +0000 Subject: [PATCH 47/50] Rework test_attribute_io_defaults --- tests/test_attribute.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_attribute.py b/tests/test_attribute.py index 50d04e37f..a9dac98ad 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from functools import partial from typing import Generic, TypeVar -from unittest.mock import call import pytest from pytest_mock import MockerFixture @@ -249,12 +248,13 @@ async def update(self, attr): await c.no_ref.update() process_spy = mocker.spy(c.no_ref, "update_display_without_process") - await c.no_ref.process(40) + # calls callback which calls update_display_without_process + # TODO: reconsider if this is what we want the default case to be + # as process already calls that + await c.no_ref.process_without_display_update(40) process_spy.assert_called_with(40) - # this is correct, but we want to reconsider this logic, it seems wasteful to - # call update_display twice... - assert process_spy.call_args_list == [call(40), call(40)] + process_spy.assert_called_once_with(40) c2 = MyController(ios=[SimpleAttributeIO()]) From ecdc15fa65800a0b2f57f9ebeddcc49d720af327 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 09:25:25 +0000 Subject: [PATCH 48/50] rework test_dynamic_attribute_io_specification --- tests/test_attribute.py | 144 +++++++++++++++++++++++++--------------- 1 file changed, 89 insertions(+), 55 deletions(-) diff --git a/tests/test_attribute.py b/tests/test_attribute.py index a9dac98ad..d593dabf6 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -93,65 +93,96 @@ class ControllerNoIO(Controller): await c.my_attr.update() +class DummyConnection: + def __init__(self): + self._connected = False + self._int_value = 5 + self._ro_int_value = 10 + self._float_value = 7.5 + + async def connect(self): + self._connected = True + + async def get(self, uri: str): + if not self._connected: + raise TimeoutError("No response from DummyConnection") + if uri == "config/introspect_api": + return [ + { + "name": "int_parameter", + "subsystem": "status", + "dtype": "int", + "min": 0, + "max": 100, + "value": self._int_value, + "read_only": False, + }, + { + "name": "ro_int_parameter", + "subsystem": "status", + "dtype": "int", + "value": self._ro_int_value, + "read_only": True, + }, + { + "name": "float_parameter", + "subsystem": "status", + "dtype": "float", + "max": 1000.0, + "value": self._float_value, + "read_only": False, + }, + ] + + # increment after getting + elif uri == "status/int_parameter": + value = self._int_value + self._int_value += 1 + elif uri == "status/ro_int_parameter": + value = self._ro_int_value + self._ro_int_value += 1 + elif uri == "status/float_parameter": + value = self._float_value + self._float_value += 1 + return value + + async def set(self, uri: str, value: float | int): + if uri == "status/int_parameter": + self._int_value = value + elif uri == "status/ro_int_parameter": + # don't update read only parameter + pass + elif uri == "status/float_parameter": + self._float_value = value + + @pytest.mark.asyncio() async def test_dynamic_attribute_io_specification(): - example_introspection_response = [ - { - "name": "int_parameter", - "dtype": "int", - "min": 0, - "max": 100, - "value": 5, - "read_only": False, - }, - {"name": "ro_int_parameter", "dtype": "int", "value": 10, "read_only": True}, - { - "name": "float_parameter", - "dtype": "float", - "max": 1000.0, - "value": 7.5, - "read_only": False, - }, - ] - @dataclass class DemoParameterAttributeIORef(AttributeIORef, Generic[NumberT]): name: str - # TODO, this is weird, we should just use the attributes's min and max fields - min: NumberT | None = None - max: NumberT | None = None - read_only: bool = False + subsystem: str + connection: DummyConnection + + @property + def uri(self): + return f"{self.subsystem}/{self.name}" class DemoParameterAttributeIO(AttributeIO[NumberT, DemoParameterAttributeIORef]): async def update( self, attr: AttrR[NumberT, DemoParameterAttributeIORef], ): - # OK, so this doesn't really work when we have min and maxes... - await attr.set(attr.get() + 1) + value = await attr.io_ref.connection.get(attr.io_ref.uri) + await attr.set(value) async def send( self, attr: AttrW[NumberT, DemoParameterAttributeIORef], value: NumberT, ) -> None: - if ( - attr.io_ref.read_only - ): # TODO, this isn't necessary as we can not call process on this anyway - raise RuntimeError( - f"Could not set read only attribute {attr.io_ref.name}" - ) - - if (io_min := attr.io_ref.min) is not None and value < io_min: - raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, min is {io_min}" - ) - - if (io_max := attr.io_ref.max) is not None and value > io_max: - raise RuntimeError( - f"Could not set {attr.io_ref.name} to {value}, max is {io_max}" - ) - # TODO: we should always end send with a update_display_without_process... + await attr.io_ref.connection.set(attr.io_ref.uri, value) + await self.update(attr) class DemoParameterController(Controller): ro_int_parameter: AttrR @@ -159,19 +190,26 @@ class DemoParameterController(Controller): float_parameter: AttrRW # hint to satisfy pyright async def initialise(self): - dtype_mapping = {"int": Int(), "float": Float()} + self._connection = DummyConnection() + await self._connection.connect() + dtype_mapping = {"int": Int, "float": Float} + example_introspection_response = await self._connection.get( + "config/introspect_api" + ) for parameter_response in example_introspection_response: try: ro = parameter_response["read_only"] ref = DemoParameterAttributeIORef( name=parameter_response["name"], - min=parameter_response.get("min", None), - max=parameter_response.get("max", None), - read_only=ro, + subsystem=parameter_response["subsystem"], + connection=self._connection, ) attr_class = AttrR if ro else AttrRW attr = attr_class( - datatype=dtype_mapping[parameter_response["dtype"]], + datatype=dtype_mapping[parameter_response["dtype"]]( + min=parameter_response.get("min", None), + max=parameter_response.get("max", None), + ), io_ref=ref, initial_value=parameter_response.get("value", None), ) @@ -190,16 +228,12 @@ async def initialise(self): await c.initialise() await c.attribute_initialise() await c.ro_int_parameter.update() + assert c.ro_int_parameter.get() == 10 + await c.ro_int_parameter.update() assert c.ro_int_parameter.get() == 11 - with pytest.raises( - RuntimeError, match="Could not set int_parameter to -10, min is 0" - ): - await c.int_parameter.process(-10) - with pytest.raises( - RuntimeError, match="Could not set int_parameter to 101, max is 100" - ): - await c.int_parameter.process(101) + await c.int_parameter.process(20) + assert c.int_parameter.get() == 20 @pytest.mark.asyncio From f73514ce1eaeaaf6a4acf10e0090f0ae4f659fa3 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 09:59:47 +0000 Subject: [PATCH 49/50] typing --- tests/test_attribute.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_attribute.py b/tests/test_attribute.py index d593dabf6..756e49835 100644 --- a/tests/test_attribute.py +++ b/tests/test_attribute.py @@ -144,6 +144,8 @@ async def get(self, uri: str): elif uri == "status/float_parameter": value = self._float_value self._float_value += 1 + else: + raise RuntimeError() return value async def set(self, uri: str, value: float | int): @@ -174,7 +176,7 @@ async def update( attr: AttrR[NumberT, DemoParameterAttributeIORef], ): value = await attr.io_ref.connection.get(attr.io_ref.uri) - await attr.set(value) + await attr.set(value) # type: ignore async def send( self, @@ -182,7 +184,8 @@ async def send( value: NumberT, ) -> None: await attr.io_ref.connection.set(attr.io_ref.uri, value) - await self.update(attr) + if isinstance(attr, AttrRW): + await self.update(attr) class DemoParameterController(Controller): ro_int_parameter: AttrR @@ -196,6 +199,7 @@ async def initialise(self): example_introspection_response = await self._connection.get( "config/introspect_api" ) + assert isinstance(example_introspection_response, list) for parameter_response in example_introspection_response: try: ro = parameter_response["read_only"] @@ -250,7 +254,9 @@ class MyController(Controller): class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): async def update(self, attr): - await attr.set(100) + match attr: + case AttrR(datatype=Int()): + await attr.set(100) with pytest.raises( RuntimeError, match="More than one AttributeIO class handles AttributeIORef" From 56fbf800b375f017f3cc46546483ff5c11688afb Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 3 Oct 2025 10:02:54 +0000 Subject: [PATCH 50/50] ruff --- src/fastcs/demo/controllers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index a41bb24ac..d1ed2bcf1 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -10,8 +10,8 @@ from fastcs.attribute_io_ref import AttributeIORef from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import BaseController, Controller -from fastcs.datatypes import Enum, Float, Int, T +from fastcs.controller import Controller +from fastcs.datatypes import Enum, Float, Int from fastcs.wrappers import command, scan NumberT = TypeVar("NumberT", int, float)