Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create ControllerAPI class to decouple transport from Controller #87

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ jobs:
strategy:
matrix:
runs-on: ["ubuntu-latest"] # can add windows-latest, macos-latest
python-version: ["3.11"]
python-version: ["3.11", "3.12"]
include:
# Include one that runs in the dev environment
- runs-on: "ubuntu-latest"
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
description = "Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango"
dependencies = [
102 changes: 71 additions & 31 deletions src/fastcs/backend.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from types import MethodType

from fastcs.cs_methods import Command, Put, Scan

from .attributes import AttrR, AttrW, Sender, Updater
from .controller import Controller, SingleMapping
from .controller import BaseController, Controller
from .controller_api import ControllerAPI
from .exceptions import FastCSException


@@ -14,19 +16,21 @@
controller: Controller,
loop: asyncio.AbstractEventLoop,
):
self._loop = loop
self._controller = controller
self._loop = loop

self._initial_coros = [controller.connect]
self._scan_tasks: set[asyncio.Task] = set()

loop.run_until_complete(self._controller.initialise())
# Initialise controller and then build its APIs
loop.run_until_complete(controller.initialise())
self.controller_api = build_controller_api(controller)
self._link_process_tasks()

def _link_process_tasks(self):
for single_mapping in self._controller.get_controller_mappings():
_link_single_controller_put_tasks(single_mapping)
_link_attribute_sender_class(single_mapping)
for controller_api in self.controller_api.walk_api():
_link_put_tasks(controller_api)
_link_attribute_sender_class(controller_api, self._controller)

def __del__(self):
self._stop_scan_tasks()
@@ -41,7 +45,8 @@

async def _start_scan_tasks(self):
self._scan_tasks = {
self._loop.create_task(coro()) for coro in _get_scan_coros(self._controller)
self._loop.create_task(coro())
for coro in _get_scan_coros(self.controller_api, self._controller)
}

def _stop_scan_tasks(self):
@@ -53,32 +58,32 @@
pass


def _link_single_controller_put_tasks(single_mapping: SingleMapping) -> None:
for name, method in single_mapping.put_methods.items():
def _link_put_tasks(controller_api: ControllerAPI) -> None:
for name, method in controller_api.put_methods.items():
name = name.removeprefix("put_")

attribute = single_mapping.attributes[name]
attribute = controller_api.attributes[name]

Check warning on line 65 in src/fastcs/backend.py

Codecov / codecov/patch

src/fastcs/backend.py#L65

Added line #L65 was not covered by tests
match attribute:
case AttrW():
attribute.set_process_callback(
MethodType(method.fn, single_mapping.controller)
)
attribute.set_process_callback(method.fn)

Check warning on line 68 in src/fastcs/backend.py

Codecov / codecov/patch

src/fastcs/backend.py#L68

Added line #L68 was not covered by tests
case _:
raise FastCSException(
f"Mode {attribute.access_mode} does not "
f"support put operations for {name}"
)


def _link_attribute_sender_class(single_mapping: SingleMapping) -> None:
for attr_name, attribute in single_mapping.attributes.items():
def _link_attribute_sender_class(
controller_api: ControllerAPI, controller: Controller
) -> None:
for attr_name, attribute in controller_api.attributes.items():
match attribute:
case AttrW(sender=Sender()):
assert not attribute.has_process_callback(), (
f"Cannot assign both put method and Sender object to {attr_name}"
)

callback = _create_sender_callback(attribute, single_mapping.controller)
callback = _create_sender_callback(attribute, controller)
attribute.set_process_callback(callback)


@@ -89,35 +94,35 @@
return callback


def _get_scan_coros(controller: Controller) -> list[Callable]:
def _get_scan_coros(
root_controller_api: ControllerAPI, controller: Controller
) -> list[Callable]:
scan_dict: dict[float, list[Callable]] = defaultdict(list)

for single_mapping in controller.get_controller_mappings():
_add_scan_method_tasks(scan_dict, single_mapping)
_add_attribute_updater_tasks(scan_dict, single_mapping)
for controller_api in root_controller_api.walk_api():
_add_scan_method_tasks(scan_dict, controller_api)
_add_attribute_updater_tasks(scan_dict, controller_api, controller)

scan_coros = _get_periodic_scan_coros(scan_dict)
return scan_coros


def _add_scan_method_tasks(
scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping
scan_dict: dict[float, list[Callable]], controller_api: ControllerAPI
):
for method in single_mapping.scan_methods.values():
scan_dict[method.period].append(
MethodType(method.fn, single_mapping.controller)
)
for method in controller_api.scan_methods.values():
scan_dict[method.period].append(method.fn)


def _add_attribute_updater_tasks(
scan_dict: dict[float, list[Callable]], single_mapping: SingleMapping
scan_dict: dict[float, list[Callable]],
controller_api: ControllerAPI,
controller: Controller,
):
for attribute in single_mapping.attributes.values():
for attribute in controller_api.attributes.values():
match attribute:
case AttrR(updater=Updater(update_period=update_period)) as attribute:
callback = _create_updater_callback(
attribute, single_mapping.controller
)
callback = _create_updater_callback(attribute, controller)
if update_period is not None:
scan_dict[update_period].append(callback)

