diff --git a/docs/conf.py b/docs/conf.py index cca5b631f..5471426f7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -90,23 +90,24 @@ # Problems in FastCS itself ("py:class", "T"), ("py:class", "AttrIOUpdateCallback"), - ("py:class", "fastcs.transport.epics.pva.pvi_tree._PviSignalInfo"), + ("py:class", "fastcs.transports.epics.pva.pvi_tree._PviSignalInfo"), ("py:class", "fastcs.logging._logging.LogLevel"), ("py:class", "fastcs.logging._graylog.GraylogEndpoint"), ("py:class", "fastcs.logging._graylog.GraylogStaticFields"), ("py:class", "fastcs.logging._graylog.GraylogEnvFields"), ("py:obj", "fastcs.control_system.build_controller_api"), - ("py:obj", "fastcs.transport.epics.util.controller_pv_prefix"), + ("py:obj", "fastcs.transports.epics.util.controller_pv_prefix"), ("docutils", "fastcs.demo.controllers.TemperatureControllerSettings"), # TypeVar without docstrings still give warnings - ("py:class", "fastcs.datatypes.T_Numerical"), ("py:class", "strawberry.schema.schema.Schema"), ] nitpick_ignore_regex = [ - ("py:class", "fastcs.*.T"), - ("py:obj", "fastcs.*.T"), + ("py:class", r"fastcs.*.DType_T"), + ("py:class", r"fastcs.*.Numeric_T"), + ("py:obj", r"fastcs.*.DType_T"), (r"py:.*", r"fastcs\.demo.*"), (r"py:.*", r"tickit.*"), + ("py:class", r"numpy.*"), ] suppress_warnings = ["docutils"] @@ -149,6 +150,7 @@ # docs in the python documentation. intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), + "numpy": ("https://numpy.org/doc/stable/", None), } # A dictionary of graphviz graph attributes for inheritance diagrams. diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py index 9c713f421..65d584978 100644 --- a/docs/snippets/dynamic.py +++ b/docs/snippets/dynamic.py @@ -4,15 +4,20 @@ from pydantic import BaseModel, ConfigDict, ValidationError -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.attributes import ( + Attribute, + AttributeIO, + AttributeIORef, + AttrR, + AttrRW, + AttrW, +) from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Bool, DataType, Float, Int, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport class TemperatureControllerParameter(BaseModel): diff --git a/docs/snippets/static01.py b/docs/snippets/static01.py index 6f9c09227..f1fa23339 100644 --- a/docs/snippets/static01.py +++ b/docs/snippets/static01.py @@ -1,4 +1,4 @@ -from fastcs.controller import Controller +from fastcs.controllers import Controller class TemperatureController(Controller): diff --git a/docs/snippets/static02.py b/docs/snippets/static02.py index e0697dff3..dbb79a551 100644 --- a/docs/snippets/static02.py +++ b/docs/snippets/static02.py @@ -1,4 +1,4 @@ -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.launch import FastCS diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py index 27b065840..ca73b357c 100644 --- a/docs/snippets/static03.py +++ b/docs/snippets/static03.py @@ -1,5 +1,5 @@ from fastcs.attributes import AttrR -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import String from fastcs.launch import FastCS diff --git a/docs/snippets/static04.py b/docs/snippets/static04.py index 23da8a3f7..6200b78f3 100644 --- a/docs/snippets/static04.py +++ b/docs/snippets/static04.py @@ -1,9 +1,9 @@ from fastcs.attributes import AttrR -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca.transport import EpicsCATransport class TemperatureController(Controller): diff --git a/docs/snippets/static05.py b/docs/snippets/static05.py index 2d531250d..1a3951974 100644 --- a/docs/snippets/static05.py +++ b/docs/snippets/static05.py @@ -1,11 +1,11 @@ from pathlib import Path from fastcs.attributes import AttrR -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): diff --git a/docs/snippets/static06.py b/docs/snippets/static06.py index beccad93d..4cd77fdd7 100644 --- a/docs/snippets/static06.py +++ b/docs/snippets/static06.py @@ -2,11 +2,11 @@ from fastcs.attributes import AttrR from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport class TemperatureController(Controller): diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py index 020a7996f..12b7333bb 100644 --- a/docs/snippets/static07.py +++ b/docs/snippets/static07.py @@ -2,15 +2,13 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py index 7fcd591c4..beb0f49b9 100644 --- a/docs/snippets/static08.py +++ b/docs/snippets/static08.py @@ -2,15 +2,13 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Float, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py index 75fb60bca..dbe6a5ee8 100644 --- a/docs/snippets/static09.py +++ b/docs/snippets/static09.py @@ -2,15 +2,13 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Float, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py index a92f3d94d..5da5add73 100644 --- a/docs/snippets/static10.py +++ b/docs/snippets/static10.py @@ -2,15 +2,13 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Float, Int, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py index a88e0b420..beba164ed 100644 --- a/docs/snippets/static11.py +++ b/docs/snippets/static11.py @@ -3,15 +3,13 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py index b182cc4a9..2b6681663 100644 --- a/docs/snippets/static12.py +++ b/docs/snippets/static12.py @@ -4,16 +4,14 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions -from fastcs.wrappers import scan +from fastcs.methods import scan +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static13.py b/docs/snippets/static13.py index fe464f23f..b454288e9 100644 --- a/docs/snippets/static13.py +++ b/docs/snippets/static13.py @@ -5,16 +5,14 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions -from fastcs.wrappers import command, scan +from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport NumberT = TypeVar("NumberT", int, float) diff --git a/docs/snippets/static14.py b/docs/snippets/static14.py index d09980979..2e1824eab 100644 --- a/docs/snippets/static14.py +++ b/docs/snippets/static14.py @@ -5,17 +5,15 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import bind_logger, configure_logging -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions -from fastcs.wrappers import command, scan +from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport logger = bind_logger(__name__) diff --git a/docs/snippets/static15.py b/docs/snippets/static15.py index ff74ab306..4df6934c2 100644 --- a/docs/snippets/static15.py +++ b/docs/snippets/static15.py @@ -5,17 +5,15 @@ from pathlib import Path from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int, String from fastcs.launch import FastCS from fastcs.logging import LogLevel, bind_logger, configure_logging -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsGUIOptions, EpicsIOCOptions -from fastcs.wrappers import command, scan +from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsGUIOptions, EpicsIOCOptions +from fastcs.transports.epics.ca import EpicsCATransport logger = bind_logger(__name__) diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md index 7768908df..09c0a975a 100644 --- a/docs/tutorials/static-drivers.md +++ b/docs/tutorials/static-drivers.md @@ -458,17 +458,17 @@ ramp so that the value updates. Check the console to see the messages. Call `di In [1]: controller.power.enable_tracing() [2025-11-18 11:11:12.060+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=0.0 [2025-11-18 11:11:12.060+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=0.0 -[2025-11-18 11:11:12.060+0000 T] PV set from attribute [fastcs.transport.epics.ca.ioc] pv=DEMO:Power, value=0.0 -[2025-11-18 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transport.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 +[2025-11-18 11:11:12.060+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=0.0 +[2025-11-18 11:11:12.194+0000 I] PV put: DEMO:R1:Enabled = 1 [fastcs.transports.epics.ca.ioc] pv=DEMO:R1:Enabled, value=1 [2025-11-18 11:11:12.195+0000 I] Sending attribute value [TemperatureControllerAttributeIO] command=N01=1, attribute=AttrRW(path=R1.enabled, datatype=Enum, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='N')) [2025-11-18 11:11:12.261+0000 T] Update attribute [AttrR] [2025-11-18 11:11:12.262+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=29.040181873093132 [2025-11-18 11:11:12.262+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=29.040181873093132 -[2025-11-18 11:11:12.262+0000 T] PV set from attribute [fastcs.transport.epics.ca.ioc] pv=DEMO:Power, value=29.04 +[2025-11-18 11:11:12.262+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=29.04 [2025-11-18 11:11:12.463+0000 T] Update attribute [AttrR] [2025-11-18 11:11:12.464+0000 T] Query for attribute [TemperatureControllerAttributeIO] query=P?, response=30.452524641833854 [2025-11-18 11:11:12.464+0000 T] Attribute set [AttrR] attribute=AttrR(path=power, datatype=Float, io_ref=TemperatureControllerAttributeIORef(update_period=0.2, name='P')), value=30.452524641833854 -[2025-11-18 11:11:12.465+0000 T] PV set from attribute [fastcs.transport.epics.ca.ioc] pv=DEMO:Power, value=30.45 +[2025-11-18 11:11:12.465+0000 T] PV set from attribute [fastcs.transports.epics.ca.ioc] pv=DEMO:Power, value=30.45 In [2]: controller.power.disable_tracing() ``` diff --git a/src/fastcs/__init__.py b/src/fastcs/__init__.py index 42f224d39..966ee60bf 100644 --- a/src/fastcs/__init__.py +++ b/src/fastcs/__init__.py @@ -6,10 +6,7 @@ Version number as calculated by https://github.com/pypa/setuptools_scm """ -from . import attributes as attributes -from . import controller as controller -from . import cs_methods as cs_methods -from . import datatypes as datatypes -from . import transport as transport from ._version import __version__ as __version__ from .control_system import FastCS as FastCS +from .launch import launch as launch +from .util import ONCE as ONCE diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py deleted file mode 100644 index 5462e29fb..000000000 --- a/src/fastcs/attributes.py +++ /dev/null @@ -1,327 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Any, Generic - -from fastcs.attribute_io_ref import AttributeIORefT -from fastcs.datatypes import ATTRIBUTE_TYPES, DataType, T -from fastcs.logging import bind_logger -from fastcs.tracer import Tracer - -ONCE = float("inf") -"""Special value to indicate that an attribute should be updated once on start up.""" - -logger = bind_logger(logger_name=__name__) - - -class Attribute(Generic[T, AttributeIORefT], Tracer): - """Base FastCS attribute. - - Instances of this class added to a ``Controller`` will be used by the FastCS class. - """ - - def __init__( - self, - datatype: DataType[T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - description: str | None = None, - ) -> None: - super().__init__() - - assert issubclass(datatype.dtype, ATTRIBUTE_TYPES), ( - f"Attr type must be one of {ATTRIBUTE_TYPES}, " - "received type {datatype.dtype}" - ) - self._io_ref = io_ref - self._datatype: DataType[T] = datatype - self._group = group - self.enabled = True - self.description = description - - # A callback to use when setting the datatype to a different value, for example - # changing the units on an int. - self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] - - # Path and name to be filled in by Controller it is bound to - self._name = "" - self._path = [] - - @property - def io_ref(self) -> AttributeIORefT: - 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 - - @property - def dtype(self) -> type[T]: - return self._datatype.dtype - - @property - def group(self) -> str | None: - return self._group - - @property - def name(self) -> str: - return self._name - - @property - def path(self) -> list[str]: - return self._path - - def add_update_datatype_callback( - self, callback: Callable[[DataType[T]], None] - ) -> None: - self._update_datatype_callbacks.append(callback) - - def update_datatype(self, datatype: DataType[T]) -> None: - if not isinstance(self._datatype, type(datatype)): - raise ValueError( - f"Attribute datatype must be of type {type(self._datatype)}" - ) - self._datatype = datatype - for callback in self._update_datatype_callbacks: - callback(datatype) - - def set_name(self, name: str): - if self._name: - raise RuntimeError( - f"Attribute is already registered with a controller as {self._name}" - ) - - self._name = name - - def set_path(self, path: list[str]): - if self._path: - raise RuntimeError( - f"Attribute is already registered with a controller at {self._path}" - ) - - self._path = path - - def __repr__(self): - name = self.__class__.__name__ - path = ".".join(self._path + [self._name]) or None - datatype = self._datatype.__class__.__name__ - - return f"{name}(path={path}, datatype={datatype}, io_ref={self._io_ref})" - - -AttrIOUpdateCallback = Callable[["AttrR[T, Any]"], Awaitable[None]] -"""An AttributeIO callback that takes an AttrR and updates its value""" -AttrUpdateCallback = Callable[[], Awaitable[None]] -"""A callback to be called periodically to update an attribute""" -AttrOnUpdateCallback = Callable[[T], Awaitable[None]] -"""A callback to be called when the value of the attribute is updated""" - - -class AttrR(Attribute[T, AttributeIORefT]): - """A read-only ``Attribute``.""" - - def __init__( - self, - datatype: DataType[T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - initial_value: T | None = None, - description: str | None = None, - ) -> None: - super().__init__(datatype, io_ref, group, description=description) - self._value: T = ( - datatype.initial_value if initial_value is None else initial_value - ) - self._update_callback: AttrIOUpdateCallback[T] | None = None - """Callback to update the value of the attribute with an IO to the source""" - self._on_update_callbacks: list[AttrOnUpdateCallback[T]] | None = None - """Callbacks to publish changes to the value of the attribute""" - - def get(self) -> T: - """Get the cached value of the attribute.""" - return self._value - - async def update(self, value: T) -> None: - """Update the value of the attibute - - This sets the cached value of the attribute presented in the API. It should - generally only be called from an IO or a controller that is updating the value - from some underlying source. - - To request a change to the setpoint of the attribute, use the ``put`` method, - which will attempt to apply the change to the underlying source. - - """ - self.log_event("Attribute set", attribute=self, value=value) - - self._value = self._datatype.validate(value) - - if self._on_update_callbacks is not None: - try: - await asyncio.gather( - *[cb(self._value) for cb in self._on_update_callbacks] - ) - except Exception as e: - logger.opt(exception=e).error( - "On update callback failed", attribute=self, value=value - ) - raise - - def add_on_update_callback(self, callback: AttrOnUpdateCallback[T]) -> None: - """Add a callback to be called when the value of the attribute is updated - - The callback will be called with the updated value. - - """ - if self._on_update_callbacks is None: - self._on_update_callbacks = [] - self._on_update_callbacks.append(callback) - - def set_update_callback(self, callback: AttrIOUpdateCallback[T]): - """Set the callback to update the value of the attribute from the source - - The callback will be converted to an async task and called periodically. - - """ - if self._update_callback is not None: - raise RuntimeError("Attribute already has an IO update callback") - - self._update_callback = callback - - def bind_update_callback(self) -> AttrUpdateCallback: - """Bind self into the registered IO update callback""" - if self._update_callback is None: - raise RuntimeError("Attribute has no update callback") - else: - update_callback = self._update_callback - - async def update_attribute(): - try: - self.log_event("Update attribute", topic=self) - await update_callback(self) - except Exception as e: - logger.opt(exception=e).error("Update loop failed", attribute=self) - raise - - return update_attribute - - -AttrOnPutCallback = Callable[["AttrW[T, Any]", T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" -AttrSyncSetpointCallback = Callable[[T], Awaitable[None]] -"""Callbacks to be called when the setpoint of an attribute is changed""" - - -class AttrW(Attribute[T, AttributeIORefT]): - """A write-only ``Attribute``.""" - - def __init__( - self, - datatype: DataType[T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - description: str | None = None, - ) -> None: - super().__init__( - datatype, # type: ignore - io_ref, - group, - description=description, - ) - self._on_put_callback: AttrOnPutCallback[T] | None = None - """Callback to action a change to the setpoint of the attribute""" - self._sync_setpoint_callbacks: list[AttrSyncSetpointCallback[T]] = [] - """Callbacks to publish changes to the setpoint of the attribute""" - - async def put(self, setpoint: T, sync_setpoint: bool = False) -> None: - """Set the setpoint of the attribute - - This should be called by clients to the attribute such as transports to apply a - change to the attribute. The ``_on_put_callback`` will be called with this new - setpoint, which may or may not take effect depending on the validity of the new - value. For example, if the attribute has an IO to some device, the value might - be rejected. - - To directly change the value of the attribute, for example from an update loop - that has read a new value from some underlying source, call the ``update`` - method. - - """ - setpoint = self._datatype.validate(setpoint) - if self._on_put_callback is not None: - try: - await self._on_put_callback(self, setpoint) - except Exception as e: - logger.opt(exception=e).error( - "Put failed", attribute=self, setpoint=setpoint - ) - - if sync_setpoint: - try: - await self._call_sync_setpoint_callbacks(setpoint) - except Exception as e: - logger.opt(exception=e).error( - "Sync setpoint failed", attribute=self, setpoint=setpoint - ) - - async def _call_sync_setpoint_callbacks(self, setpoint: T) -> None: - if self._sync_setpoint_callbacks: - await asyncio.gather( - *[cb(setpoint) for cb in self._sync_setpoint_callbacks] - ) - - def set_on_put_callback(self, callback: AttrOnPutCallback[T]) -> None: - """Set the callback to call when the setpoint is changed - - The callback will be called with the attribute and the new setpoint. - - """ - if self._on_put_callback is not None: - raise RuntimeError("Attribute already has an on put callback") - - self._on_put_callback = callback - - def add_sync_setpoint_callback(self, callback: AttrSyncSetpointCallback[T]) -> None: - """Add a callback to publish changes to the setpoint of the attribute - - The callback will be called with the new setpoint. - - """ - self._sync_setpoint_callbacks.append(callback) - - -class AttrRW(AttrR[T, AttributeIORefT], AttrW[T, AttributeIORefT]): - """A read-write ``Attribute``.""" - - def __init__( - self, - datatype: DataType[T], - io_ref: AttributeIORefT | None = None, - group: str | None = None, - initial_value: T | None = None, - description: str | None = None, - ): - super().__init__(datatype, io_ref, group, initial_value, description) - - self._setpoint_initialised = False - - if io_ref is None: - self.set_on_put_callback(self._internal_update) - - async def _internal_update(self, attr: AttrW[T, AttributeIORefT], value: T): - """Update value directly when Attribute has no IO""" - assert attr is self - await self.update(value) - - async def update(self, value: T): - await super().update(value) - - if not self._setpoint_initialised: - await self._call_sync_setpoint_callbacks(value) - self._setpoint_initialised = True diff --git a/src/fastcs/attributes/__init__.py b/src/fastcs/attributes/__init__.py new file mode 100644 index 000000000..a65c5e162 --- /dev/null +++ b/src/fastcs/attributes/__init__.py @@ -0,0 +1,8 @@ +from .attr_r import AttrR as AttrR +from .attr_rw import AttrRW as AttrRW +from .attr_w import AttrW as AttrW +from .attribute import Attribute as Attribute +from .attribute_io import AnyAttributeIO as AnyAttributeIO +from .attribute_io import AttributeIO as AttributeIO +from .attribute_io_ref import AttributeIORef as AttributeIORef +from .attribute_io_ref import AttributeIORefT as AttributeIORefT diff --git a/src/fastcs/attributes/attr_r.py b/src/fastcs/attributes/attr_r.py new file mode 100644 index 000000000..458e0ad56 --- /dev/null +++ b/src/fastcs/attributes/attr_r.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +from fastcs.attributes.attribute import Attribute +from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.datatypes import DataType, DType_T +from fastcs.logging import bind_logger + +logger = bind_logger(logger_name=__name__) + + +AttrIOUpdateCallback = Callable[["AttrR[DType_T, Any]"], Awaitable[None]] +"""An AttributeIO callback that takes an AttrR and updates its value""" +AttrUpdateCallback = Callable[[], Awaitable[None]] +"""A callback to be called periodically to update an attribute""" +AttrOnUpdateCallback = Callable[[DType_T], Awaitable[None]] +"""A callback to be called when the value of the attribute is updated""" + + +class AttrR(Attribute[DType_T, AttributeIORefT]): + """A read-only ``Attribute``.""" + + def __init__( + self, + datatype: DataType[DType_T], + io_ref: AttributeIORefT | None = None, + group: str | None = None, + initial_value: DType_T | None = None, + description: str | None = None, + ) -> None: + super().__init__(datatype, io_ref, group, description=description) + self._value: DType_T = ( + datatype.initial_value if initial_value is None else initial_value + ) + self._update_callback: AttrIOUpdateCallback[DType_T] | None = None + """Callback to update the value of the attribute with an IO to the source""" + self._on_update_callbacks: list[AttrOnUpdateCallback[DType_T]] | None = None + """Callbacks to publish changes to the value of the attribute""" + + def get(self) -> DType_T: + """Get the cached value of the attribute.""" + return self._value + + async def update(self, value: DType_T) -> None: + """Update the value of the attibute + + This sets the cached value of the attribute presented in the API. It should + generally only be called from an IO or a controller that is updating the value + from some underlying source. + + To request a change to the setpoint of the attribute, use the ``put`` method, + which will attempt to apply the change to the underlying source. + + """ + self.log_event("Attribute set", attribute=self, value=value) + + self._value = self._datatype.validate(value) + + if self._on_update_callbacks is not None: + try: + await asyncio.gather( + *[cb(self._value) for cb in self._on_update_callbacks] + ) + except Exception as e: + logger.opt(exception=e).error( + "On update callback failed", attribute=self, value=value + ) + raise + + def add_on_update_callback(self, callback: AttrOnUpdateCallback[DType_T]) -> None: + """Add a callback to be called when the value of the attribute is updated + + The callback will be called with the updated value. + + """ + if self._on_update_callbacks is None: + self._on_update_callbacks = [] + self._on_update_callbacks.append(callback) + + def set_update_callback(self, callback: AttrIOUpdateCallback[DType_T]): + """Set the callback to update the value of the attribute from the source + + The callback will be converted to an async task and called periodically. + + """ + if self._update_callback is not None: + raise RuntimeError("Attribute already has an IO update callback") + + self._update_callback = callback + + def bind_update_callback(self) -> AttrUpdateCallback: + """Bind self into the registered IO update callback""" + if self._update_callback is None: + raise RuntimeError("Attribute has no update callback") + else: + update_callback = self._update_callback + + async def update_attribute(): + try: + self.log_event("Update attribute", topic=self) + await update_callback(self) + except Exception as e: + logger.opt(exception=e).error("Update loop failed", attribute=self) + raise + + return update_attribute diff --git a/src/fastcs/attributes/attr_rw.py b/src/fastcs/attributes/attr_rw.py new file mode 100644 index 000000000..c8365373b --- /dev/null +++ b/src/fastcs/attributes/attr_rw.py @@ -0,0 +1,40 @@ +from fastcs.attributes.attr_r import AttrR +from fastcs.attributes.attr_w import AttrW +from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.datatypes import DataType, DType_T +from fastcs.logging import bind_logger + +logger = bind_logger(logger_name=__name__) + + +class AttrRW(AttrR[DType_T, AttributeIORefT], AttrW[DType_T, AttributeIORefT]): + """A read-write ``Attribute``.""" + + def __init__( + self, + datatype: DataType[DType_T], + io_ref: AttributeIORefT | None = None, + group: str | None = None, + initial_value: DType_T | None = None, + description: str | None = None, + ): + super().__init__(datatype, io_ref, group, initial_value, description) + + self._setpoint_initialised = False + + if io_ref is None: + self.set_on_put_callback(self._internal_update) + + async def _internal_update( + self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T + ): + """Update value directly when Attribute has no IO""" + assert attr is self + await self.update(value) + + async def update(self, value: DType_T): + await super().update(value) + + if not self._setpoint_initialised: + await self._call_sync_setpoint_callbacks(value) + self._setpoint_initialised = True diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py new file mode 100644 index 000000000..bd5568387 --- /dev/null +++ b/src/fastcs/attributes/attr_w.py @@ -0,0 +1,96 @@ +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +from fastcs.attributes.attribute import Attribute +from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.datatypes import DataType, DType_T +from fastcs.logging import bind_logger + +logger = bind_logger(logger_name=__name__) + + +AttrOnPutCallback = Callable[["AttrW[DType_T, Any]", DType_T], Awaitable[None]] +"""Callbacks to be called when the setpoint of an attribute is changed""" +AttrSyncSetpointCallback = Callable[[DType_T], Awaitable[None]] +"""Callbacks to be called when the setpoint of an attribute is changed""" + + +class AttrW(Attribute[DType_T, AttributeIORefT]): + """A write-only ``Attribute``.""" + + def __init__( + self, + datatype: DataType[DType_T], + io_ref: AttributeIORefT | None = None, + group: str | None = None, + description: str | None = None, + ) -> None: + super().__init__( + datatype, # type: ignore + io_ref, + group, + description=description, + ) + self._on_put_callback: AttrOnPutCallback[DType_T] | None = None + """Callback to action a change to the setpoint of the attribute""" + self._sync_setpoint_callbacks: list[AttrSyncSetpointCallback[DType_T]] = [] + """Callbacks to publish changes to the setpoint of the attribute""" + + async def put(self, setpoint: DType_T, sync_setpoint: bool = False) -> None: + """Set the setpoint of the attribute + + This should be called by clients to the attribute such as transports to apply a + change to the attribute. The ``_on_put_callback`` will be called with this new + setpoint, which may or may not take effect depending on the validity of the new + value. For example, if the attribute has an IO to some device, the value might + be rejected. + + To directly change the value of the attribute, for example from an update loop + that has read a new value from some underlying source, call the ``update`` + method. + + """ + setpoint = self._datatype.validate(setpoint) + if self._on_put_callback is not None: + try: + await self._on_put_callback(self, setpoint) + except Exception as e: + logger.opt(exception=e).error( + "Put failed", attribute=self, setpoint=setpoint + ) + + if sync_setpoint: + try: + await self._call_sync_setpoint_callbacks(setpoint) + except Exception as e: + logger.opt(exception=e).error( + "Sync setpoint failed", attribute=self, setpoint=setpoint + ) + + async def _call_sync_setpoint_callbacks(self, setpoint: DType_T) -> None: + if self._sync_setpoint_callbacks: + await asyncio.gather( + *[cb(setpoint) for cb in self._sync_setpoint_callbacks] + ) + + def set_on_put_callback(self, callback: AttrOnPutCallback[DType_T]) -> None: + """Set the callback to call when the setpoint is changed + + The callback will be called with the attribute and the new setpoint. + + """ + if self._on_put_callback is not None: + raise RuntimeError("Attribute already has an on put callback") + + self._on_put_callback = callback + + def add_sync_setpoint_callback( + self, callback: AttrSyncSetpointCallback[DType_T] + ) -> None: + """Add a callback to publish changes to the setpoint of the attribute + + The callback will be called with the new setpoint. + + """ + self._sync_setpoint_callbacks.append(callback) diff --git a/src/fastcs/attributes/attribute.py b/src/fastcs/attributes/attribute.py new file mode 100644 index 000000000..6668cff1b --- /dev/null +++ b/src/fastcs/attributes/attribute.py @@ -0,0 +1,109 @@ +from collections.abc import Callable +from typing import Generic + +from fastcs.attributes.attribute_io_ref import AttributeIORefT +from fastcs.datatypes import DATATYPE_DTYPES, DataType, DType_T +from fastcs.logging import bind_logger +from fastcs.tracer import Tracer + +logger = bind_logger(logger_name=__name__) + + +class Attribute(Generic[DType_T, AttributeIORefT], Tracer): + """Base FastCS attribute. + + Instances of this class added to a ``Controller`` will be used by the FastCS class. + """ + + def __init__( + self, + datatype: DataType[DType_T], + io_ref: AttributeIORefT | None = None, + group: str | None = None, + description: str | None = None, + ) -> None: + super().__init__() + + assert issubclass(datatype.dtype, DATATYPE_DTYPES), ( + f"Attr type must be one of {DATATYPE_DTYPES}, " + "received type {datatype.dtype}" + ) + self._io_ref = io_ref + self._datatype: DataType[DType_T] = datatype + self._group = group + self.enabled = True + self.description = description + + # A callback to use when setting the datatype to a different value, for example + # changing the units on an int. + self._update_datatype_callbacks: list[Callable[[DataType[DType_T]], None]] = [] + + # Path and name to be filled in by Controller it is bound to + self._name = "" + self._path = [] + + @property + def io_ref(self) -> AttributeIORefT: + 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[DType_T]: + return self._datatype + + @property + def dtype(self) -> type[DType_T]: + return self._datatype.dtype + + @property + def group(self) -> str | None: + return self._group + + @property + def name(self) -> str: + return self._name + + @property + def path(self) -> list[str]: + return self._path + + def add_update_datatype_callback( + self, callback: Callable[[DataType[DType_T]], None] + ) -> None: + self._update_datatype_callbacks.append(callback) + + def update_datatype(self, datatype: DataType[DType_T]) -> None: + if not isinstance(self._datatype, type(datatype)): + raise ValueError( + f"Attribute datatype must be of type {type(self._datatype)}" + ) + self._datatype = datatype + for callback in self._update_datatype_callbacks: + callback(datatype) + + def set_name(self, name: str): + if self._name: + raise RuntimeError( + f"Attribute is already registered with a controller as {self._name}" + ) + + self._name = name + + def set_path(self, path: list[str]): + if self._path: + raise RuntimeError( + f"Attribute is already registered with a controller at {self._path}" + ) + + self._path = path + + def __repr__(self): + name = self.__class__.__name__ + path = ".".join(self._path + [self._name]) or None + datatype = self._datatype.__class__.__name__ + + return f"{name}(path={path}, datatype={datatype}, io_ref={self._io_ref})" diff --git a/src/fastcs/attribute_io.py b/src/fastcs/attributes/attribute_io.py similarity index 68% rename from src/fastcs/attribute_io.py rename to src/fastcs/attributes/attribute_io.py index ed7c36f78..e3648fa3d 100644 --- a/src/fastcs/attribute_io.py +++ b/src/fastcs/attributes/attribute_io.py @@ -1,12 +1,13 @@ from typing import Any, Generic, cast, get_args -from fastcs.attribute_io_ref import AttributeIORef, AttributeIORefT -from fastcs.attributes import AttrR, AttrW -from fastcs.datatypes import T +from fastcs.attributes.attr_r import AttrR +from fastcs.attributes.attr_w import AttrW +from fastcs.attributes.attribute_io_ref import AttributeIORef, AttributeIORefT +from fastcs.datatypes import DType_T from fastcs.tracer import Tracer -class AttributeIO(Generic[T, AttributeIORefT], Tracer): +class AttributeIO(Generic[DType_T, AttributeIORefT], Tracer): """Base class for performing IO for an ``Attribute`` This class should be inherited to implement reading and writing values from @@ -29,11 +30,11 @@ def __init_subclass__(cls) -> None: def __init__(self): super().__init__() - async def update(self, attr: AttrR[T, AttributeIORefT]) -> None: + async def update(self, attr: AttrR[DType_T, AttributeIORefT]) -> None: raise NotImplementedError() - async def send(self, attr: AttrW[T, AttributeIORefT], value: T) -> None: + async def send(self, attr: AttrW[DType_T, AttributeIORefT], value: DType_T) -> None: raise NotImplementedError() -AnyAttributeIO = AttributeIO[T, AttributeIORef] +AnyAttributeIO = AttributeIO[DType_T, AttributeIORef] diff --git a/src/fastcs/attribute_io_ref.py b/src/fastcs/attributes/attribute_io_ref.py similarity index 94% rename from src/fastcs/attribute_io_ref.py rename to src/fastcs/attributes/attribute_io_ref.py index 0cd2b22c8..c6d28cbc0 100644 --- a/src/fastcs/attribute_io_ref.py +++ b/src/fastcs/attributes/attribute_io_ref.py @@ -22,3 +22,4 @@ class AttributeIORef: AttributeIORefT = TypeVar( "AttributeIORefT", bound=AttributeIORef, default=AttributeIORef, covariant=True ) +"""An `AttributeIORef` for an `Attribute`""" diff --git a/src/fastcs/control_system.py b/src/fastcs/control_system.py index 9129ac95a..00e99c796 100644 --- a/src/fastcs/control_system.py +++ b/src/fastcs/control_system.py @@ -6,16 +6,15 @@ from IPython.terminal.embed import InteractiveShellEmbed -from fastcs.controller import BaseController, Controller -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import Command, Scan, ScanCallback +from fastcs.controllers import BaseController, Controller from fastcs.exceptions import FastCSError -from fastcs.logging import logger as _fastcs_logger +from fastcs.logging import bind_logger +from fastcs.methods import Command, Scan, ScanCallback from fastcs.tracer import Tracer -from fastcs.transport import Transport +from fastcs.transports import ControllerAPI, Transport tracer = Tracer(name=__name__) -logger = _fastcs_logger.bind(logger_name=__name__) +logger = bind_logger(logger_name=__name__) class FastCS: diff --git a/src/fastcs/controllers/__init__.py b/src/fastcs/controllers/__init__.py new file mode 100644 index 000000000..80b88d896 --- /dev/null +++ b/src/fastcs/controllers/__init__.py @@ -0,0 +1,3 @@ +from .base_controller import BaseController as BaseController +from .controller import Controller as Controller +from .controller_vector import ControllerVector as ControllerVector diff --git a/src/fastcs/controller.py b/src/fastcs/controllers/base_controller.py similarity index 74% rename from src/fastcs/controller.py rename to src/fastcs/controllers/base_controller.py index e94691537..6ea709672 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controllers/base_controller.py @@ -1,14 +1,12 @@ from __future__ import annotations from collections import Counter -from collections.abc import Iterator, Mapping, MutableMapping, Sequence +from collections.abc import Sequence from copy import deepcopy from typing import _GenericAlias, get_args, get_origin, get_type_hints # type: ignore -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORefT -from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import DataType, T +from fastcs.attributes import Attribute, AttributeIO, AttributeIORefT, AttrR, AttrW +from fastcs.datatypes import DataType, DType_T from fastcs.tracer import Tracer @@ -33,7 +31,7 @@ def __init__( self, path: list[str] | None = None, description: str | None = None, - ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, + ios: Sequence[AttributeIO[DType_T, AttributeIORefT]] | None = None, ) -> None: super().__init__() @@ -82,7 +80,8 @@ class method and a controller instance, so that it can be called from any """ # Lazy import to avoid circular references - from fastcs.cs_methods import UnboundCommand, UnboundScan + from fastcs.methods.command import UnboundCommand + from fastcs.methods.scan import UnboundScan # Using a dictionary instead of a set to maintain order. class_dir = {key: None for key in dir(type(self)) if not key.startswith("_")} @@ -102,7 +101,7 @@ class method and a controller instance, so that it can be called from any elif isinstance(attr, UnboundScan | UnboundCommand): setattr(self, attr_name, attr.bind(self)) - def _validate_io(self, ios: Sequence[AttributeIO[T, AttributeIORefT]]): + def _validate_io(self, ios: Sequence[AttributeIO[DType_T, AttributeIORefT]]): """Validate that there is exactly one AttributeIO class registered to the controller for each type of AttributeIORef belonging to the attributes of the controller""" @@ -122,7 +121,7 @@ def __repr__(self): def __setattr__(self, name, value): if isinstance(value, Attribute): self.add_attribute(name, value) - elif isinstance(value, Controller): + elif isinstance(value, BaseController): self.add_sub_controller(name, value) else: super().__setattr__(name, value) @@ -246,82 +245,3 @@ def add_sub_controller(self, name: str, sub_controller: BaseController): @property def sub_controllers(self) -> dict[str, BaseController]: return self.__sub_controllers - - -class Controller(BaseController): - """Controller containing Attributes and named sub Controllers""" - - def __init__( - self, - description: str | None = None, - ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, - ) -> None: - super().__init__(description=description, ios=ios) - - def add_sub_controller(self, name: str, sub_controller: BaseController): - if name.isdigit(): - raise ValueError( - f"Cannot add sub controller {name}. " - "Numeric-only names are not allowed; use ControllerVector instead" - ) - return super().add_sub_controller(name, sub_controller) - - async def connect(self) -> None: - pass - - async def disconnect(self) -> None: - pass - - -class ControllerVector(MutableMapping[int, Controller], BaseController): - """Controller containing Attributes and indexed sub Controllers - - The sub controllers registered with this Controller should be instances of the same - Controller type, distinguished only by an integer index. The indexes do not need - to be continiguous. - """ - - def __init__( - self, - children: Mapping[int, Controller], - description: str | None = None, - ios: Sequence[AttributeIO[T, AttributeIORefT]] | None = None, - ) -> None: - super().__init__(description=description, ios=ios) - self._children: dict[int, Controller] = {} - for index, child in children.items(): - self[index] = child - - def add_sub_controller(self, name: str, sub_controller: BaseController): - raise NotImplementedError( - "Cannot add named sub controller to ControllerVector. " - "Use __setitem__ instead, for indexed sub controllers. " - "E.g., vector[1] = Controller()" - ) - - def __getitem__(self, key: int) -> Controller: - try: - return self._children[key] - except KeyError as exception: - raise KeyError( - f"ControllerVector does not have Controller with key {key}" - ) from exception - - def __setitem__(self, key: int, value: Controller) -> None: - if not isinstance(key, int): - msg = f"Expected int, got {key}" - raise TypeError(msg) - if not isinstance(value, Controller): - msg = f"Expected Controller, got {value}" - raise TypeError(msg) - self._children[key] = value - super().add_sub_controller(str(key), value) - - def __delitem__(self, key: int) -> None: - raise NotImplementedError("Cannot delete sub controller from ControllerVector.") - - def __iter__(self) -> Iterator[int]: - yield from self._children - - def __len__(self) -> int: - return len(self._children) diff --git a/src/fastcs/controllers/controller.py b/src/fastcs/controllers/controller.py new file mode 100755 index 000000000..be9287cf6 --- /dev/null +++ b/src/fastcs/controllers/controller.py @@ -0,0 +1,30 @@ +from collections.abc import Sequence + +from fastcs.attributes import AttributeIO, AttributeIORefT +from fastcs.controllers.base_controller import BaseController +from fastcs.datatypes import DType_T + + +class Controller(BaseController): + """Controller containing Attributes and named sub Controllers""" + + def __init__( + self, + description: str | None = None, + ios: Sequence[AttributeIO[DType_T, AttributeIORefT]] | None = None, + ) -> None: + super().__init__(description=description, ios=ios) + + def add_sub_controller(self, name: str, sub_controller: BaseController): + if name.isdigit(): + raise ValueError( + f"Cannot add sub controller {name}. " + "Numeric-only names are not allowed; use ControllerVector instead" + ) + return super().add_sub_controller(name, sub_controller) + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass diff --git a/src/fastcs/controllers/controller_vector.py b/src/fastcs/controllers/controller_vector.py new file mode 100755 index 000000000..f3a21a73f --- /dev/null +++ b/src/fastcs/controllers/controller_vector.py @@ -0,0 +1,60 @@ +from collections.abc import Iterator, Mapping, MutableMapping, Sequence + +from fastcs.attributes import AttributeIO, AttributeIORefT +from fastcs.controllers.base_controller import BaseController +from fastcs.controllers.controller import Controller +from fastcs.datatypes import DType_T + + +class ControllerVector(MutableMapping[int, Controller], BaseController): + """Controller containing Attributes and indexed sub Controllers + + The sub controllers registered with this Controller should be instances of the same + Controller type, distinguished only by an integer index. The indexes do not need + to be continiguous. + """ + + def __init__( + self, + children: Mapping[int, Controller], + description: str | None = None, + ios: Sequence[AttributeIO[DType_T, AttributeIORefT]] | None = None, + ) -> None: + super().__init__(description=description, ios=ios) + self._children: dict[int, Controller] = {} + for index, child in children.items(): + self[index] = child + + def add_sub_controller(self, name: str, sub_controller: BaseController): + raise NotImplementedError( + "Cannot add named sub controller to ControllerVector. " + "Use __setitem__ instead, for indexed sub controllers. " + "E.g., vector[1] = Controller()" + ) + + def __getitem__(self, key: int) -> Controller: + try: + return self._children[key] + except KeyError as exception: + raise KeyError( + f"ControllerVector does not have Controller with key {key}" + ) from exception + + def __setitem__(self, key: int, value: Controller) -> None: + if not isinstance(key, int): + msg = f"Expected int, got {key}" + raise TypeError(msg) + if not isinstance(value, Controller): + msg = f"Expected Controller, got {value}" + raise TypeError(msg) + self._children[key] = value + super().add_sub_controller(str(key), value) + + def __delitem__(self, key: int) -> None: + raise NotImplementedError("Cannot delete sub controller from ControllerVector.") + + def __iter__(self) -> Iterator[int]: + yield from self._children + + def __len__(self) -> int: + return len(self._children) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py deleted file mode 100644 index 05f143934..000000000 --- a/src/fastcs/cs_methods.py +++ /dev/null @@ -1,187 +0,0 @@ -from asyncio import iscoroutinefunction -from collections.abc import Callable, Coroutine -from inspect import Signature, getdoc, signature -from types import MethodType -from typing import Any, Generic, TypeVar - -from fastcs.controller import BaseController -from fastcs.tracer import Tracer - -from .exceptions import FastCSError - -MethodCallback = Callable[..., Coroutine[None, None, None]] -"""Generic base class for all `Controller` methods""" -Controller_T = TypeVar("Controller_T", bound=BaseController) -"""Generic `Controller` class that an unbound method must be called with as `self`""" -UnboundCommandCallback = Callable[[Controller_T], Coroutine[None, None, None]] -"""A Command callback that is unbound and must be called with a `Controller` instance""" -UnboundScanCallback = Callable[[Controller_T], Coroutine[None, None, None]] -"""A Scan callback that is unbound and must be called with a `Controller` instance""" -UnboundPutCallback = Callable[[Controller_T, Any], Coroutine[None, None, None]] -"""A Put callback that is unbound and must be called with a `Controller` instance""" -CommandCallback = Callable[[], Coroutine[None, None, None]] -"""A Command callback that is bound and can be called without `self`""" -ScanCallback = Callable[[], Coroutine[None, None, None]] -"""A Scan callback that is bound and can be called without `self`""" -PutCallback = Callable[[Any], Coroutine[None, None, None]] -"""A Put callback that is bound and can be called without `self`""" - - -method_not_bound_error = NotImplementedError( - "Method must be bound to a controller instance to be callable" -) - - -class Method(Generic[Controller_T], Tracer): - """Generic base class for all FastCS Controller methods.""" - - def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: - super().__init__() - - self._docstring = getdoc(fn) - - sig = signature(fn, eval_str=True) - self._parameters = sig.parameters - self._return_type = sig.return_annotation - self._validate(fn) - - self._fn = fn - self._group = group - self.enabled = True - - def _validate(self, fn: MethodCallback) -> None: - if self.return_type not in (None, Signature.empty): - raise FastCSError("Method return type must be None or empty") - - if not iscoroutinefunction(fn): - raise FastCSError("Method must be async function") - - @property - def return_type(self): - return self._return_type - - @property - def parameters(self): - return self._parameters - - @property - def docstring(self): - return self._docstring - - @property - def fn(self): - return self._fn - - @property - def group(self): - return self._group - - -class Command(Method[BaseController]): - """A `Controller` `Method` that performs a single action when called. - - This class contains a function that is bound to a specific `Controller` instance and - is callable outside of the class context, without an explicit `self` parameter. - Calling an instance of this class will call the bound `Controller` method. - """ - - def __init__(self, fn: CommandCallback, *, group: str | None = None): - super().__init__(fn, group=group) - - def _validate(self, fn: CommandCallback) -> None: - super()._validate(fn) - - if not len(self.parameters) == 0: - raise FastCSError(f"Command method cannot have arguments: {fn}") - - async def __call__(self): - return await self._fn() - - -class Scan(Method[BaseController]): - """A `Controller` `Method` that will be called periodically in the background. - - This class contains a function that is bound to a specific `Controller` instance and - is callable outside of the class context, without an explicit `self` parameter. - Calling an instance of this class will call the bound `Controller` method. - """ - - def __init__(self, fn: ScanCallback, period: float): - super().__init__(fn) - - self._period = period - - @property - def period(self): - return self._period - - def _validate(self, fn: ScanCallback) -> None: - super()._validate(fn) - - if not len(self.parameters) == 0: - raise FastCSError("Scan method cannot have arguments") - - async def __call__(self): - return await self._fn() - - -class UnboundCommand(Method[Controller_T]): - """A wrapper of an unbound `Controller` method to be bound into a `Command`. - - This generic class stores an unbound `Controller` method - effectively a function - that takes an instance of a specific `Controller` type (`Controller_T`). Instances - of this class can be added at `Controller` definition, either manually or with use - of the `command` wrapper, to register the method to be included in the API of the - `Controller`. When the `Controller` is instantiated, these instances will be bound - to the instance, creating a `Command` instance. - """ - - def __init__( - self, fn: UnboundCommandCallback[Controller_T], *, group: str | None = None - ) -> None: - super().__init__(fn, group=group) - - def _validate(self, fn: UnboundCommandCallback[Controller_T]) -> None: - super()._validate(fn) - - if not len(self.parameters) == 1: - raise FastCSError("Command method cannot have arguments") - - def bind(self, controller: Controller_T) -> Command: - return Command(MethodType(self.fn, controller), group=self.group) - - def __call__(self): - raise method_not_bound_error - - -class UnboundScan(Method[Controller_T]): - """A wrapper of an unbound `Controller` method to be bound into a `Scan`. - - This generic class stores an unbound `Controller` method - effectively a function - that takes an instance of a specific `Controller` type (`Controller_T`). Instances - of this class can be added at `Controller` definition, either manually or with use - of the `scan` wrapper, to register the method to be included in the API of the - `Controller`. When the `Controller` is instantiated, these instances will be bound - to the instance, creating a `Scan` instance. - """ - - def __init__(self, fn: UnboundScanCallback[Controller_T], period: float) -> None: - super().__init__(fn) - - self._period = period - - @property - def period(self): - return self._period - - def _validate(self, fn: UnboundScanCallback[Controller_T]) -> None: - super()._validate(fn) - - if not len(self.parameters) == 1: - raise FastCSError("Scan method cannot have arguments") - - def bind(self, controller: Controller_T) -> Scan: - return Scan(MethodType(self.fn, controller), self._period) - - def __call__(self): - raise method_not_bound_error diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py deleted file mode 100644 index 7d4124a4d..000000000 --- a/src/fastcs/datatypes.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -import enum -from abc import abstractmethod -from dataclasses import dataclass -from functools import cached_property -from typing import Any, Generic, TypeVar - -import numpy as np -from numpy.typing import DTypeLike - -T = TypeVar( - "T", - int, # Int - float, # Float - bool, # Bool - str, # String - enum.Enum, # Enum - np.ndarray, # Waveform - list[tuple[str, DTypeLike]], # Table -) - -ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore - - -@dataclass(frozen=True) -class DataType(Generic[T]): - """Generic datatype mapping to a python type, with additional metadata.""" - - @property - @abstractmethod - def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars - pass - - def validate(self, value: Any) -> T: - """Validate a value against the datatype. - - The base implementation is to try the cast and raise a useful error if it fails. - - Child classes can implement logic before calling ``super.validate(value)`` to - modify the value passed in and help the cast succeed or after to perform further - validation of the coerced type. - - """ - if isinstance(value, self.dtype): - return value - - try: - return self.dtype(value) - except (ValueError, TypeError) as e: - raise ValueError(f"Failed to cast {value} to type {self.dtype}") from e - - @property - @abstractmethod - def initial_value(self) -> T: - pass - - -T_Numerical = TypeVar("T_Numerical", int, float) - - -@dataclass(frozen=True) -class _Numerical(DataType[T_Numerical]): - units: str | None = None - min: T_Numerical | None = None - max: T_Numerical | None = None - min_alarm: T_Numerical | None = None - max_alarm: T_Numerical | None = None - - def validate(self, value: Any) -> T_Numerical: - _value = super().validate(value) - - if self.min is not None and _value < self.min: - raise ValueError(f"Value {_value} is less than minimum {self.min}") - - if self.max is not None and _value > self.max: - raise ValueError(f"Value {_value} is greater than maximum {self.max}") - - return _value - - @property - def initial_value(self) -> T_Numerical: - return self.dtype(0) - - -@dataclass(frozen=True) -class Int(_Numerical[int]): - """`DataType` mapping to builtin ``int``.""" - - @property - def dtype(self) -> type[int]: - return int - - -@dataclass(frozen=True) -class Float(_Numerical[float]): - """`DataType` mapping to builtin ``float``.""" - - prec: int = 2 - - @property - def dtype(self) -> type[float]: - return float - - def validate(self, value: Any) -> float: - _value = super().validate(value) - - if self.prec is not None: - _value = round(_value, self.prec) - - return _value - - -@dataclass(frozen=True) -class Bool(DataType[bool]): - """`DataType` mapping to builtin ``bool``.""" - - @property - def dtype(self) -> type[bool]: - return bool - - @property - def initial_value(self) -> bool: - return False - - -@dataclass(frozen=True) -class String(DataType[str]): - """`DataType` mapping to builtin ``str``.""" - - length: int | None = None - """Maximum length of string to display in transports""" - - @property - def dtype(self) -> type[str]: - return str - - @property - def initial_value(self) -> str: - return "" - - -T_Enum = TypeVar("T_Enum", bound=enum.Enum) - - -@dataclass(frozen=True) -class Enum(Generic[T_Enum], DataType[T_Enum]): - enum_cls: type[T_Enum] - - def __post_init__(self): - if not issubclass(self.enum_cls, enum.Enum): - raise ValueError("Enum class has to take an Enum.") - - def index_of(self, value: T_Enum) -> int: - return self.members.index(value) - - @cached_property - def members(self) -> list[T_Enum]: - return list(self.enum_cls) - - @cached_property - def names(self) -> list[str]: - return [member.name for member in self.members] - - @property - def dtype(self) -> type[T_Enum]: - return self.enum_cls - - @property - def initial_value(self) -> T_Enum: - return self.members[0] - - -@dataclass(frozen=True) -class Waveform(DataType[np.ndarray]): - array_dtype: DTypeLike - shape: tuple[int, ...] = (2000,) - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.zeros(self.shape, dtype=self.array_dtype) - - def validate(self, value: np.ndarray) -> np.ndarray: - _value = super().validate(value) - - if self.array_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype} is not the same as the array dtype " - f"{self.array_dtype}" - ) - - if len(self.shape) != len(_value.shape) or any( - shape1 > shape2 - for shape1, shape2 in zip(_value.shape, self.shape, strict=True) - ): - raise ValueError( - f"Value shape {_value.shape} exceeeds the shape maximum shape " - f"{self.shape}" - ) - - return _value - - -@dataclass(frozen=True) -class Table(DataType[np.ndarray]): - # https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation - structured_dtype: list[tuple[str, DTypeLike]] - - @property - def dtype(self) -> type[np.ndarray]: - return np.ndarray - - @property - def initial_value(self) -> np.ndarray: - return np.array([], dtype=self.structured_dtype) - - def validate(self, value: Any) -> np.ndarray: - _value = super().validate(value) - - if self.structured_dtype != _value.dtype: - raise ValueError( - f"Value dtype {_value.dtype.descr} is not the same as the structured " - f"dtype {self.structured_dtype}" - ) - - return _value diff --git a/src/fastcs/datatypes/__init__.py b/src/fastcs/datatypes/__init__.py new file mode 100644 index 000000000..7952c6c90 --- /dev/null +++ b/src/fastcs/datatypes/__init__.py @@ -0,0 +1,11 @@ +from ._util import numpy_to_fastcs_datatype as numpy_to_fastcs_datatype +from .bool import Bool as Bool +from .datatype import DATATYPE_DTYPES as DATATYPE_DTYPES +from .datatype import DataType as DataType +from .datatype import DType_T as DType_T +from .enum import Enum as Enum +from .float import Float as Float +from .int import Int as Int +from .string import String as String +from .table import Table as Table +from .waveform import Waveform as Waveform diff --git a/src/fastcs/datatypes/_numeric.py b/src/fastcs/datatypes/_numeric.py new file mode 100644 index 000000000..7ab61fcfb --- /dev/null +++ b/src/fastcs/datatypes/_numeric.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Any, TypeVar + +from fastcs.datatypes.datatype import DataType + +Numeric_T = TypeVar("Numeric_T", int, float) +"""A numeric type supported by a corresponding FastCS Attribute DataType""" + + +@dataclass(frozen=True) +class _Numeric(DataType[Numeric_T]): + """Base class for numeric FastCS DataType classes""" + + units: str | None = None + min: Numeric_T | None = None + max: Numeric_T | None = None + min_alarm: Numeric_T | None = None + max_alarm: Numeric_T | None = None + + def validate(self, value: Any) -> Numeric_T: + _value = super().validate(value) + + if self.min is not None and _value < self.min: + raise ValueError(f"Value {_value} is less than minimum {self.min}") + + if self.max is not None and _value > self.max: + raise ValueError(f"Value {_value} is greater than maximum {self.max}") + + return _value + + @property + def initial_value(self) -> Numeric_T: + return self.dtype(0) diff --git a/src/fastcs/datatypes/_util.py b/src/fastcs/datatypes/_util.py new file mode 100644 index 000000000..b590f7ff8 --- /dev/null +++ b/src/fastcs/datatypes/_util.py @@ -0,0 +1,21 @@ +import numpy as np + +from fastcs.datatypes.bool import Bool +from fastcs.datatypes.datatype import DataType +from fastcs.datatypes.float import Float +from fastcs.datatypes.int import Int +from fastcs.datatypes.string import String + + +def numpy_to_fastcs_datatype(np_type) -> DataType: + """Converts numpy types to fastcs types for widget creation. + Only types important for widget creation are explicitly converted + """ + if np.issubdtype(np_type, np.integer): + return Int() + elif np.issubdtype(np_type, np.floating): + return Float() + elif np.issubdtype(np_type, np.bool_): + return Bool() + else: + return String() diff --git a/src/fastcs/datatypes/bool.py b/src/fastcs/datatypes/bool.py new file mode 100644 index 000000000..7b99ae2a9 --- /dev/null +++ b/src/fastcs/datatypes/bool.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from fastcs.datatypes.datatype import DataType + + +@dataclass(frozen=True) +class Bool(DataType[bool]): + """`DataType` mapping to builtin ``bool``.""" + + @property + def dtype(self) -> type[bool]: + return bool + + @property + def initial_value(self) -> bool: + return False diff --git a/src/fastcs/datatypes/datatype.py b/src/fastcs/datatypes/datatype.py new file mode 100644 index 000000000..034a57623 --- /dev/null +++ b/src/fastcs/datatypes/datatype.py @@ -0,0 +1,54 @@ +import enum +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +import numpy as np +from numpy.typing import DTypeLike + +DType_T = TypeVar( + "DType_T", + int, # Int + float, # Float + bool, # Bool + str, # String + enum.Enum, # Enum + np.ndarray, # Waveform + list[tuple[str, DTypeLike]], # Table +) +"""A builtin (or numpy) type supported by a corresponding FastCS Attribute DataType""" + +DATATYPE_DTYPES: tuple[type] = DType_T.__constraints__ # type: ignore + + +@dataclass(frozen=True) +class DataType(Generic[DType_T]): + """Generic datatype mapping to a python type, with additional metadata.""" + + @property + @abstractmethod + def dtype(self) -> type[DType_T]: # Using property due to lack of Generic ClassVars + raise NotImplementedError() + + def validate(self, value: Any) -> DType_T: + """Validate a value against the datatype. + + The base implementation is to try the cast and raise a useful error if it fails. + + Child classes can implement logic before calling ``super.validate(value)`` to + modify the value passed in and help the cast succeed or after to perform further + validation of the coerced type. + + """ + if isinstance(value, self.dtype): + return value + + try: + return self.dtype(value) + except (ValueError, TypeError) as e: + raise ValueError(f"Failed to cast {value} to type {self.dtype}") from e + + @property + @abstractmethod + def initial_value(self) -> DType_T: + raise NotImplementedError() diff --git a/src/fastcs/datatypes/enum.py b/src/fastcs/datatypes/enum.py new file mode 100644 index 000000000..e490f5c76 --- /dev/null +++ b/src/fastcs/datatypes/enum.py @@ -0,0 +1,37 @@ +import enum +from dataclasses import dataclass +from functools import cached_property +from typing import Generic, TypeVar + +from fastcs.datatypes.datatype import DataType + +Enum_T = TypeVar("Enum_T", bound=enum.Enum) +"""A builtin Enum type""" + + +@dataclass(frozen=True) +class Enum(Generic[Enum_T], DataType[Enum_T]): + enum_cls: type[Enum_T] + + def __post_init__(self): + if not issubclass(self.enum_cls, enum.Enum): + raise ValueError("Enum class has to take an Enum.") + + def index_of(self, value: Enum_T) -> int: + return self.members.index(value) + + @cached_property + def members(self) -> list[Enum_T]: + return list(self.enum_cls) + + @cached_property + def names(self) -> list[str]: + return [member.name for member in self.members] + + @property + def dtype(self) -> type[Enum_T]: + return self.enum_cls + + @property + def initial_value(self) -> Enum_T: + return self.members[0] diff --git a/src/fastcs/datatypes/float.py b/src/fastcs/datatypes/float.py new file mode 100644 index 000000000..1e310ed7a --- /dev/null +++ b/src/fastcs/datatypes/float.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Any + +from fastcs.datatypes._numeric import _Numeric + + +@dataclass(frozen=True) +class Float(_Numeric[float]): + """`DataType` mapping to builtin ``float``.""" + + prec: int = 2 + + @property + def dtype(self) -> type[float]: + return float + + def validate(self, value: Any) -> float: + _value = super().validate(value) + + if self.prec is not None: + _value = round(_value, self.prec) + + return _value diff --git a/src/fastcs/datatypes/int.py b/src/fastcs/datatypes/int.py new file mode 100644 index 000000000..31858e97e --- /dev/null +++ b/src/fastcs/datatypes/int.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from fastcs.datatypes._numeric import _Numeric + + +@dataclass(frozen=True) +class Int(_Numeric[int]): + """`DataType` mapping to builtin ``int``.""" + + @property + def dtype(self) -> type[int]: + return int diff --git a/src/fastcs/datatypes/string.py b/src/fastcs/datatypes/string.py new file mode 100644 index 000000000..93c53aaec --- /dev/null +++ b/src/fastcs/datatypes/string.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from fastcs.datatypes.datatype import DataType + + +@dataclass(frozen=True) +class String(DataType[str]): + """`DataType` mapping to builtin ``str``.""" + + length: int | None = None + """Maximum length of string to display in transports""" + + @property + def dtype(self) -> type[str]: + return str + + @property + def initial_value(self) -> str: + return "" diff --git a/src/fastcs/datatypes/table.py b/src/fastcs/datatypes/table.py new file mode 100644 index 000000000..4c9757793 --- /dev/null +++ b/src/fastcs/datatypes/table.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Any + +import numpy as np +from numpy.typing import DTypeLike + +from fastcs.datatypes.datatype import DataType + + +@dataclass(frozen=True) +class Table(DataType[np.ndarray]): + # https://numpy.org/devdocs/user/basics.rec.html#structured-datatype-creation + structured_dtype: list[tuple[str, DTypeLike]] + + @property + def dtype(self) -> type[np.ndarray]: + return np.ndarray + + @property + def initial_value(self) -> np.ndarray: + return np.array([], dtype=self.structured_dtype) + + def validate(self, value: Any) -> np.ndarray: + _value = super().validate(value) + + if self.structured_dtype != _value.dtype: + raise ValueError( + f"Value dtype {_value.dtype.descr} is not the same as the structured " + f"dtype {self.structured_dtype}" + ) + + return _value diff --git a/src/fastcs/datatypes/waveform.py b/src/fastcs/datatypes/waveform.py new file mode 100644 index 000000000..24d2ba1e6 --- /dev/null +++ b/src/fastcs/datatypes/waveform.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +import numpy as np +from numpy.typing import DTypeLike + +from fastcs.datatypes.datatype import DataType + + +@dataclass(frozen=True) +class Waveform(DataType[np.ndarray]): + array_dtype: DTypeLike + shape: tuple[int, ...] = (2000,) + + @property + def dtype(self) -> type[np.ndarray]: + return np.ndarray + + @property + def initial_value(self) -> np.ndarray: + return np.zeros(self.shape, dtype=self.array_dtype) + + def validate(self, value: np.ndarray) -> np.ndarray: + _value = super().validate(value) + + if self.array_dtype != _value.dtype: + raise ValueError( + f"Value dtype {_value.dtype} is not the same as the array dtype " + f"{self.array_dtype}" + ) + + if len(self.shape) != len(_value.shape) or any( + shape1 > shape2 + for shape1, shape2 in zip(_value.shape, self.shape, strict=True) + ): + raise ValueError( + f"Value shape {_value.shape} exceeeds the shape maximum shape " + f"{self.shape}" + ) + + return _value diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py index b8226ebb6..3946769c1 100755 --- a/src/fastcs/demo/controllers.py +++ b/src/fastcs/demo/controllers.py @@ -4,13 +4,11 @@ from dataclasses import KW_ONLY, dataclass from typing import TypeVar -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.connections import IPConnection, IPConnectionSettings -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Enum, Float, Int -from fastcs.wrappers import command, scan +from fastcs.methods import command, scan NumberT = TypeVar("NumberT", int, float) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 7165fbf8b..99874cb9d 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -10,7 +10,7 @@ from fastcs import __version__ from fastcs.control_system import FastCS -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.exceptions import LaunchError from fastcs.logging import ( GraylogEndpoint, @@ -21,7 +21,7 @@ parse_graylog_env_fields, parse_graylog_static_fields, ) -from fastcs.transport import Transport +from fastcs.transports import Transport def launch( diff --git a/src/fastcs/methods/__init__.py b/src/fastcs/methods/__init__.py new file mode 100644 index 000000000..e365d7e91 --- /dev/null +++ b/src/fastcs/methods/__init__.py @@ -0,0 +1,6 @@ +from .command import Command as Command +from .command import CommandCallback as CommandCallback +from .command import command as command +from .scan import Scan as Scan +from .scan import ScanCallback as ScanCallback +from .scan import scan as scan diff --git a/src/fastcs/methods/command.py b/src/fastcs/methods/command.py new file mode 100644 index 000000000..e73bd4851 --- /dev/null +++ b/src/fastcs/methods/command.py @@ -0,0 +1,81 @@ +from collections.abc import Callable, Coroutine +from types import MethodType + +from fastcs.controllers import BaseController +from fastcs.methods.method import Controller_T, Method + +UnboundCommandCallback = Callable[[Controller_T], Coroutine[None, None, None]] +"""A Command callback that is unbound and must be called with a `Controller` instance""" +CommandCallback = Callable[[], Coroutine[None, None, None]] +"""A Command callback that is bound and can be called without `self`""" + + +class Command(Method[BaseController]): + """A `Controller` `Method` that performs a single action when called. + + This class contains a function that is bound to a specific `Controller` instance and + is callable outside of the class context, without an explicit `self` parameter. + Calling an instance of this class will call the bound `Controller` method. + """ + + def __init__(self, fn: CommandCallback, *, group: str | None = None): + super().__init__(fn, group=group) + + def _validate(self, fn: CommandCallback) -> None: + super()._validate(fn) + + if not len(self.parameters) == 0: + raise TypeError(f"Command method cannot have arguments: {fn}") + + async def __call__(self): + return await self._fn() + + +class UnboundCommand(Method[Controller_T]): + """A wrapper of an unbound `Controller` method to be bound into a `Command`. + + This generic class stores an unbound `Controller` method - effectively a function + that takes an instance of a specific `Controller` type (`Controller_T`). Instances + of this class can be added at `Controller` definition, either manually or with use + of the `command` wrapper, to register the method to be included in the API of the + `Controller`. When the `Controller` is instantiated, these instances will be bound + to the instance, creating a `Command` instance. + """ + + def __init__( + self, fn: UnboundCommandCallback[Controller_T], *, group: str | None = None + ) -> None: + super().__init__(fn, group=group) + + def _validate(self, fn: UnboundCommandCallback[Controller_T]) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise TypeError("Command method cannot have arguments") + + def bind(self, controller: Controller_T) -> Command: + return Command(MethodType(self.fn, controller), group=self.group) + + def __call__(self): + raise NotImplementedError( + "Method must be bound to a controller instance to be callable" + ) + + +def command( + *, group: str | None = None +) -> Callable[[UnboundCommandCallback[Controller_T]], UnboundCommand[Controller_T]]: + """Decorator to register a `Controller` method as a `Command` + + The `Command` will be passed to the transport layer to expose in the API + + :param: group: Group to display this command under in the transport layer + + """ + + def wrapper( + fn: UnboundCommandCallback[Controller_T], + ) -> UnboundCommand[Controller_T]: + return UnboundCommand(fn, group=group) + + return wrapper diff --git a/src/fastcs/methods/method.py b/src/fastcs/methods/method.py new file mode 100644 index 000000000..f3d5cb098 --- /dev/null +++ b/src/fastcs/methods/method.py @@ -0,0 +1,57 @@ +from asyncio import iscoroutinefunction +from collections.abc import Callable, Coroutine +from inspect import Signature, getdoc, signature +from typing import Generic, TypeVar + +from fastcs.controllers.base_controller import BaseController +from fastcs.tracer import Tracer + +MethodCallback = Callable[..., Coroutine[None, None, None]] +"""Generic protocol for all `Controller` Method callbacks""" +Controller_T = TypeVar("Controller_T", bound=BaseController) +"""Generic `Controller` class that an unbound method must be called with as `self`""" + + +class Method(Generic[Controller_T], Tracer): + """Generic base class for all FastCS Controller methods.""" + + def __init__(self, fn: MethodCallback, *, group: str | None = None) -> None: + super().__init__() + + self._docstring = getdoc(fn) + + sig = signature(fn, eval_str=True) + self._parameters = sig.parameters + self._return_type = sig.return_annotation + self._validate(fn) + + self._fn = fn + self._group = group + self.enabled = True + + def _validate(self, fn: MethodCallback) -> None: + if self.return_type not in (None, Signature.empty): + raise TypeError("Method return type must be None or empty") + + if not iscoroutinefunction(fn): + raise TypeError("Method must be async function") + + @property + def return_type(self): + return self._return_type + + @property + def parameters(self): + return self._parameters + + @property + def docstring(self): + return self._docstring + + @property + def fn(self): + return self._fn + + @property + def group(self): + return self._group diff --git a/src/fastcs/methods/scan.py b/src/fastcs/methods/scan.py new file mode 100644 index 000000000..39efd3cfc --- /dev/null +++ b/src/fastcs/methods/scan.py @@ -0,0 +1,89 @@ +from collections.abc import Callable, Coroutine +from types import MethodType + +from fastcs.controllers import BaseController +from fastcs.methods.method import Controller_T, Method + +UnboundScanCallback = Callable[[Controller_T], Coroutine[None, None, None]] +"""A Scan callback that is unbound and must be called with a `Controller` instance""" +ScanCallback = Callable[[], Coroutine[None, None, None]] +"""A Scan callback that is bound and can be called without `self`""" + + +class Scan(Method[BaseController]): + """A `Controller` `Method` that will be called periodically in the background. + + This class contains a function that is bound to a specific `Controller` instance and + is callable outside of the class context, without an explicit `self` parameter. + Calling an instance of this class will call the bound `Controller` method. + """ + + def __init__(self, fn: ScanCallback, period: float): + super().__init__(fn) + + self._period = period + + @property + def period(self): + return self._period + + def _validate(self, fn: ScanCallback) -> None: + super()._validate(fn) + + if not len(self.parameters) == 0: + raise TypeError("Scan method cannot have arguments") + + async def __call__(self): + return await self._fn() + + +class UnboundScan(Method[Controller_T]): + """A wrapper of an unbound `Controller` method to be bound into a `Scan`. + + This generic class stores an unbound `Controller` method - effectively a function + that takes an instance of a specific `Controller` type (`Controller_T`). Instances + of this class can be added at `Controller` definition, either manually or with use + of the `scan` wrapper, to register the method to be included in the API of the + `Controller`. When the `Controller` is instantiated, these instances will be bound + to the instance, creating a `Scan` instance. + """ + + def __init__(self, fn: UnboundScanCallback[Controller_T], period: float) -> None: + super().__init__(fn) + + self._period = period + + @property + def period(self): + return self._period + + def _validate(self, fn: UnboundScanCallback[Controller_T]) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise TypeError("Scan method cannot have arguments") + + def bind(self, controller: Controller_T) -> Scan: + return Scan(MethodType(self.fn, controller), self._period) + + def __call__(self): + raise NotImplementedError( + "Method must be bound to a controller instance to be callable" + ) + + +def scan( + period: float, +) -> Callable[[UnboundScanCallback[Controller_T]], UnboundScan[Controller_T]]: + """Decorator to register a `Controller` method as a `Scan` + + The `Scan` method will be called periodically in the background. + """ + + if period <= 0: + raise ValueError("Scan method must have a positive scan period") + + def wrapper(fn: UnboundScanCallback[Controller_T]) -> UnboundScan[Controller_T]: + return UnboundScan(fn, period) + + return wrapper diff --git a/src/fastcs/transport/epics/util.py b/src/fastcs/transport/epics/util.py deleted file mode 100644 index 431afe4b8..000000000 --- a/src/fastcs/transport/epics/util.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastcs.controller_api import ControllerAPI -from fastcs.util import snake_to_pascal - - -def controller_pv_prefix(prefix: str, controller_api: ControllerAPI) -> str: - return ":".join([prefix] + [snake_to_pascal(node) for node in controller_api.path]) diff --git a/src/fastcs/transport/graphql/__init__.py b/src/fastcs/transport/graphql/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastcs/transport/rest/__init__.py b/src/fastcs/transport/rest/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastcs/transport/tango/__init__.py b/src/fastcs/transport/tango/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastcs/transport/__init__.py b/src/fastcs/transports/__init__.py similarity index 93% rename from src/fastcs/transport/__init__.py rename to src/fastcs/transports/__init__.py index c5bc22b5d..0c91f9f17 100644 --- a/src/fastcs/transport/__init__.py +++ b/src/fastcs/transports/__init__.py @@ -1,3 +1,4 @@ +from .controller_api import ControllerAPI as ControllerAPI from .transport import Transport as Transport try: diff --git a/src/fastcs/controller_api.py b/src/fastcs/transports/controller_api.py similarity index 91% rename from src/fastcs/controller_api.py rename to src/fastcs/transports/controller_api.py index 431105100..9048d5ef5 100644 --- a/src/fastcs/controller_api.py +++ b/src/fastcs/transports/controller_api.py @@ -3,19 +3,19 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import ONCE, Attribute, AttrR -from fastcs.cs_methods import Command, Scan, ScanCallback -from fastcs.logging import logger as _fastcs_logger +from fastcs.attributes import Attribute, AttributeIORef, AttrR +from fastcs.logging import bind_logger +from fastcs.methods import Command, Scan, ScanCallback from fastcs.tracer import Tracer +from fastcs.util import ONCE tracer = Tracer(name=__name__) -logger = _fastcs_logger.bind(logger_name=__name__) +logger = bind_logger(logger_name=__name__) @dataclass class ControllerAPI: - """Attributes, bound methods and sub APIs of a `Controller`""" + """Attributes, Methods and sub APIs of a `Controller` to expose in a transport""" path: list[str] = field(default_factory=list) """Path within controller tree (empty if this is the root)""" diff --git a/src/fastcs/transports/epics/__init__.py b/src/fastcs/transports/epics/__init__.py new file mode 100644 index 000000000..0dad2e91e --- /dev/null +++ b/src/fastcs/transports/epics/__init__.py @@ -0,0 +1,4 @@ +from .docs import EpicsDocsOptions as EpicsDocsOptions +from .options import EpicsGUIFormat as EpicsGUIFormat +from .options import EpicsGUIOptions as EpicsGUIOptions +from .options import EpicsIOCOptions as EpicsIOCOptions diff --git a/src/fastcs/transports/epics/ca/__init__.py b/src/fastcs/transports/epics/ca/__init__.py new file mode 100644 index 000000000..0a2f59405 --- /dev/null +++ b/src/fastcs/transports/epics/ca/__init__.py @@ -0,0 +1 @@ +from .transport import EpicsCATransport as EpicsCATransport diff --git a/src/fastcs/transport/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py similarity index 95% rename from src/fastcs/transport/epics/ca/ioc.py rename to src/fastcs/transports/epics/ca/ioc.py index 7075b2915..b141a7ebe 100644 --- a/src/fastcs/transport/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -7,21 +7,20 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import Command -from fastcs.datatypes import DataType, T +from fastcs.datatypes import DataType, DType_T from fastcs.logging import bind_logger +from fastcs.methods import Command from fastcs.tracer import Tracer -from fastcs.transport.epics.ca.util import ( +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca.util import ( builder_callable_from_attribute, cast_from_epics_type, cast_to_epics_type, record_metadata_from_attribute, record_metadata_from_datatype, ) -from fastcs.transport.epics.options import EpicsIOCOptions -from fastcs.transport.epics.util import controller_pv_prefix -from fastcs.util import snake_to_pascal +from fastcs.transports.epics.util import controller_pv_prefix, snake_to_pascal EPICS_MAX_NAME_LENGTH = 60 @@ -166,11 +165,11 @@ def _create_and_link_attribute_pvs( def _create_and_link_read_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[T] + pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrR[DType_T] ) -> None: pv = f"{pv_prefix}:{pv_name}" - async def async_record_set(value: T): + async def async_record_set(value: DType_T): tracer.log_event("PV set from attribute", topic=attribute, pv=pv, value=value) record.set(cast_to_epics_type(attribute.datatype, value)) @@ -208,7 +207,7 @@ def datatype_updater(datatype: DataType): def _create_and_link_write_pv( - pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[T] + pv_prefix: str, pv_name: str, attr_name: str, attribute: AttrW[DType_T] ) -> None: pv = f"{pv_prefix}:{pv_name}" @@ -217,7 +216,7 @@ async def on_update(value): await attribute.put(cast_from_epics_type(attribute.datatype, value)) - async def set_setpoint_without_process(value: T): + async def set_setpoint_without_process(value: DType_T): tracer.log_event( "PV setpoint set from attribute", topic=attribute, pv=pv, value=value ) diff --git a/src/fastcs/transport/epics/ca/transport.py b/src/fastcs/transports/epics/ca/transport.py similarity index 78% rename from src/fastcs/transport/epics/ca/transport.py rename to src/fastcs/transports/epics/ca/transport.py index a78768eb2..13f072314 100644 --- a/src/fastcs/transport/epics/ca/transport.py +++ b/src/fastcs/transports/epics/ca/transport.py @@ -4,19 +4,19 @@ from softioc import softioc -from fastcs.controller_api import ControllerAPI -from fastcs.logging import logger as _fastcs_logger -from fastcs.transport.epics.ca.ioc import EpicsCAIOC -from fastcs.transport.epics.docs import EpicsDocs -from fastcs.transport.epics.gui import EpicsGUI -from fastcs.transport.epics.options import ( +from fastcs.logging import bind_logger +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics import ( EpicsDocsOptions, EpicsGUIOptions, EpicsIOCOptions, ) -from fastcs.transport.transport import Transport +from fastcs.transports.epics.ca.ioc import EpicsCAIOC +from fastcs.transports.epics.docs import EpicsDocs +from fastcs.transports.epics.gui import EpicsGUI +from fastcs.transports.transport import Transport -logger = _fastcs_logger.bind(logger_name=__name__) +logger = bind_logger(logger_name=__name__) @dataclass diff --git a/src/fastcs/transport/epics/ca/util.py b/src/fastcs/transports/epics/ca/util.py similarity index 94% rename from src/fastcs/transport/epics/ca/util.py rename to src/fastcs/transports/epics/ca/util.py index 29dfee287..5173be569 100644 --- a/src/fastcs/transport/epics/ca/util.py +++ b/src/fastcs/transports/epics/ca/util.py @@ -4,7 +4,7 @@ from softioc import builder from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, T, Waveform +from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform from fastcs.exceptions import FastCSError _MBB_FIELD_PREFIXES = ( @@ -45,7 +45,7 @@ def record_metadata_from_attribute( - attribute: Attribute[T], + attribute: Attribute[DType_T], ) -> dict[str, Any]: """Converts attributes on the `Attribute` to the field name/value in the record metadata.""" @@ -62,7 +62,7 @@ def record_metadata_from_attribute( def record_metadata_from_datatype( - datatype: DataType[T], out_record: bool = False + datatype: DataType[DType_T], out_record: bool = False ) -> dict[str, str]: """Converts attributes on the `DataType` to the field name/value in the record metadata.""" @@ -111,7 +111,7 @@ def _verify_in_datatype(_, value): return arguments -def cast_from_epics_type(datatype: DataType[T], value: object) -> T: +def cast_from_epics_type(datatype: DataType[DType_T], value: object) -> DType_T: """Casts from an EPICS datatype to a FastCS datatype.""" match datatype: case Bool(): @@ -132,7 +132,7 @@ def cast_from_epics_type(datatype: DataType[T], value: object) -> T: raise ValueError(f"Unsupported datatype {datatype}") -def cast_to_epics_type(datatype: DataType[T], value: T) -> Any: +def cast_to_epics_type(datatype: DataType[DType_T], value: DType_T) -> Any: """Casts from an attribute's datatype to an EPICS datatype.""" match datatype: case Enum(): diff --git a/src/fastcs/transport/epics/docs.py b/src/fastcs/transports/epics/docs.py similarity index 86% rename from src/fastcs/transport/epics/docs.py rename to src/fastcs/transports/epics/docs.py index d4c3db8c3..cbc4937dc 100644 --- a/src/fastcs/transport/epics/docs.py +++ b/src/fastcs/transports/epics/docs.py @@ -1,4 +1,4 @@ -from fastcs.controller_api import ControllerAPI +from fastcs.transports.controller_api import ControllerAPI from .options import EpicsDocsOptions diff --git a/src/fastcs/transport/epics/gui.py b/src/fastcs/transports/epics/gui.py similarity index 93% rename from src/fastcs/transport/epics/gui.py rename to src/fastcs/transports/epics/gui.py index bc38478d4..4cfa5f31f 100644 --- a/src/fastcs/transport/epics/gui.py +++ b/src/fastcs/transports/epics/gui.py @@ -23,8 +23,6 @@ from pydantic import ValidationError from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import Command from fastcs.datatypes import ( Bool, DataType, @@ -34,11 +32,11 @@ String, Waveform, ) -from fastcs.exceptions import FastCSError from fastcs.logging import bind_logger -from fastcs.util import snake_to_pascal - -from .options import EpicsGUIFormat, EpicsGUIOptions +from fastcs.methods import Command +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics.options import EpicsGUIFormat, EpicsGUIOptions +from fastcs.transports.epics.util import snake_to_pascal logger = bind_logger(logger_name=__name__) @@ -69,7 +67,7 @@ def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None: case Waveform(): return None case datatype: - raise FastCSError(f"Unsupported type {type(datatype)}: {datatype}") + raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | None: match fastcs_datatype: @@ -84,7 +82,7 @@ def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | Non case Waveform(): return None case datatype: - raise FastCSError(f"Unsupported type {type(datatype)}: {datatype}") + raise TypeError(f"Unsupported type {type(datatype)}: {datatype}") def _get_attribute_component( self, attr_path: list[str], name: str, attribute: Attribute @@ -126,7 +124,7 @@ def _get_attribute_component( write_widget=write_widget, ) case _: - raise FastCSError(f"Unsupported attribute type: {type(attribute)}") + raise TypeError(f"Unsupported attribute type: {type(attribute)}") def _get_command_component(self, attr_path: list[str], name: str): pv = self._get_pv(attr_path, name) diff --git a/src/fastcs/transport/epics/options.py b/src/fastcs/transports/epics/options.py similarity index 100% rename from src/fastcs/transport/epics/options.py rename to src/fastcs/transports/epics/options.py diff --git a/src/fastcs/transports/epics/pva/__init__.py b/src/fastcs/transports/epics/pva/__init__.py new file mode 100644 index 000000000..5358e1351 --- /dev/null +++ b/src/fastcs/transports/epics/pva/__init__.py @@ -0,0 +1 @@ +from .transport import EpicsPVATransport as EpicsPVATransport diff --git a/src/fastcs/transport/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py similarity index 99% rename from src/fastcs/transport/epics/pva/_pv_handlers.py rename to src/fastcs/transports/epics/pva/_pv_handlers.py index f8be29a77..06430becb 100644 --- a/src/fastcs/transport/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -7,8 +7,8 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.cs_methods import CommandCallback from fastcs.datatypes import Table +from fastcs.methods import CommandCallback from fastcs.tracer import Tracer from .types import ( diff --git a/src/fastcs/transport/epics/pva/gui.py b/src/fastcs/transports/epics/pva/gui.py similarity index 91% rename from src/fastcs/transport/epics/pva/gui.py rename to src/fastcs/transports/epics/pva/gui.py index 425651432..10bd44dfa 100644 --- a/src/fastcs/transport/epics/pva/gui.py +++ b/src/fastcs/transports/epics/pva/gui.py @@ -6,9 +6,8 @@ WriteWidgetUnion, ) -from fastcs.datatypes import Bool, DataType, Table -from fastcs.transport.epics.gui import EpicsGUI -from fastcs.util import numpy_to_fastcs_datatype +from fastcs.datatypes import Bool, DataType, Table, numpy_to_fastcs_datatype +from fastcs.transports.epics.gui import EpicsGUI class PvaEpicsGUI(EpicsGUI): diff --git a/src/fastcs/transport/epics/pva/ioc.py b/src/fastcs/transports/epics/pva/ioc.py similarity index 93% rename from src/fastcs/transport/epics/pva/ioc.py rename to src/fastcs/transports/epics/pva/ioc.py index 7abb07ae9..5b2dc29dc 100644 --- a/src/fastcs/transport/epics/pva/ioc.py +++ b/src/fastcs/transports/epics/pva/ioc.py @@ -3,9 +3,8 @@ from p4p.server import Server, StaticProvider from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI -from fastcs.transport.epics.util import controller_pv_prefix -from fastcs.util import snake_to_pascal +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics.util import controller_pv_prefix, snake_to_pascal from ._pv_handlers import ( make_command_pv, diff --git a/src/fastcs/transport/epics/pva/pvi.py b/src/fastcs/transports/epics/pva/pvi.py similarity index 97% rename from src/fastcs/transport/epics/pva/pvi.py rename to src/fastcs/transports/epics/pva/pvi.py index bb11defb9..a0266cb2b 100644 --- a/src/fastcs/transport/epics/pva/pvi.py +++ b/src/fastcs/transports/epics/pva/pvi.py @@ -7,8 +7,8 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI -from fastcs.util import snake_to_pascal +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics.util import snake_to_pascal from .types import p4p_alarm_states, p4p_timestamp_now diff --git a/src/fastcs/transport/epics/pva/transport.py b/src/fastcs/transports/epics/pva/transport.py similarity index 75% rename from src/fastcs/transport/epics/pva/transport.py rename to src/fastcs/transports/epics/pva/transport.py index c250e0ae2..1ccc8042d 100644 --- a/src/fastcs/transport/epics/pva/transport.py +++ b/src/fastcs/transports/epics/pva/transport.py @@ -1,20 +1,20 @@ import asyncio from dataclasses import dataclass, field -from fastcs.controller_api import ControllerAPI -from fastcs.logging import logger as _fastcs_logger -from fastcs.transport.epics.docs import EpicsDocs -from fastcs.transport.epics.options import ( +from fastcs.logging import bind_logger +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics import ( EpicsDocsOptions, EpicsGUIOptions, EpicsIOCOptions, ) -from fastcs.transport.epics.pva.gui import PvaEpicsGUI -from fastcs.transport.transport import Transport +from fastcs.transports.epics.docs import EpicsDocs +from fastcs.transports.epics.pva.gui import PvaEpicsGUI +from fastcs.transports.transport import Transport from .ioc import P4PIOC -logger = _fastcs_logger.bind(logger_name=__name__) +logger = bind_logger(logger_name=__name__) @dataclass diff --git a/src/fastcs/transport/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py similarity index 93% rename from src/fastcs/transport/epics/pva/types.py rename to src/fastcs/transports/epics/pva/types.py index 256d5d832..80f79b503 100644 --- a/src/fastcs/transport/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -7,7 +7,7 @@ from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable from fastcs.attributes import Attribute, AttrR, AttrW -from fastcs.datatypes import Bool, Enum, Float, Int, String, T, Table, Waveform +from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, String, Table, Waveform P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) @@ -51,7 +51,8 @@ def _table_with_numpy_dtypes_to_p4p_dtypes(numpy_dtypes: list[tuple[str, DTypeLi def make_p4p_type( attribute: Attribute, ) -> NTScalar | NTEnum | NTNDArray | NTTable: - """Creates a p4p type for a given `Attribute` s `fastcs.datatypes.DataType`.""" + """Creates a p4p type for a given `Attribute` `DataType`.""" + display = isinstance(attribute, AttrR) control = isinstance(attribute, AttrW) match attribute.datatype: @@ -85,7 +86,7 @@ def make_p4p_type( raise RuntimeError(f"DataType `{attribute.datatype}` unsupported in P4P.") -def cast_from_p4p_value(attribute: Attribute[T], value: object) -> T: +def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T: """Converts from a p4p value to a FastCS `Attribute` value.""" match attribute.datatype: case Enum(): @@ -153,7 +154,7 @@ def p4p_display(attribute: Attribute) -> dict: return {} -def _p4p_check_numerical_for_alarm_states(datatype: Int | Float, value: T) -> dict: +def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType_T) -> dict: low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore severity = ( @@ -176,9 +177,8 @@ def _p4p_check_numerical_for_alarm_states(datatype: Int | Float, value: T) -> di return p4p_alarm_states(severity, status, message) -def cast_to_p4p_value(attribute: Attribute[T], value: T) -> object: - """Converts a FastCS `Attribute` value to a p4p value, - including metadata and alarm states.""" +def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: + """Converts a FastCS ``Attribute`` value to a p4p value""" match attribute.datatype: case Enum(): return { @@ -197,7 +197,7 @@ def cast_to_p4p_value(attribute: Attribute[T], value: T) -> object: if isinstance(datatype, (Float | Int)): record_fields.update( - _p4p_check_numerical_for_alarm_states( + _p4p_check_numeric_for_alarm_states( datatype, value, ) diff --git a/src/fastcs/transports/epics/util.py b/src/fastcs/transports/epics/util.py new file mode 100644 index 000000000..9f8359001 --- /dev/null +++ b/src/fastcs/transports/epics/util.py @@ -0,0 +1,16 @@ +import re + +from fastcs.transports.controller_api import ControllerAPI + + +def snake_to_pascal(name: str) -> str: + """Converts string from snake case to Pascal case. + If string is not a valid snake case it will be returned unchanged + """ + if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*", name): + name = re.sub(r"(?:^|_)([a-z0-9])", lambda match: match.group(1).upper(), name) + return name + + +def controller_pv_prefix(prefix: str, controller_api: ControllerAPI) -> str: + return ":".join([prefix] + [snake_to_pascal(node) for node in controller_api.path]) diff --git a/src/fastcs/transport/epics/__init__.py b/src/fastcs/transports/graphql/__init__.py similarity index 100% rename from src/fastcs/transport/epics/__init__.py rename to src/fastcs/transports/graphql/__init__.py diff --git a/src/fastcs/transport/graphql/graphql.py b/src/fastcs/transports/graphql/graphql.py similarity index 94% rename from src/fastcs/transport/graphql/graphql.py rename to src/fastcs/transports/graphql/graphql.py index 3560f659e..79eeb7d17 100644 --- a/src/fastcs/transport/graphql/graphql.py +++ b/src/fastcs/transports/graphql/graphql.py @@ -7,10 +7,11 @@ from strawberry.tools import create_type from strawberry.types.field import StrawberryField -from fastcs.attributes import AttrR, AttrRW, AttrW, T -from fastcs.controller_api import ControllerAPI +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes.datatype import DType_T from fastcs.exceptions import FastCSError from fastcs.logging import intercept_std_logger +from fastcs.transports.controller_api import ControllerAPI from .options import GraphQLServerOptions @@ -116,8 +117,8 @@ def create_schema(self) -> strawberry.Schema: def _wrap_attr_set( - attr_name: str, attribute: AttrW[T] -) -> Callable[[T], Coroutine[Any, Any, None]]: + attr_name: str, attribute: AttrW[DType_T] +) -> Callable[[DType_T], Coroutine[Any, Any, None]]: """Wrap an attribute in a function with annotations for strawberry""" async def _dynamic_f(value): @@ -133,7 +134,7 @@ async def _dynamic_f(value): def _wrap_attr_get( - attr_name: str, attribute: AttrR[T] + attr_name: str, attribute: AttrR[DType_T] ) -> Callable[[], Coroutine[Any, Any, Any]]: """Wrap an attribute in a function with annotations for strawberry""" diff --git a/src/fastcs/transport/graphql/options.py b/src/fastcs/transports/graphql/options.py similarity index 100% rename from src/fastcs/transport/graphql/options.py rename to src/fastcs/transports/graphql/options.py diff --git a/src/fastcs/transport/graphql/transport.py b/src/fastcs/transports/graphql/transport.py similarity index 85% rename from src/fastcs/transport/graphql/transport.py rename to src/fastcs/transports/graphql/transport.py index b919bb14a..2e040e12e 100644 --- a/src/fastcs/transport/graphql/transport.py +++ b/src/fastcs/transports/graphql/transport.py @@ -1,8 +1,8 @@ import asyncio from dataclasses import dataclass, field -from fastcs.controller_api import ControllerAPI -from fastcs.transport.transport import Transport +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.transport import Transport from .graphql import GraphQLServer from .options import GraphQLServerOptions diff --git a/src/fastcs/transport/epics/ca/__init__.py b/src/fastcs/transports/rest/__init__.py similarity index 100% rename from src/fastcs/transport/epics/ca/__init__.py rename to src/fastcs/transports/rest/__init__.py diff --git a/src/fastcs/transport/rest/options.py b/src/fastcs/transports/rest/options.py similarity index 100% rename from src/fastcs/transport/rest/options.py rename to src/fastcs/transports/rest/options.py diff --git a/src/fastcs/transport/rest/rest.py b/src/fastcs/transports/rest/rest.py similarity index 92% rename from src/fastcs/transport/rest/rest.py rename to src/fastcs/transports/rest/rest.py index 66f7d3dee..f6600f94f 100644 --- a/src/fastcs/transport/rest/rest.py +++ b/src/fastcs/transports/rest/rest.py @@ -5,10 +5,11 @@ from fastapi import FastAPI from pydantic import create_model -from fastcs.attributes import AttrR, AttrRW, AttrW, T -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import CommandCallback +from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.datatypes.datatype import DType_T from fastcs.logging import intercept_std_logger +from fastcs.methods import CommandCallback +from fastcs.transports.controller_api import ControllerAPI from .options import RestServerOptions from .util import ( @@ -49,7 +50,7 @@ async def serve(self, options: RestServerOptions | None): await self._server.serve() -def _put_request_body(attribute: AttrW[T]): +def _put_request_body(attribute: AttrW[DType_T]): """ Creates a pydantic model for each datatype which defines the schema of the PUT request body @@ -64,8 +65,8 @@ def _put_request_body(attribute: AttrW[T]): def _wrap_attr_put( - attribute: AttrW[T], -) -> Callable[[T], Coroutine[Any, Any, None]]: + attribute: AttrW[DType_T], +) -> Callable[[DType_T], Coroutine[Any, Any, None]]: async def attr_put(request): await attribute.put(cast_from_rest_type(attribute.datatype, request.value)) @@ -75,7 +76,7 @@ async def attr_put(request): return attr_put -def _get_response_body(attribute: AttrR[T]): +def _get_response_body(attribute: AttrR[DType_T]): """ Creates a pydantic model for each datatype which defines the schema of the GET request body @@ -90,7 +91,7 @@ def _get_response_body(attribute: AttrR[T]): def _wrap_attr_get( - attribute: AttrR[T], + attribute: AttrR[DType_T], ) -> Callable[[], Coroutine[Any, Any, Any]]: async def attr_get() -> Any: # Must be any as response_model is set value = attribute.get() # type: ignore diff --git a/src/fastcs/transport/rest/transport.py b/src/fastcs/transports/rest/transport.py similarity index 85% rename from src/fastcs/transport/rest/transport.py rename to src/fastcs/transports/rest/transport.py index 3f3730a53..42490762c 100644 --- a/src/fastcs/transport/rest/transport.py +++ b/src/fastcs/transports/rest/transport.py @@ -1,8 +1,8 @@ import asyncio from dataclasses import dataclass, field -from fastcs.controller_api import ControllerAPI -from fastcs.transport.transport import Transport +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.transport import Transport from .options import RestServerOptions from .rest import RestServer diff --git a/src/fastcs/transport/rest/util.py b/src/fastcs/transports/rest/util.py similarity index 76% rename from src/fastcs/transport/rest/util.py rename to src/fastcs/transports/rest/util.py index e714338b3..faba0bc50 100644 --- a/src/fastcs/transport/rest/util.py +++ b/src/fastcs/transports/rest/util.py @@ -1,11 +1,11 @@ import numpy as np -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, T, Waveform +from fastcs.datatypes import Bool, DataType, DType_T, Enum, Float, Int, String, Waveform REST_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String) -def convert_datatype(datatype: DataType[T]) -> type: +def convert_datatype(datatype: DataType[DType_T]) -> type: """Converts a datatype to a rest serialisable type.""" match datatype: case Waveform(): @@ -14,7 +14,7 @@ def convert_datatype(datatype: DataType[T]) -> type: return datatype.dtype -def cast_to_rest_type(datatype: DataType[T], value: T) -> object: +def cast_to_rest_type(datatype: DataType[DType_T], value: DType_T) -> object: """Casts from an attribute value to a rest value.""" match datatype: case Waveform(): @@ -25,7 +25,7 @@ def cast_to_rest_type(datatype: DataType[T], value: T) -> object: raise ValueError(f"Unsupported datatype {datatype}") -def cast_from_rest_type(datatype: DataType[T], value: object) -> T: +def cast_from_rest_type(datatype: DataType[DType_T], value: object) -> DType_T: """Casts from a rest value to an attribute datatype.""" match datatype: case Waveform(): diff --git a/src/fastcs/transport/epics/pva/__init__.py b/src/fastcs/transports/tango/__init__.py similarity index 100% rename from src/fastcs/transport/epics/pva/__init__.py rename to src/fastcs/transports/tango/__init__.py diff --git a/src/fastcs/transport/tango/dsr.py b/src/fastcs/transports/tango/dsr.py similarity index 98% rename from src/fastcs/transport/tango/dsr.py rename to src/fastcs/transports/tango/dsr.py index dc929a5f7..09e25ed85 100644 --- a/src/fastcs/transport/tango/dsr.py +++ b/src/fastcs/transports/tango/dsr.py @@ -7,8 +7,8 @@ from tango.server import Device from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import CommandCallback +from fastcs.methods import CommandCallback +from fastcs.transports.controller_api import ControllerAPI from .options import TangoDSROptions from .util import ( diff --git a/src/fastcs/transport/tango/options.py b/src/fastcs/transports/tango/options.py similarity index 100% rename from src/fastcs/transport/tango/options.py rename to src/fastcs/transports/tango/options.py diff --git a/src/fastcs/transport/tango/transport.py b/src/fastcs/transports/tango/transport.py similarity index 82% rename from src/fastcs/transport/tango/transport.py rename to src/fastcs/transports/tango/transport.py index df646148c..e23dd8941 100644 --- a/src/fastcs/transport/tango/transport.py +++ b/src/fastcs/transports/tango/transport.py @@ -1,8 +1,8 @@ import asyncio from dataclasses import dataclass, field -from fastcs.controller_api import ControllerAPI -from fastcs.transport.transport import Transport +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.transport import Transport from .dsr import TangoDSR, TangoDSROptions diff --git a/src/fastcs/transport/tango/util.py b/src/fastcs/transports/tango/util.py similarity index 86% rename from src/fastcs/transport/tango/util.py rename to src/fastcs/transports/tango/util.py index c60e1a431..d6785468d 100644 --- a/src/fastcs/transport/tango/util.py +++ b/src/fastcs/transports/tango/util.py @@ -4,7 +4,16 @@ from tango import AttrDataFormat from fastcs.attributes import Attribute -from fastcs.datatypes import Bool, DataType, Enum, Float, Int, String, T, Waveform +from fastcs.datatypes import ( + Bool, + DataType, + DType_T, + Enum, + Float, + Int, + String, + Waveform, +) TANGO_ALLOWED_DATATYPES = (Bool, DataType, Enum, Float, Int, String, Waveform) @@ -18,7 +27,7 @@ def get_server_metadata_from_attribute( - attribute: Attribute[T], + attribute: Attribute[DType_T], ) -> dict[str, Any]: """Gets the metadata for a Tango field from an attribute.""" arguments = {} @@ -26,7 +35,7 @@ def get_server_metadata_from_attribute( return arguments -def get_server_metadata_from_datatype(datatype: DataType[T]) -> dict[str, str]: +def get_server_metadata_from_datatype(datatype: DataType[DType_T]) -> dict[str, str]: """Gets the metadata for a Tango field from a FastCS datatype.""" arguments = { DATATYPE_FIELD_TO_SERVER_FIELD[field]: value @@ -62,7 +71,7 @@ def get_server_metadata_from_datatype(datatype: DataType[T]) -> dict[str, str]: return arguments -def cast_to_tango_type(datatype: DataType[T], value: T) -> object: +def cast_to_tango_type(datatype: DataType[DType_T], value: DType_T) -> object: """Casts a value from FastCS to tango datatype.""" match datatype: case Enum(): @@ -73,7 +82,7 @@ def cast_to_tango_type(datatype: DataType[T], value: T) -> object: raise ValueError(f"Unsupported datatype {datatype}") -def cast_from_tango_type(datatype: DataType[T], value: object) -> T: +def cast_from_tango_type(datatype: DataType[DType_T], value: object) -> DType_T: """Casts a value from tango to FastCS datatype.""" match datatype: case Enum(): diff --git a/src/fastcs/transport/transport.py b/src/fastcs/transports/transport.py similarity index 93% rename from src/fastcs/transport/transport.py rename to src/fastcs/transports/transport.py index ca5995dbf..5b34ff03b 100644 --- a/src/fastcs/transport/transport.py +++ b/src/fastcs/transports/transport.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, Union -from fastcs.controller_api import ControllerAPI +from fastcs.transports.controller_api import ControllerAPI @dataclass diff --git a/src/fastcs/util.py b/src/fastcs/util.py index e4f526a49..1ef11621a 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -1,28 +1,2 @@ -import re - -import numpy as np - -from fastcs.datatypes import Bool, DataType, Float, Int, String - - -def snake_to_pascal(name: str) -> str: - """Converts string from snake case to Pascal case. - If string is not a valid snake case it will be returned unchanged - """ - if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*", name): - name = re.sub(r"(?:^|_)([a-z0-9])", lambda match: match.group(1).upper(), name) - return name - - -def numpy_to_fastcs_datatype(np_type) -> DataType: - """Converts numpy types to fastcs types for widget creation. - Only types important for widget creation are explicitly converted - """ - if np.issubdtype(np_type, np.integer): - return Int() - elif np.issubdtype(np_type, np.floating): - return Float() - elif np.issubdtype(np_type, np.bool_): - return Bool() - else: - return String() +ONCE = float("inf") +"""Sentinel value to call a ``scan`` or io ``update`` method once on start up""" diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py deleted file mode 100644 index c86985a01..000000000 --- a/src/fastcs/wrappers.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import Callable - -from .cs_methods import ( - Controller_T, - UnboundCommand, - UnboundCommandCallback, - UnboundScan, - UnboundScanCallback, -) -from .exceptions import FastCSError - - -def scan( - period: float, -) -> Callable[[UnboundScanCallback[Controller_T]], UnboundScan[Controller_T]]: - """Sets up a scan over the wrapped method.""" - - if period <= 0: - raise FastCSError("Scan method must have a positive scan period") - - def wrapper(fn: UnboundScanCallback[Controller_T]) -> UnboundScan[Controller_T]: - return UnboundScan(fn, period) - - return wrapper - - -def command( - *, group: str | None = None -) -> Callable[[UnboundCommandCallback[Controller_T]], UnboundCommand[Controller_T]]: - """Decorator to tag a `Controller` method to be turned into a `Command`. - - Args: - group: Group to display this command under in the transport layer - - """ - - def wrapper( - fn: UnboundCommandCallback[Controller_T], - ) -> UnboundCommand[Controller_T]: - return UnboundCommand(fn, group=group) - - return wrapper diff --git a/tests/assertable_controller.py b/tests/assertable_controller.py index 57327995c..b988be27a 100644 --- a/tests/assertable_controller.py +++ b/tests/assertable_controller.py @@ -5,14 +5,12 @@ from pytest_mock import MockerFixture, MockType -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.control_system import build_controller_api -from fastcs.controller import Controller -from fastcs.controller_api import ControllerAPI -from fastcs.datatypes import Int, T -from fastcs.wrappers import command, scan +from fastcs.controllers import Controller +from fastcs.datatypes import DType_T, Int +from fastcs.methods import command, scan +from fastcs.transports import ControllerAPI @dataclass @@ -20,11 +18,11 @@ class MyTestAttributeIORef(AttributeIORef): update_period = 1 -class MyTestAttributeIO(AttributeIO[T, MyTestAttributeIORef]): - async def update(self, attr: AttrR[T, MyTestAttributeIORef]): +class MyTestAttributeIO(AttributeIO[DType_T, MyTestAttributeIORef]): + async def update(self, attr: AttrR[DType_T, MyTestAttributeIORef]): print(f"update {attr}") - async def send(self, attr: AttrW[T, MyTestAttributeIORef], value: T): + async def send(self, attr: AttrW[DType_T, MyTestAttributeIORef], value: DType_T): print(f"sending {attr} = {value}") if isinstance(attr, AttrRW): await attr.update(value) diff --git a/tests/benchmarking/controller.py b/tests/benchmarking/controller.py index 363773453..7ffc2238a 100644 --- a/tests/benchmarking/controller.py +++ b/tests/benchmarking/controller.py @@ -2,14 +2,14 @@ from fastcs import FastCS from fastcs.attributes import AttrR, AttrW -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Bool, Int -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsIOCOptions -from fastcs.transport.rest.options import RestServerOptions -from fastcs.transport.rest.transport import RestTransport -from fastcs.transport.tango.options import TangoDSROptions -from fastcs.transport.tango.transport import TangoTransport +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca.transport import EpicsCATransport +from fastcs.transports.rest.options import RestServerOptions +from fastcs.transports.rest.transport import RestTransport +from fastcs.transports.tango.options import TangoDSROptions +from fastcs.transports.tango.transport import TangoTransport class MyTestController(Controller): diff --git a/tests/conftest.py b/tests/conftest.py index 21373247e..731bbe2e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ from fastcs.datatypes import Bool, Float, Int, String from fastcs.logging import configure_logging, logger from fastcs.logging._logging import LogLevel -from fastcs.transport.tango.dsr import register_dev +from fastcs.transports.tango.dsr import register_dev 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 diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 57f28a6df..b6dc843a7 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -4,17 +4,13 @@ import numpy as np -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, ControllerVector -from fastcs.datatypes import Bool, Enum, Float, Int, T, Table, Waveform +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.controllers import Controller, ControllerVector +from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, Table, Waveform from fastcs.launch import FastCS -from fastcs.transport.epics.options import ( - EpicsIOCOptions, -) -from fastcs.transport.epics.pva.transport import EpicsPVATransport -from fastcs.wrappers import command, scan +from fastcs.methods import command, scan +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.pva import EpicsPVATransport @dataclass @@ -22,8 +18,8 @@ class SimpleAttributeIORef(AttributeIORef): pass -class SimpleAttributeIO(AttributeIO[T, SimpleAttributeIORef]): - async def send(self, attr: AttrW[T, SimpleAttributeIORef], value): +class SimpleAttributeIO(AttributeIO[DType_T, SimpleAttributeIORef]): + async def send(self, attr: AttrW[DType_T, SimpleAttributeIORef], value): if isinstance(attr, AttrRW): await attr.update(value) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 0ba72b2b5..929e42470 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -1,12 +1,12 @@ from pathlib import Path from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller, ControllerVector +from fastcs.control_system import FastCS +from fastcs.controllers import Controller, ControllerVector from fastcs.datatypes import Int -from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport, EpicsGUIOptions -from fastcs.transport.epics.options import EpicsIOCOptions -from fastcs.wrappers import command +from fastcs.methods import command +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca.transport import EpicsCATransport, EpicsGUIOptions class ParentController(Controller): diff --git a/tests/test_attribute.py b/tests/test_attributes.py similarity index 79% rename from tests/test_attribute.py rename to tests/test_attributes.py index fa6d1f99e..04ced8bd3 100644 --- a/tests/test_attribute.py +++ b/tests/test_attributes.py @@ -5,19 +5,40 @@ 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.controller import Controller -from fastcs.datatypes import Float, Int, String, T +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW +from fastcs.controllers import Controller +from fastcs.datatypes import DType_T, Float, Int, String NumberT = TypeVar("NumberT", int, float) +def test_attribute(): + attr = AttrR(String(), group="test group") + + with pytest.raises(RuntimeError): + _ = attr.io_ref + + assert not attr.has_io_ref() + assert isinstance(attr.datatype, String) + assert attr.dtype == str + assert attr.group == "test group" + assert attr.name == "" + assert attr.path == [] + + attr.set_name("test_name") + attr.set_path(["test_path"]) + + assert attr.name == "test_name" + assert attr.path == ["test_path"] + + with pytest.raises(RuntimeError): + attr.set_name("test_name") + with pytest.raises(RuntimeError): + attr.set_path(["test_path"]) + + assert attr.get() == "" + + @pytest.mark.asyncio async def test_attributes(): device = {"state": "Idle", "number": 1, "count": False} @@ -52,7 +73,7 @@ class MyAttributeIORef(AttributeIORef): cool: int class MyAttributeIO(AttributeIO[int, MyAttributeIORef]): - async def update(self, attr: AttrR[T, MyAttributeIORef]): + async def update(self, attr: AttrR[DType_T, MyAttributeIORef]): print("I am updating", self.ref_type, attr.io_ref.cool) class MyController(Controller): @@ -241,7 +262,7 @@ class MyController(Controller): c = MyController() c._connect_attribute_ios() - class SimpleAttributeIO(AttributeIO[T, AttributeIORef]): + class SimpleAttributeIO(AttributeIO[DType_T]): async def update(self, attr): match attr: case AttrR(datatype=Int()): @@ -283,3 +304,65 @@ async def update(self, attr): assert c2.base_class_ref.get() == 0 await c2.base_class_ref.bind_update_callback()() assert c2.base_class_ref.get() == 100 + + +def test_add_update_callback_twice_raises(): + async def do_update(attr: AttrR[int]): + pass + + attr = AttrRW(Int()) + attr.set_update_callback(do_update) + + with pytest.raises(RuntimeError): + attr.set_update_callback(do_update) + + +@pytest.mark.asyncio +async def test_bind_update(): + attr = AttrRW(Int()) + + with pytest.raises(RuntimeError): + attr.bind_update_callback() + + async def do_update(attr: AttrR[int]): + await attr.update(5) + + attr.set_update_callback(do_update) + callback = attr.bind_update_callback() + + await callback() + assert attr.get() == 5 + + +@pytest.mark.asyncio +async def test_bind_update_exception(): + attr = AttrRW(Int()) + + async def do_update(attr: AttrR[int]): + raise ValueError("do_update failed") + + attr.set_update_callback(do_update) + + callback = attr.bind_update_callback() + + with pytest.raises(ValueError): + await callback() + + +@pytest.mark.asyncio +async def test_put(): + attr = AttrW(Int()) + + async def do_put(attr: AttrW[int], value: int): + raise ValueError("do_put failed") + + async def do_sync_setpoint(setpoint: int): + raise ValueError("do_sync_setpoint failed") + + attr.set_on_put_callback(do_put) + attr.add_sync_setpoint_callback(do_sync_setpoint) + + await attr.put(5) + + with pytest.raises(RuntimeError): + attr.set_on_put_callback(do_put) diff --git a/tests/test_control_system.py b/tests/test_control_system.py index 3f797bd95..ea9ed93bb 100644 --- a/tests/test_control_system.py +++ b/tests/test_control_system.py @@ -3,15 +3,13 @@ import pytest -from fastcs.attribute_io import AttributeIO -from fastcs.attribute_io_ref import AttributeIORef -from fastcs.attributes import ONCE, AttrR, AttrRW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW from fastcs.control_system import FastCS, build_controller_api -from fastcs.controller import Controller -from fastcs.cs_methods import Command +from fastcs.controllers import Controller from fastcs.datatypes import Int from fastcs.exceptions import FastCSError -from fastcs.wrappers import command, scan +from fastcs.methods import Command, command, scan +from fastcs.util import ONCE @pytest.mark.asyncio diff --git a/tests/test_controller.py b/tests/test_controllers.py similarity index 99% rename from tests/test_controller.py rename to tests/test_controllers.py index 132e6f1d9..b5d21d029 100644 --- a/tests/test_controller.py +++ b/tests/test_controllers.py @@ -3,7 +3,7 @@ import pytest from fastcs.attributes import AttrR, AttrRW -from fastcs.controller import Controller, ControllerVector +from fastcs.controllers import Controller, ControllerVector from fastcs.datatypes import Enum, Float, Int diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py index 4ff7ed16f..45157f2f2 100644 --- a/tests/test_datatypes.py +++ b/tests/test_datatypes.py @@ -4,6 +4,9 @@ import pytest from fastcs.datatypes import DataType, Enum, Float, Int, Waveform +from fastcs.datatypes._util import numpy_to_fastcs_datatype +from fastcs.datatypes.bool import Bool +from fastcs.datatypes.string import String def test_base_validate(): @@ -40,3 +43,21 @@ class MyIntEnum(IntEnum): def test_validate(datatype, init_args, value): with pytest.raises(ValueError): datatype(**init_args).validate(value) + + +@pytest.mark.parametrize( + "numpy_type, fastcs_datatype", + [ + (np.float16, Float()), + (np.float32, Float()), + (np.int16, Int()), + (np.int32, Int()), + (np.bool, Bool()), + (np.dtype("S1000"), String()), + (np.dtype("U25"), String()), + (np.dtype(">i4"), Int()), + (np.dtype("d"), Float()), + ], +) +def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): + assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) diff --git a/tests/test_launch.py b/tests/test_launch.py index 883fcdb9c..a482f61a1 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -10,15 +10,11 @@ from fastcs import __version__ from fastcs.attributes import AttrR -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Int from fastcs.exceptions import LaunchError -from fastcs.launch import ( - _launch, - get_controller_schema, - launch, -) -from fastcs.transport.transport import Transport +from fastcs.launch import _launch, get_controller_schema, launch +from fastcs.transports import Transport @dataclass @@ -150,16 +146,16 @@ def test_get_schema(data): def test_error_if_identical_context_in_transports(mocker: MockerFixture, data): mocker.patch( - "fastcs.transport.Transport.context", + "fastcs.transports.Transport.context", new_callable=mocker.PropertyMock, return_value={"controller": "test"}, ) mocker.patch( - "fastcs.transport.epics.pva.transport.EpicsPVATransport.serve", + "fastcs.transports.epics.pva.transport.EpicsPVATransport.serve", new_callable=mocker.PropertyMock, ) mocker.patch( - "fastcs.transport.epics.ca.transport.EpicsCATransport.serve", + "fastcs.transports.epics.ca.transport.EpicsCATransport.serve", new_callable=mocker.PropertyMock, ) app = _launch(IsHinted) diff --git a/tests/test_cs_methods.py b/tests/test_methods.py similarity index 80% rename from tests/test_cs_methods.py rename to tests/test_methods.py index e3f1ad2ce..8095200e4 100644 --- a/tests/test_cs_methods.py +++ b/tests/test_methods.py @@ -1,21 +1,23 @@ import pytest -from fastcs.controller import Controller -from fastcs.cs_methods import Command, Method, Scan, UnboundCommand, UnboundScan -from fastcs.exceptions import FastCSError +from fastcs.controllers import Controller +from fastcs.methods import Command, Scan +from fastcs.methods.command import UnboundCommand +from fastcs.methods.method import Method +from fastcs.methods.scan import UnboundScan def test_method(): def sync_do_nothing(): pass - with pytest.raises(FastCSError): + with pytest.raises(TypeError): Method(sync_do_nothing) # type: ignore async def do_nothing_with_return() -> int: return 1 - with pytest.raises(FastCSError): + with pytest.raises(TypeError): Method(do_nothing_with_return) # type: ignore async def do_nothing(): @@ -42,10 +44,10 @@ async def do_nothing_with_arg(self, arg): with pytest.raises(NotImplementedError): await unbound_command() - with pytest.raises(FastCSError): + with pytest.raises(TypeError): UnboundCommand(TestController.do_nothing_with_arg) # type: ignore - with pytest.raises(FastCSError): + with pytest.raises(TypeError): Command(TestController().do_nothing_with_arg) # type: ignore command = unbound_command.bind(TestController()) @@ -71,10 +73,10 @@ async def update_nothing_with_arg(self, arg): with pytest.raises(NotImplementedError): await unbound_scan() - with pytest.raises(FastCSError): + with pytest.raises(TypeError): UnboundScan(TestController.update_nothing_with_arg, 1.0) # type: ignore - with pytest.raises(FastCSError): + with pytest.raises(TypeError): Scan(TestController().update_nothing_with_arg, 1.0) # type: ignore scan = unbound_scan.bind(TestController()) diff --git a/tests/transport/epics/ca/test_util.py b/tests/transports/epics/ca/test_ca_util.py similarity index 99% rename from tests/transport/epics/ca/test_util.py rename to tests/transports/epics/ca/test_ca_util.py index 14cd56065..8c23d8e00 100644 --- a/tests/transport/epics/ca/test_util.py +++ b/tests/transports/epics/ca/test_ca_util.py @@ -5,7 +5,7 @@ from fastcs.attributes import AttrRW from fastcs.datatypes import Bool, Enum, Float, Int, String -from fastcs.transport.epics.ca.util import ( +from fastcs.transports.epics.ca.util import ( builder_callable_from_attribute, cast_from_epics_type, cast_to_epics_type, diff --git a/tests/transport/epics/ca/test_gui.py b/tests/transports/epics/ca/test_gui.py similarity index 98% rename from tests/transport/epics/ca/test_gui.py rename to tests/transports/epics/ca/test_gui.py index 80a6da4dc..a2ab0891f 100644 --- a/tests/transport/epics/ca/test_gui.py +++ b/tests/transports/epics/ca/test_gui.py @@ -18,9 +18,9 @@ from tests.util import ColourEnum from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform -from fastcs.transport.epics.gui import EpicsGUI +from fastcs.transports import ControllerAPI +from fastcs.transports.epics.gui import EpicsGUI def test_get_pv(controller_api): diff --git a/tests/transport/epics/ca/test_initial_value.py b/tests/transports/epics/ca/test_initial_value.py similarity index 94% rename from tests/transport/epics/ca/test_initial_value.py rename to tests/transports/epics/ca/test_initial_value.py index 2a6681f1a..e340b4c42 100644 --- a/tests/transport/epics/ca/test_initial_value.py +++ b/tests/transports/epics/ca/test_initial_value.py @@ -4,13 +4,13 @@ import numpy as np import pytest -import fastcs.transport.epics.ca.ioc as ca_ioc +import fastcs.transports.epics.ca.ioc as ca_ioc from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller +from fastcs.controllers import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform from fastcs.launch import FastCS -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.ca.transport import EpicsCATransport class InitialEnum(enum.Enum): diff --git a/tests/transport/epics/ca/test_softioc.py b/tests/transports/epics/ca/test_softioc.py similarity index 91% rename from tests/transport/epics/ca/test_softioc.py rename to tests/transports/epics/ca/test_softioc.py index 0b37b01e3..c76ba2aa6 100644 --- a/tests/transport/epics/ca/test_softioc.py +++ b/tests/transports/epics/ca/test_softioc.py @@ -13,12 +13,13 @@ from tests.util import ColourEnum from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller -from fastcs.controller_api import ControllerAPI -from fastcs.cs_methods import Command +from fastcs.controllers import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform from fastcs.exceptions import FastCSError -from fastcs.transport.epics.ca.ioc import ( +from fastcs.methods import Command +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.epics.ca import EpicsCATransport +from fastcs.transports.epics.ca.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsCAIOC, _add_attr_pvi_info, @@ -28,8 +29,7 @@ _create_and_link_write_pv, _make_record, ) -from fastcs.transport.epics.ca.transport import EpicsCATransport -from fastcs.transport.epics.ca.util import ( +from fastcs.transports.epics.ca.util import ( record_metadata_from_attribute, record_metadata_from_datatype, ) @@ -46,8 +46,10 @@ class OnOffStates(enum.IntEnum): @pytest.mark.asyncio async def test_create_and_link_read_pv(mocker: MockerFixture): - make_record = mocker.patch("fastcs.transport.epics.ca.ioc._make_record") - add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_attr_pvi_info") + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_record") + add_attr_pvi_info = mocker.patch( + "fastcs.transports.epics.ca.ioc._add_attr_pvi_info" + ) record = make_record.return_value attribute = AttrR(Int()) @@ -89,7 +91,7 @@ def test_make_input_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") pv = "PV" _make_record(pv, attribute) @@ -110,8 +112,10 @@ def test_make_record_raises(mocker: MockerFixture): @pytest.mark.asyncio async def test_create_and_link_write_pv(mocker: MockerFixture): - make_record = mocker.patch("fastcs.transport.epics.ca.ioc._make_record") - add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_attr_pvi_info") + make_record = mocker.patch("fastcs.transports.epics.ca.ioc._make_record") + add_attr_pvi_info = mocker.patch( + "fastcs.transports.epics.ca.ioc._add_attr_pvi_info" + ) record = make_record.return_value attribute = AttrW(Int()) @@ -175,7 +179,7 @@ def test_make_output_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") update = mocker.MagicMock() pv = "PV" @@ -192,7 +196,7 @@ def test_make_output_record( def test_long_enum_validator(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") update = mocker.MagicMock() attribute = AttrRW(Enum(LongEnum)) pv = "PV" @@ -225,11 +229,11 @@ def epics_controller_api(class_mocker: MockerFixture): def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): - ioc_builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") - add_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_pvi_info") + ioc_builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") + add_pvi_info = mocker.patch("fastcs.transports.epics.ca.ioc._add_pvi_info") add_sub_controller_pvi_info = mocker.patch( - "fastcs.transport.epics.ca.ioc._add_sub_controller_pvi_info" + "fastcs.transports.epics.ca.ioc._add_sub_controller_pvi_info" ) EpicsCAIOC(DEVICE, epics_controller_api) @@ -330,7 +334,7 @@ def test_ioc(mocker: MockerFixture, epics_controller_api: ControllerAPI): def test_add_pvi_info(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -358,7 +362,7 @@ def test_add_pvi_info(mocker: MockerFixture): def test_add_pvi_info_with_parent(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -394,7 +398,7 @@ def test_add_pvi_info_with_parent(mocker: MockerFixture): def test_add_sub_controller_pvi_info(mocker: MockerFixture): - add_pvi_info = mocker.patch("fastcs.transport.epics.ca.ioc._add_pvi_info") + add_pvi_info = mocker.patch("fastcs.transports.epics.ca.ioc._add_pvi_info") parent_api = mocker.MagicMock() parent_api.path = [] child_api = mocker.MagicMock() @@ -441,8 +445,8 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): - ioc_builder = mocker.patch("fastcs.transport.epics.ca.ioc.builder") - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + ioc_builder = mocker.patch("fastcs.transports.epics.ca.ioc.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") long_name_controller_api = AssertableControllerAPI(ControllerLongNames(), mocker) long_attr_name = "attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name" long_rw_name = "attr_rw_with_a_reallyreally_long_name_that_is_too_long_for_RBV" @@ -527,7 +531,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): def test_update_datatype(mocker: MockerFixture): - builder = mocker.patch("fastcs.transport.epics.ca.util.builder") + builder = mocker.patch("fastcs.transports.epics.ca.util.builder") pv_name = f"{DEVICE}:Attr" @@ -546,7 +550,7 @@ def test_update_datatype(mocker: MockerFixture): with pytest.raises( ValueError, - match="Attribute datatype must be of type ", + match="Attribute datatype must be of type ", ): attr_r.update_datatype(String()) # type: ignore @@ -566,7 +570,7 @@ def test_update_datatype(mocker: MockerFixture): with pytest.raises( ValueError, - match="Attribute datatype must be of type ", + match="Attribute datatype must be of type ", ): attr_w.update_datatype(String()) # type: ignore diff --git a/tests/transport/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py similarity index 100% rename from tests/transport/epics/ca/test_softioc_system.py rename to tests/transports/epics/ca/test_softioc_system.py diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py similarity index 98% rename from tests/transport/epics/pva/test_p4p.py rename to tests/transports/epics/pva/test_p4p.py index 9ad4ec42c..1b5ca9f18 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -14,12 +14,12 @@ from p4p.nt import NTTable from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import Controller, ControllerVector +from fastcs.controllers import Controller, ControllerVector from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform from fastcs.launch import FastCS -from fastcs.transport.epics.options import EpicsIOCOptions -from fastcs.transport.epics.pva.transport import EpicsPVATransport -from fastcs.wrappers import command +from fastcs.methods import command +from fastcs.transports.epics import EpicsIOCOptions +from fastcs.transports.epics.pva.transport import EpicsPVATransport @pytest.mark.asyncio @@ -178,7 +178,7 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): @pytest.mark.asyncio @pytest.mark.timeout(2) -async def test_numerical_alarms(p4p_subprocess: tuple[str, Queue]): +async def test_numeric_alarms(p4p_subprocess: tuple[str, Queue]): pv_prefix, _ = p4p_subprocess a_values = asyncio.Queue() ctxt = Context("pva") diff --git a/tests/transport/epics/pva/test_pva_gui.py b/tests/transports/epics/pva/test_pva_gui.py similarity index 97% rename from tests/transport/epics/pva/test_pva_gui.py rename to tests/transports/epics/pva/test_pva_gui.py index 3dbf27f3a..2aafc2088 100644 --- a/tests/transport/epics/pva/test_pva_gui.py +++ b/tests/transports/epics/pva/test_pva_gui.py @@ -13,7 +13,7 @@ from fastcs.attributes import AttrR, AttrW from fastcs.datatypes import Table -from fastcs.transport.epics.pva.gui import PvaEpicsGUI +from fastcs.transports.epics.pva.gui import PvaEpicsGUI def test_get_pv_in_pva(controller_api): diff --git a/tests/test_util.py b/tests/transports/epics/test_epics_util.py similarity index 69% rename from tests/test_util.py rename to tests/transports/epics/test_epics_util.py index f6cb87b3c..1701d6b31 100644 --- a/tests/test_util.py +++ b/tests/transports/epics/test_epics_util.py @@ -1,10 +1,8 @@ -import numpy as np import pytest from pvi.device import SignalR from pydantic import ValidationError -from fastcs.datatypes import Bool, Float, Int, String -from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal +from fastcs.transports.epics.util import snake_to_pascal def test_snake_to_pascal(): @@ -38,21 +36,3 @@ def test_pvi_validation_error(): name = snake_to_pascal("Name-With_%_Invalid-&-Symbols_£_") with pytest.raises(ValidationError): SignalR(name=name, read_pv="test") - - -@pytest.mark.parametrize( - "numpy_type, fastcs_datatype", - [ - (np.float16, Float()), - (np.float32, Float()), - (np.int16, Int()), - (np.int32, Int()), - (np.bool, Bool()), - (np.dtype("S1000"), String()), - (np.dtype("U25"), String()), - (np.dtype(">i4"), Int()), - (np.dtype("d"), Float()), - ], -) -def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): - assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) diff --git a/tests/transport/graphQL/test_graphql.py b/tests/transports/graphQL/test_graphql.py similarity index 99% rename from tests/transport/graphQL/test_graphql.py rename to tests/transports/graphQL/test_graphql.py index ef84ffe87..24f1c7c16 100644 --- a/tests/transport/graphQL/test_graphql.py +++ b/tests/transports/graphQL/test_graphql.py @@ -14,7 +14,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.datatypes import Bool, Float, Int, String -from fastcs.transport.graphql.transport import GraphQLTransport +from fastcs.transports.graphql.transport import GraphQLTransport class GraphQLController(MyTestController): diff --git a/tests/transport/rest/test_rest.py b/tests/transports/rest/test_rest.py similarity index 98% rename from tests/transport/rest/test_rest.py rename to tests/transports/rest/test_rest.py index 01f5fa9eb..bccdb0a88 100644 --- a/tests/transport/rest/test_rest.py +++ b/tests/transports/rest/test_rest.py @@ -8,9 +8,9 @@ from tests.assertable_controller import AssertableControllerAPI, MyTestController from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller_api import ControllerAPI from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform -from fastcs.transport.rest.transport import RestTransport +from fastcs.transports.controller_api import ControllerAPI +from fastcs.transports.rest.transport import RestTransport class RestController(MyTestController): diff --git a/tests/transport/tango/test_dsr.py b/tests/transports/tango/test_dsr.py similarity index 98% rename from tests/transport/tango/test_dsr.py rename to tests/transports/tango/test_dsr.py index 449732853..b269ce5a8 100644 --- a/tests/transport/tango/test_dsr.py +++ b/tests/transports/tango/test_dsr.py @@ -13,7 +13,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform -from fastcs.transport.tango.transport import TangoTransport +from fastcs.transports.tango.transport import TangoTransport async def patch_run_threadsafe_blocking(coro, loop): @@ -23,7 +23,7 @@ async def patch_run_threadsafe_blocking(coro, loop): @pytest.fixture(scope="module") def mock_run_threadsafe_blocking(module_mocker: MockerFixture): m = module_mocker.patch( - "fastcs.transport.tango.dsr._run_threadsafe_blocking", + "fastcs.transports.tango.dsr._run_threadsafe_blocking", patch_run_threadsafe_blocking, ) yield m