From 779073fede38453bde5d2ecb336c7a5155f8b6b0 Mon Sep 17 00:00:00 2001 From: Eva Lott Date: Tue, 26 Nov 2024 14:30:18 +0000 Subject: [PATCH] Added `root_attribute` Added a method to `SubController` to allow passing of a top level `Attribute` from the `SubController` to the parent controller. The `Attribute` has the same name as the sub controller in the parent controller. --- src/fastcs/controller.py | 10 +++- src/fastcs/mapping.py | 25 ++++++-- tests/backends/epics/test_ioc.py | 2 +- tests/test_controller.py | 97 ++++++++++++++++++++++++++------ 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 14fe75d4..cf93eac6 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -8,7 +8,7 @@ class BaseController: def __init__(self, path: list[str] | None = None) -> None: self._path: list[str] = path or [] - self.__sub_controller_tree: dict[str, BaseController] = {} + self.__sub_controller_tree: dict[str, SubController] = {} self._bind_attrs() @@ -25,6 +25,8 @@ def set_path(self, path: list[str]): def _bind_attrs(self) -> None: for attr_name in dir(self): + if attr_name == "root_attribute": + continue attr = getattr(self, attr_name) if isinstance(attr, Attribute): new_attribute = copy(attr) @@ -39,7 +41,7 @@ def register_sub_controller(self, name: str, sub_controller: SubController): self.__sub_controller_tree[name] = sub_controller sub_controller.set_path(self.path + [name]) - def get_sub_controllers(self) -> dict[str, BaseController]: + def get_sub_controllers(self) -> dict[str, SubController]: return self.__sub_controller_tree def get_attributes(self) -> dict[str, Attribute]: @@ -75,3 +77,7 @@ class SubController(BaseController): def __init__(self) -> None: super().__init__() + + @property + def root_attribute(self) -> Attribute | None: + return None diff --git a/src/fastcs/mapping.py b/src/fastcs/mapping.py index 8b00e88e..8eac8388 100644 --- a/src/fastcs/mapping.py +++ b/src/fastcs/mapping.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import get_type_hints -from .attributes import Attribute, AttrR, AttrW, AttrRW +from .attributes import Attribute, AttrR, AttrRW, AttrW from .controller import BaseController, Controller from .cs_methods import Command, Put, Scan from .wrappers import WrappedMethod @@ -54,6 +54,9 @@ def _get_single_mapping(controller: BaseController) -> SingleMapping: attributes: dict[str, Attribute] = {} for name in list(get_type_hints(type(controller))) + dir(type(controller)): + if name == "root_attribute": + continue + if ( isinstance( (attr := getattr(controller, name, None)), AttrRW | AttrR | AttrW @@ -71,14 +74,26 @@ def _get_single_mapping(controller: BaseController) -> SingleMapping: for key in object_defined_attributes.keys() & attributes.keys() if object_defined_attributes[key] is not attributes[key] }: - raise TypeError( - f"{controller} has conflicting attributes between those passed in" - "`get_attributes` and those obtained from the class definition: " - f"{conflicting_keys}" + raise ValueError( + f"`{type(controller).__name__}` has conflicting attributes between " + "those passed in `get_attributes` and those obtained from the " + f"class definition: {conflicting_keys}" ) attributes.update(object_defined_attributes) + for sub_controller_name, sub_controller in controller.get_sub_controllers().items(): + root_attribute = sub_controller.root_attribute + if root_attribute is None: + continue + + if sub_controller_name in attributes: + raise ValueError( + f"sub_controller `{sub_controller_name}` has a `root_attribute` " + f"already defined defined in parent controller {sub_controller_name}" + ) + attributes[sub_controller_name] = root_attribute + return SingleMapping( controller, scan_methods, put_methods, command_methods, attributes ) diff --git a/tests/backends/epics/test_ioc.py b/tests/backends/epics/test_ioc.py index 60bf97ba..6303a347 100644 --- a/tests/backends/epics/test_ioc.py +++ b/tests/backends/epics/test_ioc.py @@ -1,4 +1,4 @@ -from typing import Any, get_type_hints +from typing import Any import pytest from pytest_mock import MockerFixture diff --git a/tests/test_controller.py b/tests/test_controller.py index 6dbdd4a8..2167987a 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -33,29 +33,53 @@ def test_controller_nesting(): controller.register_sub_controller("c", sub_controller) -def test_attribute_parsing(): - runtime_attribute = AttrR(Int()) +class SomeSubController(SubController): + def __init__(self): + self._root_attribute = AttrR(Int()) + super().__init__() - class SomeController(Controller): - annotated_attr = AttrR(Int()) - annotated_attr_not_defined_in_init: AttrR[int] - equal_attr = AttrR(Int()) - annotated_and_equal_attr: AttrR[int] = AttrR(Int()) + sub_attribute = AttrR(Int()) - def get_attributes(self) -> dict[str, Attribute]: - return {"get_attributes_attr": runtime_attribute} + @property + def root_attribute(self): + return self._root_attribute - def __init__(self): - self.annotated_attr = AttrR(Int()) - super().__init__() - controller = SomeController() - mapping = next(_walk_mappings(controller)) - assert mapping.attributes == { - "get_attributes_attr": runtime_attribute, +class SomeController(Controller): + annotated_attr = AttrR(Int()) + annotated_attr_not_defined_in_init: AttrR[int] + equal_attr = AttrR(Int()) + annotated_and_equal_attr: AttrR[int] = AttrR(Int()) + + def __init__(self, sub_controller: SubController): + self.get_attributes_attribute = AttrR(Int()) + self.annotated_attr = AttrR(Int()) + self.attr_not_walked = AttrR(Int()) + + super().__init__() + + self.register_sub_controller("sub_controller", sub_controller) + + def get_attributes(self) -> dict[str, Attribute]: + return { + "get_attributes_attr": self.get_attributes_attribute, + "equal_attr": self.equal_attr, + } + + +def test_attribute_parsing(): + sub_controller = SomeSubController() + controller = SomeController(sub_controller) + + mapping_walk = _walk_mappings(controller) + + controller_mapping = next(mapping_walk) + assert controller_mapping.attributes == { + "get_attributes_attr": controller.get_attributes_attribute, "annotated_attr": controller.annotated_attr, "equal_attr": controller.equal_attr, "annotated_and_equal_attr": controller.annotated_and_equal_attr, + "sub_controller": sub_controller.root_attribute, } assert SomeController.equal_attr is not controller.equal_attr @@ -63,3 +87,44 @@ def __init__(self): SomeController.annotated_and_equal_attr is not controller.annotated_and_equal_attr ) + + sub_controller_mapping = next(mapping_walk) + assert sub_controller_mapping.attributes == { + "sub_attribute": sub_controller.sub_attribute, + } + + +def test_root_attribute(): + class FailingController(SomeController): + def get_attributes(self) -> dict[str, Attribute]: + return {"sub_controller": self.get_attributes_attribute} + + with pytest.raises( + ValueError, + match=( + "sub_controller `sub_controller` has a `root_attribute` already " + "defined defined in parent controller sub_controller" + ), + ): + next(_walk_mappings(FailingController(SomeSubController()))) + + +def test_attribute_in_both_class_and_get_attributes(): + class FailingController(Controller): + duplicate_attribute = AttrR(Int()) + + def __init__(self): + super().__init__() + + def get_attributes(self) -> dict[str, Attribute]: + return {"duplicate_attribute": AttrR(Int())} + + with pytest.raises( + ValueError, + match=( + "`FailingController` has conflicting attributes between those passed " + "in `get_attributes` and those obtained from the class definition: " + "{'duplicate_attribute'}" + ), + ): + next(_walk_mappings(FailingController()))