Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ExitCode: make the exit message parameterizable through templates #3824

Merged
Merged
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
70 changes: 45 additions & 25 deletions aiida/engine/processes/exit_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,69 @@
# For further information please visit http://www.aiida.net #
###########################################################################
"""A namedtuple and namespace for ExitCodes that can be used to exit from Processes."""

from collections import namedtuple

from aiida.common.extendeddicts import AttributeDict

__all__ = ('ExitCode', 'ExitCodesNamespace')

ExitCode = namedtuple('ExitCode', ['status', 'message', 'invalidates_cache'])
Copy link
Member

@greschd greschd Mar 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a change in interface, because namedtuple exposes all the methods of the underlying tuple. For example, one could do

status, message, invalidates_cache = ExitCode()

I'm not sure if any of these "accidental features" is used anywhere, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Do you know of a way to make a simple class replicate the exact interface/behavior of a named tuple? If not, we might have to put this in 2.0

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. Also this functionality is probably not super critical, depending on when a 2.0 release is scheduled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behold, a thing of beauty:

from collections import namedtuple

class ExitCode(namedtuple('_ExitCode', ['status', 'message', 'invalidates_cache'])):

    def __call__(self, **kwargs):
        pass

ExitCode.__new__.__defaults__ = (0, None, False)

ExitCode(0, 'bla')()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yer-a-wizard

ExitCode.__new__.__defaults__ = (0, None, False)
"""
A namedtuple to define an exit code for a :class:`~aiida.engine.processes.process.Process`.

When this namedtuple is returned from a Process._run() call, it will be interpreted that the Process
should be terminated and that the exit status and message of the namedtuple should be set to the
corresponding attributes of the node.
class ExitCode(namedtuple('ExitCode', ['status', 'message', 'invalidates_cache'])):
"""A simple data class to define an exit code for a :class:`~aiida.engine.processes.process.Process`.

:param status: positive integer exit status, where a non-zero value indicated the process failed, default is `0`
:type status: int
When an instance of this clas is returned from a `Process._run()` call, it will be interpreted that the `Process`
should be terminated and that the exit status and message of the namedtuple should be set to the corresponding
attributes of the node.

:param message: optional message with more details about the failure mode
:type message: str
.. note:: this class explicitly sub-classes a namedtuple to not break backwards compatibility and to have it behave
exactly as a tuple.

:param invalidates_cache: optional flag, indicating that a process should not be used in caching
:type invalidates_cache: bool
"""
:param status: positive integer exit status, where a non-zero value indicated the process failed, default is `0`
:type status: int

:param message: optional message with more details about the failure mode
:type message: str

class ExitCodesNamespace(AttributeDict):
:param invalidates_cache: optional flag, indicating that a process should not be used in caching
:type invalidates_cache: bool
"""
A namespace of ExitCode tuples that can be accessed through getattr as well as getitem.
Additionally, the collection can be called with an identifier, that can either reference
the integer `status` of the ExitCode that needs to be retrieved or the key in the collection

def format(self, **kwargs):
"""Create a clone of this exit code where the template message is replaced by the keyword arguments.

:param kwargs: replacement parameters for the template message
:return: `ExitCode`
"""
try:
message = self.message.format(**kwargs)
except KeyError:
template = 'insufficient or incorrect format parameters `{}` for the message template `{}`.'
raise ValueError(template.format(kwargs, self.message))

return ExitCode(self.status, message, self.invalidates_cache)

def __eq__(self, other):
return all(getattr(self, attr) == getattr(other, attr) for attr in ['status', 'message', 'invalidates_cache'])


# Set the defaults for the `ExitCode` attributes
ExitCode.__new__.__defaults__ = (0, None, False)


class ExitCodesNamespace(AttributeDict):
"""A namespace of `ExitCode` instances that can be accessed through getattr as well as getitem.

Additionally, the collection can be called with an identifier, that can either reference the integer `status` of the
`ExitCode` that needs to be retrieved or the key in the collection.
"""

def __call__(self, identifier):
"""
Return a specific exit code identified by either its exit status or label
"""Return a specific exit code identified by either its exit status or label.

:param identifier: the identifier of the exit code. If the type is integer, it will be interpreted as
the exit code status, otherwise it be interpreted as the exit code label
:param identifier: the identifier of the exit code. If the type is integer, it will be interpreted as the exit
code status, otherwise it be interpreted as the exit code label
:type identifier: str

