From 86fd3967a7c4a6ad8519b74194a23f9154e453d0 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 11 Aug 2025 15:10:38 +0000 Subject: [PATCH 1/5] assert hinted attrs are bound and have right dtype after initialisation --- src/fastcs/backend.py | 2 + src/fastcs/util.py | 36 +++++++++++++++++ tests/test_util.py | 93 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/fastcs/backend.py b/src/fastcs/backend.py index cc2bf8c5f..3e1d32cd0 100644 --- a/src/fastcs/backend.py +++ b/src/fastcs/backend.py @@ -9,6 +9,7 @@ from .controller import BaseController, Controller from .controller_api import ControllerAPI from .exceptions import FastCSException +from .util import validate_hinted_attributes class Backend: @@ -28,6 +29,7 @@ def __init__( # Initialise controller and then build its APIs loop.run_until_complete(controller.initialise()) loop.run_until_complete(controller.attribute_initialise()) + validate_hinted_attributes(controller) self.controller_api = build_controller_api(controller) self._link_process_tasks() diff --git a/src/fastcs/util.py b/src/fastcs/util.py index e4f526a49..0b4938d34 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -1,7 +1,10 @@ import re +from typing import _GenericAlias, get_args, get_origin, get_type_hints # type: ignore import numpy as np +from fastcs.attributes import Attribute +from fastcs.controller import BaseController from fastcs.datatypes import Bool, DataType, Float, Int, String @@ -26,3 +29,36 @@ def numpy_to_fastcs_datatype(np_type) -> DataType: return Bool() else: return String() + + +def validate_hinted_attributes(controller: BaseController): + """Validates that type-hinted attributes exist in the controller, and are accessible + via the dot accessor, from the attributes dictionary and with the right datatype. + """ + 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) + if not issubclass(attr_class, Attribute): + continue + args = get_args(hint) + assert len(args) == 1, f"Hinted attribute {name} has too many arguments" + (attr_dtype,) = args + attr = getattr(controller, name, None) + assert attr is not None, ( + f"No attribute named {name} bound on controller {controller}" + ) + assert type(attr) is attr_class, ( + f"Expected {attr_class} for {name}, got {type(attr)}" + ) + dict_attr = controller.attributes.get(name, None) + assert dict_attr is not None, ( + f"Hinted attribute {name} not found in controller's attribute dict" + ) + assert dict_attr is attr, ( + f"Hinted attribute {name} in controller's attribute dict" + " is not the bound attribute" + ) + assert attr.datatype.dtype == attr_dtype, ( + f"Expected dtype {attr_dtype} for {name}, got {attr.datatype.dtype}" + ) diff --git a/tests/test_util.py b/tests/test_util.py index f6cb87b3c..c1675445e 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,9 +1,15 @@ +import asyncio +import enum + import numpy as np import pytest from pvi.device import SignalR from pydantic import ValidationError -from fastcs.datatypes import Bool, Float, Int, String +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 numpy_to_fastcs_datatype, snake_to_pascal @@ -56,3 +62,88 @@ def test_pvi_validation_error(): ) def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype): assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type) + + +def test_hinted_attributes_verified(): + loop = asyncio.get_event_loop() + + class ControllerWithWrongType(Controller): + hinted_wrong_type: AttrR[int] + + async def initialise(self): + self.hinted_wrong_type = AttrR(Float()) # type: ignore + self.attributes["hinted_wrong_type"] = self.hinted_wrong_type + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerWithWrongType(), loop) + assert ( + "Expected dtype for hinted_wrong_type, got " + in str(excinfo.value) + ) + + class ControllerWithMissingAttr(Controller): + hinted_int_missing: AttrR[int] + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerWithMissingAttr(), loop) + assert "No attribute named hinted_int_missing bound on controller" in str( + excinfo.value + ) + + class ControllerAttrNotInDict(Controller): + hinted: AttrR[int] + + async def initialise(self): + self.hinted = AttrR(Int()) + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerAttrNotInDict(), loop) + assert ( + str(excinfo.value) + == "Hinted attribute hinted not found in controller's attribute dict" + ) + + class ControllerAttrMismatch(Controller): + hinted: AttrR[int] + + async def initialise(self): + self.hinted = AttrR(Int()) + self.attributes["hinted"] = AttrR(Int()) + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerAttrMismatch(), loop) + assert str(excinfo.value) == ( + "Hinted attribute hinted in controller's attribute dict" + " is not the bound attribute" + ) + + class ControllerAttrWrongAccessMode(Controller): + hinted: AttrR[int] + + async def initialise(self): + self.hinted = AttrRW(Int()) + self.attributes["hinted"] = self.hinted + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerAttrWrongAccessMode(), loop) + assert str(excinfo.value) == ( + "Expected for hinted," + " got " + ) + + class MyEnum(enum.Enum): + A = 0 + B = 1 + + class MyEnum2(enum.Enum): + A = 2 + B = 3 + + class ControllerWrongEnumClass(Controller): + hinted_enum: AttrRW[MyEnum] = AttrRW(Enum(MyEnum2)) + + with pytest.raises(AssertionError) as excinfo: + Backend(ControllerWrongEnumClass(), loop) + assert str(excinfo.value) == ( + "Expected dtype for hinted_enum, got " + ) From 453815cc5100dd5154f37e14c359aa918caf557c Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 13 Aug 2025 09:55:24 +0000 Subject: [PATCH 2/5] Don't check attributes dict in validate_hinted_attributes --- src/fastcs/util.py | 8 -------- tests/test_util.py | 27 --------------------------- 2 files changed, 35 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 0b4938d34..ad0d9401c 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -51,14 +51,6 @@ def validate_hinted_attributes(controller: BaseController): assert type(attr) is attr_class, ( f"Expected {attr_class} for {name}, got {type(attr)}" ) - dict_attr = controller.attributes.get(name, None) - assert dict_attr is not None, ( - f"Hinted attribute {name} not found in controller's attribute dict" - ) - assert dict_attr is attr, ( - f"Hinted attribute {name} in controller's attribute dict" - " is not the bound attribute" - ) assert attr.datatype.dtype == attr_dtype, ( f"Expected dtype {attr_dtype} for {name}, got {attr.datatype.dtype}" ) diff --git a/tests/test_util.py b/tests/test_util.py index c1675445e..4f33c5c9f 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -90,33 +90,6 @@ class ControllerWithMissingAttr(Controller): excinfo.value ) - class ControllerAttrNotInDict(Controller): - hinted: AttrR[int] - - async def initialise(self): - self.hinted = AttrR(Int()) - - with pytest.raises(AssertionError) as excinfo: - Backend(ControllerAttrNotInDict(), loop) - assert ( - str(excinfo.value) - == "Hinted attribute hinted not found in controller's attribute dict" - ) - - class ControllerAttrMismatch(Controller): - hinted: AttrR[int] - - async def initialise(self): - self.hinted = AttrR(Int()) - self.attributes["hinted"] = AttrR(Int()) - - with pytest.raises(AssertionError) as excinfo: - Backend(ControllerAttrMismatch(), loop) - assert str(excinfo.value) == ( - "Hinted attribute hinted in controller's attribute dict" - " is not the bound attribute" - ) - class ControllerAttrWrongAccessMode(Controller): hinted: AttrR[int] From 8728fb1309f02c0f1261617cd5bca890608aef80 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 13 Aug 2025 10:30:51 +0000 Subject: [PATCH 3/5] remove unneeded assertion to check number of hint args in validate_hinted_attributes --- src/fastcs/util.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index ad0d9401c..cf15bf1a0 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -41,9 +41,6 @@ def validate_hinted_attributes(controller: BaseController): attr_class = get_origin(hint) if not issubclass(attr_class, Attribute): continue - args = get_args(hint) - assert len(args) == 1, f"Hinted attribute {name} has too many arguments" - (attr_dtype,) = args attr = getattr(controller, name, None) assert attr is not None, ( f"No attribute named {name} bound on controller {controller}" @@ -51,6 +48,8 @@ def validate_hinted_attributes(controller: BaseController): assert type(attr) is attr_class, ( f"Expected {attr_class} for {name}, got {type(attr)}" ) + # TypeError raised if the number of args if not 1 + (attr_dtype,) = get_args(hint) assert attr.datatype.dtype == attr_dtype, ( f"Expected dtype {attr_dtype} for {name}, got {attr.datatype.dtype}" ) From 56a6858e41d204a65fc8f1d5512ab1db7ffacd1d Mon Sep 17 00:00:00 2001 From: James Souter <107045742+jsouter@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:05:07 +0100 Subject: [PATCH 4/5] Apply error message improvement suggestions from code review Co-authored-by: Gary Yendell --- src/fastcs/util.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index cf15bf1a0..da694de00 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -43,13 +43,18 @@ def validate_hinted_attributes(controller: BaseController): continue attr = getattr(controller, name, None) assert attr is not None, ( - f"No attribute named {name} bound on controller {controller}" + f"Controller `{controller.__class__.__name__}` failed to introspect hinted " + f"attribute `{name}` during initialisation" ) assert type(attr) is attr_class, ( - f"Expected {attr_class} for {name}, got {type(attr)}" + 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__}'." ) # TypeError raised if the number of args if not 1 (attr_dtype,) = get_args(hint) assert attr.datatype.dtype == attr_dtype, ( - f"Expected dtype {attr_dtype} for {name}, got {attr.datatype.dtype}" + f"Controller '{controller.__class__.__name__}' introspection of hinted " + f"attribute '{name}' does not match defined datatype. " + f"Expected '{attr_dtype.__name__}', got '{attr.datatype.dtype.__name__}'." ) From 91a7c418c3a78626ecd042a10361f0a370e85e20 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 14 Aug 2025 12:16:58 +0000 Subject: [PATCH 5/5] Change validated_hinted_attributes assertions to RuntimeErrors and fix tests --- src/fastcs/util.py | 33 ++++++++++++++++++--------------- tests/test_util.py | 28 ++++++++++++++++------------ 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index da694de00..af7289722 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -42,19 +42,22 @@ def validate_hinted_attributes(controller: BaseController): if not issubclass(attr_class, Attribute): continue attr = getattr(controller, name, None) - assert attr is not None, ( - f"Controller `{controller.__class__.__name__}` failed to introspect hinted " - f"attribute `{name}` during initialisation" - ) - assert type(attr) is attr_class, ( - 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__}'." - ) - # TypeError raised if the number of args if not 1 + if attr is None: + raise RuntimeError( + f"Controller `{controller.__class__.__name__}` failed to introspect " + f"hinted attribute `{name}` during initialisation" + ) + if type(attr) is not attr_class: + 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) - assert attr.datatype.dtype == attr_dtype, ( - f"Controller '{controller.__class__.__name__}' introspection of hinted " - f"attribute '{name}' does not match defined datatype. " - f"Expected '{attr_dtype.__name__}', got '{attr.datatype.dtype.__name__}'." - ) + if attr.datatype.dtype != attr_dtype: + raise RuntimeError( + f"Controller '{controller.__class__.__name__}' introspection of hinted " + f"attribute '{name}' does not match defined datatype. " + f"Expected '{attr_dtype.__name__}', " + f"got '{attr.datatype.dtype.__name__}'." + ) diff --git a/tests/test_util.py b/tests/test_util.py index 4f33c5c9f..fd44fee7f 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -74,20 +74,22 @@ async def initialise(self): self.hinted_wrong_type = AttrR(Float()) # type: ignore self.attributes["hinted_wrong_type"] = self.hinted_wrong_type - with pytest.raises(AssertionError) as excinfo: + with pytest.raises(RuntimeError) as excinfo: Backend(ControllerWithWrongType(), loop) - assert ( - "Expected dtype for hinted_wrong_type, got " - in str(excinfo.value) + assert str(excinfo.value) == ( + "Controller 'ControllerWithWrongType' introspection of hinted attribute " + "'hinted_wrong_type' does not match defined datatype. " + "Expected 'int', got 'float'." ) class ControllerWithMissingAttr(Controller): hinted_int_missing: AttrR[int] - with pytest.raises(AssertionError) as excinfo: + with pytest.raises(RuntimeError) as excinfo: Backend(ControllerWithMissingAttr(), loop) - assert "No attribute named hinted_int_missing bound on controller" in str( - excinfo.value + assert str(excinfo.value) == ( + "Controller `ControllerWithMissingAttr` failed to introspect hinted attribute " + "`hinted_int_missing` during initialisation" ) class ControllerAttrWrongAccessMode(Controller): @@ -97,11 +99,11 @@ async def initialise(self): self.hinted = AttrRW(Int()) self.attributes["hinted"] = self.hinted - with pytest.raises(AssertionError) as excinfo: + with pytest.raises(RuntimeError) as excinfo: Backend(ControllerAttrWrongAccessMode(), loop) assert str(excinfo.value) == ( - "Expected for hinted," - " got " + "Controller 'ControllerAttrWrongAccessMode' introspection of hinted attribute " + "'hinted' does not match defined access mode. Expected 'AttrR', got 'AttrRW'." ) class MyEnum(enum.Enum): @@ -115,8 +117,10 @@ class MyEnum2(enum.Enum): class ControllerWrongEnumClass(Controller): hinted_enum: AttrRW[MyEnum] = AttrRW(Enum(MyEnum2)) - with pytest.raises(AssertionError) as excinfo: + with pytest.raises(RuntimeError) as excinfo: Backend(ControllerWrongEnumClass(), loop) assert str(excinfo.value) == ( - "Expected dtype for hinted_enum, got " + "Controller 'ControllerWrongEnumClass' introspection of hinted attribute " + "'hinted_enum' does not match defined datatype. " + "Expected 'MyEnum', got 'MyEnum2'." )