Skip to content

Nodes

Nodes are the most fundamental building blocks of FuncNodes. Each node encapsulates a function with defined inputs and outputs. Nodes execute when all required inputs are available, producing output data for downstream nodes. Nodes can be created with two different methods: class based and decorator based. While class based nodes are more flexible and can be used to create complex nodes, decorator based nodes are simpler and faster to create.

Class Based Nodes

Class based nodes are created by subclassing the Node class from the funcnodes package. This method is more flexible and allows for more complex nodes to be created. The Node class provides a number of methods and properties that can be overridden to customize the behavior of the node.

The basic layout of a class based node is as follows:

import funcnodes as fn

class MyNode(fn.Node):
    node_name = "My Node"
    node_id = "my_node"

    async def func(self):
        """The function to be executed when the node is triggered."""

The node_name and node_id required properties define the name and ID of the node, respectively. It is important that the 'node_id' is unique across all nodes in the system since it is used for serialization and deserialization of the node. So it is recommended to make it as descriptive as possible, e.g. if the node CalculateOrbit is part of a public package named 'funcnodes_astronomy' and the node the node_id could be 'funcnodes_astronomy.calculate_orbit'. And while this is not enforced it is recommended to use a similar naming scheme for the ids, to prevent id clashes. The node name is the human readable name of the node and is used in the UI.

The async func method is the entry point for the node's execution. This method is called when the node is triggered and should contain the logic for the node's function.In the class based nodes the func method is the only method that is required to be implemented. The func method has to be an async method since the execution of the node is done asynchronously.

The node above has no inputs or outputs, which makes it relatively useless. inputs and outputs can be added on the class level as well:

import funcnodes as fn

class MyNode(fn.Node):
    node_name = "My Node"
    node_id = "my_node"

    input1 = fn.NodeInput(id="input1", type=int)
    input2 = fn.NodeInput(id="input2", type=int)

    output1 = fn.NodeOutput(id="output1", type=int)


    async def func(self, input1, input2):

        result =  input1 + input2
        self.outputs["output1"].value = result

In the example above, the node has two inputs, input1 and input2, and one output, output. The func method now takes two arguments, input1 and input2, which are the values of the inputs. The func method then adds the two inputs together and sets the result as the value of the output. While the class attributes of the inputs and outputs can be arbitrary named, it is recommended to use the same name as the id of the input or output (IO), to make the code more readable. setting the type of the IO is optional, but it is recommended since this will be used to render the corresponding IO in the UI (defaults to Any).

Warning

The typing of the IO is not enforced, to stay as pythonic as possible. If the value is not of the expected type, the node will still trigger and raise an exception if it occurs.

This is important to keep the system flexible: e.g. numpy arrays can be passed to inputs that expect a list and it should still work.

If enforcing is required, it should be done in the func method.

During triggering all inputs are passed to the func method as keyword arguments, so the order of the inputs does not matter, but the ids should be valid python variable names. In the class based approach outputs have to be set explicitly, by setting the value of the output in the func method. For more details on the IO see the Inputs and Outputs.

Decorator Based Nodes

A even simpler way to create nodes is by using the @fn.NodeDecorator decorator. This decorator can be used to create nodes from a simple function. The function should take the inputs as arguments and return the outputs as a dictionary. The decorator will automatically create the node and set the inputs and outputs based on the function signature.

import funcnodes as fn

@fn.NodeDecorator(node_id="my_node")
def my_node(input1: int, input2: int) -> float:
    return input1 / input2

This will create a node with the id my_node, which has two inputs, input1 and input2 (of type int), and one output, output1 (of type float).

The @fn.NodeDecorator decorator has the required argument node_id, which is the id of the node, similar to the node_id property in the class based nodes. The inputs are automatically created based on the function signature, as such the function should have only defined positional and keyword arguments and no expanding arguments like *args or **kwargs. Similar to the class based nodes, the type of the inputs is optional, but recommended.

The Decorator can also be used to create a Node from an arbitrary external function, by passing the function as an argument to the decorator. The corresponding inputs and outputs will be created based on the signature of the function and the type hints.

import funcnodes as fn

def myfunction(a: int=1, b: int=2) -> int:
    return a + b

MyFuncNode = fn.NodeDecorator(
    node_id="my_node",
)(myfunction)

The outputs are defined by the return type of the function, the output type is also interpreted from the return type, if present. The default id if the output is out and the default type is Any.

How the Node input and Output can be further customized with decorators is described in the Inputs and Outputs section.

Defining multiple outputs

Will the class based approach allows for multiple outputs simply by defining multiple outputs, the decorator requires a little modification.