@@ -155,3 +160,38 @@
await asyncio.gather(*[method() for method in methods])

return scan_coro


def build_controller_api(controller: Controller) -> ControllerAPI:
return _build_controller_api(controller, [])


def _build_controller_api(controller: BaseController, path: list[str]) -> ControllerAPI:
"""Build a `ControllerAPI` for a `BaseController` and its sub controllers"""
scan_methods: dict[str, Scan] = {}
put_methods: dict[str, Put] = {}
command_methods: dict[str, Command] = {}
for attr_name in dir(controller):
attr = getattr(controller, attr_name)
match attr:
case Put(enabled=True):
put_methods[attr_name] = attr

Check warning on line 178 in src/fastcs/backend.py

Codecov / codecov/patch

src/fastcs/backend.py#L178

Added line #L178 was not covered by tests
case Scan(enabled=True):
scan_methods[attr_name] = attr
case Command(enabled=True):
command_methods[attr_name] = attr
case _:
pass

return ControllerAPI(
path=path,
attributes=controller.attributes,
command_methods=command_methods,
put_methods=put_methods,
scan_methods=scan_methods,
sub_apis={
name: _build_controller_api(sub_controller, path + [name])
for name, sub_controller in controller.get_sub_controllers().items()
},
description=controller.description,
)
72 changes: 22 additions & 50 deletions src/fastcs/controller.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
from __future__ import annotations

from collections.abc import Iterator
from copy import copy
from dataclasses import dataclass
from typing import get_type_hints

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]
from fastcs.attributes import Attribute


class BaseController:
@@ -52,9 +39,26 @@ def set_path(self, path: list[str]):
self._path = path

def _bind_attrs(self) -> None:
"""Search for `Attributes` and `Methods` to bind them to this instance.

This method will search the attributes of this controller class to bind them to
this specific instance. For `Attribute`s, this is just a case of copying and
re-assigning to `self` to make it unique across multiple instances of this
controller class. For `Method`s, this requires creating a bound method from a
class method and a controller instance, so that it can be called from any
context with the controller instance passed as the `self` argument.

"""
# Lazy import to avoid circular references
from fastcs.cs_methods import UnboundCommand, UnboundPut, UnboundScan

# Using a dictionary instead of a set to maintain order.
class_dir = {key: None for key in dir(type(self))}
class_type_hints = get_type_hints(type(self))
class_dir = {key: None for key in dir(type(self)) if not key.startswith("_")}
class_type_hints = {
key: value
for key, value in get_type_hints(type(self)).items()
if not key.startswith("_")
}

for attr_name in {**class_dir, **class_type_hints}:
if attr_name == "root_attribute":
@@ -73,6 +77,8 @@ def _bind_attrs(self) -> None:
new_attribute = copy(attr)
setattr(self, attr_name, new_attribute)
self.attributes[attr_name] = new_attribute
elif isinstance(attr, UnboundPut | UnboundScan | UnboundCommand):
setattr(self, attr_name, attr.bind(self))

def register_sub_controller(self, name: str, sub_controller: SubController):
if name in self.__sub_controller_tree.keys():
@@ -95,40 +101,6 @@ def register_sub_controller(self, name: str, sub_controller: SubController):
def get_sub_controllers(self) -> dict[str, SubController]:
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] = {}
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

enabled_attributes = {
name: attribute
for name, attribute in controller.attributes.items()
if attribute.enabled
}

return SingleMapping(
controller, scan_methods, put_methods, command_methods, enabled_attributes
)


class Controller(BaseController):
"""Top-level controller for a device.
31 changes: 31 additions & 0 deletions src/fastcs/controller_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from collections.abc import Iterator
from dataclasses import dataclass, field

from fastcs.attributes import Attribute
from fastcs.cs_methods import Command, Put, Scan


@dataclass
class ControllerAPI:
"""Attributes, bound methods and sub APIs of a `Controller` / `SubController`"""

path: list[str] = field(default_factory=list)
"""Path within controller tree (empty if this is the root)"""
attributes: dict[str, Attribute] = field(default_factory=dict)
command_methods: dict[str, Command] = field(default_factory=dict)
put_methods: dict[str, Put] = field(default_factory=dict)
scan_methods: dict[str, Scan] = field(default_factory=dict)
sub_apis: dict[str, "ControllerAPI"] = field(default_factory=dict)
"""APIs of the sub controllers of the `Controller` this API was built from"""
description: str | None = None

def walk_api(self) -> Iterator["ControllerAPI"]:
"""Walk through all the nested `ControllerAPIs` of this `ControllerAPI`

yields: `ControllerAPI`s from a depth-first traversal of the tree, including
self.

"""
yield self
for api in self.sub_apis.values():
yield from api.walk_api()
Loading