From 30e8bd1a9434145ea5b0606f57b4fb1ccc1e54c4 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:23:20 +0000 Subject: [PATCH 01/19] Add initial core FastCS code --- src/fastcs/asyncio_backend.py | 30 ++++++++ src/fastcs/attributes.py | 128 +++++++++++++++++++++++++++++++ src/fastcs/backend.py | 140 ++++++++++++++++++++++++++++++++++ src/fastcs/connections.py | 54 +++++++++++++ src/fastcs/controller.py | 42 ++++++++++ src/fastcs/cs_methods.py | 74 ++++++++++++++++++ src/fastcs/datatypes.py | 44 +++++++++++ src/fastcs/exceptions.py | 2 + src/fastcs/mapping.py | 51 +++++++++++++ src/fastcs/wrappers.py | 26 +++++++ 10 files changed, 591 insertions(+) create mode 100644 src/fastcs/asyncio_backend.py create mode 100644 src/fastcs/attributes.py create mode 100644 src/fastcs/backend.py create mode 100644 src/fastcs/connections.py create mode 100755 src/fastcs/controller.py create mode 100644 src/fastcs/cs_methods.py create mode 100644 src/fastcs/datatypes.py create mode 100644 src/fastcs/exceptions.py create mode 100644 src/fastcs/mapping.py create mode 100644 src/fastcs/wrappers.py diff --git a/src/fastcs/asyncio_backend.py b/src/fastcs/asyncio_backend.py new file mode 100644 index 000000000..7aafc499b --- /dev/null +++ b/src/fastcs/asyncio_backend.py @@ -0,0 +1,30 @@ +from softioc import asyncio_dispatcher, softioc + +from .backend import Backend +from .mapping import Mapping + + +class AsyncioBackend: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def run_interactive_session(self): + # Create an asyncio dispatcher; the event loop is now running + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + + backend = Backend(self._mapping, dispatcher.loop) + + backend.link_process_tasks() + backend.run_initial_tasks() + backend.start_scan_tasks() + + # Run the interactive shell + global_variables = globals() + global_variables.update( + { + "dispatcher": dispatcher, + "mapping": self._mapping, + "controller": self._mapping.controller, + } + ) + softioc.interactive_ioc(globals()) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py new file mode 100644 index 000000000..87b78cb34 --- /dev/null +++ b/src/fastcs/attributes.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Generic, Protocol + +from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T + + +class AttrMode(Enum): + READ = 1 + WRITE = 2 + READ_WRITE = 3 + + +class Sender(Protocol): + async def put(self, controller: Any, attr: AttrW, value: Any) -> None: + pass + + +class Updater(Protocol): + update_period: float + + async def update(self, controller: Any, attr: AttrR) -> None: + pass + + +class Handler(Sender, Updater, Protocol): + pass + + +class Attribute(Generic[T]): + def __init__( + self, datatype: DataType[T], mode: AttrMode, handler: Any = None + ) -> None: + assert ( + datatype.dtype in ATTRIBUTE_TYPES + ), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}" + self._datatype: DataType[T] = datatype + self._mode: AttrMode = mode + + @property + def datatype(self) -> DataType[T]: + return self._datatype + + @property + def dtype(self) -> type[T]: + return self._datatype.dtype + + @property + def mode(self) -> AttrMode: + return self._mode + + +class AttrR(Attribute[T]): + def __init__( + self, + datatype: DataType[T], + mode=AttrMode.READ, + handler: Updater | None = None, + ) -> None: + super().__init__(datatype, mode=mode, handler=handler) # type: ignore + self._value: T = datatype.dtype() + self._update_callback: AttrCallback[T] | None = None + self._updater = handler + + def get(self) -> T: + return self._value + + async def set(self, value: T) -> None: + self._value = self._datatype.dtype(value) + + if self._update_callback is not None: + await self._update_callback(self._value) + + def set_update_callback(self, callback: AttrCallback[T] | None) -> None: + self._update_callback = callback + + @property + def updater(self) -> Updater | None: + return self._updater + + +class AttrW(Attribute[T]): + def __init__( + self, datatype: DataType[T], mode=AttrMode.WRITE, handler: Sender | None = None + ) -> None: + super().__init__(datatype, mode=mode, handler=handler) # type: ignore + self._process_callback: AttrCallback[T] | None = None + self._write_display_callback: AttrCallback[T] | None = None + self._sender = handler + + async def process(self, value: T) -> None: + if self._write_display_callback is not None: + await self._write_display_callback(self._datatype.dtype(value)) + + await self.process_without_display_update(value) + + async def process_without_display_update(self, value: T) -> None: + if self._process_callback is not None: + await self._process_callback(self._datatype.dtype(value)) + + def set_process_callback(self, callback: AttrCallback[T] | None) -> None: + self._process_callback = callback + + def has_process_callback(self) -> bool: + return self._process_callback is not None + + def set_write_display_callback(self, callback: AttrCallback[T] | None) -> None: + self._write_display_callback = callback + + @property + def sender(self) -> Sender | None: + return self._sender + + +class AttrRW(AttrW[T], AttrR[T]): + def __init__( + self, + datatype: DataType[T], + mode=AttrMode.READ_WRITE, + handler: Handler | None = None, + ) -> None: + super().__init__(datatype, mode=mode, handler=handler) # type: ignore + + async def process(self, value: T) -> None: + await self.set(value) + + await super().process(value) # type: ignore diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py new file mode 100644 index 000000000..6f5bc04bb --- /dev/null +++ b/src/fastcs/backend.py @@ -0,0 +1,140 @@ +import asyncio +from collections import defaultdict +from typing import Callable, cast + +from .attributes import AttrCallback, AttrMode, AttrR, AttrW +from .cs_methods import MethodType +from .mapping import Mapping, SingleMapping + + +def _get_initial_tasks(mapping: Mapping) -> list[Callable]: + initial_tasks: list[Callable] = [] + initial_tasks.append(mapping.controller.connect) + return initial_tasks + + +def _create_periodic_scan_task(period, methods: list[Callable]) -> Callable: + async def scan_task() -> None: + while True: + await asyncio.gather(*[method() for method in methods]) + await asyncio.sleep(period) + + return scan_task + + +def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Callable]: + periodic_scan_tasks: list[Callable] = [] + for period, methods in scan_dict.items(): + periodic_scan_tasks.append(_create_periodic_scan_task(period, methods)) + + return periodic_scan_tasks + + +def _add_wrapped_scan_tasks( + scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping +): + for method_data in single_mapping.methods: + if method_data.info.method_type == MethodType.scan: + period = method_data.info.kwargs["period"] + method = method_data.method + scan_dict[period].append(method) + + +def _create_updater_callback(attribute, controller): + async def callback(): + await attribute.updater.update(controller, attribute) + + return callback + + +def _add_updater_scan_tasks( + scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping +): + for attribute in single_mapping.attributes.values(): + if attribute.mode in (AttrMode.READ, AttrMode.READ_WRITE): + attribute = cast(AttrR, attribute) + + if attribute.updater is None: + continue + + callback = _create_updater_callback(attribute, single_mapping.controller) + scan_dict[attribute.updater.update_period].append(callback) + + +def _get_scan_tasks(mapping: Mapping) -> list[Callable]: + scan_dict: dict[float, list[Callable]] = defaultdict(list) + + for single_mapping in mapping.get_controller_mappings(): + _add_wrapped_scan_tasks(scan_dict, single_mapping) + _add_updater_scan_tasks(scan_dict, single_mapping) + + scan_tasks = _get_periodic_scan_tasks(scan_dict) + return scan_tasks + + +def _link_single_controller_put_tasks(single_mapping: SingleMapping): + put_methods = [ + method_data + for method_data in single_mapping.methods + if method_data.info.method_type == MethodType.put + ] + + for method_data in put_methods: + method = cast(AttrCallback, method_data.method) + name = method_data.name.removeprefix("put_") + + attribute = single_mapping.attributes[name] + assert attribute.mode in [ + AttrMode.WRITE, + AttrMode.READ_WRITE, + ], f"Mode {attribute.mode} does not support put operations for {name}" + attribute = cast(AttrW, attribute) + + attribute.set_process_callback(method) + + +def _create_sender_callback(attribute, controller): + async def callback(value): + await attribute.sender.put(controller, attribute, value) + + return callback + + +def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: + for attr_name, attribute in single_mapping.attributes.items(): + if attribute.mode in (AttrMode.WRITE, AttrMode.READ_WRITE): + attribute = cast(AttrW, attribute) + + if attribute.sender is None: + continue + + assert ( + not attribute.has_process_callback() + ), f"Cannot assign put method and Sender to {attr_name}" + + callback = _create_sender_callback(attribute, single_mapping.controller) + attribute.set_process_callback(callback) + + +class Backend: + def __init__(self, mapping: Mapping, loop: asyncio.AbstractEventLoop): + self._mapping = mapping + self._loop = loop + + def link_process_tasks(self): + for single_mapping in self._mapping.get_controller_mappings(): + _link_single_controller_put_tasks(single_mapping) + _link_attribute_sender_class(single_mapping) + + def run_initial_tasks(self): + initial_tasks = _get_initial_tasks(self._mapping) + + for task in initial_tasks: + future = asyncio.run_coroutine_threadsafe(task(), self._loop) + future.result() + + def start_scan_tasks(self): + scan_tasks = _get_scan_tasks(self._mapping) + + for task in scan_tasks: + asyncio.run_coroutine_threadsafe(task(), self._loop) diff --git a/src/fastcs/connections.py b/src/fastcs/connections.py new file mode 100644 index 000000000..b121066dc --- /dev/null +++ b/src/fastcs/connections.py @@ -0,0 +1,54 @@ +import asyncio +from dataclasses import dataclass + + +class DisconnectedError(Exception): + pass + + +@dataclass +class IPConnectionSettings: + ip: str = "127.0.0.1" + port: int = 25565 + + +class IPConnection: + def __init__(self): + self._reader, self._writer = (None, None) + self._lock = asyncio.Lock() + + async def connect(self, settings: IPConnectionSettings): + self._reader, self._writer = await asyncio.open_connection( + settings.ip, settings.port + ) + + def ensure_connected(self): + if self._reader is None or self._writer is None: + raise DisconnectedError("Need to call connect() before using IPConnection.") + + async def send_command(self, message) -> None: + async with self._lock: + self.ensure_connected() + await self._send_message(message) + + async def send_query(self, message) -> str: + async with self._lock: + self.ensure_connected() + await self._send_message(message) + return await self._receive_response() + + # TODO: Figure out type hinting for connections. TypeGuard fails to work as expected + async def close(self): + async with self._lock: + self.ensure_connected() + self._writer.close() + await self._writer.wait_closed() + self._reader, self._writer = (None, None) + + async def _send_message(self, message) -> None: + self._writer.write(message.encode("utf-8")) + await self._writer.drain() + + async def _receive_response(self) -> str: + data = await self._reader.readline() + return data.decode("utf-8") diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py new file mode 100755 index 000000000..cf37e531f --- /dev/null +++ b/src/fastcs/controller.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from copy import copy + +from .attributes import Attribute + + +class BaseController: + def __init__(self, path="") -> None: + self._path: str = path + self._bind_attrs() + + @property + def path(self): + return self._path + + def _bind_attrs(self) -> None: + for attr_name in dir(self): + attr = getattr(self, attr_name) + if isinstance(attr, Attribute): + new_attribute = copy(attr) + setattr(self, attr_name, new_attribute) + + +class Controller(BaseController): + def __init__(self) -> None: + super().__init__() + self.__sub_controllers: list[SubController] = [] + + async def connect(self) -> None: + pass + + def register_sub_controller(self, controller: SubController): + self.__sub_controllers.append(controller) + + def get_sub_controllers(self) -> list[SubController]: + return self.__sub_controllers + + +class SubController(BaseController): + def __init__(self, path: str) -> None: + super().__init__(path) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py new file mode 100644 index 000000000..25c32c13f --- /dev/null +++ b/src/fastcs/cs_methods.py @@ -0,0 +1,74 @@ +from asyncio import iscoroutinefunction +from enum import Enum +from inspect import Signature, getdoc, signature +from typing import Awaitable, Callable + +from .exceptions import FastCSException + +ScanCallback = Callable[..., Awaitable[None]] + + +class MethodType(Enum): + scan = "scan" + put = "put" + command = "command" + + +class MethodInfo: + def __init__(self, method_type: MethodType, fn: Callable, **kwargs) -> None: + self._method_type = method_type + self._store_method_details(fn) + self._validate_method(method_type, fn) + + self.kwargs = kwargs + + def _validate_method(self, type: MethodType, fn: Callable) -> None: + + if self.return_type not in (None, Signature.empty): + raise FastCSException("Method return type must be None or empty") + + if not iscoroutinefunction(fn): + raise FastCSException("Method must be async function") + + match type: + case MethodType.scan: + self._validate_scan_method(fn) + case MethodType.put: + self._validate_put_method(fn) + case MethodType.command: + self._validate_command_method(fn) + + def _validate_scan_method(self, fn: Callable) -> None: + if not len(self.parameters) == 1: + raise FastCSException("Scan method cannot have arguments") + + def _validate_put_method(self, fn: Callable) -> None: + if not len(self.parameters) == 2: + raise FastCSException("Put method can only take one argument") + + def _validate_command_method(self, fn: Callable) -> None: + if not len(self.parameters) == 1: + raise FastCSException("Command method cannot have arguments") + + def _store_method_details(self, fn): + self._docstring = getdoc(fn) + + sig = signature(fn, eval_str=True) + self._parameters = sig.parameters + self._return_type = sig.return_annotation + + @property + def method_type(self): + return self._method_type + + @property + def return_type(self): + return self._return_type + + @property + def parameters(self): + return self._parameters + + @property + def docstring(self): + return self._docstring diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py new file mode 100644 index 000000000..4f04c941c --- /dev/null +++ b/src/fastcs/datatypes.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass +from typing import Awaitable, Callable, Generic, TypeVar + +T = TypeVar("T", int, float, bool) +ATTRIBUTE_TYPES: tuple[type] = T.__constraints__ # type: ignore + + +AttrCallback = Callable[[T], Awaitable[None]] + + +class DataType(Generic[T]): + @property + @abstractmethod + def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars + pass + + +@dataclass(frozen=True) +class Int(DataType[int]): + @property + def dtype(self) -> type[int]: + return int + + +@dataclass(frozen=True) +class Float(DataType[float]): + prec: int = 2 + + @property + def dtype(self) -> type[float]: + return float + + +@dataclass(frozen=True) +class Bool(DataType[bool]): + znam: str = "OFF" + onam: str = "ON" + + @property + def dtype(self) -> type[bool]: + return bool diff --git a/src/fastcs/exceptions.py b/src/fastcs/exceptions.py new file mode 100644 index 000000000..5e1f375fb --- /dev/null +++ b/src/fastcs/exceptions.py @@ -0,0 +1,2 @@ +class FastCSException(Exception): + pass diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py new file mode 100644 index 000000000..941011c2a --- /dev/null +++ b/src/fastcs/mapping.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Callable, NamedTuple + +from .attributes import Attribute +from .controller import BaseController, Controller +from .cs_methods import MethodInfo + +MethodData = NamedTuple( + "MethodData", (("name", str), ("info", MethodInfo), ("method", Callable)) +) + + +@dataclass +class SingleMapping: + controller: BaseController + methods: list[MethodData] + attributes: dict[str, Attribute] + + +class Mapping: + def __init__(self, controller: Controller) -> None: + self.controller = controller + self._generate_mapping(controller) + + @staticmethod + def _get_single_mapping(controller: BaseController) -> SingleMapping: + methods = [] + attributes = {} + for attr_name in dir(controller): + attr = getattr(controller, attr_name) + if hasattr(attr, "fastcs_method_info"): + methods.append(MethodData(attr_name, attr.fastcs_method_info, attr)) + elif isinstance(attr, Attribute): + attributes[attr_name] = attr + + return SingleMapping(controller, methods, attributes) + + def _generate_mapping(self, controller: Controller) -> None: + self._controller_mappings: list[SingleMapping] = [] + self._controller_mappings.append(self._get_single_mapping(controller)) + for sub_controller in controller.get_sub_controllers(): + self._controller_mappings.append(self._get_single_mapping(sub_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 diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py new file mode 100644 index 000000000..bf36673c1 --- /dev/null +++ b/src/fastcs/wrappers.py @@ -0,0 +1,26 @@ +from typing import Any + +from .cs_methods import MethodInfo, MethodType +from .exceptions import FastCSException + + +# TODO: Consider type hints with the use of typing.Protocol +def scan(period: float) -> Any: + if period <= 0: + raise FastCSException("Scan method must have a positive scan period") + + def wrapper(fn): + fn.fastcs_method_info = MethodInfo(MethodType.scan, fn, period=period) + return fn + + return wrapper + + +def put(fn) -> Any: + fn.fastcs_method_info = MethodInfo(MethodType.put, fn) + return fn + + +def command(fn) -> Any: + fn.fastcs_method_info = MethodInfo(MethodType.command, fn) + return fn From 900d50465ca8eaad585fa3f6f589cd2491bc8e83 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:23:48 +0000 Subject: [PATCH 02/19] Setup improved docs generation --- .gitignore | 1 + docs/_templates/custom-class-template.rst | 32 +++++++++++ docs/_templates/custom-module-template.rst | 66 ++++++++++++++++++++++ docs/conf.py | 16 +++++- docs/user/reference/api.rst | 7 ++- 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 docs/_templates/custom-class-template.rst create mode 100644 docs/_templates/custom-module-template.rst diff --git a/.gitignore b/.gitignore index 9fbb6bfe0..701fcd27e 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ cov.xml # Sphinx documentation docs/_build/ +docs/**/_autosummary # PyBuilder target/ diff --git a/docs/_templates/custom-class-template.rst b/docs/_templates/custom-class-template.rst new file mode 100644 index 000000000..16ebb2f33 --- /dev/null +++ b/docs/_templates/custom-class-template.rst @@ -0,0 +1,32 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :members: + :show-inheritance: + :inherited-members: + + {% block methods %} + .. automethod:: __init__ + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst new file mode 100644 index 000000000..74078355f --- /dev/null +++ b/docs/_templates/custom-module-template.rst @@ -0,0 +1,66 @@ +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + + {% block attributes %} + {% if attributes %} + .. rubric:: Module Attributes + + .. autosummary:: + :toctree: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + {% if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + :template: custom-class-template.rst + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +{% block modules %} +{% if modules %} +.. rubric:: Modules + +.. autosummary:: + :toctree: + :template: custom-module-template.rst + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index cb2cab220..ef580206b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,7 @@ from pathlib import Path from subprocess import check_output -import requests +import requests # type:ignore import fastcs @@ -32,6 +32,7 @@ extensions = [ # Use this for generating API docs "sphinx.ext.autodoc", + "sphinx.ext.autosummary", # This can parse google style docstrings "sphinx.ext.napoleon", # For linking to external sphinx documentation @@ -48,7 +49,7 @@ # If true, Sphinx will warn about all references where the target cannot # be found. -nitpicky = True +nitpicky = False # A list of (type, target) tuples (by default empty) that should be ignored when # generating warnings in "nitpicky mode". Note that type should include the @@ -75,6 +76,12 @@ # Don't inherit docstrings from baseclasses autodoc_inherit_docstrings = False +# Generate autosummary sections +autosummary_generate = True + +templates_path = ["_templates"] +exclude_patterns = ["_build", "_templates"] + # Output graphviz directive produced images in a scalable format graphviz_output_format = "svg" @@ -98,7 +105,10 @@ # This means you can link things like `str` and `asyncio` to the relevant # docs in the python documentation. -intersphinx_mapping = dict(python=("https://docs.python.org/3/", None)) +intersphinx_mapping = dict( + python=("https://docs.python.org/3/", None), + numpy=("https://numpy.org/doc/stable/", None), +) # A dictionary of graphviz graph attributes for inheritance diagrams. inheritance_graph_attrs = dict(rankdir="TB") diff --git a/docs/user/reference/api.rst b/docs/user/reference/api.rst index 152c4a4d0..c6e74bb1f 100644 --- a/docs/user/reference/api.rst +++ b/docs/user/reference/api.rst @@ -1,7 +1,12 @@ API === -.. automodule:: fastcs +.. toctree:: + +.. autosummary:: + :toctree: _autosummary + :template: custom-module-template.rst + :recursive: ``fastcs`` ----------------------------------- From 5517331ebc3b9c0174e6f976f594653f715d3054 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:25:27 +0000 Subject: [PATCH 03/19] Set up devcontainer --- .devcontainer/devcontainer.json | 12 ++++++------ Dockerfile | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44de8d36a..b1220e057 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,12 +13,12 @@ "DISPLAY": "${localEnv:DISPLAY}" }, // Add the URLs of features you want added when the container is built. - "features": { - "ghcr.io/devcontainers/features/common-utils:1": { - "username": "none", - "upgradePackages": false - } - }, + // "features": { + // "ghcr.io/devcontainers/features/common-utils:1": { + // "username": "none", + // "upgradePackages": false + // } + // }, // Set *default* container specific settings.json values on container create. "settings": { "python.defaultInterpreterPath": "/venv/bin/python" diff --git a/Dockerfile b/Dockerfile index 90c2f35e7..13cce1505 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # The devcontainer should use the build target and run as root with podman # or docker with user namespaces. # -FROM python:3.11 as build +FROM python:3.10 as build ARG PIP_OPTIONS=. @@ -24,7 +24,7 @@ WORKDIR /context # install python package into /venv RUN pip install ${PIP_OPTIONS} -FROM python:3.11-slim as runtime +FROM python:3.10-slim as runtime # Add apt-get system dependecies for runtime here if needed From 192a3623a32d3c1f0b69c31c56fce7a431ff1938 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:27:31 +0000 Subject: [PATCH 04/19] Update pyproject.toml settings --- pyproject.toml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d5469004a..2658217b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,20 +7,19 @@ name = "fastcs" classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] description = "Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango" dependencies = [ - "typing-extensions;python_version<'3.8'", + "numpy", + "pydantic", + "pvi", ] # Add project dependencies here, e.g. ["click", "numpy"] dynamic = ["version"] license.file = "LICENSE" readme = "README.rst" -requires-python = ">=3.7" +requires-python = ">=3.10" [project.optional-dependencies] dev = [ @@ -99,8 +98,8 @@ skipsdist=True # Don't create a virtualenv for the command, requires tox-direct plugin direct = True passenv = * -allowlist_externals = - pytest +allowlist_externals = + pytest pre-commit mypy sphinx-build From d8821404df6f7bdbc682a2a87a6146211ac12d06 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:45:25 +0000 Subject: [PATCH 05/19] Add EPICS backend and reorganise packages --- src/fastcs/backends/__init__.py | 0 src/fastcs/{ => backends}/asyncio_backend.py | 4 +- src/fastcs/backends/epics/__init__.py | 0 src/fastcs/backends/epics/backend.py | 20 +++ src/fastcs/backends/epics/docs.py | 19 +++ src/fastcs/backends/epics/gui.py | 135 ++++++++++++++++++ src/fastcs/backends/epics/ioc.py | 136 +++++++++++++++++++ 7 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 src/fastcs/backends/__init__.py rename src/fastcs/{ => backends}/asyncio_backend.py (92%) create mode 100644 src/fastcs/backends/epics/__init__.py create mode 100644 src/fastcs/backends/epics/backend.py create mode 100644 src/fastcs/backends/epics/docs.py create mode 100644 src/fastcs/backends/epics/gui.py create mode 100644 src/fastcs/backends/epics/ioc.py diff --git a/src/fastcs/backends/__init__.py b/src/fastcs/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/asyncio_backend.py b/src/fastcs/backends/asyncio_backend.py similarity index 92% rename from src/fastcs/asyncio_backend.py rename to src/fastcs/backends/asyncio_backend.py index 7aafc499b..100869a26 100644 --- a/src/fastcs/asyncio_backend.py +++ b/src/fastcs/backends/asyncio_backend.py @@ -1,7 +1,7 @@ from softioc import asyncio_dispatcher, softioc -from .backend import Backend -from .mapping import Mapping +from fastcs.backend import Backend +from fastcs.mapping import Mapping class AsyncioBackend: diff --git a/src/fastcs/backends/epics/__init__.py b/src/fastcs/backends/epics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/backends/epics/backend.py b/src/fastcs/backends/epics/backend.py new file mode 100644 index 000000000..164d3dd64 --- /dev/null +++ b/src/fastcs/backends/epics/backend.py @@ -0,0 +1,20 @@ +from fastcs.mapping import Mapping +from .docs import EpicsDocs, EpicsDocsOptions +from .gui import EpicsGUI, EpicsGUIOptions +from .ioc import EpicsIOC + + +class EpicsBackend: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def create_docs(self, options: EpicsDocsOptions | None = None) -> None: + docs = EpicsDocs(self._mapping) + docs.create_docs(options) + + def create_gui(self, options: EpicsGUIOptions | None = None) -> None: + gui = EpicsGUI(self._mapping) + gui.create_gui(options) + + def get_ioc(self) -> EpicsIOC: + return EpicsIOC(self._mapping) diff --git a/src/fastcs/backends/epics/docs.py b/src/fastcs/backends/epics/docs.py new file mode 100644 index 000000000..c2bc569a2 --- /dev/null +++ b/src/fastcs/backends/epics/docs.py @@ -0,0 +1,19 @@ +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/epics/gui.py b/src/fastcs/backends/epics/gui.py new file mode 100644 index 000000000..ae6bf44b1 --- /dev/null +++ b/src/fastcs/backends/epics/gui.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from pvi._format import Formatter +from pvi._format.base import Formatter +from pvi._yaml_utils import deserialize_yaml +from pvi.device import ( + LED, + CheckBox, + Component, + Device, + Grid, + Group, + ReadWidget, + SignalR, + SignalRW, + SignalW, + SignalX, + TextRead, + TextWrite, + Tree, + WriteWidget, +) + +from fastcs.attributes import Attribute, AttrMode +from fastcs.cs_methods import MethodType +from fastcs.datatypes import DataType +from fastcs.exceptions import FastCSException +from fastcs.mapping import Mapping + +FORMATTER_YAML = Path.cwd() / ".." / "pvi" / "formatters" / "dls.bob.pvi.formatter.yaml" + + +class EpicsGUIFormat(Enum): + bob = ".bob" + edl = ".edl" + + +@dataclass +class EpicsGUIOptions: + output_path: Path = Path.cwd() / "output.bob" + file_format: EpicsGUIFormat = EpicsGUIFormat.bob + + +class EpicsGUI: + def __init__(self, mapping: Mapping) -> None: + self._mapping = mapping + + @staticmethod + def _get_pv(attr_path: str, name: str): + if attr_path: + attr_path = ":" + attr_path + attr_path += ":" + + pv = attr_path.upper() + name.title().replace("_", "") + + return pv + + @staticmethod + def _get_read_widget(datatype: DataType) -> ReadWidget: + if datatype.dtype is bool: + return LED() + else: + return TextRead() + + @staticmethod + def _get_write_widget(datatype: DataType) -> WriteWidget: + if datatype.dtype is bool: + return CheckBox() + else: + return TextWrite() + + @classmethod + def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribute): + pv = cls._get_pv(attr_path, name) + name = name.title().replace("_", " ") + + match attribute.mode: + case AttrMode.READ: + read_widget = cls._get_read_widget(attribute.datatype) + return SignalR(name, pv, read_widget) + case AttrMode.WRITE: + write_widget = cls._get_write_widget(attribute.datatype) + return SignalW(name, pv, TextWrite()) + case AttrMode.READ_WRITE: + read_widget = cls._get_read_widget(attribute.datatype) + write_widget = cls._get_write_widget(attribute.datatype) + return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget) + + @classmethod + def _get_command_component(cls, attr_path: str, name: str): + pv = cls._get_pv(attr_path, name) + name = name.title().replace("_", " ") + + return SignalX(name, pv, value=1) + + def create_gui(self, options: EpicsGUIOptions | None = None) -> None: + if options is None: + options = EpicsGUIOptions() + + if options.file_format is EpicsGUIFormat.edl: + raise FastCSException("FastCS does not support .edl screens.") + + assert options.output_path.suffix == options.file_format.value + + formatter = deserialize_yaml(Formatter, FORMATTER_YAML) + + components: Tree[Component] = [] + for single_mapping in self._mapping.get_controller_mappings(): + attr_path = single_mapping.controller.path + + group_name = type(single_mapping.controller).__name__ + " " + attr_path + group_children: list[Component] = [] + + for attr_name, attribute in single_mapping.attributes.items(): + group_children.append( + self._get_attribute_component( + attr_path, + attr_name, + attribute, + ) + ) + + for method_data in single_mapping.methods: + if method_data.info.method_type == MethodType.command: + group_children.append( + self._get_command_component(attr_path, method_data.name) + ) + + components.append(Group(group_name, Grid(), group_children)) + + device = Device("Simple Device", children=components) + + formatter.format(device, "MY-DEVICE-PREFIX", options.output_path) diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py new file mode 100644 index 000000000..4b59a6556 --- /dev/null +++ b/src/fastcs/backends/epics/ioc.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass +from typing import Any, Callable, cast + +from softioc import asyncio_dispatcher, builder, softioc + +from fastcs.attributes import AttrMode, AttrR, AttrRW, AttrW +from fastcs.backend import Backend +from fastcs.cs_methods import MethodType +from fastcs.datatypes import Bool, DataType, Float, Int +from fastcs.mapping import Mapping + + +@dataclass +class EpicsIOCOptions: + terminal: bool = True + + +def _get_input_record(pv_name: str, datatype: DataType) -> Any: + if isinstance(datatype, Bool): + return builder.boolIn(pv_name, ZNAM=datatype.znam, ONAM=datatype.onam) + elif isinstance(datatype, Int): + return builder.longIn(pv_name) + elif isinstance(datatype, Float): + return builder.aIn(pv_name, PREC=datatype.prec) + + +def _create_and_link_read_pv(pv_name: str, attribute: AttrR) -> None: + record = _get_input_record(pv_name, attribute._datatype) + + async def async_wrapper(v): + record.set(v) + + attribute.set_update_callback(async_wrapper) + + +def _get_output_record(pv_name: str, datatype: DataType, on_update: Callable) -> Any: + if isinstance(datatype, Bool): + return builder.boolOut( + pv_name, + ZNAM=datatype.znam, + ONAM=datatype.onam, + always_update=True, + on_update=on_update, + ) + elif isinstance(datatype, Int): + return builder.longOut(pv_name, always_update=True, on_update=on_update) + elif isinstance(datatype, Float): + return builder.aOut(pv_name, always_update=True, on_update=on_update, PREC=2) + + +def _create_and_link_write_pv(pv_name: str, attribute: AttrW) -> None: + record = _get_output_record( + pv_name, attribute.datatype, on_update=attribute.process_without_display_update + ) + + async def async_wrapper(v): + record.set(v) + + attribute.set_write_display_callback(async_wrapper) + + +def _create_and_link_command_pv(pv_name: str, method: Callable) -> None: + async def wrapped_method(_: Any): + await method() + + builder.aOut(pv_name, always_update=True, on_update=wrapped_method) + + +def _create_and_link_attribute_pvs(mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + for attr_name, attribute in single_mapping.attributes.items(): + attr_name = attr_name.title().replace("_", "") + pv_name = path.upper() + ":" + attr_name if path else attr_name + + match attribute.mode: + case AttrMode.READ: + attribute = cast(AttrR, attribute) + _create_and_link_read_pv(pv_name, attribute) + case AttrMode.WRITE: + attribute = cast(AttrW, attribute) + _create_and_link_write_pv(pv_name, attribute) + case AttrMode.READ_WRITE: + attribute = cast(AttrRW, attribute) + _create_and_link_read_pv(pv_name + "_RBV", attribute) + _create_and_link_write_pv(pv_name, attribute) + + +def _create_and_link_command_pvs(mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + for method_data in single_mapping.methods: + if method_data.info.method_type == MethodType.command: + name = method_data.name.title().replace("_", "") + pv_name = path.upper() + ":" + name if path else name + + _create_and_link_command_pv(pv_name, method_data.method) + + +class EpicsIOC: + def __init__(self, mapping: Mapping): + self._mapping = mapping + + def run(self, options: EpicsIOCOptions | None = None) -> None: + if options is None: + options = EpicsIOCOptions() + + # Create an asyncio dispatcher; the event loop is now running + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + backend = Backend(self._mapping, dispatcher.loop) + + # Set the record prefix + builder.SetDeviceName("MY-DEVICE-PREFIX") + + _create_and_link_attribute_pvs(self._mapping) + + _create_and_link_command_pvs(self._mapping) + + # Boilerplate to get the IOC started + builder.LoadDatabase() + softioc.iocInit(dispatcher) + + backend.link_process_tasks() + backend.run_initial_tasks() + backend.start_scan_tasks() + + # Run the interactive shell + global_variables = globals() + global_variables.update( + { + "dispatcher": dispatcher, + "mapping": self._mapping, + "controller": self._mapping.controller, + } + ) + softioc.interactive_ioc(globals()) From 7a525fb8b6d4dd481af042f89f48236c0cd19c4a Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 13:59:44 +0000 Subject: [PATCH 06/19] Improve accessibility of backends --- src/fastcs/backends/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fastcs/backends/__init__.py b/src/fastcs/backends/__init__.py index e69de29bb..a91d1277e 100644 --- a/src/fastcs/backends/__init__.py +++ b/src/fastcs/backends/__init__.py @@ -0,0 +1,4 @@ +from .asyncio_backend import AsyncioBackend +from .epics.backend import EpicsBackend + +__all__ = ["EpicsBackend", "AsyncioBackend"] From ed407afdf63a71cf86ea82226aee7456337e9d4c Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Mon, 7 Aug 2023 16:01:05 +0000 Subject: [PATCH 07/19] Use structural pattern matching for datatypes --- src/fastcs/backends/epics/backend.py | 1 + src/fastcs/backends/epics/gui.py | 24 ++++++++------ src/fastcs/backends/epics/ioc.py | 48 +++++++++++++++++----------- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/fastcs/backends/epics/backend.py b/src/fastcs/backends/epics/backend.py index 164d3dd64..80fca93df 100644 --- a/src/fastcs/backends/epics/backend.py +++ b/src/fastcs/backends/epics/backend.py @@ -1,4 +1,5 @@ from fastcs.mapping import Mapping + from .docs import EpicsDocs, EpicsDocsOptions from .gui import EpicsGUI, EpicsGUIOptions from .ioc import EpicsIOC diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index ae6bf44b1..ab893adbf 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -25,7 +25,7 @@ from fastcs.attributes import Attribute, AttrMode from fastcs.cs_methods import MethodType -from fastcs.datatypes import DataType +from fastcs.datatypes import Bool, DataType, Float, Int from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -59,17 +59,23 @@ def _get_pv(attr_path: str, name: str): @staticmethod def _get_read_widget(datatype: DataType) -> ReadWidget: - if datatype.dtype is bool: - return LED() - else: - return TextRead() + match datatype: + case Bool(): + return LED() + case Int() | Float(): + return TextRead() + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") @staticmethod def _get_write_widget(datatype: DataType) -> WriteWidget: - if datatype.dtype is bool: - return CheckBox() - else: - return TextWrite() + match datatype: + case Bool(): + return CheckBox() + case Int() | Float(): + return TextWrite() + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") @classmethod def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribute): diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 4b59a6556..a410920ef 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -2,11 +2,13 @@ from typing import Any, Callable, cast from softioc import asyncio_dispatcher, builder, softioc +from softioc.pythonSoftIoc import RecordWrapper from fastcs.attributes import AttrMode, AttrR, AttrRW, AttrW from fastcs.backend import Backend from fastcs.cs_methods import MethodType from fastcs.datatypes import Bool, DataType, Float, Int +from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -15,13 +17,16 @@ class EpicsIOCOptions: terminal: bool = True -def _get_input_record(pv_name: str, datatype: DataType) -> Any: - if isinstance(datatype, Bool): - return builder.boolIn(pv_name, ZNAM=datatype.znam, ONAM=datatype.onam) - elif isinstance(datatype, Int): - return builder.longIn(pv_name) - elif isinstance(datatype, Float): - return builder.aIn(pv_name, PREC=datatype.prec) +def _get_input_record(pv_name: str, datatype: DataType) -> RecordWrapper: + match datatype: + case Bool(znam, onam): + return builder.boolIn(pv_name, ZNAM=znam, ONAM=onam) + case Int(): + return builder.longIn(pv_name) + case Float(prec): + return builder.aIn(pv_name, PREC=prec) + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") def _create_and_link_read_pv(pv_name: str, attribute: AttrR) -> None: @@ -34,18 +39,23 @@ async def async_wrapper(v): def _get_output_record(pv_name: str, datatype: DataType, on_update: Callable) -> Any: - if isinstance(datatype, Bool): - return builder.boolOut( - pv_name, - ZNAM=datatype.znam, - ONAM=datatype.onam, - always_update=True, - on_update=on_update, - ) - elif isinstance(datatype, Int): - return builder.longOut(pv_name, always_update=True, on_update=on_update) - elif isinstance(datatype, Float): - return builder.aOut(pv_name, always_update=True, on_update=on_update, PREC=2) + match datatype: + case Bool(znam, onam): + return builder.boolOut( + pv_name, + ZNAM=znam, + ONAM=onam, + always_update=True, + on_update=on_update, + ) + case Int(): + return builder.longOut(pv_name, always_update=True, on_update=on_update) + case Float(prec): + return builder.aOut( + pv_name, always_update=True, on_update=on_update, PREC=prec + ) + case _: + raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") def _create_and_link_write_pv(pv_name: str, attribute: AttrW) -> None: From 7ecfc55435be2df24038b51358aabd38a83b4c44 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Fri, 11 Aug 2023 13:18:29 +0000 Subject: [PATCH 08/19] Fix CI tests --- pyproject.toml | 1 + src/fastcs/cs_methods.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2658217b7..307d89cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "numpy", "pydantic", "pvi", + "softioc", ] # Add project dependencies here, e.g. ["click", "numpy"] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py index 25c32c13f..15c4056b9 100644 --- a/src/fastcs/cs_methods.py +++ b/src/fastcs/cs_methods.py @@ -23,7 +23,6 @@ def __init__(self, method_type: MethodType, fn: Callable, **kwargs) -> None: self.kwargs = kwargs def _validate_method(self, type: MethodType, fn: Callable) -> None: - if self.return_type not in (None, Signature.empty): raise FastCSException("Method return type must be None or empty") From cd2975f3761d02f09f563a5df8616110854ac7d9 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Fri, 11 Aug 2023 13:56:12 +0000 Subject: [PATCH 09/19] Limit CI jobs to Python 3.10+ --- .github/workflows/code.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index cbc3e2801..f54318bb3 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -32,12 +32,12 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] # can add windows-latest, macos-latest - python: ["3.8", "3.9", "3.10", "3.11"] + python: ["3.10", "3.11"] install: ["-e .[dev]"] # Make one version be non-editable to test both paths of version code include: - os: "ubuntu-latest" - python: "3.7" + python: "3.10" install: ".[dev]" runs-on: ${{ matrix.os }} @@ -146,9 +146,9 @@ jobs: - name: Build and export to Docker local cache uses: docker/build-push-action@v4 with: - # Note build-args, context, file, and target must all match between this - # step and the later build-push-action, otherwise the second build-push-action - # will attempt to build the image again + # Note build-args, context, file, and target must all match between this + # step and the later build-push-action, otherwise the second build-push-action + # will attempt to build the image again build-args: | PIP_OPTIONS=-r lockfiles/requirements.txt dist/*.whl context: artifacts/ @@ -156,8 +156,8 @@ jobs: target: runtime load: true tags: ${{ env.TEST_TAG }} - # If you have a long docker build (2+ minutes), uncomment the - # following to turn on caching. For short build times this + # If you have a long docker build (2+ minutes), uncomment the + # following to turn on caching. For short build times this # makes it a little slower #cache-from: type=gha #cache-to: type=gha,mode=max @@ -180,12 +180,12 @@ jobs: - name: Push cached image to container registry if: github.ref_type == 'tag' # || github.ref_name == 'main' uses: docker/build-push-action@v3 - # This does not build the image again, it will find the image in the + # This does not build the image again, it will find the image in the # Docker cache and publish it with: - # Note build-args, context, file, and target must all match between this - # step and the previous build-push-action, otherwise this step will - # attempt to build the image again + # Note build-args, context, file, and target must all match between this + # step and the previous build-push-action, otherwise this step will + # attempt to build the image again build-args: | PIP_OPTIONS=-r lockfiles/requirements.txt dist/*.whl context: artifacts/ From c28650e1d8e0f160b4bfcc0f1d26ad67f7934af8 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 15:09:36 +0100 Subject: [PATCH 10/19] Rename Attribute.mode -> access_mode --- src/fastcs/attributes.py | 20 ++++++++++---------- src/fastcs/backend.py | 8 ++++---- src/fastcs/backends/epics/gui.py | 2 +- src/fastcs/backends/epics/ioc.py | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 87b78cb34..06e553ae9 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -30,13 +30,13 @@ class Handler(Sender, Updater, Protocol): class Attribute(Generic[T]): def __init__( - self, datatype: DataType[T], mode: AttrMode, handler: Any = None + self, datatype: DataType[T], access_mode: AttrMode, handler: Any = None ) -> None: assert ( datatype.dtype in ATTRIBUTE_TYPES ), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}" self._datatype: DataType[T] = datatype - self._mode: AttrMode = mode + self._access_mode: AttrMode = access_mode @property def datatype(self) -> DataType[T]: @@ -47,18 +47,18 @@ def dtype(self) -> type[T]: return self._datatype.dtype @property - def mode(self) -> AttrMode: - return self._mode + def access_mode(self) -> AttrMode: + return self._access_mode class AttrR(Attribute[T]): def __init__( self, datatype: DataType[T], - mode=AttrMode.READ, + access_mode=AttrMode.READ, handler: Updater | None = None, ) -> None: - super().__init__(datatype, mode=mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore self._value: T = datatype.dtype() self._update_callback: AttrCallback[T] | None = None self._updater = handler @@ -82,9 +82,9 @@ def updater(self) -> Updater | None: class AttrW(Attribute[T]): def __init__( - self, datatype: DataType[T], mode=AttrMode.WRITE, handler: Sender | None = None + self, datatype: DataType[T], access_mode=AttrMode.WRITE, handler: Sender | None = None ) -> None: - super().__init__(datatype, mode=mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore self._process_callback: AttrCallback[T] | None = None self._write_display_callback: AttrCallback[T] | None = None self._sender = handler @@ -117,10 +117,10 @@ class AttrRW(AttrW[T], AttrR[T]): def __init__( self, datatype: DataType[T], - mode=AttrMode.READ_WRITE, + access_mode=AttrMode.READ_WRITE, handler: Handler | None = None, ) -> None: - super().__init__(datatype, mode=mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore async def process(self, value: T) -> None: await self.set(value) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 6f5bc04bb..e29f40df1 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -51,7 +51,7 @@ def _add_updater_scan_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): for attribute in single_mapping.attributes.values(): - if attribute.mode in (AttrMode.READ, AttrMode.READ_WRITE): + if attribute.access_mode in (AttrMode.READ, AttrMode.READ_WRITE): attribute = cast(AttrR, attribute) if attribute.updater is None: @@ -84,10 +84,10 @@ def _link_single_controller_put_tasks(single_mapping: SingleMapping): name = method_data.name.removeprefix("put_") attribute = single_mapping.attributes[name] - assert attribute.mode in [ + assert attribute.access_mode in [ AttrMode.WRITE, AttrMode.READ_WRITE, - ], f"Mode {attribute.mode} does not support put operations for {name}" + ], f"Mode {attribute.access_mode} does not support put operations for {name}" attribute = cast(AttrW, attribute) attribute.set_process_callback(method) @@ -102,7 +102,7 @@ async def callback(value): def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: for attr_name, attribute in single_mapping.attributes.items(): - if attribute.mode in (AttrMode.WRITE, AttrMode.READ_WRITE): + if attribute.access_mode in (AttrMode.WRITE, AttrMode.READ_WRITE): attribute = cast(AttrW, attribute) if attribute.sender is None: diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index ab893adbf..90b15682b 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -82,7 +82,7 @@ def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribut pv = cls._get_pv(attr_path, name) name = name.title().replace("_", " ") - match attribute.mode: + match attribute.access_mode: case AttrMode.READ: read_widget = cls._get_read_widget(attribute.datatype) return SignalR(name, pv, read_widget) diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index a410920ef..6e642d8df 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -83,7 +83,7 @@ def _create_and_link_attribute_pvs(mapping: Mapping) -> None: attr_name = attr_name.title().replace("_", "") pv_name = path.upper() + ":" + attr_name if path else attr_name - match attribute.mode: + match attribute.access_mode: case AttrMode.READ: attribute = cast(AttrR, attribute) _create_and_link_read_pv(pv_name, attribute) From e66bbaac6c36b5f616006e1dfcffb7f6f5d5f10c Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 15:14:11 +0100 Subject: [PATCH 11/19] Refactor Mapping and MethodInfo for clearer initialisation --- src/fastcs/cs_methods.py | 14 ++++++-------- src/fastcs/mapping.py | 12 ++++++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py index 15c4056b9..e6cf81145 100644 --- a/src/fastcs/cs_methods.py +++ b/src/fastcs/cs_methods.py @@ -17,7 +17,12 @@ class MethodType(Enum): class MethodInfo: def __init__(self, method_type: MethodType, fn: Callable, **kwargs) -> None: self._method_type = method_type - self._store_method_details(fn) + + self._docstring = getdoc(fn) + + sig = signature(fn, eval_str=True) + self._parameters = sig.parameters + self._return_type = sig.return_annotation self._validate_method(method_type, fn) self.kwargs = kwargs @@ -49,13 +54,6 @@ def _validate_command_method(self, fn: Callable) -> None: if not len(self.parameters) == 1: raise FastCSException("Command method cannot have arguments") - def _store_method_details(self, fn): - self._docstring = getdoc(fn) - - sig = signature(fn, eval_str=True) - self._parameters = sig.parameters - self._return_type = sig.return_annotation - @property def method_type(self): return self._method_type diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py index 941011c2a..8c2d44a8f 100644 --- a/src/fastcs/mapping.py +++ b/src/fastcs/mapping.py @@ -20,7 +20,13 @@ class SingleMapping: class Mapping: def __init__(self, controller: Controller) -> None: self.controller = controller + + self._controller_mappings: list[SingleMapping] = [] self._generate_mapping(controller) + self._controller_mappings.append(self._get_single_mapping(controller)) + + for sub_controller in controller.get_sub_controllers(): + self._controller_mappings.append(self._get_single_mapping(sub_controller)) @staticmethod def _get_single_mapping(controller: BaseController) -> SingleMapping: @@ -35,12 +41,6 @@ def _get_single_mapping(controller: BaseController) -> SingleMapping: return SingleMapping(controller, methods, attributes) - def _generate_mapping(self, controller: Controller) -> None: - self._controller_mappings: list[SingleMapping] = [] - self._controller_mappings.append(self._get_single_mapping(controller)) - for sub_controller in controller.get_sub_controllers(): - self._controller_mappings.append(self._get_single_mapping(sub_controller)) - def __str__(self) -> str: result = "Controller mappings:\n" for mapping in self._controller_mappings: From 553638e44a2d7d3ed92eb29c15a1f5695fcbcd15 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Thu, 7 Sep 2023 13:58:31 +0100 Subject: [PATCH 12/19] Use match case on attribute itself instead of mode --- src/fastcs/backend.py | 52 +++++++++++++++----------------- src/fastcs/backends/epics/gui.py | 16 +++++----- src/fastcs/backends/epics/ioc.py | 15 ++++----- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index e29f40df1..5f6686830 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -2,8 +2,9 @@ from collections import defaultdict from typing import Callable, cast -from .attributes import AttrCallback, AttrMode, AttrR, AttrW +from .attributes import AttrCallback, AttrR, AttrW, Sender, Updater from .cs_methods import MethodType +from .exceptions import FastCSException from .mapping import Mapping, SingleMapping @@ -51,14 +52,12 @@ def _add_updater_scan_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): for attribute in single_mapping.attributes.values(): - if attribute.access_mode in (AttrMode.READ, AttrMode.READ_WRITE): - attribute = cast(AttrR, attribute) - - if attribute.updater is None: - continue - - callback = _create_updater_callback(attribute, single_mapping.controller) - scan_dict[attribute.updater.update_period].append(callback) + match attribute: + case AttrR(updater=Updater(update_period)) as attribute: + callback = _create_updater_callback( + attribute, single_mapping.controller + ) + scan_dict[update_period].append(callback) def _get_scan_tasks(mapping: Mapping) -> list[Callable]: @@ -84,13 +83,14 @@ def _link_single_controller_put_tasks(single_mapping: SingleMapping): name = method_data.name.removeprefix("put_") attribute = single_mapping.attributes[name] - assert attribute.access_mode in [ - AttrMode.WRITE, - AttrMode.READ_WRITE, - ], f"Mode {attribute.access_mode} does not support put operations for {name}" - attribute = cast(AttrW, attribute) - - attribute.set_process_callback(method) + match attribute: + case AttrW(): + attribute.set_process_callback(method) + case _: + raise FastCSException( + f"Mode {attribute.access_mode} does not " + f"support put operations for {name}" + ) def _create_sender_callback(attribute, controller): @@ -102,18 +102,14 @@ async def callback(value): def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: for attr_name, attribute in single_mapping.attributes.items(): - if attribute.access_mode in (AttrMode.WRITE, AttrMode.READ_WRITE): - attribute = cast(AttrW, attribute) - - if attribute.sender is None: - continue - - assert ( - not attribute.has_process_callback() - ), f"Cannot assign put method and Sender to {attr_name}" - - callback = _create_sender_callback(attribute, single_mapping.controller) - attribute.set_process_callback(callback) + match attribute: + case AttrW(sender=Sender()): + assert ( + not attribute.has_process_callback() + ), f"Cannot assign both put method and Sender to {attr_name}" + + callback = _create_sender_callback(attribute, single_mapping.controller) + attribute.set_process_callback(callback) class Backend: diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index 90b15682b..58306beb2 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -23,7 +23,7 @@ WriteWidget, ) -from fastcs.attributes import Attribute, AttrMode +from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW from fastcs.cs_methods import MethodType from fastcs.datatypes import Bool, DataType, Float, Int from fastcs.exceptions import FastCSException @@ -82,17 +82,17 @@ def _get_attribute_component(cls, attr_path: str, name: str, attribute: Attribut pv = cls._get_pv(attr_path, name) name = name.title().replace("_", " ") - match attribute.access_mode: - case AttrMode.READ: + match attribute: + case AttrRW(): read_widget = cls._get_read_widget(attribute.datatype) - return SignalR(name, pv, read_widget) - case AttrMode.WRITE: write_widget = cls._get_write_widget(attribute.datatype) - return SignalW(name, pv, TextWrite()) - case AttrMode.READ_WRITE: + return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget) + case AttrR(): read_widget = cls._get_read_widget(attribute.datatype) + return SignalR(name, pv, read_widget) + case AttrW(): write_widget = cls._get_write_widget(attribute.datatype) - return SignalRW(name, pv, write_widget, pv + "_RBV", read_widget) + return SignalW(name, pv, TextWrite()) @classmethod def _get_command_component(cls, attr_path: str, name: str): diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 6e642d8df..643bb76bd 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -83,17 +83,14 @@ def _create_and_link_attribute_pvs(mapping: Mapping) -> None: attr_name = attr_name.title().replace("_", "") pv_name = path.upper() + ":" + attr_name if path else attr_name - match attribute.access_mode: - case AttrMode.READ: - attribute = cast(AttrR, attribute) - _create_and_link_read_pv(pv_name, attribute) - case AttrMode.WRITE: - attribute = cast(AttrW, attribute) - _create_and_link_write_pv(pv_name, attribute) - case AttrMode.READ_WRITE: - attribute = cast(AttrRW, attribute) + match attribute: + case AttrRW(): _create_and_link_read_pv(pv_name + "_RBV", attribute) _create_and_link_write_pv(pv_name, attribute) + case AttrR(): + _create_and_link_read_pv(pv_name, attribute) + case AttrW(): + _create_and_link_write_pv(pv_name, attribute) def _create_and_link_command_pvs(mapping: Mapping) -> None: From bd1ff32483ba053ffe24da0e3d3103f80705a3eb Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 15:42:54 +0100 Subject: [PATCH 13/19] Improve function naming in backend.py --- src/fastcs/backend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 5f6686830..35d4a030b 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -31,7 +31,7 @@ def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Cal return periodic_scan_tasks -def _add_wrapped_scan_tasks( +def _add_scan_method_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): for method_data in single_mapping.methods: @@ -48,7 +48,7 @@ async def callback(): return callback -def _add_updater_scan_tasks( +def _add_attribute_updater_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): for attribute in single_mapping.attributes.values(): @@ -64,8 +64,8 @@ def _get_scan_tasks(mapping: Mapping) -> list[Callable]: scan_dict: dict[float, list[Callable]] = defaultdict(list) for single_mapping in mapping.get_controller_mappings(): - _add_wrapped_scan_tasks(scan_dict, single_mapping) - _add_updater_scan_tasks(scan_dict, single_mapping) + _add_scan_method_tasks(scan_dict, single_mapping) + _add_attribute_updater_tasks(scan_dict, single_mapping) scan_tasks = _get_periodic_scan_tasks(scan_dict) return scan_tasks From 932d815452d4d373e51dda22e3f796941c6a8b68 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Thu, 7 Sep 2023 13:59:28 +0100 Subject: [PATCH 14/19] Apply black and flake8 changes --- src/fastcs/attributes.py | 11 +++++++---- src/fastcs/backends/epics/ioc.py | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 06e553ae9..812cd364a 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -58,7 +58,7 @@ def __init__( access_mode=AttrMode.READ, handler: Updater | None = None, ) -> None: - super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode, handler) # type: ignore self._value: T = datatype.dtype() self._update_callback: AttrCallback[T] | None = None self._updater = handler @@ -82,9 +82,12 @@ def updater(self) -> Updater | None: class AttrW(Attribute[T]): def __init__( - self, datatype: DataType[T], access_mode=AttrMode.WRITE, handler: Sender | None = None + self, + datatype: DataType[T], + access_mode=AttrMode.WRITE, + handler: Sender | None = None, ) -> None: - super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode, handler) # type: ignore self._process_callback: AttrCallback[T] | None = None self._write_display_callback: AttrCallback[T] | None = None self._sender = handler @@ -120,7 +123,7 @@ def __init__( access_mode=AttrMode.READ_WRITE, handler: Handler | None = None, ) -> None: - super().__init__(datatype, access_mode=access_mode, handler=handler) # type: ignore + super().__init__(datatype, access_mode, handler) # type: ignore async def process(self, value: T) -> None: await self.set(value) diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index 643bb76bd..b7034e1b0 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -1,10 +1,10 @@ from dataclasses import dataclass -from typing import Any, Callable, cast +from typing import Any, Callable from softioc import asyncio_dispatcher, builder, softioc from softioc.pythonSoftIoc import RecordWrapper -from fastcs.attributes import AttrMode, AttrR, AttrRW, AttrW +from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.backend import Backend from fastcs.cs_methods import MethodType from fastcs.datatypes import Bool, DataType, Float, Int From cf42643fc511e56ec4d794f79781d5523139dd8d Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Thu, 7 Sep 2023 13:59:53 +0100 Subject: [PATCH 15/19] Use structural pattern matching for wrapped method types --- src/fastcs/backend.py | 29 ++++------- src/fastcs/backends/epics/gui.py | 8 +-- src/fastcs/backends/epics/ioc.py | 10 ++-- src/fastcs/cs_methods.py | 86 ++++++++++++++++++-------------- src/fastcs/mapping.py | 37 ++++++++------ src/fastcs/wrappers.py | 8 +-- 6 files changed, 89 insertions(+), 89 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 35d4a030b..2dd788f75 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -1,9 +1,8 @@ import asyncio from collections import defaultdict -from typing import Callable, cast +from typing import Callable -from .attributes import AttrCallback, AttrR, AttrW, Sender, Updater -from .cs_methods import MethodType +from .attributes import AttrR, AttrW, Sender, Updater from .exceptions import FastCSException from .mapping import Mapping, SingleMapping @@ -34,11 +33,8 @@ def _get_periodic_scan_tasks(scan_dict: dict[float, list[Callable]]) -> list[Cal def _add_scan_method_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): - for method_data in single_mapping.methods: - if method_data.info.method_type == MethodType.scan: - period = method_data.info.kwargs["period"] - method = method_data.method - scan_dict[period].append(method) + for method in single_mapping.scan_methods.values(): + scan_dict[method.period].append(method.fn) def _create_updater_callback(attribute, controller): @@ -53,7 +49,7 @@ def _add_attribute_updater_tasks( ): for attribute in single_mapping.attributes.values(): match attribute: - case AttrR(updater=Updater(update_period)) as attribute: + case AttrR(updater=Updater(update_period=update_period)) as attribute: callback = _create_updater_callback( attribute, single_mapping.controller ) @@ -71,21 +67,14 @@ def _get_scan_tasks(mapping: Mapping) -> list[Callable]: return scan_tasks -def _link_single_controller_put_tasks(single_mapping: SingleMapping): - put_methods = [ - method_data - for method_data in single_mapping.methods - if method_data.info.method_type == MethodType.put - ] - - for method_data in put_methods: - method = cast(AttrCallback, method_data.method) - name = method_data.name.removeprefix("put_") +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(method) + attribute.set_process_callback(method.fn) case _: raise FastCSException( f"Mode {attribute.access_mode} does not " diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index 58306beb2..a2b3bc096 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -24,7 +24,6 @@ ) from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.cs_methods import MethodType from fastcs.datatypes import Bool, DataType, Float, Int from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -128,11 +127,8 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None: ) ) - for method_data in single_mapping.methods: - if method_data.info.method_type == MethodType.command: - group_children.append( - self._get_command_component(attr_path, method_data.name) - ) + for name in single_mapping.command_methods: + group_children.append(self._get_command_component(attr_path, name)) components.append(Group(group_name, Grid(), group_children)) diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index b7034e1b0..f328dad98 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -6,7 +6,6 @@ from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.backend import Backend -from fastcs.cs_methods import MethodType from fastcs.datatypes import Bool, DataType, Float, Int from fastcs.exceptions import FastCSException from fastcs.mapping import Mapping @@ -96,12 +95,11 @@ def _create_and_link_attribute_pvs(mapping: Mapping) -> None: def _create_and_link_command_pvs(mapping: Mapping) -> None: for single_mapping in mapping.get_controller_mappings(): path = single_mapping.controller.path - for method_data in single_mapping.methods: - if method_data.info.method_type == MethodType.command: - name = method_data.name.title().replace("_", "") - pv_name = path.upper() + ":" + name if path else name + for name, method in single_mapping.command_methods.items(): + name = name.title().replace("_", "") + pv_name = path.upper() + ":" + name if path else name - _create_and_link_command_pv(pv_name, method_data.method) + _create_and_link_command_pv(pv_name, method.fn) class EpicsIOC: diff --git a/src/fastcs/cs_methods.py b/src/fastcs/cs_methods.py index e6cf81145..20cc4b37d 100644 --- a/src/fastcs/cs_methods.py +++ b/src/fastcs/cs_methods.py @@ -1,5 +1,4 @@ from asyncio import iscoroutinefunction -from enum import Enum from inspect import Signature, getdoc, signature from typing import Awaitable, Callable @@ -8,56 +7,24 @@ ScanCallback = Callable[..., Awaitable[None]] -class MethodType(Enum): - scan = "scan" - put = "put" - command = "command" - - -class MethodInfo: - def __init__(self, method_type: MethodType, fn: Callable, **kwargs) -> None: - self._method_type = method_type - +class Method: + def __init__(self, fn: Callable) -> None: self._docstring = getdoc(fn) sig = signature(fn, eval_str=True) self._parameters = sig.parameters self._return_type = sig.return_annotation - self._validate_method(method_type, fn) + self._validate(fn) - self.kwargs = kwargs + self._fn = fn - def _validate_method(self, type: MethodType, fn: Callable) -> None: + def _validate(self, fn: Callable) -> None: if self.return_type not in (None, Signature.empty): raise FastCSException("Method return type must be None or empty") if not iscoroutinefunction(fn): raise FastCSException("Method must be async function") - match type: - case MethodType.scan: - self._validate_scan_method(fn) - case MethodType.put: - self._validate_put_method(fn) - case MethodType.command: - self._validate_command_method(fn) - - def _validate_scan_method(self, fn: Callable) -> None: - if not len(self.parameters) == 1: - raise FastCSException("Scan method cannot have arguments") - - def _validate_put_method(self, fn: Callable) -> None: - if not len(self.parameters) == 2: - raise FastCSException("Put method can only take one argument") - - def _validate_command_method(self, fn: Callable) -> None: - if not len(self.parameters) == 1: - raise FastCSException("Command method cannot have arguments") - - @property - def method_type(self): - return self._method_type - @property def return_type(self): return self._return_type @@ -69,3 +36,46 @@ def parameters(self): @property def docstring(self): return self._docstring + + @property + def fn(self): + return self._fn + + +class Scan(Method): + def __init__(self, fn: Callable, period) -> None: + super().__init__(fn) + + self._period = period + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise FastCSException("Scan method cannot have arguments") + + @property + def period(self): + return self._period + + +class Put(Method): + def __init__(self, fn: Callable) -> None: + super().__init__(fn) + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 2: + raise FastCSException("Put method can only take one argument") + + +class Command(Method): + def __init__(self, fn: Callable) -> None: + super().__init__(fn) + + def _validate(self, fn: Callable) -> None: + super()._validate(fn) + + if not len(self.parameters) == 1: + raise FastCSException("Command method cannot have arguments") diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py index 8c2d44a8f..a6665ef91 100644 --- a/src/fastcs/mapping.py +++ b/src/fastcs/mapping.py @@ -1,19 +1,16 @@ from dataclasses import dataclass -from typing import Callable, NamedTuple from .attributes import Attribute from .controller import BaseController, Controller -from .cs_methods import MethodInfo - -MethodData = NamedTuple( - "MethodData", (("name", str), ("info", MethodInfo), ("method", Callable)) -) +from .cs_methods import Command, Put, Scan @dataclass class SingleMapping: controller: BaseController - methods: list[MethodData] + scan_methods: dict[str, Scan] + put_methods: dict[str, Put] + command_methods: dict[str, Command] attributes: dict[str, Attribute] @@ -22,7 +19,6 @@ def __init__(self, controller: Controller) -> None: self.controller = controller self._controller_mappings: list[SingleMapping] = [] - self._generate_mapping(controller) self._controller_mappings.append(self._get_single_mapping(controller)) for sub_controller in controller.get_sub_controllers(): @@ -30,16 +26,27 @@ def __init__(self, controller: Controller) -> None: @staticmethod def _get_single_mapping(controller: BaseController) -> SingleMapping: - methods = [] + scan_methods = {} + put_methods = {} + command_methods = {} attributes = {} for attr_name in dir(controller): attr = getattr(controller, attr_name) - if hasattr(attr, "fastcs_method_info"): - methods.append(MethodData(attr_name, attr.fastcs_method_info, attr)) - elif isinstance(attr, Attribute): - attributes[attr_name] = attr - - return SingleMapping(controller, methods, attributes) + match attr: + case object(fastcs_method=fastcs_method): + match fastcs_method: + case Put(): + put_methods[attr_name] = fastcs_method + case Scan(): + scan_methods[attr_name] = fastcs_method + case Command(): + command_methods[attr_name] = fastcs_method + case Attribute(): + attributes[attr_name] = attr + + return SingleMapping( + controller, scan_methods, put_methods, command_methods, attributes + ) def __str__(self) -> str: result = "Controller mappings:\n" diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py index bf36673c1..3ba4db20f 100644 --- a/src/fastcs/wrappers.py +++ b/src/fastcs/wrappers.py @@ -1,6 +1,6 @@ from typing import Any -from .cs_methods import MethodInfo, MethodType +from .cs_methods import Command, Put, Scan from .exceptions import FastCSException @@ -10,17 +10,17 @@ def scan(period: float) -> Any: raise FastCSException("Scan method must have a positive scan period") def wrapper(fn): - fn.fastcs_method_info = MethodInfo(MethodType.scan, fn, period=period) + fn.fastcs_method = Scan(fn, period) return fn return wrapper def put(fn) -> Any: - fn.fastcs_method_info = MethodInfo(MethodType.put, fn) + fn.fastcs_method = Put(fn) return fn def command(fn) -> Any: - fn.fastcs_method_info = MethodInfo(MethodType.command, fn) + fn.fastcs_method = Command(fn) return fn From b810a0be0b49e585d4cc60526be0d9430e606c2d Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 21:12:04 +0100 Subject: [PATCH 16/19] Fix mypy type hints with a WrappedMethod protocol Naming for methods vs fns is getting confusing. --- src/fastcs/mapping.py | 3 ++- src/fastcs/wrappers.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py index a6665ef91..015804094 100644 --- a/src/fastcs/mapping.py +++ b/src/fastcs/mapping.py @@ -3,6 +3,7 @@ from .attributes import Attribute from .controller import BaseController, Controller from .cs_methods import Command, Put, Scan +from .wrappers import WrappedMethod @dataclass @@ -33,7 +34,7 @@ def _get_single_mapping(controller: BaseController) -> SingleMapping: for attr_name in dir(controller): attr = getattr(controller, attr_name) match attr: - case object(fastcs_method=fastcs_method): + case WrappedMethod(fastcs_method=fastcs_method): match fastcs_method: case Put(): put_methods[attr_name] = fastcs_method diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py index 3ba4db20f..f5de70ce8 100644 --- a/src/fastcs/wrappers.py +++ b/src/fastcs/wrappers.py @@ -1,9 +1,13 @@ -from typing import Any +from typing import Any, Protocol -from .cs_methods import Command, Put, Scan +from .cs_methods import Command, Method, Put, Scan from .exceptions import FastCSException +class WrappedMethod(Protocol): + fastcs_method: Method + + # TODO: Consider type hints with the use of typing.Protocol def scan(period: float) -> Any: if period <= 0: From 8cc9a8b79edf245104a9d2a14d0b70fd34ee605d Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 21:17:11 +0100 Subject: [PATCH 17/19] Ensure Protocols are runtime checkable --- src/fastcs/attributes.py | 5 ++++- src/fastcs/wrappers.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 812cd364a..67f24b024 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, runtime_checkable from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T @@ -12,11 +12,13 @@ class AttrMode(Enum): READ_WRITE = 3 +@runtime_checkable class Sender(Protocol): async def put(self, controller: Any, attr: AttrW, value: Any) -> None: pass +@runtime_checkable class Updater(Protocol): update_period: float @@ -24,6 +26,7 @@ async def update(self, controller: Any, attr: AttrR) -> None: pass +@runtime_checkable class Handler(Sender, Updater, Protocol): pass diff --git a/src/fastcs/wrappers.py b/src/fastcs/wrappers.py index f5de70ce8..9d45e4cc9 100644 --- a/src/fastcs/wrappers.py +++ b/src/fastcs/wrappers.py @@ -1,9 +1,10 @@ -from typing import Any, Protocol +from typing import Any, Protocol, runtime_checkable from .cs_methods import Command, Method, Put, Scan from .exceptions import FastCSException +@runtime_checkable class WrappedMethod(Protocol): fastcs_method: Method From 51b2566b07f0eb2fa9cba74c7bf255d638027e47 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Wed, 30 Aug 2023 21:19:21 +0100 Subject: [PATCH 18/19] Improve error message --- src/fastcs/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 2dd788f75..42bcc8508 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -95,7 +95,7 @@ def _link_attribute_sender_class(single_mapping: SingleMapping) -> None: case AttrW(sender=Sender()): assert ( not attribute.has_process_callback() - ), f"Cannot assign both put method and Sender to {attr_name}" + ), 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) From 7a734343c376af895981b98707feb3979ec91b74 Mon Sep 17 00:00:00 2001 From: Martin Gaughran Date: Thu, 7 Sep 2023 14:00:42 +0100 Subject: [PATCH 19/19] Fix binding issue with Method.fn --- src/fastcs/backend.py | 9 +++++++-- src/fastcs/backends/epics/gui.py | 1 - src/fastcs/backends/epics/ioc.py | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index 42bcc8508..e2806c68f 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -1,5 +1,6 @@ import asyncio from collections import defaultdict +from types import MethodType from typing import Callable from .attributes import AttrR, AttrW, Sender, Updater @@ -34,7 +35,9 @@ def _add_scan_method_tasks( scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping ): for method in single_mapping.scan_methods.values(): - scan_dict[method.period].append(method.fn) + scan_dict[method.period].append( + MethodType(method.fn, single_mapping.controller) + ) def _create_updater_callback(attribute, controller): @@ -74,7 +77,9 @@ def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None: attribute = single_mapping.attributes[name] match attribute: case AttrW(): - attribute.set_process_callback(method.fn) + attribute.set_process_callback( + MethodType(method.fn, single_mapping.controller) + ) case _: raise FastCSException( f"Mode {attribute.access_mode} does not " diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index a2b3bc096..31b2dce50 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -2,7 +2,6 @@ from enum import Enum from pathlib import Path -from pvi._format import Formatter from pvi._format.base import Formatter from pvi._yaml_utils import deserialize_yaml from pvi.device import ( diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index f328dad98..adc14758a 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from types import MethodType from typing import Any, Callable from softioc import asyncio_dispatcher, builder, softioc @@ -99,7 +100,9 @@ def _create_and_link_command_pvs(mapping: Mapping) -> None: name = name.title().replace("_", "") pv_name = path.upper() + ":" + name if path else name - _create_and_link_command_pv(pv_name, method.fn) + _create_and_link_command_pv( + pv_name, MethodType(method.fn, single_mapping.controller) + ) class EpicsIOC: