From 6f1beac3727de485a6f12c07044b8bc0131c45bf Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Tue, 26 Nov 2024 18:17:13 +0000 Subject: [PATCH 01/10] Add proposal API, launcher --- src/fastcs/__init__.py | 2 + src/fastcs/backend.py | 26 ++- src/fastcs/backends/__init__.py | 5 - src/fastcs/backends/asyncio_backend.py | 13 -- src/fastcs/backends/epics/backend.py | 28 --- src/fastcs/backends/tango/backend.py | 14 -- src/fastcs/connections/__init__.py | 4 + src/fastcs/exceptions.py | 4 + src/fastcs/main.py | 178 ++++++++++++++++++ src/fastcs/transport/__init__.py | 8 + src/fastcs/transport/adapter.py | 15 ++ .../{backends => transport}/epics/__init__.py | 0 src/fastcs/transport/epics/adapter.py | 40 ++++ .../{backends => transport}/epics/docs.py | 9 +- .../{backends => transport}/epics/gui.py | 16 +- .../{backends => transport}/epics/ioc.py | 19 +- src/fastcs/transport/epics/options.py | 34 ++++ .../{backends => transport}/epics/util.py | 0 .../{backends => transport}/rest/__init__.py | 0 src/fastcs/transport/rest/adapter.py | 25 +++ .../{backends => transport}/rest/backend.py | 0 src/fastcs/transport/rest/options.py | 13 ++ .../{backends => transport}/rest/rest.py | 14 +- .../{backends => transport}/tango/__init__.py | 0 src/fastcs/transport/tango/adapter.py | 25 +++ .../{backends => transport}/tango/dsr.py | 8 +- src/fastcs/transport/tango/options.py | 14 ++ src/fastcs/util.py | 50 +++++ tests/conftest.py | 8 + tests/data/config_full.yaml | 7 + tests/data/config_minimal.yaml | 3 + tests/ioc.py | 14 +- tests/test_main.py | 127 +++++++++++++ .../{backends => transport}/epics/test_gui.py | 2 +- .../{backends => transport}/epics/test_ioc.py | 62 +++--- .../epics/test_ioc_system.py | 0 .../epics/test_util.py | 4 +- .../{backends => transport}/rest/test_rest.py | 4 +- .../{backends => transport}/tango/test_dsr.py | 4 +- 39 files changed, 630 insertions(+), 169 deletions(-) delete mode 100644 src/fastcs/backends/__init__.py delete mode 100644 src/fastcs/backends/asyncio_backend.py delete mode 100644 src/fastcs/backends/epics/backend.py delete mode 100644 src/fastcs/backends/tango/backend.py create mode 100644 src/fastcs/main.py create mode 100644 src/fastcs/transport/__init__.py create mode 100644 src/fastcs/transport/adapter.py rename src/fastcs/{backends => transport}/epics/__init__.py (100%) create mode 100644 src/fastcs/transport/epics/adapter.py rename src/fastcs/{backends => transport}/epics/docs.py (64%) rename src/fastcs/{backends => transport}/epics/gui.py (95%) rename src/fastcs/{backends => transport}/epics/ioc.py (98%) create mode 100644 src/fastcs/transport/epics/options.py rename src/fastcs/{backends => transport}/epics/util.py (100%) rename src/fastcs/{backends => transport}/rest/__init__.py (100%) create mode 100644 src/fastcs/transport/rest/adapter.py rename src/fastcs/{backends => transport}/rest/backend.py (100%) create mode 100644 src/fastcs/transport/rest/options.py rename src/fastcs/{backends => transport}/rest/rest.py (94%) rename src/fastcs/{backends => transport}/tango/__init__.py (100%) create mode 100644 src/fastcs/transport/tango/adapter.py rename src/fastcs/{backends => transport}/tango/dsr.py (97%) create mode 100644 src/fastcs/transport/tango/options.py create mode 100644 tests/data/config_full.yaml create mode 100644 tests/data/config_minimal.yaml create mode 100644 tests/test_main.py rename tests/{backends => transport}/epics/test_gui.py (98%) rename tests/{backends => transport}/epics/test_ioc.py (88%) rename tests/{backends => transport}/epics/test_ioc_system.py (100%) rename tests/{backends => transport}/epics/test_util.py (96%) rename tests/{backends => transport}/rest/test_rest.py (96%) rename tests/{backends => transport}/tango/test_dsr.py (97%) diff --git a/src/fastcs/__init__.py b/src/fastcs/__init__.py index a2ffbf369..00557d56a 100644 --- a/src/fastcs/__init__.py +++ b/src/fastcs/__init__.py @@ -7,5 +7,7 @@ """ from ._version import __version__ +from .main import FastCS as FastCS +from .main import launch as launch __all__ = ["__version__"] diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index d20075e27..f22cf403f 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -4,7 +4,7 @@ from concurrent.futures import Future from types import MethodType -from softioc.asyncio_dispatcher import AsyncioDispatcher +from fastcs.util import AsyncioDispatcher from .attributes import AttrR, AttrW, Sender, Updater from .controller import Controller @@ -14,10 +14,12 @@ class Backend: def __init__( - self, controller: Controller, loop: asyncio.AbstractEventLoop | None = None + self, + controller: Controller, + loop: asyncio.AbstractEventLoop | None = None, ): - self._dispatcher = AsyncioDispatcher(loop) - self._loop = self._dispatcher.loop + self.dispatcher = AsyncioDispatcher(loop) + self._loop = self.dispatcher.loop self._controller = controller self._initial_coros = [controller.connect] @@ -27,17 +29,17 @@ def __init__( self._controller.initialise(), self._loop ).result() - self._mapping = Mapping(self._controller) + self.mapping = Mapping(self._controller) self._link_process_tasks() - self._context = { - "dispatcher": self._dispatcher, + self.context = { + "dispatcher": self.dispatcher, "controller": self._controller, - "mapping": self._mapping, + "mapping": self.mapping, } def _link_process_tasks(self): - for single_mapping in self._mapping.get_controller_mappings(): + for single_mapping in self.mapping.get_controller_mappings(): _link_single_controller_put_tasks(single_mapping) _link_attribute_sender_class(single_mapping) @@ -47,7 +49,6 @@ def __del__(self): def run(self): self._run_initial_futures() self.start_scan_futures() - self._run() def _run_initial_futures(self): for coro in self._initial_coros: @@ -57,7 +58,7 @@ def _run_initial_futures(self): def start_scan_futures(self): self._scan_futures = { asyncio.run_coroutine_threadsafe(coro(), self._loop) - for coro in _get_scan_coros(self._mapping) + for coro in _get_scan_coros(self.mapping) } def stop_scan_futures(self): @@ -68,9 +69,6 @@ def stop_scan_futures(self): except asyncio.CancelledError: pass - def _run(self): - raise NotImplementedError("Specific Backend must implement _run") - def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: for name, method in single_mapping.put_methods.items(): diff --git a/src/fastcs/backends/__init__.py b/src/fastcs/backends/__init__.py deleted file mode 100644 index f2253721f..000000000 --- a/src/fastcs/backends/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .asyncio_backend import AsyncioBackend -from .epics.backend import EpicsBackend -from .tango.backend import TangoBackend - -__all__ = ["EpicsBackend", "AsyncioBackend", "TangoBackend"] diff --git a/src/fastcs/backends/asyncio_backend.py b/src/fastcs/backends/asyncio_backend.py deleted file mode 100644 index adf2a8350..000000000 --- a/src/fastcs/backends/asyncio_backend.py +++ /dev/null @@ -1,13 +0,0 @@ -from softioc import softioc - -from fastcs.backend import Backend -from fastcs.controller import Controller - - -class AsyncioBackend(Backend): - def __init__(self, controller: Controller): # noqa: F821 - super().__init__(controller) - - def _run(self): - # Run the interactive shell - softioc.interactive_ioc(self._context) diff --git a/src/fastcs/backends/epics/backend.py b/src/fastcs/backends/epics/backend.py deleted file mode 100644 index b02ec9ed2..000000000 --- a/src/fastcs/backends/epics/backend.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastcs.backend import Backend -from fastcs.controller import Controller - -from .docs import EpicsDocs, EpicsDocsOptions -from .gui import EpicsGUI, EpicsGUIOptions -from .ioc import EpicsIOC, EpicsIOCOptions - - -class EpicsBackend(Backend): - def __init__( - self, - controller: Controller, - pv_prefix: str = "MY-DEVICE-PREFIX", - options: EpicsIOCOptions | None = None, - ): - super().__init__(controller) - - self._pv_prefix = pv_prefix - self._ioc = EpicsIOC(pv_prefix, self._mapping, options=options) - - def create_docs(self, options: EpicsDocsOptions | None = None) -> None: - EpicsDocs(self._mapping).create_docs(options) - - def create_gui(self, options: EpicsGUIOptions | None = None) -> None: - EpicsGUI(self._mapping, self._pv_prefix).create_gui(options) - - def _run(self): - self._ioc.run(self._dispatcher, self._context) diff --git a/src/fastcs/backends/tango/backend.py b/src/fastcs/backends/tango/backend.py deleted file mode 100644 index d30b0e8c6..000000000 --- a/src/fastcs/backends/tango/backend.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastcs.backend import Backend -from fastcs.controller import Controller - -from .dsr import TangoDSR - - -class TangoBackend(Backend): - def __init__(self, controller: Controller): - super().__init__(controller) - - self._dsr = TangoDSR(self._mapping) - - def _run(self): - self._dsr.run() diff --git a/src/fastcs/connections/__init__.py b/src/fastcs/connections/__init__.py index b4880c838..c60796d60 100644 --- a/src/fastcs/connections/__init__.py +++ b/src/fastcs/connections/__init__.py @@ -1,3 +1,7 @@ from .ip_connection import IPConnection +from .ip_connection import IPConnectionSettings as IPConnectionSettings +from .ip_connection import StreamConnection as StreamConnection +from .serial_connection import SerialConnection as SerialConnection +from .serial_connection import SerialConnectionSettings as SerialConnectionSettings __all__ = ["IPConnection"] diff --git a/src/fastcs/exceptions.py b/src/fastcs/exceptions.py index 5e1f375fb..64964cbf6 100644 --- a/src/fastcs/exceptions.py +++ b/src/fastcs/exceptions.py @@ -1,2 +1,6 @@ class FastCSException(Exception): pass + + +class LaunchError(FastCSException): + pass diff --git a/src/fastcs/main.py b/src/fastcs/main.py new file mode 100644 index 000000000..71142425d --- /dev/null +++ b/src/fastcs/main.py @@ -0,0 +1,178 @@ +import inspect +import json +from pathlib import Path +from typing import Annotated, TypeAlias, get_type_hints + +import typer +from pydantic import create_model +from ruamel.yaml import YAML + +from .backend import Backend +from .controller import Controller +from .exceptions import LaunchError +from .transport.adapter import TransportAdapter +from .transport.epics.options import EpicsOptions +from .transport.rest.options import RestOptions +from .transport.tango.options import TangoOptions + +# Define a type alias for transport options +TransportOptions: TypeAlias = EpicsOptions | TangoOptions | RestOptions + + +class FastCS: + def __init__( + self, + controller: Controller, + transport_options: TransportOptions, + ): + self._backend = Backend(controller) + self._transport: TransportAdapter + match transport_options: + case EpicsOptions(): + from .transport.epics.adapter import EpicsTransport + + self._transport = EpicsTransport( + self._backend.mapping, + self._backend.context, + self._backend.dispatcher, + transport_options, + ) + case TangoOptions(): + from .transport.tango.adapter import TangoTransport + + self._transport = TangoTransport( + self._backend.mapping, + transport_options, + ) + case RestOptions(): + from .transport.rest.adapter import RestTransport + + self._transport = RestTransport( + self._backend.mapping, + transport_options, + ) + + def create_docs(self) -> None: + self._transport.create_docs() + + def create_gui(self) -> None: + self._transport.create_gui() + + def run(self) -> None: + self._backend.run() + self._transport.run() + + +def launch(controller_class: type[Controller]) -> None: + """ + Serves as an entry point for starting FastCS applications. + + By utilizing type hints in a Controller's __init__ method, this + function provides a command-line interface to describe and gather the + required configuration before instantiating the application. + + Args: + controller_class (type[Controller]): The FastCS Controller to instantiate. + It must have a type-hinted __init__ method and no more than 2 arguments. + + Raises: + LaunchError: If the class's __init__ is not as expected + + Example of the expected Controller implementation: + class MyController(Controller): + def __init__(self, my_arg: MyControllerOptions) -> None: + ... + + Typical usage: + if __name__ == "__main__": + launch(MyController) + """ + _launch(controller_class)() + + +def _launch(controller_class: type[Controller]) -> typer.Typer: + sig = inspect.signature(controller_class.__init__) + args = inspect.getfullargspec(controller_class.__init__)[0] + if len(args) == 1: + fastcs_options = create_model( + f"{controller_class.__name__}", + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + elif len(args) == 2: + hints = get_type_hints(controller_class.__init__) + if hints: + options_type = list(hints.values())[-1] + else: + raise LaunchError( + f"Expected typehinting in '{controller_class.__name__}" + f".__init__' but received {sig}. Add a typehint for `{args[-1]}`." + ) + fastcs_options = create_model( + f"{controller_class.__name__}", + controller=(options_type, ...), + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + else: + raise LaunchError( + f"Expected no more than 2 arguments for '{controller_class.__name__}" + f".__init__' but received {len(args)} as `{sig}`" + ) + + launch_typer = typer.Typer() + + class LaunchContext: + def __init__(self, controller_class, fastcs_options): + self.controller_class = controller_class + self.fastcs_options = fastcs_options + + @launch_typer.callback() + def create_context(ctx: typer.Context): + ctx.obj = LaunchContext( + controller_class, + fastcs_options, + ) + + @launch_typer.command(help=f"Produce json schema for a {controller_class.__name__}") + def schema(ctx: typer.Context): + system_schema = ctx.obj.fastcs_options.model_json_schema() + print(json.dumps(system_schema, indent=2)) + + @launch_typer.command(help=f"Start up a {controller_class.__name__}") + def run( + ctx: typer.Context, + config: Annotated[ + Path, + typer.Argument( + help=f"A yaml file matching the {controller_class.__name__} schema" + ), + ], + ): + """ + Start the controller + """ + controller_class = ctx.obj.controller_class + fastcs_options = ctx.obj.fastcs_options + + yaml = YAML(typ="safe") + options_yaml = yaml.load(config) + # To do: Handle a k8s "values.yaml" file + instance_options = fastcs_options.model_validate(options_yaml) + if hasattr(instance_options, "controller"): + controller = controller_class(instance_options.controller) + else: + controller = controller_class() + + instance = FastCS( + controller, + instance_options.transport, + ) + + if "gui" in options_yaml["transport"]: + instance.create_gui() + if "docs" in options_yaml["transport"]: + instance.create_docs() + instance.run() + + return launch_typer diff --git a/src/fastcs/transport/__init__.py b/src/fastcs/transport/__init__.py new file mode 100644 index 000000000..cc22afedc --- /dev/null +++ b/src/fastcs/transport/__init__.py @@ -0,0 +1,8 @@ +from .epics.options import EpicsDocsOptions as EpicsDocsOptions +from .epics.options import EpicsGUIOptions as EpicsGUIOptions +from .epics.options import EpicsIOCOptions as EpicsIOCOptions +from .epics.options import EpicsOptions as EpicsOptions +from .tango.options import TangoDSROptions as TangoDSROptions +from .tango.options import TangoOptions as TangoOptions + +__all__ = ["EpicsOptions", "TangoOptions"] diff --git a/src/fastcs/transport/adapter.py b/src/fastcs/transport/adapter.py new file mode 100644 index 000000000..bb2ce856e --- /dev/null +++ b/src/fastcs/transport/adapter.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class TransportAdapter(ABC): + @abstractmethod + def run(self) -> None: + pass + + @abstractmethod + def create_docs(self) -> None: + pass + + @abstractmethod + def create_gui(self) -> None: + pass diff --git a/src/fastcs/backends/epics/__init__.py b/src/fastcs/transport/epics/__init__.py similarity index 100% rename from src/fastcs/backends/epics/__init__.py rename to src/fastcs/transport/epics/__init__.py diff --git a/src/fastcs/transport/epics/adapter.py b/src/fastcs/transport/epics/adapter.py new file mode 100644 index 000000000..0ef4930f8 --- /dev/null +++ b/src/fastcs/transport/epics/adapter.py @@ -0,0 +1,40 @@ +from typing import cast + +from softioc.asyncio_dispatcher import AsyncioDispatcher as Dispatcher + +from fastcs.mapping import Mapping +from fastcs.transport.adapter import TransportAdapter +from fastcs.util import AsyncioDispatcher + +from .docs import EpicsDocs +from .gui import EpicsGUI +from .ioc import EpicsIOC +from .options import EpicsOptions + + +class EpicsTransport(TransportAdapter): + def __init__( + self, + mapping: Mapping, + context: dict, + dispatcher: AsyncioDispatcher, + options: EpicsOptions | None = None, + ) -> None: + self.options = options or EpicsOptions() + self._mapping = mapping + self._context = context + self._dispatcher = dispatcher + self._pv_prefix = self.options.ioc.pv_prefix + self._ioc = EpicsIOC(self.options.ioc.pv_prefix, self._mapping) + + def create_docs(self) -> None: + EpicsDocs(self._mapping).create_docs(self.options.docs) + + def create_gui(self) -> None: + EpicsGUI(self._mapping, self._pv_prefix).create_gui(self.options.gui) + + def run(self): + self._ioc.run( + cast(Dispatcher, self._dispatcher), + self._context, + ) diff --git a/src/fastcs/backends/epics/docs.py b/src/fastcs/transport/epics/docs.py similarity index 64% rename from src/fastcs/backends/epics/docs.py rename to src/fastcs/transport/epics/docs.py index c2bc569a2..02c896993 100644 --- a/src/fastcs/backends/epics/docs.py +++ b/src/fastcs/transport/epics/docs.py @@ -1,13 +1,6 @@ -from dataclasses import dataclass -from pathlib import Path - from fastcs.mapping import Mapping - -@dataclass -class EpicsDocsOptions: - path: Path = Path.cwd() - depth: int | None = None +from .options import EpicsDocsOptions class EpicsDocs: diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/transport/epics/gui.py similarity index 95% rename from src/fastcs/backends/epics/gui.py rename to src/fastcs/transport/epics/gui.py index b9c48751a..a611a8859 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/transport/epics/gui.py @@ -1,7 +1,3 @@ -from dataclasses import dataclass -from enum import Enum -from pathlib import Path - from pvi._format.dls import DLSFormatter from pvi.device import ( LED, @@ -33,17 +29,7 @@ from fastcs.mapping import Mapping, SingleMapping, _get_single_mapping from fastcs.util import snake_to_pascal - -class EpicsGUIFormat(Enum): - bob = ".bob" - edl = ".edl" - - -@dataclass -class EpicsGUIOptions: - output_path: Path = Path.cwd() / "output.bob" - file_format: EpicsGUIFormat = EpicsGUIFormat.bob - title: str = "Simple Device" +from .options import EpicsGUIFormat, EpicsGUIOptions class EpicsGUI: diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/transport/epics/ioc.py similarity index 98% rename from src/fastcs/backends/epics/ioc.py rename to src/fastcs/transport/epics/ioc.py index e616d5675..1d1d857d0 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/transport/epics/ioc.py @@ -1,5 +1,5 @@ from collections.abc import Callable -from dataclasses import asdict, dataclass +from dataclasses import asdict from types import MethodType from typing import Any, Literal @@ -8,23 +8,20 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.backends.epics.util import ( +from fastcs.controller import BaseController +from fastcs.datatypes import Bool, DataType, Float, Int, String, T +from fastcs.exceptions import FastCSException +from fastcs.mapping import Mapping +from fastcs.transport.epics.util import ( MBB_STATE_FIELDS, attr_is_enum, enum_index_to_value, enum_value_to_index, ) -from fastcs.controller import BaseController -from fastcs.datatypes import Bool, DataType, Float, Int, String, T -from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping -EPICS_MAX_NAME_LENGTH = 60 +from .options import EpicsIOCOptions - -@dataclass -class EpicsIOCOptions: - terminal: bool = True +EPICS_MAX_NAME_LENGTH = 60 DATATYPE_NAME_TO_RECORD_FIELD = { diff --git a/src/fastcs/transport/epics/options.py b/src/fastcs/transport/epics/options.py new file mode 100644 index 000000000..c70fa2206 --- /dev/null +++ b/src/fastcs/transport/epics/options.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + + +@dataclass +class EpicsDocsOptions: + path: Path = Path.cwd() + depth: int | None = None + + +class EpicsGUIFormat(Enum): + bob = ".bob" + edl = ".edl" + + +@dataclass +class EpicsGUIOptions: + output_path: Path = Path.cwd() / "output.bob" + file_format: EpicsGUIFormat = EpicsGUIFormat.bob + title: str = "Simple Device" + + +@dataclass +class EpicsIOCOptions: + terminal: bool = True + pv_prefix: str = "MY-DEVICE-PREFIX" + + +@dataclass +class EpicsOptions: + docs: EpicsDocsOptions = field(default_factory=EpicsDocsOptions) + gui: EpicsGUIOptions = field(default_factory=EpicsGUIOptions) + ioc: EpicsIOCOptions = field(default_factory=EpicsIOCOptions) diff --git a/src/fastcs/backends/epics/util.py b/src/fastcs/transport/epics/util.py similarity index 100% rename from src/fastcs/backends/epics/util.py rename to src/fastcs/transport/epics/util.py diff --git a/src/fastcs/backends/rest/__init__.py b/src/fastcs/transport/rest/__init__.py similarity index 100% rename from src/fastcs/backends/rest/__init__.py rename to src/fastcs/transport/rest/__init__.py diff --git a/src/fastcs/transport/rest/adapter.py b/src/fastcs/transport/rest/adapter.py new file mode 100644 index 000000000..fc7738e46 --- /dev/null +++ b/src/fastcs/transport/rest/adapter.py @@ -0,0 +1,25 @@ +from fastcs.mapping import Mapping +from fastcs.transport.adapter import TransportAdapter + +from .options import RestOptions +from .rest import RestServer + + +class RestTransport(TransportAdapter): + def __init__( + self, + mapping: Mapping, + options: RestOptions | None = None, + ): + self.options = options or RestOptions() + self._mapping = mapping + self._server = RestServer(self._mapping) + + def create_docs(self) -> None: + raise NotImplementedError + + def create_gui(self) -> None: + raise NotImplementedError + + def run(self) -> None: + self._server.run(self.options.rest) diff --git a/src/fastcs/backends/rest/backend.py b/src/fastcs/transport/rest/backend.py similarity index 100% rename from src/fastcs/backends/rest/backend.py rename to src/fastcs/transport/rest/backend.py diff --git a/src/fastcs/transport/rest/options.py b/src/fastcs/transport/rest/options.py new file mode 100644 index 000000000..32ecd6f24 --- /dev/null +++ b/src/fastcs/transport/rest/options.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field + + +@dataclass +class RestServerOptions: + host: str = "localhost" + port: int = 8080 + log_level: str = "info" + + +@dataclass +class RestOptions: + rest: RestServerOptions = field(default_factory=RestServerOptions) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/transport/rest/rest.py similarity index 94% rename from src/fastcs/backends/rest/rest.py rename to src/fastcs/transport/rest/rest.py index d41acdfde..ef891ee56 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/transport/rest/rest.py @@ -1,5 +1,4 @@ from collections.abc import Awaitable, Callable, Coroutine -from dataclasses import dataclass from typing import Any import uvicorn @@ -10,12 +9,7 @@ from fastcs.controller import BaseController from fastcs.mapping import Mapping - -@dataclass -class RestServerOptions: - host: str = "localhost" - port: int = 8080 - log_level: str = "info" +from .options import RestServerOptions class RestServer: @@ -30,10 +24,8 @@ def _create_app(self): return app - def run(self, options: RestServerOptions | None = None) -> None: - if options is None: - options = RestServerOptions() - + def run(self, options: RestServerOptions | None) -> None: + options = options or RestServerOptions() uvicorn.run( self._app, host=options.host, diff --git a/src/fastcs/backends/tango/__init__.py b/src/fastcs/transport/tango/__init__.py similarity index 100% rename from src/fastcs/backends/tango/__init__.py rename to src/fastcs/transport/tango/__init__.py diff --git a/src/fastcs/transport/tango/adapter.py b/src/fastcs/transport/tango/adapter.py new file mode 100644 index 000000000..c51be6e31 --- /dev/null +++ b/src/fastcs/transport/tango/adapter.py @@ -0,0 +1,25 @@ +from fastcs.mapping import Mapping +from fastcs.transport.adapter import TransportAdapter + +from .dsr import TangoDSR +from .options import TangoOptions + + +class TangoTransport(TransportAdapter): + def __init__( + self, + mapping: Mapping, + options: TangoOptions | None = None, + ): + self.options = options or TangoOptions() + self._mapping = mapping + self._dsr = TangoDSR(self._mapping) + + def create_docs(self) -> None: + raise NotImplementedError + + def create_gui(self) -> None: + raise NotImplementedError + + def run(self) -> None: + self._dsr.run(self.options.dsr) diff --git a/src/fastcs/backends/tango/dsr.py b/src/fastcs/transport/tango/dsr.py similarity index 97% rename from src/fastcs/backends/tango/dsr.py rename to src/fastcs/transport/tango/dsr.py index 6df5fc573..88f8aac7e 100644 --- a/src/fastcs/backends/tango/dsr.py +++ b/src/fastcs/transport/tango/dsr.py @@ -1,5 +1,4 @@ from collections.abc import Awaitable, Callable -from dataclasses import dataclass from typing import Any import tango @@ -11,12 +10,7 @@ from fastcs.datatypes import Float from fastcs.mapping import Mapping - -@dataclass -class TangoDSROptions: - dev_name: str = "MY/DEVICE/NAME" - dsr_instance: str = "MY_SERVER_INSTANCE" - debug: bool = False +from .options import TangoDSROptions def _wrap_updater_fget( diff --git a/src/fastcs/transport/tango/options.py b/src/fastcs/transport/tango/options.py new file mode 100644 index 000000000..4ba4eff7f --- /dev/null +++ b/src/fastcs/transport/tango/options.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field + + +@dataclass +class TangoDSROptions: + dev_name: str = "MY/DEVICE/NAME" + dev_class: str = "FAST_CS_DEVICE" + dsr_instance: str = "MY_SERVER_INSTANCE" + debug: bool = False + + +@dataclass +class TangoOptions: + dsr: TangoDSROptions = field(default_factory=TangoDSROptions) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index b8c7f1cd3..f16f25b54 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -1,5 +1,55 @@ +import asyncio +import atexit +import inspect +import logging +import threading + + def snake_to_pascal(input: str) -> str: """Convert a snake_case string to PascalCase.""" return "".join( part.title() if part.islower() else part for part in input.split("_") ) + + +class AsyncioDispatcher: + def __init__(self, loop=None): + """A dispatcher for `asyncio` based IOCs, suitable to be passed to + `softioc.iocInit`. Means that `on_update` callback functions can be + async. + + If a ``loop`` is provided it must already be running. Otherwise a new + Event Loop will be created and run in a dedicated thread. + """ + if loop is None: + # Make one and run it in a background thread + self.loop = asyncio.new_event_loop() + worker = threading.Thread(target=self.loop.run_forever) + # Explicitly manage worker thread as part of interpreter shutdown. + # Otherwise threading module will deadlock trying to join() + # before our atexit hook runs, while the loop is still running. + worker.daemon = True + + @atexit.register + def aioJoin(worker=worker, loop=self.loop): + loop.call_soon_threadsafe(loop.stop) + worker.join() + + worker.start() + elif not loop.is_running(): + raise ValueError("Provided asyncio event loop is not running") + else: + self.loop = loop + + def __call__(self, func, func_args=(), completion=None, completion_args=()): + async def async_wrapper(): + try: + ret = func(*func_args) + if inspect.isawaitable(ret): + await ret + if completion: + completion(*completion_args) + except Exception: + logging.exception("Exception when running dispatched callback") + + asyncio.run_coroutine_threadsafe(async_wrapper(), self.loop) diff --git a/tests/conftest.py b/tests/conftest.py index 727796428..63d21106e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,14 @@ from fastcs.mapping import Mapping from fastcs.wrappers import command, scan +DATA_PATH = Path(__file__).parent / "data" + + +@pytest.fixture +def data() -> Path: + return DATA_PATH + + # Prevent pytest from catching exceptions when debugging in vscode so that break on # exception works correctly (see: https://github.com/pytest-dev/pytest/issues/7409) if os.getenv("PYTEST_RAISE", "0") == "1": diff --git a/tests/data/config_full.yaml b/tests/data/config_full.yaml new file mode 100644 index 000000000..b396f679c --- /dev/null +++ b/tests/data/config_full.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=schema.json +transport: + ioc: {} + docs: {} + gui: {} +controller: + name: controller-name diff --git a/tests/data/config_minimal.yaml b/tests/data/config_minimal.yaml new file mode 100644 index 000000000..8b887af6a --- /dev/null +++ b/tests/data/config_minimal.yaml @@ -0,0 +1,3 @@ +# yaml-language-server: $schema=schema.json +transport: + dsr: {} diff --git a/tests/ioc.py b/tests/ioc.py index 45f9c42bc..05ea47b6b 100644 --- a/tests/ioc.py +++ b/tests/ioc.py @@ -1,7 +1,8 @@ from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.backends.epics.backend import EpicsBackend from fastcs.controller import Controller, SubController from fastcs.datatypes import Int +from fastcs.main import FastCS +from fastcs.transport.epics.options import EpicsIOCOptions, EpicsOptions from fastcs.wrappers import command @@ -9,6 +10,10 @@ class ParentController(Controller): a: AttrR = AttrR(Int()) b: AttrRW = AttrRW(Int()) + def __init__(self): + super().__init__() + self.register_sub_controller("Child", ChildController()) + class ChildController(SubController): c: AttrW = AttrW(Int()) @@ -19,11 +24,10 @@ async def d(self): def run(): + epics_options = EpicsOptions(ioc=EpicsIOCOptions(pv_prefix="DEVICE")) controller = ParentController() - controller.register_sub_controller("Child", ChildController()) - - backend = EpicsBackend(controller, "DEVICE") - backend.run() + fastcs = FastCS(controller, epics_options) + fastcs.run() if __name__ == "__main__": diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 000000000..6a0cee685 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,127 @@ +import json +from dataclasses import dataclass + +import pytest +from pydantic import create_model +from pytest_mock import MockerFixture +from typer.testing import CliRunner + +from fastcs.controller import Controller +from fastcs.exceptions import LaunchError +from fastcs.main import TransportOptions, _launch, launch + + +@dataclass +class SomeConfig: + name: str + + +class SingleArg(Controller): + def __init__(self): + super().__init__() + + +class NotHinted(Controller): + def __init__(self, arg): + super().__init__() + + +class IsHinted(Controller): + def __init__(self, arg: SomeConfig): + super().__init__() + + +class ManyArgs(Controller): + def __init__(self, arg: SomeConfig, too_many): + super().__init__() + + +runner = CliRunner() + + +def test_single_arg_schema(): + target_model = create_model( + "SingleArg", + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + target_dict = target_model.model_json_schema() + + app = _launch(SingleArg) + result = runner.invoke(app, ["schema"]) + assert result.exit_code == 0 + result_dict = json.loads(result.stdout) + + assert result_dict == target_dict + + +def test_is_hinted_schema(data): + target_model = create_model( + "IsHinted", + controller=(SomeConfig, ...), + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + target_dict = target_model.model_json_schema() + + app = _launch(IsHinted) + result = runner.invoke(app, ["schema"]) + assert result.exit_code == 0 + result_dict = json.loads(result.stdout) + + assert result_dict == target_dict + + # # store a schema to use for debugging + # with open(data / "schema.json", mode="w") as f: + # json.dump(result_dict, f, indent=2) + + +def test_not_hinted_schema(): + error = ( + "Expected typehinting in 'NotHinted.__init__' but received " + "(self, arg). Add a typehint for `arg`." + ) + + with pytest.raises(LaunchError) as exc_info: + launch(NotHinted) + assert str(exc_info.value) == error + + +def test_over_defined_schema(): + error = ( + "" + "Expected no more than 2 arguments for 'ManyArgs.__init__' " + "but received 3 as `(self, arg: test_main.SomeConfig, too_many)`" + ) + + with pytest.raises(LaunchError) as exc_info: + launch(ManyArgs) + assert str(exc_info.value) == error + + +def test_launch_minimal(mocker: MockerFixture, data): + run = mocker.patch("fastcs.main.FastCS.run") + gui = mocker.patch("fastcs.main.FastCS.create_gui") + docs = mocker.patch("fastcs.main.FastCS.create_docs") + + app = _launch(SingleArg) + result = runner.invoke(app, ["run", str(data / "config_minimal.yaml")]) + assert result.exit_code == 0 + + run.assert_called_once() + gui.assert_not_called() + docs.assert_not_called() + + +def test_launch_full(mocker: MockerFixture, data): + run = mocker.patch("fastcs.main.FastCS.run") + gui = mocker.patch("fastcs.main.FastCS.create_gui") + docs = mocker.patch("fastcs.main.FastCS.create_docs") + + app = _launch(IsHinted) + result = runner.invoke(app, ["run", str(data / "config_full.yaml")]) + assert result.exit_code == 0 + + run.assert_called_once() + gui.assert_called_once() + docs.assert_called_once() diff --git a/tests/backends/epics/test_gui.py b/tests/transport/epics/test_gui.py similarity index 98% rename from tests/backends/epics/test_gui.py rename to tests/transport/epics/test_gui.py index c4d9b0626..ee01d65e5 100644 --- a/tests/backends/epics/test_gui.py +++ b/tests/transport/epics/test_gui.py @@ -14,9 +14,9 @@ ToggleButton, ) -from fastcs.backends.epics.gui import EpicsGUI from fastcs.controller import Controller from fastcs.mapping import Mapping +from fastcs.transport.epics.gui import EpicsGUI def test_get_pv(): diff --git a/tests/backends/epics/test_ioc.py b/tests/transport/epics/test_ioc.py similarity index 88% rename from tests/backends/epics/test_ioc.py rename to tests/transport/epics/test_ioc.py index 721bb2952..ec30bc64e 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/transport/epics/test_ioc.py @@ -4,7 +4,12 @@ from pytest_mock import MockerFixture from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.backends.epics.ioc import ( +from fastcs.controller import Controller +from fastcs.cs_methods import Command +from fastcs.datatypes import Int, String +from fastcs.exceptions import FastCSException +from fastcs.mapping import Mapping +from fastcs.transport.epics.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsIOC, _add_attr_pvi_info, @@ -15,11 +20,6 @@ _get_input_record, _get_output_record, ) -from fastcs.controller import Controller -from fastcs.cs_methods import Command -from fastcs.datatypes import Int, String -from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping DEVICE = "DEVICE" @@ -29,9 +29,9 @@ @pytest.mark.asyncio async def test_create_and_link_read_pv(mocker: MockerFixture): - get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") - attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") + get_input_record = mocker.patch("fastcs.transport.epics.ioc._get_input_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + attr_is_enum = mocker.patch("fastcs.transport.epics.ioc.attr_is_enum") record = get_input_record.return_value attribute = mocker.MagicMock() @@ -52,11 +52,11 @@ async def test_create_and_link_read_pv(mocker: MockerFixture): @pytest.mark.asyncio async def test_create_and_link_read_pv_enum(mocker: MockerFixture): - get_input_record = mocker.patch("fastcs.backends.epics.ioc._get_input_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") - attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") + get_input_record = mocker.patch("fastcs.transport.epics.ioc._get_input_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + attr_is_enum = mocker.patch("fastcs.transport.epics.ioc.attr_is_enum") record = get_input_record.return_value - enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index") + enum_value_to_index = mocker.patch("fastcs.transport.epics.ioc.enum_value_to_index") attribute = mocker.MagicMock() @@ -93,7 +93,7 @@ def test_get_input_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") pv = "PV" _get_input_record(pv, attribute) @@ -109,9 +109,9 @@ def test_get_input_record_raises(mocker: MockerFixture): @pytest.mark.asyncio async def test_create_and_link_write_pv(mocker: MockerFixture): - get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") - attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") + get_output_record = mocker.patch("fastcs.transport.epics.ioc._get_output_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + attr_is_enum = mocker.patch("fastcs.transport.epics.ioc.attr_is_enum") record = get_output_record.return_value attribute = mocker.MagicMock() @@ -141,11 +141,11 @@ async def test_create_and_link_write_pv(mocker: MockerFixture): @pytest.mark.asyncio async def test_create_and_link_write_pv_enum(mocker: MockerFixture): - get_output_record = mocker.patch("fastcs.backends.epics.ioc._get_output_record") - add_attr_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_attr_pvi_info") - attr_is_enum = mocker.patch("fastcs.backends.epics.ioc.attr_is_enum") - enum_value_to_index = mocker.patch("fastcs.backends.epics.ioc.enum_value_to_index") - enum_index_to_value = mocker.patch("fastcs.backends.epics.ioc.enum_index_to_value") + get_output_record = mocker.patch("fastcs.transport.epics.ioc._get_output_record") + add_attr_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_attr_pvi_info") + attr_is_enum = mocker.patch("fastcs.transport.epics.ioc.attr_is_enum") + enum_value_to_index = mocker.patch("fastcs.transport.epics.ioc.enum_value_to_index") + enum_index_to_value = mocker.patch("fastcs.transport.epics.ioc.enum_index_to_value") record = get_output_record.return_value attribute = mocker.MagicMock() @@ -194,7 +194,7 @@ def test_get_output_record( kwargs: dict[str, Any], mocker: MockerFixture, ): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") update = mocker.MagicMock() pv = "PV" @@ -221,10 +221,10 @@ def test_get_output_record_raises(mocker: MockerFixture): def test_ioc(mocker: MockerFixture, mapping: Mapping): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") - add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") + add_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_pvi_info") add_sub_controller_pvi_info = mocker.patch( - "fastcs.backends.epics.ioc._add_sub_controller_pvi_info" + "fastcs.transport.epics.ioc._add_sub_controller_pvi_info" ) EpicsIOC(DEVICE, mapping) @@ -280,7 +280,7 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping): def test_add_pvi_info(mocker: MockerFixture): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -308,7 +308,7 @@ def test_add_pvi_info(mocker: MockerFixture): def test_add_pvi_info_with_parent(mocker: MockerFixture): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -344,7 +344,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.backends.epics.ioc._add_pvi_info") + add_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_pvi_info") controller = mocker.MagicMock() controller.path = [] child = mocker.MagicMock() @@ -394,7 +394,7 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") long_name_controller = ControllerLongNames() long_name_mapping = Mapping(long_name_controller) long_attr_name = "attr_r_with_reallyreallyreallyreallyreallyreallyreally_long_name" @@ -462,7 +462,7 @@ def test_long_pv_names_discarded(mocker: MockerFixture): def test_update_datatype(mocker: MockerFixture): - builder = mocker.patch("fastcs.backends.epics.ioc.builder") + builder = mocker.patch("fastcs.transport.epics.ioc.builder") pv_name = f"{DEVICE}:Attr" diff --git a/tests/backends/epics/test_ioc_system.py b/tests/transport/epics/test_ioc_system.py similarity index 100% rename from tests/backends/epics/test_ioc_system.py rename to tests/transport/epics/test_ioc_system.py diff --git a/tests/backends/epics/test_util.py b/tests/transport/epics/test_util.py similarity index 96% rename from tests/backends/epics/test_util.py rename to tests/transport/epics/test_util.py index 2e0af2856..dd018601b 100644 --- a/tests/backends/epics/test_util.py +++ b/tests/transport/epics/test_util.py @@ -1,12 +1,12 @@ import pytest from fastcs.attributes import AttrR -from fastcs.backends.epics.util import ( +from fastcs.datatypes import String +from fastcs.transport.epics.util import ( attr_is_enum, enum_index_to_value, enum_value_to_index, ) -from fastcs.datatypes import String def test_attr_is_enum(): diff --git a/tests/backends/rest/test_rest.py b/tests/transport/rest/test_rest.py similarity index 96% rename from tests/backends/rest/test_rest.py rename to tests/transport/rest/test_rest.py index 6cb8527c9..cfbb7dc22 100644 --- a/tests/backends/rest/test_rest.py +++ b/tests/transport/rest/test_rest.py @@ -1,13 +1,13 @@ import pytest from fastapi.testclient import TestClient -from fastcs.backends.rest.backend import RestBackend +from fastcs.transport.rest.adapter import RestTransport class TestRestServer: @pytest.fixture(scope="class") def client(self, assertable_controller): - app = RestBackend(assertable_controller)._server._app + app = RestTransport(assertable_controller)._server._app with TestClient(app) as client: yield client diff --git a/tests/backends/tango/test_dsr.py b/tests/transport/tango/test_dsr.py similarity index 97% rename from tests/backends/tango/test_dsr.py rename to tests/transport/tango/test_dsr.py index c55019a1e..3fbe8931d 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/transport/tango/test_dsr.py @@ -2,14 +2,14 @@ from tango import DevState from tango.test_context import DeviceTestContext -from fastcs.backends.tango.backend import TangoBackend +from fastcs.transport.tango.adapter import TangoTransport class TestTangoDevice: @pytest.fixture(scope="class") def tango_context(self, assertable_controller): # https://tango-controls.readthedocs.io/projects/pytango/en/v9.5.1/testing/test_context.html - device = TangoBackend(assertable_controller)._dsr._device + device = TangoTransport(assertable_controller)._dsr._device with DeviceTestContext(device) as proxy: yield proxy From 9edc6a45f985fd6e9c0f5c4b07a9859647397e33 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Tue, 26 Nov 2024 19:57:29 +0000 Subject: [PATCH 02/10] Mapping -> Controller --- src/fastcs/backend.py | 18 +++----- src/fastcs/controller.py | 44 ++++++++++++++++++++ src/fastcs/main.py | 7 ++-- src/fastcs/mapping.py | 59 --------------------------- src/fastcs/transport/epics/adapter.py | 15 +++---- src/fastcs/transport/epics/docs.py | 6 +-- src/fastcs/transport/epics/gui.py | 8 ++-- src/fastcs/transport/epics/ioc.py | 28 ++++++++----- src/fastcs/transport/rest/adapter.py | 7 ++-- src/fastcs/transport/rest/backend.py | 14 ------- src/fastcs/transport/rest/rest.py | 19 ++++----- src/fastcs/transport/tango/adapter.py | 7 ++-- src/fastcs/transport/tango/dsr.py | 31 +++++++------- tests/conftest.py | 6 --- tests/test_controller.py | 8 +++- tests/transport/epics/test_gui.py | 12 +++--- tests/transport/epics/test_ioc.py | 10 ++--- 17 files changed, 127 insertions(+), 172 deletions(-) delete mode 100644 src/fastcs/mapping.py delete mode 100644 src/fastcs/transport/rest/backend.py diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index f22cf403f..c92a70dfb 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -7,9 +7,8 @@ from fastcs.util import AsyncioDispatcher from .attributes import AttrR, AttrW, Sender, Updater -from .controller import Controller +from .controller import Controller, SingleMapping from .exceptions import FastCSException -from .mapping import Mapping, SingleMapping class Backend: @@ -29,17 +28,10 @@ def __init__( self._controller.initialise(), self._loop ).result() - self.mapping = Mapping(self._controller) self._link_process_tasks() - self.context = { - "dispatcher": self.dispatcher, - "controller": self._controller, - "mapping": self.mapping, - } - def _link_process_tasks(self): - for single_mapping in self.mapping.get_controller_mappings(): + for single_mapping in self._controller.get_controller_mappings(): _link_single_controller_put_tasks(single_mapping) _link_attribute_sender_class(single_mapping) @@ -58,7 +50,7 @@ def _run_initial_futures(self): def start_scan_futures(self): self._scan_futures = { asyncio.run_coroutine_threadsafe(coro(), self._loop) - for coro in _get_scan_coros(self.mapping) + for coro in _get_scan_coros(self._controller) } def stop_scan_futures(self): @@ -106,10 +98,10 @@ async def callback(value): return callback -def _get_scan_coros(mapping: Mapping) -> list[Callable]: +def _get_scan_coros(controller: Controller) -> list[Callable]: scan_dict: dict[float, list[Callable]] = defaultdict(list) - for single_mapping in mapping.get_controller_mappings(): + for single_mapping in controller.get_controller_mappings(): _add_scan_method_tasks(scan_dict, single_mapping) _add_attribute_updater_tasks(scan_dict, single_mapping) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 95c64d31f..0928ba0b5 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -1,8 +1,21 @@ from __future__ import annotations +from collections.abc import Iterator from copy import copy +from dataclasses import dataclass from .attributes import Attribute +from .cs_methods import Command, Put, Scan +from .wrappers import WrappedMethod + + +@dataclass +class SingleMapping: + controller: BaseController + scan_methods: dict[str, Scan] + put_methods: dict[str, Put] + command_methods: dict[str, Command] + attributes: dict[str, Attribute] class BaseController: @@ -42,6 +55,37 @@ def register_sub_controller(self, name: str, sub_controller: SubController): def get_sub_controllers(self) -> dict[str, BaseController]: return self.__sub_controller_tree + def get_controller_mappings(self) -> list[SingleMapping]: + return list(_walk_mappings(self)) + + +def _walk_mappings(controller: BaseController) -> Iterator[SingleMapping]: + yield _get_single_mapping(controller) + for sub_controller in controller.get_sub_controllers().values(): + yield from _walk_mappings(sub_controller) + + +def _get_single_mapping(controller: BaseController) -> SingleMapping: + scan_methods: dict[str, Scan] = {} + put_methods: dict[str, Put] = {} + command_methods: dict[str, Command] = {} + attributes: dict[str, Attribute] = {} + for attr_name in dir(controller): + attr = getattr(controller, attr_name) + match attr: + case WrappedMethod(fastcs_method=Put(enabled=True) as put_method): + put_methods[attr_name] = put_method + case WrappedMethod(fastcs_method=Scan(enabled=True) as scan_method): + scan_methods[attr_name] = scan_method + case WrappedMethod(fastcs_method=Command(enabled=True) as command_method): + command_methods[attr_name] = command_method + case Attribute(enabled=True): + attributes[attr_name] = attr + + return SingleMapping( + controller, scan_methods, put_methods, command_methods, attributes + ) + class Controller(BaseController): """Top-level controller for a device. diff --git a/src/fastcs/main.py b/src/fastcs/main.py index 71142425d..60239ee46 100644 --- a/src/fastcs/main.py +++ b/src/fastcs/main.py @@ -32,8 +32,7 @@ def __init__( from .transport.epics.adapter import EpicsTransport self._transport = EpicsTransport( - self._backend.mapping, - self._backend.context, + controller, self._backend.dispatcher, transport_options, ) @@ -41,14 +40,14 @@ def __init__( from .transport.tango.adapter import TangoTransport self._transport = TangoTransport( - self._backend.mapping, + controller, transport_options, ) case RestOptions(): from .transport.rest.adapter import RestTransport self._transport = RestTransport( - self._backend.mapping, + controller, transport_options, ) diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py deleted file mode 100644 index d15cb481b..000000000 --- a/src/fastcs/mapping.py +++ /dev/null @@ -1,59 +0,0 @@ -from collections.abc import Iterator -from dataclasses import dataclass - -from .attributes import Attribute -from .controller import BaseController, Controller -from .cs_methods import Command, Put, Scan -from .wrappers import WrappedMethod - - -@dataclass -class SingleMapping: - controller: BaseController - scan_methods: dict[str, Scan] - put_methods: dict[str, Put] - command_methods: dict[str, Command] - attributes: dict[str, Attribute] - - -class Mapping: - def __init__(self, controller: Controller) -> None: - self.controller = controller - self._controller_mappings = list(_walk_mappings(controller)) - - def __str__(self) -> str: - result = "Controller mappings:\n" - for mapping in self._controller_mappings: - result += f"{mapping}\n" - return result - - def get_controller_mappings(self) -> list[SingleMapping]: - return self._controller_mappings - - -def _walk_mappings(controller: BaseController) -> Iterator[SingleMapping]: - yield _get_single_mapping(controller) - for sub_controller in controller.get_sub_controllers().values(): - yield from _walk_mappings(sub_controller) - - -def _get_single_mapping(controller: BaseController) -> SingleMapping: - scan_methods: dict[str, Scan] = {} - put_methods: dict[str, Put] = {} - command_methods: dict[str, Command] = {} - attributes: dict[str, Attribute] = {} - for attr_name in dir(controller): - attr = getattr(controller, attr_name) - match attr: - case WrappedMethod(fastcs_method=Put(enabled=True) as put_method): - put_methods[attr_name] = put_method - case WrappedMethod(fastcs_method=Scan(enabled=True) as scan_method): - scan_methods[attr_name] = scan_method - case WrappedMethod(fastcs_method=Command(enabled=True) as command_method): - command_methods[attr_name] = command_method - case Attribute(enabled=True): - attributes[attr_name] = attr - - return SingleMapping( - controller, scan_methods, put_methods, command_methods, attributes - ) diff --git a/src/fastcs/transport/epics/adapter.py b/src/fastcs/transport/epics/adapter.py index 0ef4930f8..8ed13c595 100644 --- a/src/fastcs/transport/epics/adapter.py +++ b/src/fastcs/transport/epics/adapter.py @@ -2,7 +2,7 @@ from softioc.asyncio_dispatcher import AsyncioDispatcher as Dispatcher -from fastcs.mapping import Mapping +from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter from fastcs.util import AsyncioDispatcher @@ -15,26 +15,23 @@ class EpicsTransport(TransportAdapter): def __init__( self, - mapping: Mapping, - context: dict, + controller: Controller, dispatcher: AsyncioDispatcher, options: EpicsOptions | None = None, ) -> None: self.options = options or EpicsOptions() - self._mapping = mapping - self._context = context + self._controller = controller self._dispatcher = dispatcher self._pv_prefix = self.options.ioc.pv_prefix - self._ioc = EpicsIOC(self.options.ioc.pv_prefix, self._mapping) + self._ioc = EpicsIOC(self.options.ioc.pv_prefix, controller) def create_docs(self) -> None: - EpicsDocs(self._mapping).create_docs(self.options.docs) + EpicsDocs(self._controller).create_docs(self.options.docs) def create_gui(self) -> None: - EpicsGUI(self._mapping, self._pv_prefix).create_gui(self.options.gui) + EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui) def run(self): self._ioc.run( cast(Dispatcher, self._dispatcher), - self._context, ) diff --git a/src/fastcs/transport/epics/docs.py b/src/fastcs/transport/epics/docs.py index 02c896993..bec5469d2 100644 --- a/src/fastcs/transport/epics/docs.py +++ b/src/fastcs/transport/epics/docs.py @@ -1,11 +1,11 @@ -from fastcs.mapping import Mapping +from fastcs.controller import Controller from .options import EpicsDocsOptions class EpicsDocs: - def __init__(self, mapping: Mapping) -> None: - self._mapping = mapping + def __init__(self, controller: Controller) -> None: + self._controller = controller def create_docs(self, options: EpicsDocsOptions | None = None) -> None: if options is None: diff --git a/src/fastcs/transport/epics/gui.py b/src/fastcs/transport/epics/gui.py index a611a8859..e866f88a0 100644 --- a/src/fastcs/transport/epics/gui.py +++ b/src/fastcs/transport/epics/gui.py @@ -23,18 +23,18 @@ from pydantic import ValidationError from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW +from fastcs.controller import Controller, SingleMapping, _get_single_mapping from fastcs.cs_methods import Command from fastcs.datatypes import Bool, Float, Int, String from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping, SingleMapping, _get_single_mapping from fastcs.util import snake_to_pascal from .options import EpicsGUIFormat, EpicsGUIOptions class EpicsGUI: - def __init__(self, mapping: Mapping, pv_prefix: str) -> None: - self._mapping = mapping + def __init__(self, controller: Controller, pv_prefix: str) -> None: + self._controller = controller self._pv_prefix = pv_prefix def _get_pv(self, attr_path: list[str], name: str): @@ -117,7 +117,7 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None: assert options.output_path.suffix == options.file_format.value - controller_mapping = self._mapping.get_controller_mappings()[0] + controller_mapping = self._controller.get_controller_mappings()[0] components = self.extract_mapping_components(controller_mapping) device = Device(label=options.title, children=components) diff --git a/src/fastcs/transport/epics/ioc.py b/src/fastcs/transport/epics/ioc.py index 1d1d857d0..5316a7897 100644 --- a/src/fastcs/transport/epics/ioc.py +++ b/src/fastcs/transport/epics/ioc.py @@ -8,10 +8,9 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.controller import BaseController +from fastcs.controller import BaseController, Controller from fastcs.datatypes import Bool, DataType, Float, Int, String, T from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping from fastcs.transport.epics.util import ( MBB_STATE_FIELDS, attr_is_enum, @@ -46,24 +45,31 @@ def datatype_to_epics_fields(datatype: DataType) -> dict[str, Any]: class EpicsIOC: def __init__( - self, pv_prefix: str, mapping: Mapping, options: EpicsIOCOptions | None = None + self, + pv_prefix: str, + controller: Controller, + options: EpicsIOCOptions | None = None, ): self.options = options or EpicsIOCOptions() + self._controller = controller _add_pvi_info(f"{pv_prefix}:PVI") - _add_sub_controller_pvi_info(pv_prefix, mapping.controller) + _add_sub_controller_pvi_info(pv_prefix, controller) - _create_and_link_attribute_pvs(pv_prefix, mapping) - _create_and_link_command_pvs(pv_prefix, mapping) + _create_and_link_attribute_pvs(pv_prefix, controller) + _create_and_link_command_pvs(pv_prefix, controller) def run( self, dispatcher: AsyncioDispatcher, - context: dict[str, Any], ) -> None: builder.LoadDatabase() softioc.iocInit(dispatcher) if self.options.terminal: + context = { + "dispatcher": dispatcher, + "controller": self._controller, + } softioc.interactive_ioc(context) @@ -131,8 +137,8 @@ def _add_sub_controller_pvi_info(pv_prefix: str, parent: BaseController): _add_sub_controller_pvi_info(pv_prefix, child) -def _create_and_link_attribute_pvs(pv_prefix: str, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): +def _create_and_link_attribute_pvs(pv_prefix: str, controller: Controller) -> None: + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for attr_name, attribute in single_mapping.attributes.items(): pv_name = attr_name.title().replace("_", "") @@ -322,8 +328,8 @@ def datatype_updater(datatype: DataType): return record -def _create_and_link_command_pvs(pv_prefix: str, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): +def _create_and_link_command_pvs(pv_prefix: str, controller: Controller) -> None: + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for attr_name, method in single_mapping.command_methods.items(): pv_name = attr_name.title().replace("_", "") diff --git a/src/fastcs/transport/rest/adapter.py b/src/fastcs/transport/rest/adapter.py index fc7738e46..dd96ad819 100644 --- a/src/fastcs/transport/rest/adapter.py +++ b/src/fastcs/transport/rest/adapter.py @@ -1,4 +1,4 @@ -from fastcs.mapping import Mapping +from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter from .options import RestOptions @@ -8,12 +8,11 @@ class RestTransport(TransportAdapter): def __init__( self, - mapping: Mapping, + controller: Controller, options: RestOptions | None = None, ): self.options = options or RestOptions() - self._mapping = mapping - self._server = RestServer(self._mapping) + self._server = RestServer(controller) def create_docs(self) -> None: raise NotImplementedError diff --git a/src/fastcs/transport/rest/backend.py b/src/fastcs/transport/rest/backend.py deleted file mode 100644 index 97b9d2322..000000000 --- a/src/fastcs/transport/rest/backend.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastcs.backend import Backend -from fastcs.controller import Controller - -from .rest import RestServer - - -class RestBackend(Backend): - def __init__(self, controller: Controller): - super().__init__(controller) - - self._server = RestServer(self._mapping) - - def _run(self): - self._server.run() diff --git a/src/fastcs/transport/rest/rest.py b/src/fastcs/transport/rest/rest.py index ef891ee56..9eb0e9254 100644 --- a/src/fastcs/transport/rest/rest.py +++ b/src/fastcs/transport/rest/rest.py @@ -6,21 +6,20 @@ from pydantic import create_model from fastcs.attributes import AttrR, AttrRW, AttrW, T -from fastcs.controller import BaseController -from fastcs.mapping import Mapping +from fastcs.controller import BaseController, Controller from .options import RestServerOptions class RestServer: - def __init__(self, mapping: Mapping): - self._mapping = mapping + def __init__(self, controller: Controller): + self._controller = controller self._app = self._create_app() def _create_app(self): app = FastAPI() - _add_attribute_api_routes(app, self._mapping) - _add_command_api_routes(app, self._mapping) + _add_attribute_api_routes(app, self._controller) + _add_command_api_routes(app, self._controller) return app @@ -82,8 +81,8 @@ async def attr_get() -> Any: # Must be any as response_model is set return attr_get -def _add_attribute_api_routes(app: FastAPI, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): +def _add_attribute_api_routes(app: FastAPI, controller: Controller) -> None: + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for attr_name, attribute in single_mapping.attributes.items(): @@ -132,8 +131,8 @@ async def command() -> None: return command -def _add_command_api_routes(app: FastAPI, mapping: Mapping) -> None: - for single_mapping in mapping.get_controller_mappings(): +def _add_command_api_routes(app: FastAPI, controller: Controller) -> None: + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for name, method in single_mapping.command_methods.items(): diff --git a/src/fastcs/transport/tango/adapter.py b/src/fastcs/transport/tango/adapter.py index c51be6e31..7c5f103ad 100644 --- a/src/fastcs/transport/tango/adapter.py +++ b/src/fastcs/transport/tango/adapter.py @@ -1,4 +1,4 @@ -from fastcs.mapping import Mapping +from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter from .dsr import TangoDSR @@ -8,12 +8,11 @@ class TangoTransport(TransportAdapter): def __init__( self, - mapping: Mapping, + controller: Controller, options: TangoOptions | None = None, ): self.options = options or TangoOptions() - self._mapping = mapping - self._dsr = TangoDSR(self._mapping) + self._dsr = TangoDSR(controller) def create_docs(self) -> None: raise NotImplementedError diff --git a/src/fastcs/transport/tango/dsr.py b/src/fastcs/transport/tango/dsr.py index 88f8aac7e..9b7f6f716 100644 --- a/src/fastcs/transport/tango/dsr.py +++ b/src/fastcs/transport/tango/dsr.py @@ -8,7 +8,6 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import BaseController from fastcs.datatypes import Float -from fastcs.mapping import Mapping from .options import TangoDSROptions @@ -41,9 +40,9 @@ async def fset(tango_device: Device, val): return fset -def _collect_dev_attributes(mapping: Mapping) -> dict[str, Any]: +def _collect_dev_attributes(controller: BaseController) -> dict[str, Any]: collection: dict[str, Any] = {} - for single_mapping in mapping.get_controller_mappings(): + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for attr_name, attribute in single_mapping.attributes.items(): @@ -101,9 +100,9 @@ async def _dynamic_f(tango_device: Device) -> None: return _dynamic_f -def _collect_dev_commands(mapping: Mapping) -> dict[str, Any]: +def _collect_dev_commands(controller: BaseController) -> dict[str, Any]: collection: dict[str, Any] = {} - for single_mapping in mapping.get_controller_mappings(): + for single_mapping in controller.get_controller_mappings(): path = single_mapping.controller.path for name, method in single_mapping.command_methods.items(): @@ -116,12 +115,12 @@ def _collect_dev_commands(mapping: Mapping) -> dict[str, Any]: return collection -def _collect_dev_properties(mapping: Mapping) -> dict[str, Any]: +def _collect_dev_properties(controller: BaseController) -> dict[str, Any]: collection: dict[str, Any] = {} return collection -def _collect_dev_init(mapping: Mapping) -> dict[str, Callable]: +def _collect_dev_init(controller: BaseController) -> dict[str, Callable]: async def init_device(tango_device: Device): await server.Device.init_device(tango_device) # type: ignore tango_device.set_state(DevState.ON) @@ -129,7 +128,7 @@ async def init_device(tango_device: Device): return {"init_device": init_device} -def _collect_dev_flags(mapping: Mapping) -> dict[str, Any]: +def _collect_dev_flags(controller: BaseController) -> dict[str, Any]: collection: dict[str, Any] = {} collection["green_mode"] = tango.GreenMode.Asyncio @@ -147,18 +146,18 @@ def _collect_dsr_args(options: TangoDSROptions) -> list[str]: class TangoDSR: - def __init__(self, mapping: Mapping): - self._mapping = mapping - self.dev_class = self._mapping.controller.__class__.__name__ + def __init__(self, controller: BaseController): + self._controller = controller + self.dev_class = self._controller.__class__.__name__ self._device = self._create_device() def _create_device(self): class_dict: dict = { - **_collect_dev_attributes(self._mapping), - **_collect_dev_commands(self._mapping), - **_collect_dev_properties(self._mapping), - **_collect_dev_init(self._mapping), - **_collect_dev_flags(self._mapping), + **_collect_dev_attributes(self._controller), + **_collect_dev_commands(self._controller), + **_collect_dev_properties(self._controller), + **_collect_dev_init(self._controller), + **_collect_dev_flags(self._controller), } class_bases = (server.Device,) diff --git a/tests/conftest.py b/tests/conftest.py index 63d21106e..fad58597e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,6 @@ from fastcs.attributes import AttrR, AttrRW, AttrW, Handler, Sender, Updater from fastcs.controller import Controller, SubController from fastcs.datatypes import Bool, Float, Int, String -from fastcs.mapping import Mapping from fastcs.wrappers import command, scan DATA_PATH = Path(__file__).parent / "data" @@ -163,11 +162,6 @@ def assertable_controller(class_mocker: MockerFixture): return AssertableController(class_mocker) -@pytest.fixture -def mapping(controller): - return Mapping(controller) - - PV_PREFIX = "".join(random.choice(string.ascii_lowercase) for _ in range(12)) HERE = Path(os.path.dirname(os.path.abspath(__file__))) diff --git a/tests/test_controller.py b/tests/test_controller.py index a55dc89b8..44b3f0fae 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,7 +1,11 @@ import pytest -from fastcs.controller import Controller, SubController -from fastcs.mapping import _get_single_mapping, _walk_mappings +from fastcs.controller import ( + Controller, + SubController, + _get_single_mapping, + _walk_mappings, +) def test_controller_nesting(): diff --git a/tests/transport/epics/test_gui.py b/tests/transport/epics/test_gui.py index ee01d65e5..ae73004a5 100644 --- a/tests/transport/epics/test_gui.py +++ b/tests/transport/epics/test_gui.py @@ -14,23 +14,21 @@ ToggleButton, ) -from fastcs.controller import Controller -from fastcs.mapping import Mapping from fastcs.transport.epics.gui import EpicsGUI -def test_get_pv(): - gui = EpicsGUI(Mapping(Controller()), "DEVICE") +def test_get_pv(controller): + gui = EpicsGUI(controller, "DEVICE") assert gui._get_pv([], "A") == "DEVICE:A" assert gui._get_pv(["B"], "C") == "DEVICE:B:C" assert gui._get_pv(["D", "E"], "F") == "DEVICE:D:E:F" -def test_get_components(mapping): - gui = EpicsGUI(mapping, "DEVICE") +def test_get_components(controller): + gui = EpicsGUI(controller, "DEVICE") - components = gui.extract_mapping_components(mapping.get_controller_mappings()[0]) + components = gui.extract_mapping_components(controller.get_controller_mappings()[0]) assert components == [ Group( name="SubController01", diff --git a/tests/transport/epics/test_ioc.py b/tests/transport/epics/test_ioc.py index ec30bc64e..57a1ed7c3 100644 --- a/tests/transport/epics/test_ioc.py +++ b/tests/transport/epics/test_ioc.py @@ -8,7 +8,6 @@ from fastcs.cs_methods import Command from fastcs.datatypes import Int, String from fastcs.exceptions import FastCSException -from fastcs.mapping import Mapping from fastcs.transport.epics.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsIOC, @@ -220,14 +219,14 @@ def test_get_output_record_raises(mocker: MockerFixture): } -def test_ioc(mocker: MockerFixture, mapping: Mapping): +def test_ioc(mocker: MockerFixture, controller: Controller): builder = mocker.patch("fastcs.transport.epics.ioc.builder") add_pvi_info = mocker.patch("fastcs.transport.epics.ioc._add_pvi_info") add_sub_controller_pvi_info = mocker.patch( "fastcs.transport.epics.ioc._add_sub_controller_pvi_info" ) - EpicsIOC(DEVICE, mapping) + EpicsIOC(DEVICE, controller) # Check records are created builder.boolIn.assert_called_once_with(f"{DEVICE}:ReadBool", ZNAM="OFF", ONAM="ON") @@ -276,7 +275,7 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping): # Check info tags are added add_pvi_info.assert_called_once_with(f"{DEVICE}:PVI") - add_sub_controller_pvi_info.assert_called_once_with(DEVICE, mapping.controller) + add_sub_controller_pvi_info.assert_called_once_with(DEVICE, controller) def test_add_pvi_info(mocker: MockerFixture): @@ -396,12 +395,11 @@ class ControllerLongNames(Controller): def test_long_pv_names_discarded(mocker: MockerFixture): builder = mocker.patch("fastcs.transport.epics.ioc.builder") long_name_controller = ControllerLongNames() - long_name_mapping = Mapping(long_name_controller) 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" assert long_name_controller.attr_rw_short_name.enabled assert getattr(long_name_controller, long_attr_name).enabled - EpicsIOC(DEVICE, long_name_mapping) + EpicsIOC(DEVICE, long_name_controller) assert long_name_controller.attr_rw_short_name.enabled assert not getattr(long_name_controller, long_attr_name).enabled From c3874c95d4f9be16469393314661b68a373b5fa5 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Wed, 27 Nov 2024 11:21:23 +0000 Subject: [PATCH 03/10] Link during controller instantiation --- src/fastcs/backend.py | 46 +--------------------------------------- src/fastcs/controller.py | 46 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index c92a70dfb..e8b180b22 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -6,9 +6,8 @@ from fastcs.util import AsyncioDispatcher -from .attributes import AttrR, AttrW, Sender, Updater +from .attributes import AttrR, Updater from .controller import Controller, SingleMapping -from .exceptions import FastCSException class Backend: @@ -28,13 +27,6 @@ def __init__( self._controller.initialise(), self._loop ).result() - self._link_process_tasks() - - def _link_process_tasks(self): - for single_mapping in self._controller.get_controller_mappings(): - _link_single_controller_put_tasks(single_mapping) - _link_attribute_sender_class(single_mapping) - def __del__(self): self.stop_scan_futures() @@ -62,42 +54,6 @@ def stop_scan_futures(self): pass -def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: - for name, method in single_mapping.put_methods.items(): - name = name.removeprefix("put_") - - attribute = single_mapping.attributes[name] - match attribute: - case AttrW(): - attribute.set_process_callback( - MethodType(method.fn, single_mapping.controller) - ) - case _: - raise FastCSException( - f"Mode {attribute.access_mode} does not " - f"support put operations for {name}" - ) - - -def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: - for attr_name, attribute in single_mapping.attributes.items(): - match attribute: - case AttrW(sender=Sender()): - assert ( - not attribute.has_process_callback() - ), f"Cannot assign both put method and Sender object to {attr_name}" - - callback = _create_sender_callback(attribute, single_mapping.controller) - attribute.set_process_callback(callback) - - -def _create_sender_callback(attribute, controller): - async def callback(value): - await attribute.sender.put(controller, attribute, value) - - return callback - - def _get_scan_coros(controller: Controller) -> list[Callable]: scan_dict: dict[float, list[Callable]] = defaultdict(list) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 0928ba0b5..fbf5bf4d3 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -3,9 +3,11 @@ from collections.abc import Iterator from copy import copy from dataclasses import dataclass +from types import MethodType -from .attributes import Attribute +from .attributes import Attribute, AttrW, Sender from .cs_methods import Command, Put, Scan +from .exceptions import FastCSException from .wrappers import WrappedMethod @@ -98,6 +100,7 @@ class Controller(BaseController): def __init__(self) -> None: super().__init__() + self.link_process_tasks() async def initialise(self) -> None: pass @@ -105,6 +108,11 @@ async def initialise(self) -> None: async def connect(self) -> None: pass + def link_process_tasks(self): + for single_mapping in self.get_controller_mappings(): + _link_single_controller_put_tasks(single_mapping) + _link_attribute_sender_class(single_mapping) + class SubController(BaseController): """A subordinate to a ``Controller`` for managing a subset of a device. @@ -115,3 +123,39 @@ class SubController(BaseController): def __init__(self) -> None: super().__init__() + + +def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: + for name, method in single_mapping.put_methods.items(): + name = name.removeprefix("put_") + + attribute = single_mapping.attributes[name] + match attribute: + case AttrW(): + attribute.set_process_callback( + MethodType(method.fn, single_mapping.controller) + ) + case _: + raise FastCSException( + f"Mode {attribute.access_mode} does not " + f"support put operations for {name}" + ) + + +def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: + for attr_name, attribute in single_mapping.attributes.items(): + match attribute: + case AttrW(sender=Sender()): + assert ( + not attribute.has_process_callback() + ), f"Cannot assign both put method and Sender object to {attr_name}" + + callback = _create_sender_callback(attribute, single_mapping.controller) + attribute.set_process_callback(callback) + + +def _create_sender_callback(attribute, controller): + async def callback(value): + await attribute.sender.put(controller, attribute, value) + + return callback From 267753dd359bf811773482f80a2527d74779ab97 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Wed, 27 Nov 2024 15:20:02 +0000 Subject: [PATCH 04/10] Revert "Link during controller instantiation" This reverts commit c3874c95d4f9be16469393314661b68a373b5fa5. --- src/fastcs/backend.py | 46 +++++++++++++++++++++++++++++++++++++++- src/fastcs/controller.py | 46 +--------------------------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index e8b180b22..c92a70dfb 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -6,8 +6,9 @@ from fastcs.util import AsyncioDispatcher -from .attributes import AttrR, Updater +from .attributes import AttrR, AttrW, Sender, Updater from .controller import Controller, SingleMapping +from .exceptions import FastCSException class Backend: @@ -27,6 +28,13 @@ def __init__( self._controller.initialise(), self._loop ).result() + self._link_process_tasks() + + def _link_process_tasks(self): + for single_mapping in self._controller.get_controller_mappings(): + _link_single_controller_put_tasks(single_mapping) + _link_attribute_sender_class(single_mapping) + def __del__(self): self.stop_scan_futures() @@ -54,6 +62,42 @@ def stop_scan_futures(self): pass +def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: + for name, method in single_mapping.put_methods.items(): + name = name.removeprefix("put_") + + attribute = single_mapping.attributes[name] + match attribute: + case AttrW(): + attribute.set_process_callback( + MethodType(method.fn, single_mapping.controller) + ) + case _: + raise FastCSException( + f"Mode {attribute.access_mode} does not " + f"support put operations for {name}" + ) + + +def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: + for attr_name, attribute in single_mapping.attributes.items(): + match attribute: + case AttrW(sender=Sender()): + assert ( + not attribute.has_process_callback() + ), f"Cannot assign both put method and Sender object to {attr_name}" + + callback = _create_sender_callback(attribute, single_mapping.controller) + attribute.set_process_callback(callback) + + +def _create_sender_callback(attribute, controller): + async def callback(value): + await attribute.sender.put(controller, attribute, value) + + return callback + + def _get_scan_coros(controller: Controller) -> list[Callable]: scan_dict: dict[float, list[Callable]] = defaultdict(list) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index fbf5bf4d3..0928ba0b5 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -3,11 +3,9 @@ from collections.abc import Iterator from copy import copy from dataclasses import dataclass -from types import MethodType -from .attributes import Attribute, AttrW, Sender +from .attributes import Attribute from .cs_methods import Command, Put, Scan -from .exceptions import FastCSException from .wrappers import WrappedMethod @@ -100,7 +98,6 @@ class Controller(BaseController): def __init__(self) -> None: super().__init__() - self.link_process_tasks() async def initialise(self) -> None: pass @@ -108,11 +105,6 @@ async def initialise(self) -> None: async def connect(self) -> None: pass - def link_process_tasks(self): - for single_mapping in self.get_controller_mappings(): - _link_single_controller_put_tasks(single_mapping) - _link_attribute_sender_class(single_mapping) - class SubController(BaseController): """A subordinate to a ``Controller`` for managing a subset of a device. @@ -123,39 +115,3 @@ class SubController(BaseController): def __init__(self) -> None: super().__init__() - - -def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: - for name, method in single_mapping.put_methods.items(): - name = name.removeprefix("put_") - - attribute = single_mapping.attributes[name] - match attribute: - case AttrW(): - attribute.set_process_callback( - MethodType(method.fn, single_mapping.controller) - ) - case _: - raise FastCSException( - f"Mode {attribute.access_mode} does not " - f"support put operations for {name}" - ) - - -def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: - for attr_name, attribute in single_mapping.attributes.items(): - match attribute: - case AttrW(sender=Sender()): - assert ( - not attribute.has_process_callback() - ), f"Cannot assign both put method and Sender object to {attr_name}" - - callback = _create_sender_callback(attribute, single_mapping.controller) - attribute.set_process_callback(callback) - - -def _create_sender_callback(attribute, controller): - async def callback(value): - await attribute.sender.put(controller, attribute, value) - - return callback From 78847fe0fad851dfc67cd5fee95d5dd06f8b540f Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 28 Nov 2024 10:29:39 +0000 Subject: [PATCH 05/10] Revert to ioc asyncio dispatcher --- src/fastcs/backend.py | 2 +- src/fastcs/transport/epics/adapter.py | 9 ++--- src/fastcs/util.py | 50 --------------------------- tests/test_cli.py | 3 +- 4 files changed, 5 insertions(+), 59 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index c92a70dfb..35ff2de0e 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -4,7 +4,7 @@ from concurrent.futures import Future from types import MethodType -from fastcs.util import AsyncioDispatcher +from softioc.asyncio_dispatcher import AsyncioDispatcher from .attributes import AttrR, AttrW, Sender, Updater from .controller import Controller, SingleMapping diff --git a/src/fastcs/transport/epics/adapter.py b/src/fastcs/transport/epics/adapter.py index 8ed13c595..3162d170f 100644 --- a/src/fastcs/transport/epics/adapter.py +++ b/src/fastcs/transport/epics/adapter.py @@ -1,10 +1,7 @@ -from typing import cast - -from softioc.asyncio_dispatcher import AsyncioDispatcher as Dispatcher +from softioc.asyncio_dispatcher import AsyncioDispatcher from fastcs.controller import Controller from fastcs.transport.adapter import TransportAdapter -from fastcs.util import AsyncioDispatcher from .docs import EpicsDocs from .gui import EpicsGUI @@ -32,6 +29,4 @@ def create_gui(self) -> None: EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui) def run(self): - self._ioc.run( - cast(Dispatcher, self._dispatcher), - ) + self._ioc.run(self._dispatcher) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index f16f25b54..b8c7f1cd3 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -1,55 +1,5 @@ -import asyncio -import atexit -import inspect -import logging -import threading - - def snake_to_pascal(input: str) -> str: """Convert a snake_case string to PascalCase.""" return "".join( part.title() if part.islower() else part for part in input.split("_") ) - - -class AsyncioDispatcher: - def __init__(self, loop=None): - """A dispatcher for `asyncio` based IOCs, suitable to be passed to - `softioc.iocInit`. Means that `on_update` callback functions can be - async. - - If a ``loop`` is provided it must already be running. Otherwise a new - Event Loop will be created and run in a dedicated thread. - """ - if loop is None: - # Make one and run it in a background thread - self.loop = asyncio.new_event_loop() - worker = threading.Thread(target=self.loop.run_forever) - # Explicitly manage worker thread as part of interpreter shutdown. - # Otherwise threading module will deadlock trying to join() - # before our atexit hook runs, while the loop is still running. - worker.daemon = True - - @atexit.register - def aioJoin(worker=worker, loop=self.loop): - loop.call_soon_threadsafe(loop.stop) - worker.join() - - worker.start() - elif not loop.is_running(): - raise ValueError("Provided asyncio event loop is not running") - else: - self.loop = loop - - def __call__(self, func, func_args=(), completion=None, completion_args=()): - async def async_wrapper(): - try: - ret = func(*func_args) - if inspect.isawaitable(ret): - await ret - if completion: - completion(*completion_args) - except Exception: - logging.exception("Exception when running dispatched callback") - - asyncio.run_coroutine_threadsafe(async_wrapper(), self.loop) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a16a0bfd..ee8416e3c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,4 +6,5 @@ def test_cli_version(): cmd = [sys.executable, "-m", "fastcs", "--version"] - assert subprocess.check_output(cmd).decode().strip() == __version__ + info = "INFO: PVXS QSRV2 is loaded, permitted, and ENABLED.\n" + assert subprocess.check_output(cmd).decode().strip() == info + __version__ From 333d15215f66f60893a8ab5f0d183e28cee6d256 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 28 Nov 2024 11:16:00 +0000 Subject: [PATCH 06/10] Remove unused tango option --- src/fastcs/transport/tango/options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fastcs/transport/tango/options.py b/src/fastcs/transport/tango/options.py index 4ba4eff7f..850393eb7 100644 --- a/src/fastcs/transport/tango/options.py +++ b/src/fastcs/transport/tango/options.py @@ -4,7 +4,6 @@ @dataclass class TangoDSROptions: dev_name: str = "MY/DEVICE/NAME" - dev_class: str = "FAST_CS_DEVICE" dsr_instance: str = "MY_SERVER_INSTANCE" debug: bool = False From 15544b90ed2aef573c99e794bb3cb16f5543941b Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 28 Nov 2024 11:37:03 +0000 Subject: [PATCH 07/10] Rename main to launch --- src/fastcs/__init__.py | 4 ++-- src/fastcs/{main.py => launch.py} | 0 tests/ioc.py | 2 +- tests/{test_main.py => test_launch.py} | 16 ++++++++-------- 4 files changed, 11 insertions(+), 11 deletions(-) rename src/fastcs/{main.py => launch.py} (100%) rename tests/{test_main.py => test_launch.py} (85%) diff --git a/src/fastcs/__init__.py b/src/fastcs/__init__.py index 00557d56a..43507e2e0 100644 --- a/src/fastcs/__init__.py +++ b/src/fastcs/__init__.py @@ -7,7 +7,7 @@ """ from ._version import __version__ -from .main import FastCS as FastCS -from .main import launch as launch +from .launch import FastCS as FastCS +from .launch import launch as launch __all__ = ["__version__"] diff --git a/src/fastcs/main.py b/src/fastcs/launch.py similarity index 100% rename from src/fastcs/main.py rename to src/fastcs/launch.py diff --git a/tests/ioc.py b/tests/ioc.py index 05ea47b6b..8968c24ef 100644 --- a/tests/ioc.py +++ b/tests/ioc.py @@ -1,7 +1,7 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.controller import Controller, SubController from fastcs.datatypes import Int -from fastcs.main import FastCS +from fastcs.launch import FastCS from fastcs.transport.epics.options import EpicsIOCOptions, EpicsOptions from fastcs.wrappers import command diff --git a/tests/test_main.py b/tests/test_launch.py similarity index 85% rename from tests/test_main.py rename to tests/test_launch.py index 6a0cee685..e703fcba3 100644 --- a/tests/test_main.py +++ b/tests/test_launch.py @@ -8,7 +8,7 @@ from fastcs.controller import Controller from fastcs.exceptions import LaunchError -from fastcs.main import TransportOptions, _launch, launch +from fastcs.launch import TransportOptions, _launch, launch @dataclass @@ -91,7 +91,7 @@ def test_over_defined_schema(): error = ( "" "Expected no more than 2 arguments for 'ManyArgs.__init__' " - "but received 3 as `(self, arg: test_main.SomeConfig, too_many)`" + "but received 3 as `(self, arg: test_launch.SomeConfig, too_many)`" ) with pytest.raises(LaunchError) as exc_info: @@ -100,9 +100,9 @@ def test_over_defined_schema(): def test_launch_minimal(mocker: MockerFixture, data): - run = mocker.patch("fastcs.main.FastCS.run") - gui = mocker.patch("fastcs.main.FastCS.create_gui") - docs = mocker.patch("fastcs.main.FastCS.create_docs") + run = mocker.patch("fastcs.launch.FastCS.run") + gui = mocker.patch("fastcs.launch.FastCS.create_gui") + docs = mocker.patch("fastcs.launch.FastCS.create_docs") app = _launch(SingleArg) result = runner.invoke(app, ["run", str(data / "config_minimal.yaml")]) @@ -114,9 +114,9 @@ def test_launch_minimal(mocker: MockerFixture, data): def test_launch_full(mocker: MockerFixture, data): - run = mocker.patch("fastcs.main.FastCS.run") - gui = mocker.patch("fastcs.main.FastCS.create_gui") - docs = mocker.patch("fastcs.main.FastCS.create_docs") + run = mocker.patch("fastcs.launch.FastCS.run") + gui = mocker.patch("fastcs.launch.FastCS.create_gui") + docs = mocker.patch("fastcs.launch.FastCS.create_docs") app = _launch(IsHinted) result = runner.invoke(app, ["run", str(data / "config_full.yaml")]) From 5295b9644e6a2f80c6edcc1f3c4f85348439e545 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 28 Nov 2024 13:13:56 +0000 Subject: [PATCH 08/10] Revert ioc test changes --- tests/ioc.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/ioc.py b/tests/ioc.py index 8968c24ef..12918a675 100644 --- a/tests/ioc.py +++ b/tests/ioc.py @@ -10,10 +10,6 @@ class ParentController(Controller): a: AttrR = AttrR(Int()) b: AttrRW = AttrRW(Int()) - def __init__(self): - super().__init__() - self.register_sub_controller("Child", ChildController()) - class ChildController(SubController): c: AttrW = AttrW(Int()) @@ -26,6 +22,7 @@ async def d(self): def run(): epics_options = EpicsOptions(ioc=EpicsIOCOptions(pv_prefix="DEVICE")) controller = ParentController() + controller.register_sub_controller("Child", ChildController()) fastcs = FastCS(controller, epics_options) fastcs.run() From 4dae3e558e0d32b7f73ddae61b73bcd97240e7f5 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Thu, 28 Nov 2024 13:29:17 +0000 Subject: [PATCH 09/10] Break launch into subfunction --- src/fastcs/launch.py | 64 +++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 60239ee46..d3d3fcf7c 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -4,7 +4,7 @@ from typing import Annotated, TypeAlias, get_type_hints import typer -from pydantic import create_model +from pydantic import BaseModel, create_model from ruamel.yaml import YAML from .backend import Backend @@ -90,35 +90,7 @@ def __init__(self, my_arg: MyControllerOptions) -> None: def _launch(controller_class: type[Controller]) -> typer.Typer: - sig = inspect.signature(controller_class.__init__) - args = inspect.getfullargspec(controller_class.__init__)[0] - if len(args) == 1: - fastcs_options = create_model( - f"{controller_class.__name__}", - transport=(TransportOptions, ...), - __config__={"extra": "forbid"}, - ) - elif len(args) == 2: - hints = get_type_hints(controller_class.__init__) - if hints: - options_type = list(hints.values())[-1] - else: - raise LaunchError( - f"Expected typehinting in '{controller_class.__name__}" - f".__init__' but received {sig}. Add a typehint for `{args[-1]}`." - ) - fastcs_options = create_model( - f"{controller_class.__name__}", - controller=(options_type, ...), - transport=(TransportOptions, ...), - __config__={"extra": "forbid"}, - ) - else: - raise LaunchError( - f"Expected no more than 2 arguments for '{controller_class.__name__}" - f".__init__' but received {len(args)} as `{sig}`" - ) - + fastcs_options = _extract_options_model(controller_class) launch_typer = typer.Typer() class LaunchContext: @@ -175,3 +147,35 @@ def run( instance.run() return launch_typer + + +def _extract_options_model(controller_class: type[Controller]) -> type[BaseModel]: + sig = inspect.signature(controller_class.__init__) + args = inspect.getfullargspec(controller_class.__init__)[0] + if len(args) == 1: + fastcs_options = create_model( + f"{controller_class.__name__}", + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + elif len(args) == 2: + hints = get_type_hints(controller_class.__init__) + if hints: + options_type = list(hints.values())[-1] + else: + raise LaunchError( + f"Expected typehinting in '{controller_class.__name__}" + f".__init__' but received {sig}. Add a typehint for `{args[-1]}`." + ) + fastcs_options = create_model( + f"{controller_class.__name__}", + controller=(options_type, ...), + transport=(TransportOptions, ...), + __config__={"extra": "forbid"}, + ) + else: + raise LaunchError( + f"Expected no more than 2 arguments for '{controller_class.__name__}" + f".__init__' but received {len(args)} as `{sig}`" + ) + return fastcs_options From a2389376a85eb2150723b47a1813f519f52d12e7 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 29 Nov 2024 13:21:27 +0000 Subject: [PATCH 10/10] Fix import namespace --- src/fastcs/connections/__init__.py | 4 +--- src/fastcs/transport/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/fastcs/connections/__init__.py b/src/fastcs/connections/__init__.py index c60796d60..5001409a1 100644 --- a/src/fastcs/connections/__init__.py +++ b/src/fastcs/connections/__init__.py @@ -1,7 +1,5 @@ -from .ip_connection import IPConnection +from .ip_connection import IPConnection as IPConnection from .ip_connection import IPConnectionSettings as IPConnectionSettings from .ip_connection import StreamConnection as StreamConnection from .serial_connection import SerialConnection as SerialConnection from .serial_connection import SerialConnectionSettings as SerialConnectionSettings - -__all__ = ["IPConnection"] diff --git a/src/fastcs/transport/__init__.py b/src/fastcs/transport/__init__.py index cc22afedc..0ca90d436 100644 --- a/src/fastcs/transport/__init__.py +++ b/src/fastcs/transport/__init__.py @@ -2,7 +2,7 @@ from .epics.options import EpicsGUIOptions as EpicsGUIOptions from .epics.options import EpicsIOCOptions as EpicsIOCOptions from .epics.options import EpicsOptions as EpicsOptions +from .rest.options import RestOptions as RestOptions +from .rest.options import RestServerOptions as RestServerOptions from .tango.options import TangoDSROptions as TangoDSROptions from .tango.options import TangoOptions as TangoOptions - -__all__ = ["EpicsOptions", "TangoOptions"]