diff --git a/src/fastcs/__init__.py b/src/fastcs/__init__.py index a2ffbf369..43507e2e0 100644 --- a/src/fastcs/__init__.py +++ b/src/fastcs/__init__.py @@ -7,5 +7,7 @@ """ from ._version import __version__ +from .launch import FastCS as FastCS +from .launch import launch as launch __all__ = ["__version__"] diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index d20075e27..35ff2de0e 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -7,17 +7,18 @@ from softioc.asyncio_dispatcher 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: 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 +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) @@ -47,7 +41,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 +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): @@ -68,9 +61,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(): @@ -108,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/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/epics/docs.py b/src/fastcs/backends/epics/docs.py deleted file mode 100644 index c2bc569a2..000000000 --- a/src/fastcs/backends/epics/docs.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path - -from fastcs.mapping import Mapping - - -@dataclass -class EpicsDocsOptions: - path: Path = Path.cwd() - depth: int | None = None - - -class EpicsDocs: - def __init__(self, mapping: Mapping) -> None: - self._mapping = mapping - - def create_docs(self, options: EpicsDocsOptions | None = None) -> None: - if options is None: - options = EpicsDocsOptions() diff --git a/src/fastcs/backends/rest/backend.py b/src/fastcs/backends/rest/backend.py deleted file mode 100644 index 97b9d2322..000000000 --- a/src/fastcs/backends/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/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..5001409a1 100644 --- a/src/fastcs/connections/__init__.py +++ b/src/fastcs/connections/__init__.py @@ -1,3 +1,5 @@ -from .ip_connection import IPConnection - -__all__ = ["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 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/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/launch.py b/src/fastcs/launch.py new file mode 100644 index 000000000..d3d3fcf7c --- /dev/null +++ b/src/fastcs/launch.py @@ -0,0 +1,181 @@ +import inspect +import json +from pathlib import Path +from typing import Annotated, TypeAlias, get_type_hints + +import typer +from pydantic import BaseModel, 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( + controller, + self._backend.dispatcher, + transport_options, + ) + case TangoOptions(): + from .transport.tango.adapter import TangoTransport + + self._transport = TangoTransport( + controller, + transport_options, + ) + case RestOptions(): + from .transport.rest.adapter import RestTransport + + self._transport = RestTransport( + controller, + 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: + fastcs_options = _extract_options_model(controller_class) + 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 + + +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 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/__init__.py b/src/fastcs/transport/__init__.py new file mode 100644 index 000000000..0ca90d436 --- /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 .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 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..3162d170f --- /dev/null +++ b/src/fastcs/transport/epics/adapter.py @@ -0,0 +1,32 @@ +from softioc.asyncio_dispatcher import AsyncioDispatcher + +from fastcs.controller import Controller +from fastcs.transport.adapter import TransportAdapter + +from .docs import EpicsDocs +from .gui import EpicsGUI +from .ioc import EpicsIOC +from .options import EpicsOptions + + +class EpicsTransport(TransportAdapter): + def __init__( + self, + controller: Controller, + dispatcher: AsyncioDispatcher, + options: EpicsOptions | None = None, + ) -> None: + self.options = options or EpicsOptions() + self._controller = controller + self._dispatcher = dispatcher + self._pv_prefix = self.options.ioc.pv_prefix + self._ioc = EpicsIOC(self.options.ioc.pv_prefix, controller) + + def create_docs(self) -> None: + EpicsDocs(self._controller).create_docs(self.options.docs) + + def create_gui(self) -> None: + EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui) + + def run(self): + self._ioc.run(self._dispatcher) diff --git a/src/fastcs/transport/epics/docs.py b/src/fastcs/transport/epics/docs.py new file mode 100644 index 000000000..bec5469d2 --- /dev/null +++ b/src/fastcs/transport/epics/docs.py @@ -0,0 +1,12 @@ +from fastcs.controller import Controller + +from .options import EpicsDocsOptions + + +class EpicsDocs: + def __init__(self, controller: Controller) -> None: + self._controller = controller + + def create_docs(self, options: EpicsDocsOptions | None = None) -> None: + if options is None: + options = EpicsDocsOptions() diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/transport/epics/gui.py similarity index 91% rename from src/fastcs/backends/epics/gui.py rename to src/fastcs/transport/epics/gui.py index b9c48751a..e866f88a0 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, @@ -27,28 +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 - -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: - 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): @@ -131,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/backends/epics/ioc.py b/src/fastcs/transport/epics/ioc.py similarity index 92% rename from src/fastcs/backends/epics/ioc.py rename to src/fastcs/transport/epics/ioc.py index e616d5675..5316a7897 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,19 @@ from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrR, AttrRW, AttrW -from fastcs.backends.epics.util import ( +from fastcs.controller import BaseController, Controller +from fastcs.datatypes import Bool, DataType, Float, Int, String, T +from fastcs.exceptions import FastCSException +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 = { @@ -49,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) @@ -134,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("_", "") @@ -325,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/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..dd96ad819 --- /dev/null +++ b/src/fastcs/transport/rest/adapter.py @@ -0,0 +1,24 @@ +from fastcs.controller import Controller +from fastcs.transport.adapter import TransportAdapter + +from .options import RestOptions +from .rest import RestServer + + +class RestTransport(TransportAdapter): + def __init__( + self, + controller: Controller, + options: RestOptions | None = None, + ): + self.options = options or RestOptions() + self._server = RestServer(controller) + + 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/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 84% rename from src/fastcs/backends/rest/rest.py rename to src/fastcs/transport/rest/rest.py index d41acdfde..9eb0e9254 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 @@ -7,33 +6,25 @@ 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 - -@dataclass -class RestServerOptions: - host: str = "localhost" - port: int = 8080 - log_level: str = "info" +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 - 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, @@ -90,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(): @@ -140,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/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..7c5f103ad --- /dev/null +++ b/src/fastcs/transport/tango/adapter.py @@ -0,0 +1,24 @@ +from fastcs.controller import Controller +from fastcs.transport.adapter import TransportAdapter + +from .dsr import TangoDSR +from .options import TangoOptions + + +class TangoTransport(TransportAdapter): + def __init__( + self, + controller: Controller, + options: TangoOptions | None = None, + ): + self.options = options or TangoOptions() + self._dsr = TangoDSR(controller) + + 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 84% rename from src/fastcs/backends/tango/dsr.py rename to src/fastcs/transport/tango/dsr.py index 6df5fc573..9b7f6f716 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 @@ -9,14 +8,8 @@ from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.controller import BaseController 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( @@ -47,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(): @@ -107,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(): @@ -122,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) @@ -135,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 @@ -153,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/src/fastcs/transport/tango/options.py b/src/fastcs/transport/tango/options.py new file mode 100644 index 000000000..850393eb7 --- /dev/null +++ b/src/fastcs/transport/tango/options.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field + + +@dataclass +class TangoDSROptions: + dev_name: str = "MY/DEVICE/NAME" + dsr_instance: str = "MY_SERVER_INSTANCE" + debug: bool = False + + +@dataclass +class TangoOptions: + dsr: TangoDSROptions = field(default_factory=TangoDSROptions) diff --git a/tests/conftest.py b/tests/conftest.py index 727796428..fad58597e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,9 +15,16 @@ 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" + + +@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": @@ -155,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/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..12918a675 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.launch import FastCS +from fastcs.transport.epics.options import EpicsIOCOptions, EpicsOptions from fastcs.wrappers import command @@ -19,11 +20,11 @@ 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_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__ 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/test_launch.py b/tests/test_launch.py new file mode 100644 index 000000000..e703fcba3 --- /dev/null +++ b/tests/test_launch.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.launch 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_launch.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.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")]) + 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.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")]) + 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 87% rename from tests/backends/epics/test_gui.py rename to tests/transport/epics/test_gui.py index c4d9b0626..ae73004a5 100644 --- a/tests/backends/epics/test_gui.py +++ b/tests/transport/epics/test_gui.py @@ -14,23 +14,21 @@ 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(): - 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/backends/epics/test_ioc.py b/tests/transport/epics/test_ioc.py similarity index 87% rename from tests/backends/epics/test_ioc.py rename to tests/transport/epics/test_ioc.py index 721bb2952..57a1ed7c3 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/transport/epics/test_ioc.py @@ -4,7 +4,11 @@ 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.transport.epics.ioc import ( EPICS_MAX_NAME_LENGTH, EpicsIOC, _add_attr_pvi_info, @@ -15,11 +19,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 +28,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 +51,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 +92,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 +108,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 +140,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 +193,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" @@ -220,14 +219,14 @@ 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") +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.backends.epics.ioc._add_sub_controller_pvi_info" + "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,11 +275,11 @@ 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): - 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 +307,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 +343,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,14 +393,13 @@ 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" 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 @@ -462,7 +460,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