import enum from datetime import datetime from enum import Enum from typing import Any, ClassVar import pydase import pydase.units as u import pytest from pydase.components.coloured_enum import ColouredEnum from pydase.task.task_status import TaskStatus from pydase.utils.decorators import frontend from pydase.utils.serialization.serializer import ( SerializationPathError, SerializedObject, add_prefix_to_full_access_path, dump, generate_serialized_data_paths, get_container_item_by_key, get_data_paths_from_serialized_object, get_nested_dict_by_path, serialized_dict_is_nested_object, set_nested_value_by_path, ) class MyEnum(enum.Enum): """MyEnum description""" RUNNING = "running" FINISHED = "finished" class MySubclass(pydase.DataService): attr3 = 1.0 list_attr: ClassVar[list[Any]] = [1.0, 1] some_quantity: u.Quantity = 1.0 * u.units.A class ServiceClass(pydase.DataService): attr1 = 1.0 attr2 = MySubclass() enum_attr = MyEnum.RUNNING attr_list: ClassVar[list[Any]] = [0, 1, MySubclass()] dict_attr: ClassVar[dict[Any, Any]] = {"foo": 1.0, "bar": {"foo": "bar"}} def my_task(self) -> None: pass service_instance = ServiceClass() @pytest.mark.parametrize( "test_input, expected", [ ( 1, { "full_access_path": "", "type": "int", "value": 1, "readonly": False, "doc": None, }, ), ( 1.0, { "full_access_path": "", "type": "float", "value": 1.0, "readonly": False, "doc": None, }, ), ( True, { "full_access_path": "", "type": "bool", "value": True, "readonly": False, "doc": None, }, ), ( u.Quantity(10, "m"), { "full_access_path": "", "type": "Quantity", "value": {"magnitude": 10, "unit": "meter"}, "readonly": False, "doc": None, }, ), ( datetime.fromisoformat("2024-07-09 15:37:08.249845"), { "full_access_path": "", "type": "datetime", "value": "2024-07-09 15:37:08.249845", "readonly": True, "doc": None, }, ), ], ) def test_dump(test_input: Any, expected: dict[str, Any]) -> None: assert dump(test_input) == expected def test_enum_serialize() -> None: class EnumClass(Enum): FOO = "foo" BAR = "bar" class EnumAttribute(pydase.DataService): def __init__(self) -> None: super().__init__() self.some_enum = EnumClass.FOO class EnumPropertyWithoutSetter(pydase.DataService): def __init__(self) -> None: super().__init__() self._some_enum = EnumClass.FOO @property def some_enum(self) -> EnumClass: return self._some_enum class EnumPropertyWithSetter(pydase.DataService): def __init__(self) -> None: super().__init__() self._some_enum = EnumClass.FOO @property def some_enum(self) -> EnumClass: return self._some_enum @some_enum.setter def some_enum(self, value: EnumClass) -> None: self._some_enum = value assert dump(EnumAttribute())["value"] == { "some_enum": { "full_access_path": "some_enum", "type": "Enum", "name": "EnumClass", "value": "FOO", "enum": {"FOO": "foo", "BAR": "bar"}, "readonly": False, "doc": None, } } assert dump(EnumPropertyWithoutSetter())["value"] == { "some_enum": { "full_access_path": "some_enum", "type": "Enum", "name": "EnumClass", "value": "FOO", "enum": {"FOO": "foo", "BAR": "bar"}, "readonly": True, "doc": None, } } assert dump(EnumPropertyWithSetter())["value"] == { "some_enum": { "full_access_path": "some_enum", "type": "Enum", "name": "EnumClass", "value": "FOO", "enum": {"FOO": "foo", "BAR": "bar"}, "readonly": False, "doc": None, } } def test_ColouredEnum_serialize() -> None: class Status(ColouredEnum): """Status description.""" PENDING = "#FFA500" RUNNING = "#0000FF80" PAUSED = "rgb(169, 169, 169)" RETRYING = "rgba(255, 255, 0, 0.3)" COMPLETED = "hsl(120, 100%, 50%)" FAILED = "hsla(0, 100%, 50%, 0.7)" CANCELLED = "SlateGray" assert dump(Status.FAILED) == { "full_access_path": "", "type": "ColouredEnum", "name": "Status", "value": "FAILED", "enum": { "CANCELLED": "SlateGray", "COMPLETED": "hsl(120, 100%, 50%)", "FAILED": "hsla(0, 100%, 50%, 0.7)", "PAUSED": "rgb(169, 169, 169)", "PENDING": "#FFA500", "RETRYING": "rgba(255, 255, 0, 0.3)", "RUNNING": "#0000FF80", }, "readonly": False, "doc": "Status description.", } @pytest.mark.asyncio(scope="module") async def test_method_serialization() -> None: class ClassWithMethod(pydase.DataService): def some_method(self) -> str: return "some method" async def some_task(self) -> None: pass instance = ClassWithMethod() assert dump(instance)["value"] == { "some_method": { "full_access_path": "some_method", "type": "method", "value": None, "readonly": True, "doc": None, "async": False, "signature": {"parameters": {}, "return_annotation": {}}, "frontend_render": False, }, "some_task": { "full_access_path": "some_task", "type": "method", "value": None, "readonly": True, "doc": None, "async": True, "signature": { "parameters": {}, "return_annotation": {}, }, "frontend_render": True, }, } def test_methods_with_type_hints() -> None: def method_without_type_hint(arg_without_type_hint) -> None: pass def method_with_type_hint(some_argument: int) -> None: pass def method_with_union_type_hint(some_argument: int | float) -> None: pass assert dump(method_without_type_hint) == { "full_access_path": "", "async": False, "doc": None, "signature": { "parameters": { "arg_without_type_hint": { "annotation": "", "default": {}, } }, "return_annotation": {}, }, "readonly": True, "type": "method", "value": None, "frontend_render": False, } assert dump(method_with_type_hint) == { "full_access_path": "", "type": "method", "value": None, "readonly": True, "doc": None, "async": False, "signature": { "parameters": { "some_argument": {"annotation": "", "default": {}} }, "return_annotation": {}, }, "frontend_render": False, } assert dump(method_with_union_type_hint) == { "full_access_path": "", "type": "method", "value": None, "readonly": True, "doc": None, "async": False, "signature": { "parameters": { "some_argument": {"annotation": "int | float", "default": {}} }, "return_annotation": {}, }, "frontend_render": False, } def test_exposed_function_serialization() -> None: class MyService(pydase.DataService): @frontend def some_method(self) -> None: pass @frontend def some_function() -> None: pass assert dump(MyService().some_method) == { "full_access_path": "", "type": "method", "value": None, "readonly": True, "doc": None, "async": False, "signature": {"parameters": {}, "return_annotation": {}}, "frontend_render": True, } assert dump(some_function) == { "full_access_path": "", "type": "method", "value": None, "readonly": True, "doc": None, "async": False, "signature": {"parameters": {}, "return_annotation": {}}, "frontend_render": True, } def test_list_serialization() -> None: class MySubclass(pydase.DataService): _name = "hi" bool_attr = True int_attr = 1 @property def name(self) -> str: return self._name class ClassWithListAttribute(pydase.DataService): list_attr = [1, MySubclass()] instance = ClassWithListAttribute() assert dump(instance)["value"] == { "list_attr": { "full_access_path": "list_attr", "doc": None, "readonly": False, "type": "list", "value": [ { "full_access_path": "list_attr[0]", "doc": None, "readonly": False, "type": "int", "value": 1, }, { "full_access_path": "list_attr[1]", "doc": None, "readonly": False, "type": "DataService", "name": "MySubclass", "value": { "bool_attr": { "full_access_path": "list_attr[1].bool_attr", "doc": None, "readonly": False, "type": "bool", "value": True, }, "int_attr": { "full_access_path": "list_attr[1].int_attr", "doc": None, "readonly": False, "type": "int", "value": 1, }, "name": { "full_access_path": "list_attr[1].name", "doc": None, "readonly": True, "type": "str", "value": "hi", }, }, }, ], } } def test_dict_serialization() -> None: class MyClass(pydase.DataService): name = "my class" test_dict = { "int_key": 1, "1.0": 1.0, "bool_key": True, "Quantity_key": 1.0 * u.units.s, "DataService_key": MyClass(), } assert dump(test_dict) == { "full_access_path": "", "doc": None, "readonly": False, "type": "dict", "value": { "DataService_key": { "full_access_path": '["DataService_key"]', "name": "MyClass", "doc": None, "readonly": False, "type": "DataService", "value": { "name": { "full_access_path": '["DataService_key"].name', "doc": None, "readonly": False, "type": "str", "value": "my class", } }, }, "Quantity_key": { "full_access_path": '["Quantity_key"]', "doc": None, "readonly": False, "type": "Quantity", "value": {"magnitude": 1.0, "unit": "s"}, }, "bool_key": { "full_access_path": '["bool_key"]', "doc": None, "readonly": False, "type": "bool", "value": True, }, "1.0": { "full_access_path": '["1.0"]', "doc": None, "readonly": False, "type": "float", "value": 1.0, }, "int_key": { "full_access_path": '["int_key"]', "doc": None, "readonly": False, "type": "int", "value": 1, }, }, } def test_derived_data_service_serialization() -> None: class BaseService(pydase.DataService): class_attr = 1337 def __init__(self) -> None: super().__init__() self._name = "Service" @property def name(self) -> str: return self._name @name.setter def name(self, value: str) -> None: self._name = value class DerivedService(BaseService): ... base_service_serialization = dump(BaseService()) derived_service_serialization = dump(DerivedService()) # Names of the classes obviously differ base_service_serialization.pop("name") derived_service_serialization.pop("name") assert base_service_serialization == derived_service_serialization @pytest.fixture def setup_dict() -> dict[str, Any]: return ServiceClass().serialize()["value"] # type: ignore @pytest.mark.parametrize( "serialized_object, attr_name, allow_append, expected", [ ( dump(service_instance)["value"], "attr1", False, { "doc": None, "full_access_path": "attr1", "readonly": False, "type": "float", "value": 1.0, }, ), ( dump(service_instance.attr_list)["value"], "[0]", False, { "doc": None, "full_access_path": "[0]", "readonly": False, "type": "int", "value": 0, }, ), ( dump(service_instance.attr_list)["value"], "[3]", True, { # we do not know the full_access_path of this entry within the # serialized object "full_access_path": "", "value": None, "type": "None", "doc": None, "readonly": False, }, ), ( dump(service_instance.attr_list)["value"], "[3]", False, SerializationPathError, ), ( dump(service_instance.dict_attr)["value"], "['foo']", False, { "full_access_path": '["foo"]', "value": 1.0, "type": "float", "doc": None, "readonly": False, }, ), ( dump(service_instance.dict_attr)["value"], "['unset_key']", True, { # we do not know the full_access_path of this entry within the # serialized object "full_access_path": "", "value": None, "type": "None", "doc": None, "readonly": False, }, ), ( dump(service_instance.dict_attr)["value"], "['unset_key']", False, SerializationPathError, ), ( dump(service_instance)["value"], "invalid_path", True, { # we do not know the full_access_path of this entry within the # serialized object "full_access_path": "", "value": None, "type": "None", "doc": None, "readonly": False, }, ), ( dump(service_instance)["value"], "invalid_path", False, SerializationPathError, ), ], ) def test_get_container_item_by_key( serialized_object: dict[str, Any], attr_name: str, allow_append: bool, expected: Any ) -> None: if isinstance(expected, type) and issubclass(expected, Exception): with pytest.raises(expected): get_container_item_by_key( serialized_object, attr_name, allow_append=allow_append ) else: nested_dict = get_container_item_by_key( serialized_object, attr_name, allow_append=allow_append ) assert nested_dict == expected def test_update_attribute(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "attr1", 15) assert setup_dict["attr1"]["value"] == 15 def test_update_nested_attribute(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "attr2.attr3", 25.0) assert setup_dict["attr2"]["value"]["attr3"]["value"] == 25.0 def test_update_float_attribute_to_enum(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "attr2.attr3", MyEnum.RUNNING) assert setup_dict["attr2"]["value"]["attr3"] == { "full_access_path": "attr2.attr3", "name": "MyEnum", "doc": "MyEnum description", "enum": {"FINISHED": "finished", "RUNNING": "running"}, "readonly": False, "type": "Enum", "value": "RUNNING", } def test_update_enum_attribute_to_float(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "enum_attr", 1.01) assert setup_dict["enum_attr"] == { "full_access_path": "enum_attr", "doc": None, "readonly": False, "type": "float", "value": 1.01, } def test_update_task_state(setup_dict: dict[str, Any]) -> None: assert setup_dict["my_task"] == { "full_access_path": "my_task", "async": False, "doc": None, "frontend_render": False, "readonly": True, "signature": {"parameters": {}, "return_annotation": {}}, "type": "method", "value": None, } set_nested_value_by_path(setup_dict, "my_task", TaskStatus.RUNNING) assert setup_dict["my_task"] == { "full_access_path": "my_task", "async": False, "doc": None, "frontend_render": False, "readonly": True, "signature": {"parameters": {}, "return_annotation": {}}, "type": "method", "value": "RUNNING", } def test_update_list_entry(setup_dict: dict[str, SerializedObject]) -> None: set_nested_value_by_path(setup_dict, "attr_list[1]", 20) assert setup_dict["attr_list"]["value"][1]["value"] == 20 # type: ignore # noqa def test_update_list_append(setup_dict: dict[str, SerializedObject]) -> None: set_nested_value_by_path(setup_dict, "attr_list[3]", MyEnum.RUNNING) assert setup_dict["attr_list"]["value"][3] == { # type: ignore "full_access_path": "attr_list[3]", "doc": "MyEnum description", "name": "MyEnum", "enum": {"FINISHED": "finished", "RUNNING": "running"}, "readonly": False, "type": "Enum", "value": "RUNNING", } def test_update_invalid_list_index( setup_dict: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: set_nested_value_by_path(setup_dict, "attr_list[10]", 30) assert ( "Error occured trying to change 'attr_list[10]': Index '10': list index out of " "range" in caplog.text ) def test_update_list_inside_class(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "attr2.list_attr[1]", 40) assert setup_dict["attr2"]["value"]["list_attr"]["value"][1]["value"] == 40 # noqa def test_update_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None: set_nested_value_by_path(setup_dict, "attr_list[2].attr3", 50) assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50 # noqa def test_get_attribute(setup_dict: dict[str, Any]) -> None: nested_dict = get_nested_dict_by_path(setup_dict, "attr1") assert nested_dict["value"] == 1.0 def test_get_nested_attribute(setup_dict: dict[str, Any]) -> None: nested_dict = get_nested_dict_by_path(setup_dict, "attr2.attr3") assert nested_dict["value"] == 1.0 def test_get_list_entry(setup_dict: dict[str, Any]) -> None: nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[1]") assert nested_dict["value"] == 1 def test_get_list_inside_class(setup_dict: dict[str, Any]) -> None: nested_dict = get_nested_dict_by_path(setup_dict, "attr2.list_attr[1]") assert nested_dict["value"] == 1.0 def test_get_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None: nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[2].attr3") assert nested_dict["value"] == 1.0 def test_get_invalid_list_index(setup_dict: dict[str, Any]) -> None: with pytest.raises(SerializationPathError): get_nested_dict_by_path(setup_dict, "attr_list[10]") def test_get_invalid_path(setup_dict: dict[str, Any]) -> None: with pytest.raises(SerializationPathError): get_nested_dict_by_path(setup_dict, "invalid_path") def test_serialized_dict_is_nested_object() -> None: serialized_dict = { "list_attr": { "type": "list", "value": [ {"type": "float", "value": 1.4, "readonly": False, "doc": None}, {"type": "float", "value": 2.0, "readonly": False, "doc": None}, ], "readonly": False, "doc": None, }, "my_slider": { "type": "NumberSlider", "value": { "max": { "type": "float", "value": 101.0, "readonly": False, "doc": "The min property.", }, "min": { "type": "float", "value": 1.0, "readonly": False, "doc": "The min property.", }, "step_size": { "type": "float", "value": 2.0, "readonly": False, "doc": "The min property.", }, "value": { "type": "float", "value": 1.0, "readonly": False, "doc": "The value property.", }, }, "readonly": False, "doc": None, }, "string": { "type": "str", "value": "Another name", "readonly": True, "doc": None, }, "float": { "type": "int", "value": 10, "readonly": False, "doc": None, }, "unit": { "type": "Quantity", "value": {"magnitude": 12.0, "unit": "A"}, "readonly": False, "doc": None, }, "state": { "type": "ColouredEnum", "value": "FAILED", "readonly": True, "doc": None, "enum": { "RUNNING": "#0000FF80", "COMPLETED": "hsl(120, 100%, 50%)", "FAILED": "hsla(0, 100%, 50%, 0.7)", }, }, "subservice": { "type": "DataService", "value": { "name": { "type": "str", "value": "SubService", "readonly": False, "doc": None, } }, "readonly": False, "doc": None, }, } assert serialized_dict_is_nested_object(serialized_dict["list_attr"]) assert serialized_dict_is_nested_object(serialized_dict["my_slider"]) assert serialized_dict_is_nested_object(serialized_dict["subservice"]) assert not serialized_dict_is_nested_object( serialized_dict["list_attr"]["value"][0] # type: ignore[index] ) assert not serialized_dict_is_nested_object(serialized_dict["string"]) assert not serialized_dict_is_nested_object(serialized_dict["unit"]) assert not serialized_dict_is_nested_object(serialized_dict["float"]) assert not serialized_dict_is_nested_object(serialized_dict["state"]) class MyService(pydase.DataService): name = "MyService" @pytest.mark.parametrize( "test_input, expected", [ ( 1, { "new_attr": { "full_access_path": "new_attr", "type": "int", "value": 1, "readonly": False, "doc": None, } }, ), ( 1.0, { "new_attr": { "full_access_path": "new_attr", "type": "float", "value": 1.0, "readonly": False, "doc": None, }, }, ), ( True, { "new_attr": { "full_access_path": "new_attr", "type": "bool", "value": True, "readonly": False, "doc": None, }, }, ), ( u.Quantity(10, "m"), { "new_attr": { "full_access_path": "new_attr", "type": "Quantity", "value": {"magnitude": 10, "unit": "meter"}, "readonly": False, "doc": None, }, }, ), ( MyEnum.RUNNING, { "new_attr": { "full_access_path": "new_attr", "value": "RUNNING", "type": "Enum", "doc": "MyEnum description", "readonly": False, "name": "MyEnum", "enum": {"RUNNING": "running", "FINISHED": "finished"}, } }, ), ( [1.0], { "new_attr": { "full_access_path": "new_attr", "value": [ { "full_access_path": "new_attr[0]", "doc": None, "readonly": False, "type": "float", "value": 1.0, } ], "type": "list", "doc": None, "readonly": False, } }, ), ( {"key": 1.0}, { "new_attr": { "full_access_path": "new_attr", "value": { "key": { "full_access_path": 'new_attr["key"]', "doc": None, "readonly": False, "type": "float", "value": 1.0, } }, "type": "dict", "doc": None, "readonly": False, } }, ), ( MyService(), { "new_attr": { "full_access_path": "new_attr", "value": { "name": { "full_access_path": "new_attr.name", "doc": None, "readonly": False, "type": "str", "value": "MyService", } }, "type": "DataService", "doc": None, "readonly": False, "name": "MyService", } }, ), ], ) def test_dynamically_add_attributes(test_input: Any, expected: dict[str, Any]) -> None: serialized_object: dict[str, SerializedObject] = {} set_nested_value_by_path(serialized_object, "new_attr", test_input) assert serialized_object == expected @pytest.mark.parametrize( "obj, expected", [ ( service_instance.attr2, [ "attr3", "list_attr", "list_attr[0]", "list_attr[1]", "some_quantity", ], ), ( service_instance.dict_attr, [ '["foo"]', '["bar"]', '["bar"]["foo"]', ], ), ( service_instance.attr_list, [ "[0]", "[1]", "[2]", "[2].attr3", "[2].list_attr", "[2].list_attr[0]", "[2].list_attr[1]", "[2].some_quantity", ], ), ], ) def test_get_data_paths_from_serialized_object(obj: Any, expected: list[str]) -> None: assert get_data_paths_from_serialized_object(dump(obj=obj)) == expected @pytest.mark.parametrize( "obj, expected", [ ( service_instance, [ "attr1", "attr2", "attr2.attr3", "attr2.list_attr", "attr2.list_attr[0]", "attr2.list_attr[1]", "attr2.some_quantity", "attr_list", "attr_list[0]", "attr_list[1]", "attr_list[2]", "attr_list[2].attr3", "attr_list[2].list_attr", "attr_list[2].list_attr[0]", "attr_list[2].list_attr[1]", "attr_list[2].some_quantity", "dict_attr", 'dict_attr["foo"]', 'dict_attr["bar"]', 'dict_attr["bar"]["foo"]', "enum_attr", "my_task", ], ), ( service_instance.attr2, [ "attr3", "list_attr", "list_attr[0]", "list_attr[1]", "some_quantity", ], ), ], ) def test_generate_serialized_data_paths(obj: Any, expected: list[str]) -> None: assert generate_serialized_data_paths(dump(obj=obj)["value"]) == expected @pytest.mark.parametrize( "serialized_obj, prefix, expected", [ ( { "full_access_path": "new_attr", "value": { "name": { "full_access_path": "new_attr.name", "value": "MyService", } }, }, "prefix", { "full_access_path": "prefix.new_attr", "value": { "name": { "full_access_path": "prefix.new_attr.name", "value": "MyService", } }, }, ), ( { "full_access_path": "new_attr", "value": [ { "full_access_path": "new_attr[0]", "value": 1.0, } ], }, "prefix", { "full_access_path": "prefix.new_attr", "value": [ { "full_access_path": "prefix.new_attr[0]", "value": 1.0, } ], }, ), ( { "full_access_path": "new_attr", "value": { "key": { "full_access_path": 'new_attr["key"]', "value": 1.0, } }, }, "prefix", { "full_access_path": "prefix.new_attr", "value": { "key": { "full_access_path": 'prefix.new_attr["key"]', "value": 1.0, } }, }, ), ( { "full_access_path": "new_attr", "value": {"magnitude": 10, "unit": "meter"}, }, "prefix", { "full_access_path": "prefix.new_attr", "value": {"magnitude": 10, "unit": "meter"}, }, ), ( { "full_access_path": "quantity_list", "value": [ { "full_access_path": "quantity_list[0]", "value": {"magnitude": 1.0, "unit": "A"}, } ], }, "prefix", { "full_access_path": "prefix.quantity_list", "value": [ { "full_access_path": "prefix.quantity_list[0]", "value": {"magnitude": 1.0, "unit": "A"}, } ], }, ), ( { "full_access_path": "", "value": { "dict_attr": { "type": "dict", "full_access_path": "dict_attr", "value": { "foo": { "full_access_path": 'dict_attr["foo"]', "type": "dict", "value": { "some_int": { "full_access_path": 'dict_attr["foo"].some_int', "type": "int", "value": 1, }, }, }, }, } }, }, "prefix", { "full_access_path": "prefix", "value": { "dict_attr": { "type": "dict", "full_access_path": "prefix.dict_attr", "value": { "foo": { "full_access_path": 'prefix.dict_attr["foo"]', "type": "dict", "value": { "some_int": { "full_access_path": 'prefix.dict_attr["foo"].some_int', "type": "int", "value": 1, }, }, }, }, } }, }, ), ], ) def test_add_prefix_to_full_access_path( serialized_obj: SerializedObject, prefix: str, expected: SerializedObject ) -> None: assert add_prefix_to_full_access_path(serialized_obj, prefix) == expected