Advanced node writing

Adjust parameters

Sometimes you want to adjust the configuration parameters to the input data that your node receives. This is especially useful to update the choices in a list parameter.

As an example let us consider a node that takes a table as input. The node has among its parameters a list of all the columns in the input table. In this list the user can choose which column the node will operate on. To make sure that the list is always updated when the columns in the input data change, it could implement adjust_parameters something like this:

def adjust_parameters(self, node_context):
    """Update the configuration with current input table columns."""

    # The following line will raise an exception if there is no data.
    # Read ahead to see how to fix this.
    new_columns = node_context.input['input_port'].column_names()

    if parameters['chosen_column'].selected not in new_columns:
        new_columns.insert(0, parameters['chosen_column'].selected)
    parameters['chosen_column'].list = new_columns

This method will be called before executing the node and before opening the GUI. Since the user might decide to open the GUI even when there is no data ready on the input ports (e.g. when no node has been connected to the input port), we need to check that there actually is data ready on that port before using it. To test if the input data is available you can use the method is_valid() on the port. If it returns True you can safely use the input data. An improved version of the above example which takes this into account could look like this:

def adjust_parameters(self, node_context):
    """Update the configuration with current input table columns."""
    if node_context.input['input_port'].is_valid():
        new_columns = node_context.input['input_port'].column_names()
    else:
        new_columns = []

    if parameters['chosen_column'].selected not in new_columns:
        new_columns.insert(0, parameters['chosen_column'].selected)
    parameters['chosen_column'].list = new_columns

See also All parameters example for another example of adjust_parameters in action.

Note

In Sympathy 1.2 you also had to return the updated node_context from adjust_parameters_managed(). This has been changed in 1.3, but if you are writing nodes that need to be compatible with both 1.2 and 1.3 you should of course still return it. See Library compatibility between 1.2 and 1.3 for more info about writing compatible nodes.

Controllers

To make the GUI of your nodes easier to use, you can add controllers that clarify the interconnection between different parameters. Controllers can make sure that when some option is chosen some other option becomes available/unavailable. For example:

from sympathy.api import node as synode


class HelloWorldNode(synode.Node):
    """Prints a custom greeting to the node output."""

    name = 'Hello world!'
    nodeid = 'com.example.boblib.helloworld'
    author = 'Bob <bob@example.com>'
    copyright = '(C) 2014 Example Organization'
    version = '3.0'

    parameters = synode.parameters()
    parameters.set_boolean(
        'use_custom_greeting',
        value=False,
        label='Use custom greeting',
        description='While unchecked the node will always print '
                    '"Hello World!" ignoring any custom greeting.')
    parameters.set_string(
        'greeting',
        value='Hello World!',
        label='Greeting:',
        description='Choose what kind of greeting the node will print.')

    controllers = synode.controller(
        when=synode.field('use_custom_greeting', 'checked'),
        action=synode.field('greeting', 'enabled'))

    def execute(self, node_context):
        if node_context.parameters['use_custom_greeting'].value:
            greeting = node_context.parameters['greeting'].value
        else:
            greeting = "Hello World!"
        print(greeting)

By disabling elements of the GUI that are not relevant to the current configuration you can make the configuration GUI easier to understand.

Each controller can have multiple actions and multiple controllers can be added by simply wrapping them in a tuple. The code below, which contains multiple controllers, is not directly compatible with the controller example above:

controllers = (
    synode.controller(
        when=synode.field('use_regex', state='checked'),
        action=(synode.field('regex_pattern', state='enabled'),
                synode.field('wildcard_pattern', state='disabled'))),
    synode.controller(
        when=synode.field('use_magic', state='checked'),
        action=synode.field('more_magic', state='enabled')))

For another example of how to use controllers, see Controller example.

Using custom port types

Note

When writing nodes that should be compatible with both Sympathy version 1.2 and 1.3, you should refrain from using Custom(). Any other port type available via Port in 1.2 is also available in the same way in 1.3, but some custom ports (e.g. generic types and lambdas) will not work in 1.2. For more information see Library compatibility between 1.2 and 1.3.

Previously we learned how to add input and output ports to your nodes:

inputs = Ports([Port.Table('Input Table', name='foo')])
outputs = Ports([Port.Table('Table with some added bar', name='foobar')])

This is the most convenient way to add ports with the most common data types like Table, Datasource, ADAF, and so on. If you want add a generic type, lambda, or any other type which does not have its own Port method you need to use the method Custom(). As its first argument Custom() takes a textual representation of the port type. The other two arguments are the same as in the other Port methods. The textual representation of the port type can contain combinations of the following:

  • type aliases (e.g. adaf or table)
  • lists (e.g. [table], meaning a list of tables)
  • lambdas (represented as an arrow from input type to output type, e.g. table -> adaf meaning a lambda with table input and adaf output)
  • generic types (e.g. <a> meaning any type or [<a>] meaning a list of arbitrary items)

Here are some examples of valid port types:

  • [table] (a list of tables)
  • [[table]] (a list of lists of tables)
  • adaf -> [adaf] (a lambda whose input is an adaf and whose output is a list of adafs)
  • [adaf -> [adaf]] (a list of such lambdas)
  • <a> (Any type)
  • <a> -> <a> (a lambda whose output is of the same type as its input)
  • <a> -> <b> (a lambda with arbitrary input and output)
  • [<a> -> <a>] (a list of such lambdas)
  • (<a>, <a>) -> <b> (a lambda where the input is a tuple in which both elements are of the same type)

If you use generic types, all ports with the same identifier (the a in <a>) have to be of the same type. For example in the node Append List:

