Skip to content

Commit

Permalink
Process functions: Add serialization for Python base type defaults
Browse files Browse the repository at this point in the history
As of 0680209, process functions will
automatically add the `to_aiida_type` serializer for arguments such that
Python base types are automatically converted to the corresponding AiiDA
data type if not already a `Data` instance.

The same was not added for defaults however, so if a user defined a
calcfunction with a Python base type default, an exception would be
raised once the dynamic `FunctionProcess` would be constructed. The
default would be passed to the `InputPort` constructor as is and so
would not validate against the type check.

Here we update the dynamic process spec generation to detect if a
default is provided for an argument and if so serialize it to the node
equivalent. Note that this is done indirectly through a lambda as using
constructed node instances as defaults in process specs can cause issues
for example when the spec is exposed into another process spec, the
ports are deep copied, including the defaults of the ports and deep
copying of data nodes is not supported.
  • Loading branch information
sphuber committed Nov 9, 2022
1 parent ed620c0 commit 025d91a
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 3 deletions.
19 changes: 17 additions & 2 deletions aiida/engine/processes/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,14 @@ def build(func: Callable[..., Any], node_class: Type['ProcessNode']) -> Type['Fu

def _define(cls, spec): # pylint: disable=unused-argument
"""Define the spec dynamically"""
from plumpy.ports import UNSPECIFIED

super().define(spec)

for i, arg in enumerate(args):
default = ()

default = UNSPECIFIED

if defaults and i >= first_default_pos:
default = defaults[i - first_default_pos]

Expand All @@ -253,7 +257,18 @@ def _define(cls, spec): # pylint: disable=unused-argument
else:
valid_type = (Data,)

spec.input(arg, valid_type=valid_type, default=default, serializer=to_aiida_type)
# If a default is defined and it is not a ``Data`` instance it should be serialized, but this should
# be done lazily using a lambda, just as any port defaults should not define node instances directly
# as is also checked by the ``spec.input`` call.
if (
default is not None and default != UNSPECIFIED and not isinstance(default, Data) and
not callable(default)
):
indirect_default = lambda value=default: to_aiida_type(value)
else:
indirect_default = default # type: ignore[assignment]

spec.input(arg, valid_type=valid_type, default=indirect_default, serializer=to_aiida_type)

# Set defaults for label and description based on function name and docstring, if not explicitly defined
port_label = spec.inputs['metadata']['label']
Expand Down
12 changes: 12 additions & 0 deletions docs/source/howto/run_workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ Just calling the ``add_and_multiply`` function with regular integers will result
result = add_and_multiply(2, 3, 5)
and AiiDA will recognize that the arguments are of type ``int`` and automatically wrap them in an ``Int`` node.
The same goes for argument defaults; if the argument accepts a Python base type it can specify a default value for it.
This will be automatically converted to the corresponding AiiDA data type when the function is called:

.. code-block:: python
@calcfunction
def add(a, b: int = 10):
return a + b
add(10)
The result will be an ``Int`` node with the value ``20``.


.. note::
Expand Down
46 changes: 45 additions & 1 deletion tests/engine/test_process_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ class DummyEnum(enum.Enum):
))
# yapf: enable
def test_input_serialization(argument, node_cls):
"""Test that Python base type inputs are automatically serialized to AiiDA node counterpart."""
"""Test that Python base type inputs are automatically serialized to the AiiDA node counterpart."""
result = function_args(argument)
assert isinstance(result, node_cls)

Expand All @@ -520,6 +520,50 @@ def test_input_serialization(argument, node_cls):
assert result.value == argument


# yapf: disable
@pytest.mark.parametrize('default, node_cls', (
(True, orm.Bool),
({'a': 1}, orm.Dict),
(1.0, orm.Float),
(1, orm.Int),
('string', orm.Str),
([1], orm.List),
(DummyEnum.VALUE, orm.EnumData),
))
# yapf: enable
def test_default_serialization(default, node_cls):
"""Test that Python base type defaults are automatically serialized to the AiiDA node counterpart."""

@workfunction
def function_with_default(data_a=default):
return data_a

result = function_with_default()
assert isinstance(result, node_cls)

if isinstance(result, orm.EnumData):
assert result.get_member() == default
elif isinstance(result, orm.List):
assert result.get_list() == default
elif isinstance(result, orm.Dict):
assert result.get_dict() == default
else:
assert result.value == default


def test_multiple_default_serialization():
"""Test that Python base type defaults are automatically serialized to the AiiDA node counterpart."""

@workfunction
def function_with_multiple_defaults(integer: int = 10, string: str = 'default', boolean: bool = False):
return {'integer': integer, 'string': string, 'boolean': boolean}

results = function_with_multiple_defaults()
assert results['integer'].value == 10
assert results['string'].value == 'default'
assert results['boolean'].value is False


def test_input_serialization_none_default():
"""Test that calling a function with explicit ``None`` for an argument that defines ``None`` as default works."""
assert function_with_none_default(int_a=1, int_b=2, int_c=None).value == 3

0 comments on commit 025d91a

Please sign in to comment.