To have multiple outputs, the function should return multiple values, which would make the return type a tuple.

import funcnodes as fn

@fn.NodeDecorator(node_id="my_node")
def my_node(input1: int, input2: int) -> tuple:
    result1 =  input1 + input2
    result2 =  input1 - input2
    return result1, result2

But this will result in a single output out of the type tuple. To actually have multiple outputs the return type has to be a typed tuple, to be able to interfere the number of outputs:

from typing import Tuple
import funcnodes as fn

@fn.NodeDecorator(
    node_id="my_node",
)
def my_node(input1: int, input2: int) -> Tuple[int, int]:
    result1 =  input1 + input2
    result2 =  input1 - input2
    return result1, result2

By default the outputs are numbered, to give them a more descriptive name, the outputs can be customized with the outputs argument of the decorator:

from typing import Tuple
import funcnodes as fn

@fn.NodeDecorator(
    node_id="my_node",
    outputs=[
        {"name": "output1"},
        {"name": "output2"},
    ]
)
def my_node(input1: int, input2: int) -> Tuple[int, int]:
    result1 =  input1 + input2
    result2 =  input1 - input2
    return result1, result2

The outputs argument of the decorator is a list of dictionaries, where each dictionary represents an output. The dictionary should have the key name which is the id of the output. To specify the type, the type argument can be used. Alternatively, the type can be specified in the return type of the function as in the example above.

Further info in IO in decorator

In a similar manner the inputs can be customized with the inputs argument.

from typing import Tuple
import funcnodes as fn

@fn.NodeDecorator(
    node_id="my_node",
    inputs=[
        {"name": "a"},
        {"name": "b"},
    ],
)
def myfunction(var_name_i_dont_like_a: int=1, var_name_i_dont_like_b: int=2) -> int:
    return var_name_i_dont_like_a + var_name_i_dont_like_b

Defining the inputs and outputs in the decorator is especially useful when the function is an external function and the signature cannot be changed.

In the following example, the function divmod is an external function and the signature cannot be changed.

from typing import Tuple
import funcnodes as fn

MyFuncNode = fn.NodeDecorator(
    node_id="divmod",
)(divmod)

As you can see the function has the expected inputs, but it is not typed. As such the inputs are of type Any, which allows no manual input and the return type is not defined, meaning the function has no output.

To fix this, the inputs and outputs can be defined in the decorator.

from typing import Tuple
import funcnodes as fn


MyFuncNode = fn.NodeDecorator(
    node_id="divmod",
    inputs=[
        {"name": "a"},
        {"name": "b"},
    ],
    outputs=[
        {"name": "quotient", "type": int},
        {"name": "remainder", "type": int},
    ]
)(divmod)

While under normal circumstances this works as expected, it is recommended to use the fn.NodeDecorator as a decorator, and create a wrapper function that calls the external function, to make the node more readable and to allow for more customization.

from typing import Tuple
import funcnodes as fn


@fn.NodeDecorator(
    node_id="divmod",
    outputs=[
        {"name": "quotient"},
        {"name": "remainder"},
    ]
)
def divmod_node(a: int=11, b: int=5) -> Tuple[int, int]:
    return divmod(a, b)

Furthermore by wrapping it in a function, it can be make sure, that the function accepts all arguments as keyword arguments. Since internally Funcnodes calls the function with all-keyword arguments, which is some functions don't accept:

from typing import Tuple
import funcnodes as fn

MyFuncNode = fn.NodeDecorator(
    node_id="divmod",
    inputs=[
        {"name": "a", "default":11}, # setting default to show the effect
        {"name": "b", "default":5},
    ],
    outputs=[
        {"name": "quotient", "type": int},
        {"name": "remainder", "type": int},
    ]
)(divmod) # this will not work since divmod does not accept keyword arguments

Defining the node name

The node name is especially important for the UI, as it is the human readable name of the node. If not present, the node name will be the name of the function or the class. To set the node name, the node_name class attribute or the name argument of the decorator can be used.

import funcnodes as fn

@fn.NodeDecorator(node_id="my_node1", name="My Node Decorator")
def my_node(input1: int, input2: int) -> float:
    return input1 / input2

class MyNode(fn.Node):
    node_name = "My Node Class"
    node_id = "my_node2"

    async def func(self):
        pass

Defining the node description

In a similar manner the node description can be set with the description argument of the decorator or the description class attribute of the class based nodes.

Description is a human readable description of the node, which can be used to provide more information about the node to the user.

Additionaly if no description is provided, the docstring of the function or the class will be used as the description (if present).

import funcnodes as fn