:returns: an ExitCode named tuple
:returns: an `ExitCode` instance
:rtype: :class:`aiida.engine.ExitCode`

:raises ValueError: if no exit code with the given label is defined for this process
Expand Down
4 changes: 4 additions & 0 deletions aiida/engine/processes/workchains/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections import namedtuple
from functools import partial
from inspect import getfullargspec
from types import FunctionType # pylint: disable=no-name-in-module
from wrapt import decorator

from ..exit_code import ExitCode
Expand Down Expand Up @@ -68,6 +69,9 @@ def process_handler(wrapped=None, *, priority=0, exit_codes=None, enabled=True):
if wrapped is None:
return partial(process_handler, priority=priority, exit_codes=exit_codes, enabled=enabled)

if not isinstance(wrapped, FunctionType):
raise TypeError('first argument can only be an instance method, use keywords for decorator arguments.')

if not isinstance(priority, int):
raise TypeError('the `priority` keyword should be an integer.')

Expand Down
2 changes: 1 addition & 1 deletion docs/source/working/functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ In the case of the example above, it would look something like the following:
However, in this particular example the exception is not so much an unexpected error, but one we could have considered and have seen coming, so it might be more applicable to simply mark the process as failed.
To accomplish this, there is the concept of an :ref:`exit status<concepts_process_exit_codes>` that can be set on the process, which is an integer that, when non-zero, marks a process in the ``Finished`` state as 'failed'.
Since the exit status is set as an attribute on the process node, it also makes it very easy to query for failed processes.
To set a non-zero exit status on a calculation function to indicate it as failed, simply return an instance of the :py:class:`~aiida.engine.processes.exit_code.ExitCode` named tuple.
To set a non-zero exit status on a calculation function to indicate it as failed, simply return an instance of the :py:class:`~aiida.engine.processes.exit_code.ExitCode` class.
Time for a demonstration:

.. include:: include/snippets/processes/functions/calcfunction_exit_code.py
Expand Down
37 changes: 31 additions & 6 deletions docs/source/working/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ Exit codes
----------

To terminate the execution of a work function and mark it as failed, one simply has to return an :ref:`exit code<working_processes_exit_codes>`.
The :py:class:`~aiida.engine.processes.exit_code.ExitCode` named tuple is constructed with an integer, to denote the desired exit status and an optional message
When such as exit code is returned, the engine will mark the node of the work function as ``Finished`` and set the exit status and message to the value of the tuple.
The :py:class:`~aiida.engine.processes.exit_code.ExitCode` class is constructed with an integer, to denote the desired exit status and an optional message
When such as exit code is returned, the engine will mark the node of the work function as ``Finished`` and set the exit status and message to the value of the exit code.
Consider the following example:

.. code:: python
Expand All @@ -120,7 +120,7 @@ Consider the following example:
from aiida.engine import ExitCode
return ExitCode(418, 'I am a teapot')

The execution of the work function will be immediately terminated as soon as the tuple is returned, and the exit status and message will be set to ``418`` and ``I am a teapot``, respectively.
The execution of the work function will be immediately terminated as soon as the exit code is returned, and the exit status and message will be set to ``418`` and ``I am a teapot``, respectively.
Since no output nodes are returned, the ``WorkFunctionNode`` node will have no outputs and the value returned from the function call will be an empty dictionary.


Expand Down Expand Up @@ -483,15 +483,40 @@ In the ``inspect_calculation`` outline, we retrieve the calculation that was sub
If this returns ``False``, in this example we simply fire a report message and return the exit code corresponding to the label ``ERROR_CALCULATION_FAILED``.
Note that the specific exit code can be retrieved through the ``WorkChain`` property ``exit_codes``.
This will return a collection of exit codes that have been defined for that ``WorkChain`` and any specific exit code can then be retrieved by accessing it as an attribute.
Returning this exit code, which will be an instance of the :py:class:`~aiida.engine.processes.exit_code.ExitCode` named tuple, will cause the work chain to be aborted and the ``exit_status`` and ``exit_message`` to be set on the node, which were defined in the spec.
Returning this exit code, which will be an instance of the :py:class:`~aiida.engine.processes.exit_code.ExitCode` class, will cause the work chain to be aborted and the ``exit_status`` and ``exit_message`` to be set on the node, which were defined in the spec.

.. note::

