Skip to content
Merged

Test #33

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## v1.0.0 (2025-09-16)

### Feat

- **pyobjects**: ✨ add nodes for Python object attribute inspection and mutation

## v0.2.4 (2025-06-05)

### Fix
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",]
requires-python = ">=3.11"
dependencies = [
"funcnodes-core>=0.4.1",
"funcnodes-core>=1.0.5",
"funcnodes",
]
authors = [{name = "Julian Kimmig", email = "julian.kimmig@linkdlab.de"}]
Expand Down
2 changes: 2 additions & 0 deletions src/funcnodes_basic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .dicts import NODE_SHELF as dicts_shelf
from .input import NODE_SHELF as input_shelf
from .dataclass import NODE_SHELF as dataclass_shelf
from .pyobjects import NODE_SHELF as pyobjects_shelf

__version__ = "0.2.3"

Expand All @@ -15,6 +16,7 @@
input_shelf,
lists_shelf,
dicts_shelf,
pyobjects_shelf,
dataclass_shelf,
strings_shelf,
math_shelf,
Expand Down
183 changes: 183 additions & 0 deletions src/funcnodes_basic/pyobjects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Utilities that expose common Python object interactions as nodes."""

from __future__ import annotations

from typing import Any, List, Annotated, Dict

import funcnodes_core as fn
from funcnodes_core.io import InputMeta, OutputMeta


def _list_public_attributes(obj: Any) -> List[str]:
"""Return a sorted list of the object's non-private attribute names."""
if obj is None:
return []
try:
attributes = dir(obj)
except Exception: # pragma: no cover - dir() rarely raises but stay defensive
return []
# Sorting keeps the dropdown stable for UI components that rely on deterministic
# value ordering across reruns.
return sorted(attr for attr in attributes if not attr.startswith("_"))


def _ensure_non_private(attribute: str) -> None:
"""Reject attribute names that appear private."""

if attribute.startswith("_"):
raise AttributeError(
f"Access to private attribute '{attribute}' is not permitted by this node."
)


@fn.NodeDecorator(
id="pyobject_get_attribute",
name="Get Attribute",
description="Retrieve the value of a non-private attribute from a Python object.",
# default_io_options=_attribute_io_options(),
)
def get_attribute(
obj: Annotated[
Any,
InputMeta(
description="Python object that exposes the desired attribute.",
on={
"after_set_value": fn.decorator.update_other_io_options(
"attribute",
_list_public_attributes,
)
}
),
],
attribute: Annotated[
str,
InputMeta(
description="Name of the attribute to retrieve; private attributes are rejected.",
),
],
) -> Annotated[
Any,
OutputMeta(
description="Value read from the requested attribute.",
),
]:
"""Return the value of the selected non-private attribute for the provided object."""
_ensure_non_private(attribute)
if not hasattr(obj, attribute):
raise AttributeError(
f"Attribute '{attribute}' is not available on object of type {type(obj).__name__}."
)
# getattr performs the actual attribute retrieval once validation is complete.
return getattr(obj, attribute)


@fn.NodeDecorator(
id="pyobject_has_attribute",
name="Has Attribute",
description="Check whether an object exposes a given attribute.",
)
def has_attribute(
obj: Annotated[
Any,
InputMeta(
description="Python object that may expose the attribute.",
),
],
attribute: Annotated[
str,
InputMeta(
description="Attribute name to probe; private attributes are rejected.",
),
],
) -> Annotated[
bool,
OutputMeta(description="True if the attribute exists on the object."),
]:
"""Return True when the object defines the requested attribute."""

return hasattr(obj, attribute)


@fn.NodeDecorator(
id="pyobject_set_attribute",
name="Set Attribute",
description="Assign a new value to a non-private attribute on a Python object.",
)
def set_attribute(
obj: Annotated[
Any,
InputMeta(
description="Python object whose attribute should be updated.",
),
],
attribute: Annotated[
str,
InputMeta(
description="Attribute name that will receive the new value.",
),
],
value: Annotated[
Any,
InputMeta(description="Value to assign to the attribute."),
],
) -> Annotated[
Any,
OutputMeta(description="The original object after assignment."),
]:
"""Set an attribute on the provided object and return the object for chaining."""

