Source code for fastcs.controllers.base_controller

from __future__ import annotations

from collections import Counter
from collections.abc import Sequence
from copy import deepcopy
from typing import _GenericAlias, get_args, get_origin, get_type_hints  # type: ignore

from fastcs.attributes import AnyAttributeIO, Attribute, AttrR, AttrW, HintedAttribute
from fastcs.logging import bind_logger
from fastcs.methods import Command, Scan, UnboundCommand, UnboundScan
from fastcs.tracer import Tracer

logger = bind_logger(logger_name=__name__)


[docs] class BaseController(Tracer): """Base class for controllers Instances of this class can be loaded into FastCS to expose its Attributes to the transport layer, which can then perform a specific function such as generating a UI or creating parameters for a control system. This class is public for type hinting purposes, but should not be inherited to implement device drivers. Use either ``Controller`` or ``ControllerVector`` instead. """ # These class attributes can be overridden on child classes to define default # behaviour of instantiated controllers root_attribute: Attribute | None = None description: str | None = None def __init__( self, path: list[str] | None = None, description: str | None = None, ios: Sequence[AnyAttributeIO] | None = None, ) -> None: super().__init__() if description is not None: # Use the argument over the one class defined description. self.description = description self._path: list[str] = path or [] # Internal state that should not be accessed directly by base classes self.__attributes: dict[str, Attribute] = {} self.__sub_controllers: dict[str, BaseController] = {} self.__command_methods: dict[str, Command] = {} self.__scan_methods: dict[str, Scan] = {} self.__hinted_attributes: dict[str, HintedAttribute] = {} self.__hinted_sub_controllers: dict[str, type[BaseController]] = {} self._find_type_hints() self._bind_attrs() ios = ios or [] self._attribute_ref_io_map = {io.ref_type: io for io in ios} self._validate_io(ios) def _find_type_hints(self): """Find `Attribute` and `Controller` type hints for introspection validation""" for name, hint in get_type_hints(type(self)).items(): if isinstance(hint, _GenericAlias): # e.g. AttrR[int] args = get_args(hint) hint = get_origin(hint) else: args = None if isinstance(hint, type) and issubclass(hint, Attribute): if args is None: dtype = None else: if len(args) == 2: dtype = args[0] else: raise TypeError( f"Invalid type hint for attribute {name}: {hint}" ) self.__hinted_attributes[name] = HintedAttribute( attr_type=hint, dtype=dtype ) elif isinstance(hint, type) and issubclass(hint, BaseController): self.__hinted_sub_controllers[name] = hint 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 Attributes, this is just a case of copying and re-assigning to ``self`` to make it unique across multiple instances of this controller class. For Methods, 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. """ # Using a dictionary instead of a set to maintain order. 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": continue attr = getattr(self, attr_name, None) if isinstance(attr, Attribute): setattr(self, attr_name, deepcopy(attr)) else: if isinstance(attr, Command): self.add_command(attr_name, attr) elif isinstance(attr, Scan): self.add_scan(attr_name, attr) elif isinstance( unbound_command := getattr(attr, "__unbound_command__", None), UnboundCommand, ): self.add_command(attr_name, unbound_command.bind(self)) elif isinstance( unbound_scan := getattr(attr, "__unbound_scan__", None), UnboundScan, ): self.add_scan(attr_name, unbound_scan.bind(self)) def _validate_io(self, ios: Sequence[AnyAttributeIO]): """Validate that there is exactly one AttributeIO class registered to the controller for each type of AttributeIORef belonging to the attributes of the controller""" for ref_type, count in Counter([io.ref_type for io in ios]).items(): if count > 1: raise RuntimeError( f"More than one AttributeIO class handles {ref_type.__name__}" ) def __repr__(self): name = self.__class__.__name__ path = ".".join(self.path) or None sub_controllers = list(self.sub_controllers.keys()) or None return f"{name}(path={path}, sub_controllers={sub_controllers})" def __setattr__(self, name, value): if isinstance(value, Attribute): self.add_attribute(name, value) elif isinstance(value, Command): self.add_command(name, value) elif isinstance(value, Scan): self.add_scan(name, value) elif isinstance(value, BaseController): self.add_sub_controller(name, value) else: super().__setattr__(name, value)
[docs] async def initialise(self): """Hook for subclasses to dynamically add attributes before building the API""" pass
[docs] def post_initialise(self): """Hook to call after all attributes added, before serving the application""" self._validate_type_hints() self._connect_attribute_ios()
def _validate_type_hints(self): """Validate all `Attribute` and `Controller` type-hints were introspected""" for name in self.__hinted_attributes: self._validate_hinted_attribute(name) for name in self.__hinted_sub_controllers: self._validate_hinted_controller(name) for subcontroller in self.sub_controllers.values(): subcontroller._validate_type_hints() # noqa: SLF001 def _validate_hinted_attribute(self, name: str): """Check that an `Attribute` with the given name exists on the controller""" attr = getattr(self, name, None) if attr is None or not isinstance(attr, Attribute): raise RuntimeError( f"Controller `{self.__class__.__name__}` failed to introspect " f"hinted attribute `{name}` during initialisation" ) else: logger.debug( "Validated hinted attribute", name=name, controller=self, attribute=attr, ) def _validate_hinted_controller(self, name: str): """Check that a sub controller with the given name exists on the controller""" controller = getattr(self, name, None) if controller is None or not isinstance(controller, BaseController): raise RuntimeError( f"Controller `{self.__class__.__name__}` failed to introspect " f"hinted controller `{name}` during initialisation" ) else: logger.debug( "Validated hinted sub controller", name=name, controller=self, sub_controller=controller, ) def _connect_attribute_ios(self) -> None: """Connect ``Attribute`` callbacks to ``AttributeIO``s""" for attr in self.__attributes.values(): ref = attr.io_ref if attr.has_io_ref() else None if ref is None: continue io = self._attribute_ref_io_map.get(type(ref)) if io is None: raise ValueError( f"{self.__class__.__name__} does not have an AttributeIO " f"to handle {attr.io_ref.__class__.__name__}" ) if isinstance(attr, AttrW): attr.set_on_put_callback(io.send) if isinstance(attr, AttrR): attr.set_update_callback(io.update) for controller in self.sub_controllers.values(): controller._connect_attribute_ios() # noqa: SLF001 @property def path(self) -> list[str]: """Path prefix of attributes, recursively including parent Controllers.""" return self._path def set_path(self, path: list[str]): if self._path: raise ValueError(f"sub controller is already registered under {self.path}") self._path = path for attribute in self.__attributes.values(): attribute.set_path(path) def add_attribute(self, name, attr: Attribute): if name in self.__attributes: raise ValueError( f"Cannot add attribute {attr}. " f"Controller {self} has has existing attribute {name}: " f"{self.__attributes[name]}" ) elif name in self.__hinted_attributes: hint = self.__hinted_attributes[name] if not isinstance(attr, hint.attr_type): raise RuntimeError( f"Controller '{self.__class__.__name__}' introspection of " f"hinted attribute '{name}' does not match defined access mode. " f"Expected '{hint.attr_type.__name__}' got '{type(attr).__name__}'." ) if hint.dtype is not None and hint.dtype != attr.datatype.dtype: raise RuntimeError( f"Controller '{self.__class__.__name__}' introspection of " f"hinted attribute '{name}' does not match defined datatype. " f"Expected '{hint.dtype.__name__}', " f"got '{attr.datatype.dtype.__name__}'." ) elif name in self.__sub_controllers.keys(): raise ValueError( f"Cannot add attribute {attr}. " f"Controller {self} has existing sub controller {name}: " f"{self.__sub_controllers[name]}" ) attr.set_name(name) attr.set_path(self.path) self.__attributes[name] = attr super().__setattr__(name, attr) @property def attributes(self) -> dict[str, Attribute]: return self.__attributes def add_sub_controller(self, name: str, sub_controller: BaseController): if name in self.__sub_controllers.keys(): raise ValueError( f"Cannot add sub controller {sub_controller}. " f"Controller {self} has existing sub controller {name}: " f"{self.__sub_controllers[name]}" ) elif name in self.__hinted_sub_controllers: hint = self.__hinted_sub_controllers[name] if not isinstance(sub_controller, hint): raise RuntimeError( f"Controller '{self.__class__.__name__}' introspection of " f"hinted sub controller '{name}' does not match defined type. " f"Expected '{hint.__name__}' got " f"'{sub_controller.__class__.__name__}'." ) elif name in self.__attributes: raise ValueError( f"Cannot add sub controller {sub_controller}. " f"Controller {self} has existing attribute {name}: " f"{self.__attributes[name]}" ) sub_controller.set_path(self.path + [name]) self.__sub_controllers[name] = sub_controller super().__setattr__(name, sub_controller) if isinstance(sub_controller.root_attribute, Attribute): self.__attributes[name] = sub_controller.root_attribute @property def sub_controllers(self) -> dict[str, BaseController]: return self.__sub_controllers def add_command(self, name: str, command: Command): self.__command_methods[name] = command super().__setattr__(name, command) @property def command_methods(self) -> dict[str, Command]: return self.__command_methods def add_scan(self, name: str, scan: Scan): self.__scan_methods[name] = scan super().__setattr__(name, scan) @property def scan_methods(self) -> dict[str, Scan]: return self.__scan_methods