Skip to content

Commit

Permalink
Create ControllerAPI class to decouple transport from Controller
Browse files Browse the repository at this point in the history
  • Loading branch information
GDYendell committed Mar 4, 2025
1 parent 88f80a2 commit 07d6568
Show file tree
Hide file tree
Showing 25 changed files with 811 additions and 529 deletions.
65 changes: 34 additions & 31 deletions src/fastcs/backend.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from types import MethodType

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


Expand All @@ -14,19 +14,21 @@ def __init__(
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()
Expand All @@ -41,7 +43,8 @@ async def _run_initial_coros(self):

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):
Expand All @@ -53,32 +56,32 @@ def _stop_scan_tasks(self):
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 63 in src/fastcs/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backend.py#L63

Added line #L63 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 66 in src/fastcs/backend.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/backend.py#L66

Added line #L66 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)


Expand All @@ -89,35 +92,35 @@ async def callback(value):
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)

Expand Down
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:
Expand All @@ -43,9 +30,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":
Expand All @@ -64,6 +68,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():
Expand All @@ -86,40 +92,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.
Expand Down
65 changes: 65 additions & 0 deletions src/fastcs/controller_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from collections.abc import Iterator
from dataclasses import dataclass, field

from fastcs.attributes import Attribute
from fastcs.controller import BaseController, Controller
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"""

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()


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 47 in src/fastcs/controller_api.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/controller_api.py#L47

Added line #L47 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()
},
)
Loading

0 comments on commit 07d6568

Please sign in to comment.