@fn.NodeDecorator(node_id="my_node1", description="This is a node created with the decorator")
def my_node(ip:int) -> float:
    return ip/2

@fn.NodeDecorator(node_id="my_node2")
def my_node(ip:int) -> float:
    """This is a node created with the decorator and a docstring"""
    return ip/2

class MyNode(fn.Node):
    node_name = "My Node Class"
    node_id = "my_node3"
    description = """
This is a node created with the class

Multi line is supported
    """

    ip = fn.NodeInput(id="ip", type=int)

    async def func(self, ip):
        self.outputs["output1"].value = ip / 2

(Hover over the node header in the UI to see the description)

Future Plans

We plan to render the description as via Markdown/Sphinx in the UI, so it is recommended to use Markdown in the description.

Node progress bar

Especially for long running nodes, it is recommended to provide a progress bar to the user. For this purpose the node has a custom property progress which wraps the tqdm progress bar and automatically streams the progress to the UI.

import asyncio
import funcnodes as fn

class MyNode(fn.Node):
    node_name = "My Node Class"
    node_id = "my_node3"
    description = "This is a node created with the class"

    ip = fn.NodeInput(id="ip", type=int,default=30)

    async def func(self, ip):
        for i in self.progress(range(ip)):
            await asyncio.sleep(10)

(All nodes on this page here run in parallel processes in pyodide, each with all the individual management overhead, which is why the progress bar is not 100% iterating with the sleep time. A normal use-case would be only little processes with multiple nodes per process)

To access the progress bar in a decorator based node, we need to access the underlying node object. For this purpose an input argument node can be added, which will not be considered as normal input, but as a reference to the node object.

import asyncio
import funcnodes as fn

@fn.NodeDecorator(node_id="my_node")
async def my_node(ip:int=30, node: fn.Node=None) -> float:
    for i in node.progress(range(ip)):
        await asyncio.sleep(10)

    return ip/2

Heavy Tasks

Since Funcnodes uses the asyncio library, a blocking function will block the event loop and prevent other nodes from executing. To prevent this, heavy tasks should be executed in a separate thread or process. This can be done e.g. by using the asyncio.to_thread function, which will run the function in a separate thread and return the result.

import asyncio
import time
import funcnodes as fn

@fn.NodeDecorator(node_id="my_node")
async def my_node(input1: int, input2: int) -> int:
    def heavy_task(input1, input2):
        time.sleep(1)
        return input1 + input2

    return await asyncio.to_thread(heavy_task, input1, input2)

Pyodide Runtime

Funcnodes is also able to run in pyodide ("Pyodide makes it possible to install and run Python packages in the browser"). We use this also in all the Nodes you see here running live. But pyodide does not yet support multithreading or multiprocessing.

This works for both class based and decorator based nodes. Alternatively, the NodeDecorator accepts a separate_thread=True argument, which will automatically run the function in a separate thread. (The decorator alternativly accepts a separate_process=True argument, which will run the function in a separate process, but this is still experimental and should only considered for heavy CPU bound tasks)

Nested Inheritance

While the class based approach allows for more complex inheritance patterns:

import funcnodes as fn

class BaseNode(fn.Node):
    """
    `Abstract` base class does not need a `func` method or a `node_id`
    """

    my_id = fn.NodeOutput(id="my_id", type=int)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.outputs["my_id"].value = id(self)


class MyNode(BaseNode):
    node_name = "My Node"
    node_id = "my_node"

    input1 = fn.NodeInput(id="input1", type=int)
    input2 = fn.NodeInput(id="input2", type=int)

    output1 = fn.NodeOutput(id="output1", type=int)

    async def func(self, input1, input2):
        result =  input1 + input2
        self.outputs["output1"].value = result

class MyNodeTwo(BaseNode):
    node_name = "My Node Two"
    node_id = "my_node_two"

    input1 = fn.NodeInput(id="input1", type=int)
    output1 = fn.NodeOutput(id="output1", type=float)

    async def func(self, input1):
        self.outputs["output1"].value = input1/2

The decorator also allows to use different baseclasses than the default Node class, by using the superclass argument of the decorator.

import funcnodes as fn

class BaseNode(fn.Node):
    """
    `Abstract` base class does not need a `func` method or a `node_id`
    """

    my_id = fn.NodeOutput(id="my_id", type=int)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.outputs["my_id"].value = id(self)


@fn.NodeDecorator(node_id="my_node", superclass=BaseNode)
def my_node(input1: int, input2: int) -> int:
    return input1 + input2

instance = my_node()
instance.outputs["my_id"].value == id(instance) # True

Try it out yourself