The notation ``self.exit_codes.ERROR_CALCULATION_FAILED`` is just syntactic sugar to retrieve the ``ExitCode`` tuple that was defined in the spec with that error label.
The notation ``self.exit_codes.ERROR_CALCULATION_FAILED`` is just syntactic sugar to retrieve the ``ExitCode`` instance that was defined in the spec with that error label.
Constructing your own ``ExitCode`` directly and returning that from the outline step will have exactly the same effect in terms of aborting the work chain execution and setting the exit status and message.
However, it is strongly advised to define the exit code through the spec and retrieve it through the ``self.exit_codes`` collection, as that makes it easily retrievable through the spec by the caller of the work chain.

The best part about this method of aborting a work chains execution, is that the exit status can now be used programmatically, by for example a parent work chain.
The ``message`` attribute of an ``ExitCode`` can also be a string that contains placeholders.
This is useful when the exit code's message is generic enough to a host of situations, but one would just like to parameterize the exit message.
To concretize the template message of an exit code, simply call the :meth:`~aiida.engine.processes.exit_code.ExitCode.format` method and pass the parameters as keyword arguments::

.. code:: python

exit_code_template = ExitCode(450, 'the parameter {parameter} is invalid.')
exit_code_concrete = exit_code_template.format(parameter='some_specific_key')

This concept can also be applied within the scope of a process.
In the process spec, we can declare a generic exit code whose exact message should depend on one or multiple parameters::

.. code:: python

spec.exit_code(450, 'ERROR_INVALID_PARAMETER, 'the parameter {parameter} is invalid.')

Through the ``self.exit_codes`` collection of a ``WorkChain``, this generic can be easily customized as follows:

.. code:: python

def inspect_calculation(self):
return self.exit_codes.ERROR_INVALID_PARAMETER.format(parameter='some_specific_key')

This is no different than the example before, because ``self.exit_codes.ERROR_INVALID_PARAMETER`` simply returns an instance of ``ExitCode``, which we then call ``format`` on with the substitution parameters.

In conclusion, the best part about using exit codes to abort a work chain's execution, is that the exit status can now be used programmatically, by for example a parent work chain.
Imagine that a parent work chain submitted this work chain.
After it has terminated its execution, the parent work chain will want to know what happened to the child work chain.
As already noted in the :ref:`report<working_workchains_reporting>` section, the report messages of the work chain should not be used.
Expand Down
68 changes: 68 additions & 0 deletions tests/engine/processes/text_exit_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""Tests for `aiida.engine.processes.exit_code.ExitCode`."""
import pytest

from aiida.engine import ExitCode


def test_exit_code_defaults():
"""Test that the defaults are properly set."""
exit_code = ExitCode()
assert exit_code.status == 0
assert exit_code.message is None
assert exit_code.invalidates_cache is False


def test_exit_code_construct():
"""Test that the constructor allows to override defaults."""
status = 418
message = 'I am a teapot'
invalidates_cache = True

exit_code = ExitCode(status, message, invalidates_cache)
assert exit_code.status == status
assert exit_code.message == message
assert exit_code.invalidates_cache == invalidates_cache


def test_exit_code_equality():
"""Test that the equality operator works properly."""
exit_code_origin = ExitCode(1, 'message', True)
exit_code_clone = ExitCode(1, 'message', True)
exit_code_different = ExitCode(2, 'message', True)

assert exit_code_origin == exit_code_clone
assert exit_code_clone != exit_code_different


def test_exit_code_template_message():
"""Test that an exit code with a templated message can be called to replace the parameters."""
message_template = 'Wrong parameter {parameter}'
parameter_name = 'some_parameter'

exit_code_base = ExitCode(418, message_template)
exit_code_called = exit_code_base.format(parameter=parameter_name)

# Incorrect placeholder
with pytest.raises(ValueError):
exit_code_base.format(non_existing_parameter=parameter_name)

# Missing placeholders
with pytest.raises(ValueError):
exit_code_base.format()

assert exit_code_base != exit_code_called # Calling the exit code should return a new instance
assert exit_code_called.message == message_template.format(parameter=parameter_name)


def test_exit_code_expand_tuple():
"""Test that an exit code instance can be expanded in its attributes like a tuple."""
status = 418
message = 'I am a teapot'
invalidates_cache = True

status_exp, message_exp, invalidates_cache_exp = ExitCode(418, message, True)

assert status == status_exp
assert message == message_exp
assert invalidates_cache == invalidates_cache_exp