_ensure_non_private(attribute)
setattr(obj, attribute, value)
return obj


@fn.NodeDecorator(
id="pyobject_delete_attribute",
name="Delete Attribute",
description="Remove a non-private attribute from a Python object.",
)
def delete_attribute(
obj: Annotated[
Any,
InputMeta(
description="Python object whose attribute should be removed.",
on={
"after_set_value": fn.decorator.update_other_io_options(
"attribute",
_list_public_attributes,
)
}
),
],
attribute: Annotated[
str,
InputMeta(
description="Attribute name to remove from the object.",
),
],
) -> Annotated[
Any,
OutputMeta(description="The original object after deletion."),
]:
"""Delete an attribute from the object and return the mutated object."""

_ensure_non_private(attribute)
if not hasattr(obj, attribute):
raise AttributeError(
f"Attribute '{attribute}' is not available on object of type {type(obj).__name__}."
)
delattr(obj, attribute)
return obj


NODE_SHELF = fn.Shelf(
nodes=[
get_attribute,
has_attribute,
set_attribute,
delete_attribute,
],
name="Python Objects",
description="Access and transform general Python objects.",
subshelves=[],
)
124 changes: 124 additions & 0 deletions tests/test_pyobjects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import funcnodes_core as fn
import pytest
import pytest_funcnodes

from funcnodes_basic import pyobjects


class Sample:
class_attr = "class-level"

def __init__(self) -> None:
self.public = "value"
self._private = "hidden"

@property
def public_property(self) -> str:
return "prop"

def public_method(self) -> str:
return "method"


@pytest_funcnodes.nodetest(pyobjects.get_attribute)
async def test_get_attribute_node_exposes_public_attributes():
node = pyobjects.get_attribute()
sample = Sample()

# Setting the object input triggers the attribute dropdown update.
node.inputs["obj"].value = sample

options = node.inputs["attribute"].value_options["options"]
assert options == [
"class_attr",
"public",
"public_method",
"public_property",
]

node.inputs["attribute"].value = "public"
await node
assert node.outputs["out"].value == "value"

node.inputs["attribute"].value = "public_property"
await node
assert node.outputs["out"].value == "prop"

node.inputs["attribute"].value = "public_method"
await node
method = node.outputs["out"].value
assert callable(method)
assert method() == "method"

with pytest.raises(fn.NodeTriggerError):
node.inputs["attribute"].value = "_private"
await node

with pytest.raises(fn.NodeTriggerError):
node.inputs["attribute"].value = "missing"
await node


@pytest_funcnodes.nodetest(pyobjects.has_attribute)
async def test_has_attribute_node_checks_presence():
node = pyobjects.has_attribute()
sample = Sample()

node.inputs["obj"].value = sample

node.inputs["attribute"].value = "public_property"
await node
assert node.outputs["out"].value is True

node.inputs["attribute"].value = "missing"
await node
assert node.outputs["out"].value is False

node.inputs["attribute"].value = "_private"
await node
assert node.outputs["out"].value is True


@pytest_funcnodes.nodetest(pyobjects.set_attribute)
async def test_set_attribute_node_updates_value():
node = pyobjects.set_attribute()
sample = Sample()

node.inputs["obj"].value = sample
node.inputs["attribute"].value = "public"
node.inputs["value"].value = "updated"
await node

assert sample.public == "updated"
assert node.outputs["out"].value is sample

node.inputs["attribute"].value = "new_attr"
node.inputs["value"].value = 42
await node
assert getattr(sample, "new_attr") == 42

with pytest.raises(fn.NodeTriggerError):
node.inputs["attribute"].value = "_private"
node.inputs["value"].value = "secret"
await node


@pytest_funcnodes.nodetest(pyobjects.delete_attribute)
async def test_delete_attribute_node_removes_value():
node = pyobjects.delete_attribute()
sample = Sample()

node.inputs["obj"].value = sample
node.inputs["attribute"].value = "public"
await node

assert not hasattr(sample, "public")
assert node.outputs["out"].value is sample

with pytest.raises(fn.NodeTriggerError):
node.inputs["attribute"].value = "_private"
await node

with pytest.raises(fn.NodeTriggerError):
node.inputs["attribute"].value = "missing"
await node
Loading
Loading