inputs = Ports([Port.Custom('<a>', 'Item', name='item'),
                Port.Custom('[<a>]', 'List', name='list')])
outputs = Ports([Port.Custom('[<a>]', 'List', name='list')])

The two input ports can be, for example, Table and [Table], or ADAF and [ADAF], but not Table and [ADAF]. Another example of this is in the Map node:

inputs = Ports([
    Port.Custom('<a> -> <b>', 'Lambda Function', name='Function'),
    Port.Custom('[<a>]', 'Argument List', name='List')])
outputs = Ports([
    Port.Custom('[<b>]', 'Output List', name='List')])

Where the input and output type of the lambda determine what type the other ports must have. Or, if you connect the other ports first, they determine what types the lambda’s input and output must have.

Port.Custom accepts n as an optional keyword argument to create a range of ports from the same definition:

# Exactly 3 ports.
inputs = Ports([
    Port.Custom('[<a>]', 'Argument List', name='List', n=3)])


# Minimum of 3 ports with no upper bound, though 6 ports in total is
# The current limit.
inputs = Ports([
    Port.Custom('[<a>]', 'Argument List', name='List', n=(3,))])


# Minimum of 3 ports up to a maximum of 5 ports.
inputs = Ports([
    Port.Custom('[<a>]', 'Argument List', name='List', n=(3,5))])


# Minimum of 0 ports up to a maximum of 5 ports, starting out with a
# default of 2 ports.
inputs = Ports([
    Port.Custom('[<a>]', 'Argument List', name='List', n=(0,5,2))])

As you can see, n accepts either a single integer or a tuple of up to 3 integer components: minimum, maximum, and default. When maximum and default are not supplied, they assume the same value as minimum.

When the n argument is used, a name is also required and all port names need to be unique. This is a good practice, in general.

Managing node updates

When developing a node over time it is not uncommon that the set of node parameters change slightly from one version of the node to the next.

Default value (the arguments value, value_names, list, plist) can always be updated without risk of breaking old workflows. The change simply wont affect old workflows at all.

As of Sympathy 1.2.5 newly added parameters are automatically added to old instances of nodes when they are configured, executed and so on. So simply add the new parameter to the node definition and you can expect the new parameter to always be there when you reach any node method, such as execute. As of Sympathy 1.3.0 any changes to the label or description of an existing parameter are automatically applied to nodes.

If you need more fine-grained control you can implement the node method update_parameters(self, old_params) (available as of Sympathy 1.2.5). This method can create new parameters where the default value of the new parameter depends on the value of some of the old parameters. You do this by making changes to the argument old_params. Any parameters that are still missing after this method are added automatically from the parameter definition.

Here is an example of update_parameters from Calculator List:

def update_parameters(self, old_params):
    # Old nodes without the same_length_res option work the same way as if
    # they had the option, set to False.
    if 'same_length_res' not in old_params:
        old_params['same_length_res'] = self.parameters['same_length_res']
        old_params['same_length_res']['value'] = False

Custom GUIs

For most basic nodes the configuration GUI can be created automatically. This is very convenient but is of course a bit limited. More advanced nodes can also choose to implement their own custom configuration GUIs without such limitations. All GUIs in Sympathy are created using Qt (http://www.qt-project.org).

To create a custom GUI implement the method exec_parameter_view(self, node_context) to return a custom widget which will be run when configuring the node.

Note

In versions before 1.2 you also had to implement the method has_parameter_view which had to return True for the custom GUI to be created.

This is probably a good place for an example. Let us continue with the Hello World example and add a custom GUI in which the user can not only set the greeting, but also click a button to test it:

from sympathy.api import node as synode
from sympathy.api import ParameterView
from sympathy.api import qt

QtGui= qt.import_module('QtGui')


class MyWidget(ParameterView):
    def __init__(self, parameters, parent=None):
        super(MyWidget, self).__init__(parent=parent)
        self._parameters = parameters

        greeting_edit = self._parameters['greeting'].gui()

        button = QtGui.QPushButton('Test greeting')
        button.clicked.connect(self.test_greeting)

        layout = QtGui.QHBoxLayout()
        layout.addWidget(greeting_edit)
        layout.addWidget(button)
        self.setLayout(layout)

    def test_greeting(self):
        QtGui.QMessageBox.information(
            self, 'A greeting...', self._parameters['greeting'].value,
            QtGui.QMessageBox.Ok)


class HelloWorldNode(synode.Node):
    """Prints a custom greeting to the node output."""

    name = 'Hello World!'
    nodeid = 'com.example.boblib.helloworld'
    author = 'Bob <bob@example.com>'
    copyright = '(C) 2014 Example Organization'
    version = '4.0'

    parameters = synode.parameters()
    parameters.set_string(
        'greeting',
        value='Hello World!',
        label='Greeting:',
        description='Choose what kind of greeting the node will print.')


    def exec_parameter_view(self, node_context):
        return MyWidget(node_context.parameters)

    def execute(self, node_context):
        greeting = node_context.parameters['greeting'].value
        print(greeting)

The editors/widgets created in the parameter definition can also be used in a custom GUI, but one has to add them to the layout one by one, as it is done with regular Qt widgets. The benefit of using widgets defined in the parameter definition, is that the signals emitted from the widgets are taken care of, and the parameters are updated automatically when the user makes changes in the GUI.

If one has created a list of parameters with the name 'combo_example', the command to access its editor widget would look like:

example_combo = self._parameters['combo_example'].gui()

Just as in the section Adjust parameters, if the GUI attempts to use the input ports, it should first check that there is actually data on the port by calling some_port.is_valid().

If the widget keeps an internal model of the parameters it should define a method called save_parameters which updates node_context.parameters.