diff --git a/pyproject.toml b/pyproject.toml index ac30118..200df8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "funcnodes-basic" -version = "0.2.2" +version = "0.2.3" description = "Basic functionalities for funcnodes" readme = "README.md" classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",] diff --git a/src/funcnodes_basic/__init__.py b/src/funcnodes_basic/__init__.py index 3904961..65e27d0 100644 --- a/src/funcnodes_basic/__init__.py +++ b/src/funcnodes_basic/__init__.py @@ -5,9 +5,9 @@ from .strings import NODE_SHELF as strings_shelf from .dicts import NODE_SHELF as dicts_shelf from .input import NODE_SHELF as input_shelf +from .dataclass import NODE_SHELF as dataclass_shelf - -__version__ = "0.2.2" +__version__ = "0.2.3" NODE_SHELF = Shelf( nodes=[], @@ -15,6 +15,7 @@ input_shelf, lists_shelf, dicts_shelf, + dataclass_shelf, strings_shelf, math_shelf, logic_shelf, diff --git a/src/funcnodes_basic/dataclass.py b/src/funcnodes_basic/dataclass.py new file mode 100644 index 0000000..b16e56f --- /dev/null +++ b/src/funcnodes_basic/dataclass.py @@ -0,0 +1,64 @@ +import dataclasses +import funcnodes_core as fn +from typing import Any, Dict + + +@fn.NodeDecorator( + id="dataclass.to_dict", +) +def dataclass_to_dict(instance: Any) -> Dict[str, Any]: + """ + Convert a dataclass instance to a dictionary. + + Args: + instance (object): The dataclass instance to convert. + + Returns: + dict: The dictionary representation of the dataclass instance. + """ + if not dataclasses.is_dataclass(instance): + raise TypeError(f"Expected a dataclass instance, got {type(instance)}") + + return dataclasses.asdict(instance) + + +@fn.NodeDecorator( + id="dataclass.get_field", + default_io_options={ + "instance": { + "on": { + "after_set_value": fn.decorator.update_other_io_value_options( + "field_name", + lambda result: { + "options": [field.name for field in dataclasses.fields(result)] + if dataclasses.is_dataclass(result) + else None, + }, + ) + } + } + }, +) +def dataclass_get_field(instance: Any, field_name: str) -> Any: + """ + Get a field value from a dataclass instance. + """ + if not dataclasses.is_dataclass(instance): + raise TypeError(f"Expected a dataclass instance, got {type(instance)}") + + if not hasattr(instance, field_name): + raise AttributeError( + f"{instance.__class__.__name__} has no field '{field_name}'" + ) + + return getattr(instance, field_name) + + +NODE_SHELF = fn.Shelf( + nodes=[ + dataclass_to_dict, + dataclass_get_field, + ], + name="dataclass", + description="Nodes for working with dataclasses", +) diff --git a/tests/test_dataclass.py b/tests/test_dataclass.py new file mode 100644 index 0000000..03211f3 --- /dev/null +++ b/tests/test_dataclass.py @@ -0,0 +1,141 @@ +from funcnodes_basic import dataclass as dc_nodes +import pytest_funcnodes +import pytest +import funcnodes_core as fn +from dataclasses import dataclass + + +@dataclass +class SimpleDataClass: + name: str + value: int + is_active: bool = True + + +@dataclass +class NestedDataClass: + id: int + data: SimpleDataClass + + +@pytest_funcnodes.nodetest(dc_nodes.dataclass_to_dict) +async def test_dataclass_to_dict(): + node = dc_nodes.dataclass_to_dict() + + # Test with a simple dataclass + instance1 = SimpleDataClass(name="Test1", value=100) + node.inputs["instance"].value = instance1 + await node + assert node.outputs["out"].value == { + "name": "Test1", + "value": 100, + "is_active": True, + } + + # Test with a nested dataclass + instance2 = NestedDataClass( + id=1, data=SimpleDataClass(name="Nested", value=200, is_active=False) + ) + node.inputs["instance"].value = instance2 + await node + assert node.outputs["out"].value == { + "id": 1, + "data": {"name": "Nested", "value": 200, "is_active": False}, + } + + # Test with non-dataclass input + node.inputs["instance"].value = {"not": "a dataclass"} + with pytest.raises(fn.NodeTriggerError) as excinfo: + await node + assert "Expected a dataclass instance" in str(excinfo.value) + + node.inputs["instance"].value = 123 + with pytest.raises(fn.NodeTriggerError) as excinfo: + await node + assert "Expected a dataclass instance" in str(excinfo.value) + + +@pytest_funcnodes.nodetest(dc_nodes.dataclass_get_field) +async def test_dataclass_get_field(): + node = dc_nodes.dataclass_get_field() + + instance_simple = SimpleDataClass(name="TestSimple", value=123) + instance_nested = NestedDataClass( + id=1, data=SimpleDataClass(name="Nested", value=200, is_active=False) + ) + + # Test setting instance updates field_name options + node.inputs["instance"].value = instance_simple + await node # Initial trigger to process instance and update options + assert node.inputs["field_name"].value_options["options"] == [ + "name", + "value", + "is_active", + ] + + # Test getting a valid field 'name' + node.inputs["field_name"].value = "name" + await node + assert node.outputs["out"].value == "TestSimple" + + # Test getting a valid field 'value' + node.inputs["field_name"].value = "value" + await node + assert node.outputs["out"].value == 123 + + # Test getting a valid field 'is_active' + node.inputs["field_name"].value = "is_active" + await node + assert node.outputs["out"].value is True + + # Test with nested dataclass, first update options + node.inputs["instance"].value = instance_nested + + assert node.inputs["field_name"].value_options["options"] == ["id", "data"] + + # Test getting 'id' from nested + node.inputs["field_name"].value = "id" + await node + assert node.outputs["out"].value == 1 + + # Test getting 'data' (which is another dataclass) + node.inputs["field_name"].value = "data" + await node + assert node.outputs["out"].value == SimpleDataClass( + name="Nested", value=200, is_active=False + ) + + # Test getting non-existent field + node.inputs["instance"].value = instance_simple + node.inputs["field_name"].value = "non_existent_field" + with pytest.raises(fn.NodeTriggerError) as excinfo: + await node + assert "has no field 'non_existent_field'" in str(excinfo.value) + + # Test with non-dataclass input + node.inputs["instance"].value = {"not": "a dataclass"} + node.inputs["field_name"].value = "some_field" # field_name options will be None + assert node.inputs["field_name"].value_options["options"] is None + + with pytest.raises(fn.NodeTriggerError) as excinfo: + await node # trigger the func with invalid instance + assert "Expected a dataclass instance" in str(excinfo.value) + + # Test dynamic update of options when instance changes + node.inputs["instance"].value = instance_nested + assert node.inputs["field_name"].value_options["options"] == ["id", "data"] + node.inputs["field_name"].value = "id" + await node + assert node.outputs["out"].value == 1 + + node.inputs["instance"].value = instance_simple + assert node.inputs["field_name"].value_options["options"] == [ + "name", + "value", + "is_active", + ] + node.inputs[ + "field_name" + ].value = "name" # previous 'id' is no longer valid for options, but value remains + await node + assert node.outputs["out"].value == "TestSimple" diff --git a/uv.lock b/uv.lock index 8703899..30c447d 100644 --- a/uv.lock +++ b/uv.lock @@ -380,7 +380,7 @@ wheels = [ [[package]] name = "funcnodes-basic" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "funcnodes" },