From 44bb0a254174d2741c9d40585c5d2a4895756293 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 10 Sep 2025 12:05:03 +0000 Subject: [PATCH 01/10] validate hinted attributes on subcontrollers --- src/fastcs/util.py | 3 ++- tests/test_util.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 16769b95c..34b32c97a 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -37,7 +37,8 @@ def validate_hinted_attributes(controller: BaseController): For each type-hinted attribute, validate that a corresponding instance exists in the controller with the correct access mode and datatype. """ - + for subcontroller in controller.sub_controllers.values(): + validate_hinted_attributes(subcontroller) hints = get_type_hints(type(controller)) alias_hints = {k: v for k, v in hints.items() if isinstance(v, _GenericAlias)} for name, hint in alias_hints.items(): diff --git a/tests/test_util.py b/tests/test_util.py index d20797d6b..c0a48746e 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from fastcs.attributes import AttrR, AttrRW +from fastcs.backend import Backend from fastcs.controller import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String from fastcs.util import ( @@ -136,3 +137,26 @@ class ControllerWrongEnumClass(Controller): "'hinted_enum' does not match defined datatype. " "Expected 'MyEnum', got 'MyEnum2'." ) + + +def test_hinted_attributes_verified_on_subcontrollers(): + loop = asyncio.get_event_loop() + + class ControllerWithWrongType(SubController): + hinted_missing: AttrR[int] + + async def connect(self): + return + + class TopController(Controller): + async def initialise(self): + subcontroller = ControllerWithWrongType() + self.register_sub_controller("MySubController", subcontroller) + + with pytest.raises(RuntimeError) as excinfo: + Backend(TopController(), loop) + + assert str(excinfo.value) == ( + "Controller `ControllerWithWrongType` failed to introspect hinted attribute " + "`hinted_missing` during initialisation" + ) From 61c0a9c3c651b2e096cea6e61aec11395de2bd54 Mon Sep 17 00:00:00 2001 From: James Souter <107045742+jsouter@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:25:42 +0100 Subject: [PATCH 02/10] Update tests/test_util.py Co-authored-by: Gary Yendell --- tests/test_util.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index c0a48746e..2b944171a 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -153,10 +153,5 @@ async def initialise(self): subcontroller = ControllerWithWrongType() self.register_sub_controller("MySubController", subcontroller) - with pytest.raises(RuntimeError) as excinfo: + with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): Backend(TopController(), loop) - - assert str(excinfo.value) == ( - "Controller `ControllerWithWrongType` failed to introspect hinted attribute " - "`hinted_missing` during initialisation" - ) From 46697b4c188f1b772c8c750412429ebcd115c52e Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 10 Sep 2025 13:06:14 +0000 Subject: [PATCH 03/10] validate non-GenericAlias attribute hints --- src/fastcs/util.py | 24 +++++++++++++++--------- tests/test_util.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 34b32c97a..6df81438c 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -39,10 +39,18 @@ def validate_hinted_attributes(controller: BaseController): """ for subcontroller in controller.sub_controllers.values(): validate_hinted_attributes(subcontroller) - hints = get_type_hints(type(controller)) - alias_hints = {k: v for k, v in hints.items() if isinstance(v, _GenericAlias)} - for name, hint in alias_hints.items(): - attr_class = get_origin(hint) + hints = { + k: v + for k, v in get_type_hints(type(controller)).items() + if isinstance(v, _GenericAlias | type) + } + for name, hint in hints.items(): + if isinstance(hint, type): + attr_class = hint + attr_dtype = None + else: + attr_class = get_origin(hint) + (attr_dtype,) = get_args(hint) if not issubclass(attr_class, Attribute): continue @@ -52,16 +60,14 @@ def validate_hinted_attributes(controller: BaseController): f"Controller `{controller.__class__.__name__}` failed to introspect " f"hinted attribute `{name}` during initialisation" ) - - if type(attr) is not attr_class: + if attr_class not in [type(attr), Attribute]: + # skip validation if access mode not specified raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " f"attribute '{name}' does not match defined access mode. " f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." ) - - attr_dtype = get_args(hint)[0] - if attr.datatype.dtype != attr_dtype: + if attr_dtype not in [attr.datatype.dtype, None]: raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " f"attribute '{name}' does not match defined datatype. " diff --git a/tests/test_util.py b/tests/test_util.py index 2b944171a..66613ee83 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,7 +5,7 @@ from pvi.device import SignalR from pydantic import ValidationError -from fastcs.attributes import AttrR, AttrRW +from fastcs.attributes import Attribute, AttrR, AttrRW from fastcs.backend import Backend from fastcs.controller import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String @@ -138,6 +138,15 @@ class ControllerWrongEnumClass(Controller): "Expected 'MyEnum', got 'MyEnum2'." ) + class ControllerUnspecifiedAccessMode(Controller): + hinted: Attribute[int] + + async def initialise(self): + self.hinted = AttrR(Int()) + + # no assertion thrown + Backend(ControllerUnspecifiedAccessMode(), loop) + def test_hinted_attributes_verified_on_subcontrollers(): loop = asyncio.get_event_loop() @@ -155,3 +164,25 @@ async def initialise(self): with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): Backend(TopController(), loop) + + +def test_hinted_attribute_types_verified(): + # test verification works with non-GenericAlias type hints + loop = asyncio.get_event_loop() + + class ControllerAttrWrongAccessMode(Controller): + read_attr: AttrR + + async def initialise(self): + self.read_attr = AttrRW(Int()) + + with pytest.raises(RuntimeError, match="does not match defined access mode"): + Backend(ControllerAttrWrongAccessMode(), loop) + + class ControllerUnspecifiedAccessMode(Controller): + unspecified_access_mode: Attribute + + async def initialise(self): + self.unspecified_access_mode = AttrRW(Int()) + + Backend(ControllerUnspecifiedAccessMode(), loop) From fad563c14d06c43e32bf26c28f25814bf84b7758 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 10 Sep 2025 13:13:19 +0000 Subject: [PATCH 04/10] pyright --- src/fastcs/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 6df81438c..ee552d135 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -67,7 +67,7 @@ def validate_hinted_attributes(controller: BaseController): f"attribute '{name}' does not match defined access mode. " f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." ) - if attr_dtype not in [attr.datatype.dtype, None]: + if attr_dtype is not None and attr_dtype != attr.datatype.dtype: raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " f"attribute '{name}' does not match defined datatype. " From 8fce5f27d2607349d418ddfa5a2747084423f915 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 10 Sep 2025 14:49:54 +0000 Subject: [PATCH 05/10] fix type checking logic for hinted Attributes --- src/fastcs/util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index ee552d135..17e2cb470 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -60,8 +60,10 @@ def validate_hinted_attributes(controller: BaseController): f"Controller `{controller.__class__.__name__}` failed to introspect " f"hinted attribute `{name}` during initialisation" ) - if attr_class not in [type(attr), Attribute]: + if attr_class is not type(attr): # skip validation if access mode not specified + if attr_class is Attribute and isinstance(attr, Attribute): + continue raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " f"attribute '{name}' does not match defined access mode. " From 83d809444c6457aab5b6ab660dc83c6a287dec7c Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 6 Oct 2025 08:34:15 +0000 Subject: [PATCH 06/10] fixes after rebase --- src/fastcs/util.py | 2 +- tests/test_util.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 17e2cb470..02dfb6966 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -50,7 +50,7 @@ def validate_hinted_attributes(controller: BaseController): attr_dtype = None else: attr_class = get_origin(hint) - (attr_dtype,) = get_args(hint) + attr_dtype = get_args(hint)[0] if not issubclass(attr_class, Attribute): continue diff --git a/tests/test_util.py b/tests/test_util.py index 66613ee83..c6fc8b4b8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,7 +6,6 @@ from pydantic import ValidationError from fastcs.attributes import Attribute, AttrR, AttrRW -from fastcs.backend import Backend from fastcs.controller import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String from fastcs.util import ( @@ -145,13 +144,13 @@ async def initialise(self): self.hinted = AttrR(Int()) # no assertion thrown - Backend(ControllerUnspecifiedAccessMode(), loop) + FastCS(ControllerUnspecifiedAccessMode(), [], loop) def test_hinted_attributes_verified_on_subcontrollers(): loop = asyncio.get_event_loop() - class ControllerWithWrongType(SubController): + class ControllerWithWrongType(Controller): hinted_missing: AttrR[int] async def connect(self): @@ -163,7 +162,7 @@ async def initialise(self): self.register_sub_controller("MySubController", subcontroller) with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): - Backend(TopController(), loop) + FastCS(TopController(), [], loop) def test_hinted_attribute_types_verified(): @@ -177,7 +176,7 @@ async def initialise(self): self.read_attr = AttrRW(Int()) with pytest.raises(RuntimeError, match="does not match defined access mode"): - Backend(ControllerAttrWrongAccessMode(), loop) + FastCS(ControllerAttrWrongAccessMode(), [], loop) class ControllerUnspecifiedAccessMode(Controller): unspecified_access_mode: Attribute @@ -185,4 +184,4 @@ class ControllerUnspecifiedAccessMode(Controller): async def initialise(self): self.unspecified_access_mode = AttrRW(Int()) - Backend(ControllerUnspecifiedAccessMode(), loop) + FastCS(ControllerUnspecifiedAccessMode(), [], loop) From 3c0f564b02f6ac6572398833d9867544f007c6ed Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 13 Nov 2025 08:59:05 +0000 Subject: [PATCH 07/10] fix tests after rebase --- tests/test_util.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index c6fc8b4b8..d7db014cf 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,3 +1,4 @@ +import asyncio import enum import numpy as np @@ -8,6 +9,7 @@ from fastcs.attributes import Attribute, AttrR, AttrRW from fastcs.controller import Controller from fastcs.datatypes import Bool, Enum, Float, Int, String +from fastcs.launch import FastCS from fastcs.util import ( numpy_to_fastcs_datatype, snake_to_pascal, @@ -137,15 +139,6 @@ class ControllerWrongEnumClass(Controller): "Expected 'MyEnum', got 'MyEnum2'." ) - class ControllerUnspecifiedAccessMode(Controller): - hinted: Attribute[int] - - async def initialise(self): - self.hinted = AttrR(Int()) - - # no assertion thrown - FastCS(ControllerUnspecifiedAccessMode(), [], loop) - def test_hinted_attributes_verified_on_subcontrollers(): loop = asyncio.get_event_loop() @@ -157,15 +150,16 @@ async def connect(self): return class TopController(Controller): - async def initialise(self): + async def initialise(self): # why does this not get called? subcontroller = ControllerWithWrongType() - self.register_sub_controller("MySubController", subcontroller) + self.add_sub_controller("MySubController", subcontroller) + fastcs = FastCS(TopController(), [], loop) with pytest.raises(RuntimeError, match="failed to introspect hinted attribute"): - FastCS(TopController(), [], loop) + fastcs.run() -def test_hinted_attribute_types_verified(): +def test_hinted_attribute_access_mode_verified(): # test verification works with non-GenericAlias type hints loop = asyncio.get_event_loop() @@ -175,13 +169,20 @@ class ControllerAttrWrongAccessMode(Controller): async def initialise(self): self.read_attr = AttrRW(Int()) + fastcs = FastCS(ControllerAttrWrongAccessMode(), [], loop) with pytest.raises(RuntimeError, match="does not match defined access mode"): - FastCS(ControllerAttrWrongAccessMode(), [], loop) + fastcs.run() + +@pytest.mark.asyncio +async def test_hinted_attributes_with_unspecified_access_mode(): class ControllerUnspecifiedAccessMode(Controller): unspecified_access_mode: Attribute async def initialise(self): self.unspecified_access_mode = AttrRW(Int()) - FastCS(ControllerUnspecifiedAccessMode(), [], loop) + controller = ControllerUnspecifiedAccessMode() + await controller.initialise() + # no assertion thrown + validate_hinted_attributes(controller) From 5ee28f904f47a6ba165dcb33ff9116ee4331f09a Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 13 Nov 2025 09:08:39 +0000 Subject: [PATCH 08/10] allow datatype hinting on Attributes with undefined access mode --- src/fastcs/util.py | 13 ++++++------- tests/test_util.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 02dfb6966..e3e81181d 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -62,13 +62,12 @@ def validate_hinted_attributes(controller: BaseController): ) if attr_class is not type(attr): # skip validation if access mode not specified - if attr_class is Attribute and isinstance(attr, Attribute): - continue - raise RuntimeError( - f"Controller '{controller.__class__.__name__}' introspection of hinted " - f"attribute '{name}' does not match defined access mode. " - f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." - ) + if not (attr_class is Attribute and isinstance(attr, Attribute)): + raise RuntimeError( + f"Controller '{controller.__class__.__name__}' introspection of " + f"hinted attribute '{name}' does not match defined access mode. " + f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." + ) if attr_dtype is not None and attr_dtype != attr.datatype.dtype: raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " diff --git a/tests/test_util.py b/tests/test_util.py index d7db014cf..0657071f4 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -186,3 +186,14 @@ async def initialise(self): await controller.initialise() # no assertion thrown validate_hinted_attributes(controller) + + class ControllerUnspecifiedAccessModeWrongType(Controller): + unspecified_access_mode_wrong_type: Attribute[int] + + async def initialise(self): + self.unspecified_access_mode_wrong_type = AttrRW(Float()) + + controller = ControllerUnspecifiedAccessModeWrongType() + await controller.initialise() + with pytest.raises(RuntimeError, match="does not match defined datatype"): + validate_hinted_attributes(controller) From be79d11ca23c44b2c7a0a7f330455aae4d7ce8dd Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 13 Nov 2025 09:16:07 +0000 Subject: [PATCH 09/10] pyright tests --- tests/test_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_util.py b/tests/test_util.py index 0657071f4..7d13536f9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -191,7 +191,7 @@ class ControllerUnspecifiedAccessModeWrongType(Controller): unspecified_access_mode_wrong_type: Attribute[int] async def initialise(self): - self.unspecified_access_mode_wrong_type = AttrRW(Float()) + self.unspecified_access_mode_wrong_type = AttrRW(Float()) # type: ignore controller = ControllerUnspecifiedAccessModeWrongType() await controller.initialise() From 37da3e9fc8b4279d3d01d273d03da9776bf60c9e Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 13 Nov 2025 09:22:46 +0000 Subject: [PATCH 10/10] do not permit hinted attributes with unspecified access mode to be validated --- src/fastcs/util.py | 12 +++++------- tests/test_util.py | 17 ++++++----------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index e3e81181d..ec67e8092 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -61,13 +61,11 @@ def validate_hinted_attributes(controller: BaseController): f"hinted attribute `{name}` during initialisation" ) if attr_class is not type(attr): - # skip validation if access mode not specified - if not (attr_class is Attribute and isinstance(attr, Attribute)): - raise RuntimeError( - f"Controller '{controller.__class__.__name__}' introspection of " - f"hinted attribute '{name}' does not match defined access mode. " - f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." - ) + raise RuntimeError( + f"Controller '{controller.__class__.__name__}' introspection of " + f"hinted attribute '{name}' does not match defined access mode. " + f"Expected '{attr_class.__name__}', got '{type(attr).__name__}'." + ) if attr_dtype is not None and attr_dtype != attr.datatype.dtype: raise RuntimeError( f"Controller '{controller.__class__.__name__}' introspection of hinted " diff --git a/tests/test_util.py b/tests/test_util.py index 7d13536f9..8a1365719 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -185,15 +185,10 @@ async def initialise(self): controller = ControllerUnspecifiedAccessMode() await controller.initialise() # no assertion thrown - validate_hinted_attributes(controller) - - class ControllerUnspecifiedAccessModeWrongType(Controller): - unspecified_access_mode_wrong_type: Attribute[int] - - async def initialise(self): - self.unspecified_access_mode_wrong_type = AttrRW(Float()) # type: ignore - - controller = ControllerUnspecifiedAccessModeWrongType() - await controller.initialise() - with pytest.raises(RuntimeError, match="does not match defined datatype"): + with pytest.raises( + RuntimeError, + match=( + "does not match defined access mode. Expected 'Attribute', got 'AttrRW'" + ), + ): validate_hinted_attributes(controller)