From adefb228f31b8fd51a748e7255a744f2f0eae13e Mon Sep 17 00:00:00 2001 From: Sebastiaan Huber Date: Fri, 22 Jun 2018 10:48:57 +0200 Subject: [PATCH] Add built-in support and API for exit codes in WorkChains (#1640) Currently it is possible to return non-zero integers from a WorkChain outline step to abort its execution and assign the exit status to the node, however, there is no official API yet to make this easier. Here we define the concept of an ExitCode, a named tuple that takes an integer exit status and a descriptive message. Through the ProcessSpec, a WorkChain developer can add exit codes that correspond to errors that may crop up during the execution of the workchain. For example the following spec definition: @classmethod def define(cls, spec): super(CifCleanWorkChain, cls).define(spec) spec.exit_code(418, 'ERROR_I_AM_A_TEAPOT', message='workchain found itself in an identity crisis') In one of the outline steps, the exit code can be used by retrieving it through either the integer exit status or the string label: return self.exit_codes('I_AM_A_TEAPOT') or return self.exit_codes(418) and return it. Returning an instance of ExitCode will trigger the exact same mechanism as returning an integer from an outline step. The engine will detect the ExitCode and return its exit status as the result, triggering the abort and the exit status to be set on the Node. Note that this addition required name changes in parts of the existing API. For example the Calculation attribute `finish_status` was renamed to `exit_status`. This is because it can now be accompanied by an string `exit_message`. The `exit_status` and `exit_message` together form the `ExitCode`. --- .ci/test_daemon.py | 12 +- .pre-commit-config.yaml | 2 + aiida/backends/tests/inline_calculation.py | 6 +- aiida/backends/tests/work/__init__.py | 1 - .../backends/tests/work/test_process_spec.py | 39 +++++++ .../backends/tests/work/test_workfunctions.py | 26 +++-- aiida/backends/tests/work/work_chain.py | 72 ++++++++++-- aiida/cmdline/commands/calculation.py | 14 +-- aiida/cmdline/commands/work.py | 26 ++--- aiida/cmdline/utils/common.py | 4 +- aiida/daemon/execmanager.py | 4 +- aiida/orm/calculation/job/__init__.py | 2 +- aiida/orm/implementation/calculation.py | 2 +- .../general/calculation/__init__.py | 55 ++++++--- .../general/calculation/job/__init__.py | 34 +++--- aiida/work/__init__.py | 3 +- aiida/work/exceptions.py | 23 +--- aiida/work/exit_code.py | 50 ++++++++ aiida/work/job_processes.py | 7 +- aiida/work/process_spec.py | 36 ++++++ aiida/work/processes.py | 49 ++++---- aiida/work/workchain.py | 13 ++- docs/source/concepts/processes.rst | 13 ++- docs/source/concepts/workflows.rst | 108 ++++++++++++------ 24 files changed, 415 insertions(+), 186 deletions(-) create mode 100644 aiida/work/exit_code.py diff --git a/.ci/test_daemon.py b/.ci/test_daemon.py index 076934dcf3..086ce1b7b0 100644 --- a/.ci/test_daemon.py +++ b/.ci/test_daemon.py @@ -76,8 +76,8 @@ def validate_calculations(expected_results): for pk, expected_dict in expected_results.iteritems(): calc = load_node(pk) if not calc.is_finished_ok: - print 'Calculation<{}> not finished ok: process_state<{}> finish_status<{}>'.format( - pk, calc.process_state, calc.finish_status) + print 'Calculation<{}> not finished ok: process_state<{}> exit_status<{}>'.format( + pk, calc.process_state, calc.exit_status) print_logshow(pk) valid = False @@ -118,8 +118,8 @@ def validate_workchains(expected_results): # I check only if this_valid, otherwise calc could not exist if this_valid and not calc.is_finished_ok: - print 'Calculation<{}> not finished ok: process_state<{}> finish_status<{}>'.format( - pk, calc.process_state, calc.finish_status) + print 'Calculation<{}> not finished ok: process_state<{}> exit_status<{}>'.format( + pk, calc.process_state, calc.exit_status) print_logshow(pk) valid = False this_valid = False @@ -142,8 +142,8 @@ def validate_cached(cached_calcs): for calc in cached_calcs: if not calc.is_finished_ok: - print 'Cached calculation<{}> not finished ok: process_state<{}> finish_status<{}>'.format( - pk, calc.process_state, calc.finish_status) + print 'Cached calculation<{}> not finished ok: process_state<{}> exit_status<{}>'.format( + pk, calc.process_state, calc.exit_status) print_logshow(pk) valid = False diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e9b479422..21227abed5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,6 +22,7 @@ aiida/work/awaitable.py| aiida/work/context.py| aiida/work/exceptions.py| + aiida/work/exit_code.py| aiida/work/futures.py| aiida/work/launch.py| aiida/work/persistence.py| @@ -61,6 +62,7 @@ aiida/work/awaitable.py| aiida/work/context.py| aiida/work/exceptions.py| + aiida/work/exit_code.py| aiida/work/futures.py| aiida/work/launch.py| aiida/work/persistence.py| diff --git a/aiida/backends/tests/inline_calculation.py b/aiida/backends/tests/inline_calculation.py index e141364a70..02fa4bfb1c 100644 --- a/aiida/backends/tests/inline_calculation.py +++ b/aiida/backends/tests/inline_calculation.py @@ -40,16 +40,16 @@ def test_inline_calculation_process_state(self): self.assertEquals(calculation.is_finished_ok, True) self.assertEquals(calculation.is_failed, False) - def test_finish_status(self): + def test_exit_status(self): """ If an InlineCalculation reaches the FINISHED process state, it has to have been successful - which means that the finish status always has to be 0 + which means that the exit status always has to be 0 """ calculation, result = self.incr_inline(inp=Int(11)) self.assertEquals(calculation.is_finished, True) self.assertEquals(calculation.is_finished_ok, True) self.assertEquals(calculation.is_failed, False) - self.assertEquals(calculation.finish_status, 0) + self.assertEquals(calculation.exit_status, 0) def test_incr(self): """ diff --git a/aiida/backends/tests/work/__init__.py b/aiida/backends/tests/work/__init__.py index 70f504a40c..a7e3fad50c 100644 --- a/aiida/backends/tests/work/__init__.py +++ b/aiida/backends/tests/work/__init__.py @@ -7,4 +7,3 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### - diff --git a/aiida/backends/tests/work/test_process_spec.py b/aiida/backends/tests/work/test_process_spec.py index 24371ed777..f78f0633d4 100644 --- a/aiida/backends/tests/work/test_process_spec.py +++ b/aiida/backends/tests/work/test_process_spec.py @@ -44,3 +44,42 @@ def test_dynamic_output(self): self.assertFalse(self.spec.validate_outputs({'key': 5})[0]) self.assertFalse(self.spec.validate_outputs({'key': n})[0]) self.assertTrue(self.spec.validate_outputs({'key': d})[0]) + + def test_exit_code(self): + """ + Test the definition of error codes through the ProcessSpec + """ + label = 'SOME_EXIT_CODE' + status = 418 + message = 'I am a teapot' + + self.spec.exit_code(status, label, message) + + self.assertEquals(self.spec.exit_codes.SOME_EXIT_CODE.status, status) + self.assertEquals(self.spec.exit_codes.SOME_EXIT_CODE.message, message) + + self.assertEquals(self.spec.exit_codes['SOME_EXIT_CODE'].status, status) + self.assertEquals(self.spec.exit_codes['SOME_EXIT_CODE'].message, message) + + self.assertEquals(self.spec.exit_codes[label].status, status) + self.assertEquals(self.spec.exit_codes[label].message, message) + + def test_exit_code_invalid(self): + """ + Test type validation for registering new error codes + """ + status = 418 + label = 'SOME_EXIT_CODE' + message = 'I am a teapot' + + with self.assertRaises(TypeError): + self.spec.exit_code(status, 256, message) + + with self.assertRaises(TypeError): + self.spec.exit_code('string', label, message) + + with self.assertRaises(ValueError): + self.spec.exit_code(-256, label, message) + + with self.assertRaises(TypeError): + self.spec.exit_code(status, label, 8) diff --git a/aiida/backends/tests/work/test_workfunctions.py b/aiida/backends/tests/work/test_workfunctions.py index dc52da1e0d..dfee0967a7 100644 --- a/aiida/backends/tests/work/test_workfunctions.py +++ b/aiida/backends/tests/work/test_workfunctions.py @@ -14,7 +14,7 @@ from aiida.orm.data.str import Str from aiida.orm import load_node from aiida.orm.calculation.function import FunctionCalculation -from aiida.work import run, run_get_node, submit, workfunction, Process, Exit +from aiida.work import run, run_get_node, submit, workfunction, Process, ExitCode DEFAULT_INT = 256 DEFAULT_LABEL = 'Default label' @@ -64,8 +64,8 @@ def wf_default_label_description(a=Int(DEFAULT_INT), label=DEFAULT_LABEL, descri return a @workfunction - def wf_exit(exit_code): - raise Exit(exit_code.value) + def wf_exit_code(exit_status, exit_message): + return ExitCode(exit_status.value, exit_message.value) @workfunction def wf_excepts(exception): @@ -79,7 +79,7 @@ def wf_excepts(exception): self.wf_args_and_kwargs = wf_args_and_kwargs self.wf_args_and_default = wf_args_and_default self.wf_default_label_description = wf_default_label_description - self.wf_exit = wf_exit + self.wf_exit_code = wf_exit_code self.wf_excepts = wf_excepts def tearDown(self): @@ -217,13 +217,13 @@ def test_wf_default_label_description(self): self.assertEquals(node.label, CUSTOM_LABEL) self.assertEquals(node.description, CUSTOM_DESCRIPTION) - def test_finish_status(self): + def test_exit_status(self): """ If a workfunction reaches the FINISHED process state, it has to have been successful - which means that the finish status always has to be 0 + which means that the exit status always has to be 0 """ result, node = self.wf_args_with_default.run_get_node() - self.assertEquals(node.finish_status, 0) + self.assertEquals(node.exit_status, 0) self.assertEquals(node.is_finished_ok, True) self.assertEquals(node.is_failed, False) @@ -242,15 +242,17 @@ def test_launchers(self): with self.assertRaises(AssertionError): submit(self.wf_return_true) - def test_exit_exception(self): + def test_return_exit_code(self): """ - A workfunction that raises the Exit exception should not EXCEPT but be FINISHED + A workfunction that returns an ExitCode namedtuple should have its exit status and message set FINISHED """ - finish_status = 418 - result, node = self.wf_exit.run_get_node(exit_code=Int(finish_status)) + exit_status = 418 + exit_message = 'I am a teapot' + result, node = self.wf_exit_code.run_get_node(exit_status=Int(exit_status), exit_message=Str(exit_message)) self.assertTrue(node.is_finished) self.assertFalse(node.is_finished_ok) - self.assertEquals(node.finish_status, finish_status) + self.assertEquals(node.exit_status, exit_status) + self.assertEquals(node.exit_message, exit_message) def test_normal_exception(self): """ diff --git a/aiida/backends/tests/work/work_chain.py b/aiida/backends/tests/work/work_chain.py index 60110e228d..9486ad094b 100644 --- a/aiida/backends/tests/work/work_chain.py +++ b/aiida/backends/tests/work/work_chain.py @@ -7,11 +7,9 @@ # For further information on the license, see the LICENSE.txt file # # For further information please visit http://www.aiida.net # ########################################################################### - import inspect import plumpy import plumpy.test_utils -import unittest from aiida.backends.testbase import AiidaTestCase from aiida.common.links import LinkType @@ -117,13 +115,17 @@ def _set_finished(self, function_name): self.finished_steps[function_name] = True -class ReturnWorkChain(WorkChain): - FAILURE_STATUS = 1 +class PotentialFailureWorkChain(WorkChain): + + EXIT_STATUS = 1 + EXIT_MESSAGE = 'Well you did ask for it' @classmethod def define(cls, spec): - super(ReturnWorkChain, cls).define(spec) + super(PotentialFailureWorkChain, cls).define(spec) spec.input('success', valid_type=Bool) + spec.input('through_exit_code', valid_type=Bool, default=Bool(False)) + spec.exit_code(cls.EXIT_STATUS, 'EXIT_STATUS', cls.EXIT_MESSAGE) spec.outline( cls.failure, cls.success @@ -131,24 +133,36 @@ def define(cls, spec): def failure(self): if self.inputs.success.value is False: - return self.FAILURE_STATUS + if self.inputs.through_exit_code.value is False: + return self.EXIT_STATUS + else: + return self.exit_codes.EXIT_STATUS def success(self): return -class TestFinishStatus(AiidaTestCase): +class TestExitStatus(AiidaTestCase): def test_failing_workchain(self): - result, node = work.run_get_node(ReturnWorkChain, success=Bool(False)) - self.assertEquals(node.finish_status, ReturnWorkChain.FAILURE_STATUS) + result, node = work.run_get_node(PotentialFailureWorkChain, success=Bool(False)) + self.assertEquals(node.exit_status, PotentialFailureWorkChain.EXIT_STATUS) + self.assertEquals(node.exit_message, None) + self.assertEquals(node.is_finished, True) + self.assertEquals(node.is_finished_ok, False) + self.assertEquals(node.is_failed, True) + + def test_failing_workchain_with_message(self): + result, node = work.run_get_node(PotentialFailureWorkChain, success=Bool(False), through_exit_code=Bool(True)) + self.assertEquals(node.exit_status, PotentialFailureWorkChain.EXIT_STATUS) + self.assertEquals(node.exit_message, PotentialFailureWorkChain.EXIT_MESSAGE) self.assertEquals(node.is_finished, True) self.assertEquals(node.is_finished_ok, False) self.assertEquals(node.is_failed, True) def test_successful_workchain(self): - result, node = work.run_get_node(ReturnWorkChain, success=Bool(True)) - self.assertEquals(node.finish_status, 0) + result, node = work.run_get_node(PotentialFailureWorkChain, success=Bool(True)) + self.assertEquals(node.exit_status, 0) self.assertEquals(node.is_finished, True) self.assertEquals(node.is_finished_ok, True) self.assertEquals(node.is_failed, False) @@ -547,6 +561,41 @@ def check_input(self): run_and_check_success(TestWorkChain, namespace={'value': value}) + def test_exit_codes(self): + status = 418 + label = 'SOME_EXIT_CODE' + message = 'I am a teapot' + + class ExitCodeWorkChain(WorkChain): + + @classmethod + def define(cls, spec): + super(ExitCodeWorkChain, cls).define(spec) + spec.outline(cls.run) + spec.exit_code(status, label, message) + + def run(self): + pass + + wc = ExitCodeWorkChain() + + # The exit code can be gotten by calling it with the status or label, as well as using attribute dereferencing + self.assertEquals(wc.exit_codes(status).status, status) + self.assertEquals(wc.exit_codes(label).status, status) + self.assertEquals(wc.exit_codes.SOME_EXIT_CODE.status, status) + + with self.assertRaises(AttributeError): + wc.exit_codes.NON_EXISTENT_ERROR + + self.assertEquals(ExitCodeWorkChain.exit_codes.SOME_EXIT_CODE.status, status) + self.assertEquals(ExitCodeWorkChain.exit_codes.SOME_EXIT_CODE.message, message) + + self.assertEquals(ExitCodeWorkChain.exit_codes['SOME_EXIT_CODE'].status, status) + self.assertEquals(ExitCodeWorkChain.exit_codes['SOME_EXIT_CODE'].message, message) + + self.assertEquals(ExitCodeWorkChain.exit_codes[label].status, status) + self.assertEquals(ExitCodeWorkChain.exit_codes[label].message, message) + def _run_with_checkpoints(self, wf_class, inputs=None): if inputs is None: inputs = {} @@ -1084,4 +1133,3 @@ def test_nested_expose(self): 'sub.sub.sub_2.b': Float(1.2), 'sub.sub.sub_2.sub_3.c': Bool(False) } ) - diff --git a/aiida/cmdline/commands/calculation.py b/aiida/cmdline/commands/calculation.py index 8629e746fb..9be6345724 100644 --- a/aiida/cmdline/commands/calculation.py +++ b/aiida/cmdline/commands/calculation.py @@ -175,10 +175,10 @@ def calculation_list(self, *args): parser.set_defaults(all_states=False) parser.add_argument('-S', '--process-state', choices=([e.value for e in ProcessState]), help='Only include entries with this process state') - parser.add_argument('-f', '--finish-status', type=int, - help='Only include entries with this finish status') + parser.add_argument('-E', '--exit-status', type=int, + help='Only include entries with this exit status') parser.add_argument('-n', '--failed', dest='failed', action='store_true', - help='Only include entries that are failed, i.e. whose finish status is non-zero') + help='Only include entries that are failed, i.e. whose exit status is non-zero') parser.add_argument('-A', '--all-users', dest='all_users', action='store_true', help="Show calculations for all users, rather than only for the current user") @@ -216,7 +216,7 @@ def calculation_list(self, *args): parsed_args.states = None PROCESS_STATE_KEY = 'attributes.{}'.format(C.PROCESS_STATE_KEY) - FINISH_STATUS_KEY = 'attributes.{}'.format(C.FINISH_STATUS_KEY) + EXIT_STATUS_KEY = 'attributes.{}'.format(C.EXIT_STATUS_KEY) filters = {} @@ -227,12 +227,12 @@ def calculation_list(self, *args): if parsed_args.failed: parsed_args.states = None filters[PROCESS_STATE_KEY] = {'==': ProcessState.FINISHED.value} - filters[FINISH_STATUS_KEY] = {'!==': 0} + filters[EXIT_STATUS_KEY] = {'!==': 0} - if parsed_args.finish_status: + if parsed_args.exit_status: parsed_args.states = None filters[PROCESS_STATE_KEY] = {'==': ProcessState.FINISHED.value} - filters[FINISH_STATUS_KEY] = {'==': parsed_args.finish_status} + filters[EXIT_STATUS_KEY] = {'==': parsed_args.exit_status} C._list_calculations( states=parsed_args.states, diff --git a/aiida/cmdline/commands/work.py b/aiida/cmdline/commands/work.py index 355539a3ae..aae730a220 100644 --- a/aiida/cmdline/commands/work.py +++ b/aiida/cmdline/commands/work.py @@ -18,7 +18,7 @@ CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) -LIST_CMDLINE_PROJECT_CHOICES = ('pk', 'uuid', 'ctime', 'mtime', 'state', 'process_state', 'finish_status', 'sealed', 'process_label', 'label', 'description', 'type') +LIST_CMDLINE_PROJECT_CHOICES = ('pk', 'uuid', 'ctime', 'mtime', 'state', 'process_state', 'exit_status', 'sealed', 'process_label', 'label', 'description', 'type') LIST_CMDLINE_PROJECT_DEFAULT = ('pk', 'ctime', 'state', 'process_label') @@ -78,12 +78,12 @@ def complete_plugins(self, subargs_idx, subargs): help='Only include entries with this process state' ) @click.option( - '-f', '--finish-status', type=click.INT, - help='Only include entries with this finish status' + '-E', '--exit-status', type=click.INT, + help='Only include entries with this exit status' ) @click.option( '-n', '--failed', is_flag=True, - help='Only include entries that are failed, i.e. whose finish status is non-zero' + help='Only include entries that are failed, i.e. whose exit status is non-zero' ) @click.option( '-l', '--limit', type=int, default=None, @@ -97,7 +97,7 @@ def complete_plugins(self, subargs_idx, subargs): '-r', '--raw', is_flag=True, help='Only print the query result, without any headers, footers or other additional information' ) -def do_list(past_days, all_states, process_state, finish_status, failed, limit, project, raw): +def do_list(past_days, all_states, process_state, exit_status, failed, limit, project, raw): """ Return a list of work calculations that are still running """ @@ -116,7 +116,7 @@ def do_list(past_days, all_states, process_state, finish_status, failed, limit, SEALED_KEY = 'attributes.{}'.format(Sealable.SEALED_KEY) PROCESS_LABEL_KEY = 'attributes.{}'.format(Calculation.PROCESS_LABEL_KEY) PROCESS_STATE_KEY = 'attributes.{}'.format(Calculation.PROCESS_STATE_KEY) - FINISH_STATUS_KEY = 'attributes.{}'.format(Calculation.FINISH_STATUS_KEY) + EXIT_STATUS_KEY = 'attributes.{}'.format(Calculation.EXIT_STATUS_KEY) TERMINAL_STATES = [ProcessState.FINISHED.value, ProcessState.KILLED.value, ProcessState.EXCEPTED.value] now = timezone.now() @@ -129,7 +129,7 @@ def do_list(past_days, all_states, process_state, finish_status, failed, limit, 'type': 'Type', 'state': 'State', 'process_state': 'Process state', - 'finish_status': 'Finish status', + 'exit_status': 'Exit status', 'sealed': 'Sealed', 'process_label': 'Process label', 'label': 'Label', @@ -143,7 +143,7 @@ def do_list(past_days, all_states, process_state, finish_status, failed, limit, 'mtime': 'mtime', 'type': 'type', 'process_state': PROCESS_STATE_KEY, - 'finish_status': FINISH_STATUS_KEY, + 'exit_status': EXIT_STATUS_KEY, 'sealed': SEALED_KEY, 'process_label': PROCESS_LABEL_KEY, 'label': 'label', @@ -156,9 +156,9 @@ def do_list(past_days, all_states, process_state, finish_status, failed, limit, 'ctime': lambda value: str_timedelta(timezone.delta(value['ctime'], now), negative_to_zero=True, max_num_fields=1), 'mtime': lambda value: str_timedelta(timezone.delta(value['mtime'], now), negative_to_zero=True, max_num_fields=1), 'type': lambda value: value['type'], - 'state': lambda value: '{} | {}'.format(value[PROCESS_STATE_KEY].capitalize() if value[PROCESS_STATE_KEY] else None, value[FINISH_STATUS_KEY]), + 'state': lambda value: '{} | {}'.format(value[PROCESS_STATE_KEY].capitalize() if value[PROCESS_STATE_KEY] else None, value[EXIT_STATUS_KEY]), 'process_state': lambda value: value[PROCESS_STATE_KEY].capitalize(), - 'finish_status': lambda value: value[FINISH_STATUS_KEY], + 'exit_status': lambda value: value[EXIT_STATUS_KEY], 'sealed': lambda value: 'True' if value[SEALED_KEY] == 1 else 'False', 'process_label': lambda value: value[PROCESS_LABEL_KEY], 'label': lambda value: value['label'], @@ -176,11 +176,11 @@ def do_list(past_days, all_states, process_state, finish_status, failed, limit, if failed: filters[PROCESS_STATE_KEY] = {'==': ProcessState.FINISHED.value} - filters[FINISH_STATUS_KEY] = {'!==': 0} + filters[EXIT_STATUS_KEY] = {'!==': 0} - if finish_status is not None: + if exit_status is not None: filters[PROCESS_STATE_KEY] = {'==': ProcessState.FINISHED.value} - filters[FINISH_STATUS_KEY] = {'==': finish_status} + filters[EXIT_STATUS_KEY] = {'==': exit_status} query = _build_query( limit=limit, diff --git a/aiida/cmdline/utils/common.py b/aiida/cmdline/utils/common.py index 8a2e595b69..cd9d748285 100644 --- a/aiida/cmdline/utils/common.py +++ b/aiida/cmdline/utils/common.py @@ -104,9 +104,9 @@ def print_node_summary(node): table.append(['process state', process_state]) try: - table.append(['finish status', node.finish_status]) + table.append(['exit status', node.exit_status]) except AttributeError: - table.append(['finish status', None]) + table.append(['exit status', None]) try: computer = node.get_computer() diff --git a/aiida/daemon/execmanager.py b/aiida/daemon/execmanager.py index 536624ac9d..647cb57027 100644 --- a/aiida/daemon/execmanager.py +++ b/aiida/daemon/execmanager.py @@ -393,7 +393,7 @@ def parse_results(job, retrieved_temporary_folder=None): :returns: integer exit code, where 0 indicates success and non-zero failure """ - from aiida.orm.calculation.job import JobCalculationFinishStatus + from aiida.orm.calculation.job import JobCalculationExitStatus logger_extra = get_dblogger_extra(job) @@ -413,7 +413,7 @@ def parse_results(job, retrieved_temporary_folder=None): if isinstance(exit_code, bool) and exit_code is True: exit_code = 0 elif isinstance(exit_code, bool) and exit_code is False: - exit_code = JobCalculationFinishStatus[calc_states.FAILED] + exit_code = JobCalculationExitStatus[calc_states.FAILED] for label, n in new_nodes_tuple: n.add_link_from(job, label=label, link_type=LinkType.CREATE) diff --git a/aiida/orm/calculation/job/__init__.py b/aiida/orm/calculation/job/__init__.py index dcfc5d5721..d031d6e463 100644 --- a/aiida/orm/calculation/job/__init__.py +++ b/aiida/orm/calculation/job/__init__.py @@ -8,4 +8,4 @@ # For further information please visit http://www.aiida.net # ########################################################################### from aiida.orm.calculation import Calculation -from aiida.orm.implementation.calculation import JobCalculation, _input_subfolder, JobCalculationFinishStatus +from aiida.orm.implementation.calculation import JobCalculation, _input_subfolder, JobCalculationExitStatus diff --git a/aiida/orm/implementation/calculation.py b/aiida/orm/implementation/calculation.py index fbf4c76c68..b4700847cb 100644 --- a/aiida/orm/implementation/calculation.py +++ b/aiida/orm/implementation/calculation.py @@ -15,7 +15,7 @@ from aiida.backends.profile import BACKEND_DJANGO, BACKEND_SQLA from aiida.orm.implementation.general.calculation.job import _input_subfolder -from aiida.orm.implementation.general.calculation.job import JobCalculationFinishStatus +from aiida.orm.implementation.general.calculation.job import JobCalculationExitStatus if BACKEND == BACKEND_SQLA: diff --git a/aiida/orm/implementation/general/calculation/__init__.py b/aiida/orm/implementation/general/calculation/__init__.py index 9cde66f4f8..2569cb1aaa 100644 --- a/aiida/orm/implementation/general/calculation/__init__.py +++ b/aiida/orm/implementation/general/calculation/__init__.py @@ -29,11 +29,12 @@ class AbstractCalculation(Sealable): calculations run via a scheduler. """ + CHECKPOINT_KEY = 'checkpoints' EXCEPTION_KEY = 'exception' + EXIT_MESSAGE_KEY = 'exit_message' + EXIT_STATUS_KEY = 'exit_status' PROCESS_LABEL_KEY = '_process_label' PROCESS_STATE_KEY = 'process_state' - FINISH_STATUS_KEY = 'finish_status' - CHECKPOINT_KEY = 'checkpoints' # The link_type might not be correct while the object is being created. _hash_ignored_inputs = ['CALL'] @@ -42,11 +43,12 @@ class AbstractCalculation(Sealable): @classproperty def _updatable_attributes(cls): return super(AbstractCalculation, cls)._updatable_attributes + ( + cls.CHECKPOINT_KEY, cls.EXCEPTION_KEY, + cls.EXIT_MESSAGE_KEY, + cls.EXIT_STATUS_KEY, cls.PROCESS_LABEL_KEY, cls.PROCESS_STATE_KEY, - cls.FINISH_STATUS_KEY, - cls.CHECKPOINT_KEY, ) @classproperty @@ -288,12 +290,12 @@ def is_finished(self): def is_finished_ok(self): """ Returns whether the Calculation has finished successfully, which means that it - terminated nominally and had a zero exit code indicating a successful execution + terminated nominally and had a zero exit status indicating a successful execution :return: True if the calculation has finished successfully, False otherwise :rtype: bool """ - return self.is_finished and self.finish_status == 0 + return self.is_finished and self.exit_status == 0 @property def is_failed(self): @@ -304,20 +306,20 @@ def is_failed(self): :return: True if the calculation has failed, False otherwise :rtype: bool """ - return self.is_finished and self.finish_status != 0 + return self.is_finished and self.exit_status != 0 @property - def finish_status(self): + def exit_status(self): """ - Return the finish status of the Calculation + Return the exit status of the Calculation - :returns: the finish status, an integer exit code or None + :returns: the exit status, an integer exit code or None """ - return self.get_attr(self.FINISH_STATUS_KEY, None) + return self.get_attr(self.EXIT_STATUS_KEY, None) - def _set_finish_status(self, status): + def _set_exit_status(self, status): """ - Set the finish status of the Calculation + Set the exit status of the Calculation :param state: an integer exit code or None, which will be interpreted as zero """ @@ -328,9 +330,32 @@ def _set_finish_status(self, status): status = status.value if not isinstance(status, int): - raise ValueError('finish status has to be an integer, got {}'.format(status)) + raise ValueError('exit status has to be an integer, got {}'.format(status)) + + return self._set_attr(self.EXIT_STATUS_KEY, status) + + @property + def exit_message(self): + """ + Return the exit message of the Calculation + + :returns: the exit message + """ + return self.get_attr(self.EXIT_MESSAGE_KEY, None) + + def _set_exit_message(self, message): + """ + Set the exit message of the Calculation, if None nothing will be done + + :param message: a string message + """ + if message is None: + return + + if not isinstance(message, basestring): + raise ValueError('exit message has to be a string type, got {}'.format(type(message))) - return self._set_attr(self.FINISH_STATUS_KEY, status) + return self._set_attr(self.EXIT_MESSAGE_KEY, message) @property def exception(self): diff --git a/aiida/orm/implementation/general/calculation/job/__init__.py b/aiida/orm/implementation/general/calculation/job/__init__.py index 6998556e0c..6339c9f298 100644 --- a/aiida/orm/implementation/general/calculation/job/__init__.py +++ b/aiida/orm/implementation/general/calculation/job/__init__.py @@ -32,16 +32,16 @@ CALCULATION_STATE_KEY = 'state' SCHEDULER_STATE_KEY = 'attributes.scheduler_state' PROCESS_STATE_KEY = 'attributes.{}'.format(AbstractCalculation.PROCESS_STATE_KEY) -FINISH_STATUS_KEY = 'attributes.{}'.format(AbstractCalculation.FINISH_STATUS_KEY) +EXIT_STATUS_KEY = 'attributes.{}'.format(AbstractCalculation.EXIT_STATUS_KEY) DEPRECATION_DOCS_URL = 'http://aiida-core.readthedocs.io/en/latest/process/index.html#the-process-builder' _input_subfolder = 'raw_input' -class JobCalculationFinishStatus(enum.Enum): +class JobCalculationExitStatus(enum.Enum): """ This enumeration maps specific calculation states to an integer. This integer can - then be used to set the finish status of a JobCalculation node. The values defined + then be used to set the exit status of a JobCalculation node. The values defined here map directly on the failed calculation states, but the idea is that sub classes of AbstractJobCalculation can extend this enum with additional error codes """ @@ -60,25 +60,23 @@ class AbstractJobCalculation(AbstractCalculation): """ @classproperty - def finish_status_enum(cls): - return JobCalculationFinishStatus + def exit_status_enum(cls): + return JobCalculationExitStatus @property - def finish_status_label(self): + def exit_status_label(self): """ - Return the label belonging to the finish status of the Calculation + Return the label belonging to the exit status of the Calculation - :returns: the finish status, an integer exit code or None + :returns: the exit status label """ - finish_status = self.finish_status - try: - finish_status_enum = self.finish_status_enum(finish_status) - finish_status_label = finish_status_enum.name + exit_status_enum = self.exit_status_enum(self.exit_status) + exit_status_label = exit_status_enum.name except ValueError: - finish_status_label = 'UNKNOWN' + exit_status_label = 'UNKNOWN' - return finish_status_label + return exit_status_label _cacheable = True @@ -699,7 +697,7 @@ def _is_running(self): def finished_ok(self): """ Returns whether the Calculation has finished successfully, which means that it - terminated nominally and had a zero exit code indicating a successful execution + terminated nominally and had a zero exit status indicating a successful execution :return: True if the calculation has finished successfully, False otherwise :rtype: bool @@ -922,7 +920,7 @@ def _get_last_jobinfo(self): 'scheduler_state': ('calculation', SCHEDULER_STATE_KEY), 'calculation_state': ('calculation', CALCULATION_STATE_KEY), 'process_state': ('calculation', PROCESS_STATE_KEY), - 'finish_status': ('calculation', FINISH_STATUS_KEY), + 'exit_status': ('calculation', EXIT_STATUS_KEY), 'sealed': ('calculation', SEALED_KEY), 'type': ('calculation', 'type'), 'description': ('calculation', 'description'), @@ -933,7 +931,7 @@ def _get_last_jobinfo(self): } compound_projection_map = { - 'state': ('calculation', (PROCESS_STATE_KEY, FINISH_STATUS_KEY)), + 'state': ('calculation', (PROCESS_STATE_KEY, EXIT_STATUS_KEY)), 'job_state': ('calculation', ('state', SCHEDULER_STATE_KEY)) } @@ -984,7 +982,7 @@ def _list_calculations( 'pk': 'PK', 'state': 'State', 'process_state': 'Process state', - 'finish_status': 'Finish status', + 'exit_status': 'Exit status', 'sealed': 'Sealed', 'ctime': 'Creation', 'mtime': 'Modification', diff --git a/aiida/work/__init__.py b/aiida/work/__init__.py index f78f96ed34..6889caecaa 100644 --- a/aiida/work/__init__.py +++ b/aiida/work/__init__.py @@ -11,6 +11,7 @@ from plumpy import Bundle from plumpy import ProcessState from .exceptions import * +from .exit_code import * from .futures import * from .launch import * from .job_processes import * @@ -22,7 +23,7 @@ from .workfunctions import * from .workchain import * -__all__ = (exceptions.__all__ + processes.__all__ + runners.__all__ + utils.__all__ + +__all__ = (exceptions.__all__ + exit_code.__all__ + processes.__all__ + runners.__all__ + utils.__all__ + workchain.__all__ + launch.__all__ + workfunctions.__all__ + ['ProcessState'] + job_processes.__all__ + rmq.__all__ + futures.__all__ + persistence.__all__) diff --git a/aiida/work/exceptions.py b/aiida/work/exceptions.py index 6dcb442609..ecdf62de7b 100644 --- a/aiida/work/exceptions.py +++ b/aiida/work/exceptions.py @@ -2,28 +2,7 @@ """Exceptions that can be thrown by parts of the workflow engine.""" from aiida.common.exceptions import AiidaException -__all__ = ['Exit', 'PastException'] - - -class Exit(AiidaException): - """ - This can be raised from within a workfunction to tell it to exit immediately, but as opposed to all other - exceptions will not cause the workflow engine to mark the workfunction as excepted. Rather it will take - the exit code set for the exception and set that as the finish status of the workfunction. - """ - - def __init__(self, exit_code=0): - """ - Construct the exception with a given exit code - - :param exit_code: the integer exit code, default is 0 - """ - super(Exit, self).__init__() - self._exit_code = exit_code - - @property - def exit_code(self): - return self._exit_code +__all__ = ['PastException'] class PastException(AiidaException): diff --git a/aiida/work/exit_code.py b/aiida/work/exit_code.py new file mode 100644 index 0000000000..5318da6327 --- /dev/null +++ b/aiida/work/exit_code.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""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') +ExitCode.__new__.__defaults__ = (0, None) +""" +A namedtuple to define an exit code for a :class:`~aiida.work.processes.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. + +:param status: positive integer exit status, where a non-zero value indicated the process failed, default is `0` +:param message: string, optional message with more details about the failure mode +""" + + +class ExitCodesNamespace(AttributeDict): + """ + 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 __call__(self, identifier): + """ + 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 + :returns: an ExitCode named tuple + :raises ValueError: if no exit code with the given label is defined for this process + """ + if isinstance(identifier, int): + for exit_code in self.values(): + if exit_code.status == identifier: + return exit_code + + raise ValueError('the exit code status {} does not correspond to a valid exit code') + else: + try: + exit_code = self[identifier] + except KeyError: + raise ValueError('the exit code label {} does not correspond to a valid exit code') + else: + return exit_code diff --git a/aiida/work/job_processes.py b/aiida/work/job_processes.py index 390de2e87c..0ea1365262 100644 --- a/aiida/work/job_processes.py +++ b/aiida/work/job_processes.py @@ -20,7 +20,7 @@ from aiida.common.lang import override from aiida.daemon import execmanager from aiida.orm.calculation.job import JobCalculation -from aiida.orm.calculation.job import JobCalculationFinishStatus +from aiida.orm.calculation.job import JobCalculationExitStatus from aiida.scheduler.datastructures import job_states from aiida.work.process_builder import JobProcessBuilder @@ -252,9 +252,8 @@ def execute(self): raise RuntimeError("Unknown waiting command") except TransportTaskException as exception: - finish_status = JobCalculationFinishStatus[exception.calc_state] - raise Return( - self.create_state(processes.ProcessState.FINISHED, finish_status, finish_status is 0)) + exit_status = JobCalculationExitStatus[exception.calc_state] + raise Return(self.create_state(processes.ProcessState.FINISHED, exit_status, exit_status is 0)) except plumpy.CancelledError: # A task was cancelled because the state (and process) is being killed next_state = yield self._do_kill() diff --git a/aiida/work/process_spec.py b/aiida/work/process_spec.py index 5ca74d82bc..458c331bfc 100644 --- a/aiida/work/process_spec.py +++ b/aiida/work/process_spec.py @@ -2,6 +2,7 @@ """AiiDA specific implementation of plumpy's ProcessSpec.""" import plumpy +from aiida.work.exit_code import ExitCode, ExitCodesNamespace from aiida.work.ports import InputPort, PortNamespace @@ -13,3 +14,38 @@ class ProcessSpec(plumpy.ProcessSpec): INPUT_PORT_TYPE = InputPort PORT_NAMESPACE_TYPE = PortNamespace + + def __init__(self): + super(ProcessSpec, self).__init__() + self._exit_codes = ExitCodesNamespace() + + @property + def exit_codes(self): + """ + Return the namespace of exit codes defined for this ProcessSpec + + :returns: ExitCodesNamespace of ExitCode named tuples + """ + return self._exit_codes + + def exit_code(self, status, label, message): + """ + Add an exit code to the ProcessSpec + + :param status: the exit status integer + :param label: a label by which the exit code can be addressed + :param message: a more detailed description of the exit code + """ + if not isinstance(status, int): + raise TypeError('status should be of integer type and not of {}'.format(type(status))) + + if status < 0: + raise ValueError('status should be a positive integer, received {}'.format(type(status))) + + if not isinstance(label, basestring): + raise TypeError('label should be of basestring type and not of {}'.format(type(label))) + + if not isinstance(message, basestring): + raise TypeError('message should be of basestring type and not of {}'.format(type(message))) + + self._exit_codes[label] = ExitCode(status, message) diff --git a/aiida/work/processes.py b/aiida/work/processes.py index d9c90c8e45..a371fd859d 100644 --- a/aiida/work/processes.py +++ b/aiida/work/processes.py @@ -19,6 +19,7 @@ from pika.exceptions import ConnectionClosed from plumpy import ProcessState + from aiida.common import exceptions from aiida.common.lang import override, protected from aiida.common.links import LinkType @@ -30,9 +31,8 @@ from aiida.orm.calculation.work import WorkCalculation from aiida.orm.data import Data from aiida.utils.serialize import serialize_data, deserialize_data -from aiida.work.exceptions import Exit from aiida.work.ports import InputPort, PortNamespace -from aiida.work.process_spec import ProcessSpec +from aiida.work.process_spec import ProcessSpec, ExitCode from aiida.work.process_builder import ProcessBuilder from .runners import get_runner from . import utils @@ -235,7 +235,7 @@ def on_terminated(self): try: self.runner.persister.delete_checkpoint(self.pid) except BaseException as exception: - self.logger.warning("Failed to delete checkpoint: {}\n{}".format(exception, traceback.format_exc())) + self.logger.warning('Failed to delete checkpoint: {}\n{}'.format(exception, traceback.format_exc())) try: self.calc.seal() @@ -260,7 +260,14 @@ def on_finish(self, result, successful): Set the finish status on the Calculation node """ super(Process, self).on_finish(result, successful) - self.calc._set_finish_status(result) + + if result is None or isinstance(result, int): + self.calc._set_exit_status(result) + elif isinstance(result, ExitCode): + self.calc._set_exit_status(result.status) + self.calc._set_exit_message(result.message) + else: + raise ValueError('the result should be an integer, ExitCode or None, got {}'.format(type(result))) @override def on_output_emitting(self, output_port, value): @@ -271,9 +278,10 @@ def on_output_emitting(self, output_port, value): :param value: The value emitted """ super(Process, self).on_output_emitting(output_port, value) + if not isinstance(value, Data): raise TypeError( - 'Values outputted from process must be instances of AiiDA Data types. Got: {}'.format(value.__class__) + 'Values output from process must be instances of AiiDA Data types, got {}'.format(value.__class__) ) # end region @@ -693,22 +701,19 @@ def _run(self): except ValueError: kwargs[name] = value - try: - result = self._func(*args, **kwargs) - except Exit as exception: - finish_status = exception.exit_code - else: - finish_status = 0 + result = self._func(*args, **kwargs) - if result is not None: - if isinstance(result, Data): - self.out(self.SINGLE_RETURN_LINKNAME, result) - elif isinstance(result, collections.Mapping): - for name, value in result.iteritems(): - self.out(name, value) - else: - raise TypeError( - "Workfunction returned unsupported type '{}'\n" - "Must be a Data type or a Mapping of {{string: Data}}".format(result.__class__)) + if result is None or isinstance(result, ExitCode): + return result + + if isinstance(result, Data): + self.out(self.SINGLE_RETURN_LINKNAME, result) + elif isinstance(result, collections.Mapping): + for name, value in result.iteritems(): + self.out(name, value) + else: + raise TypeError( + "Workfunction returned unsupported type '{}'\n" + "Must be a Data type or a Mapping of {{string: Data}}".format(result.__class__)) - return finish_status + return ExitCode() diff --git a/aiida/work/workchain.py b/aiida/work/workchain.py index 7e90cfb5ac..ac569c32e9 100644 --- a/aiida/work/workchain.py +++ b/aiida/work/workchain.py @@ -12,10 +12,10 @@ from plumpy import auto_persist, WorkChainSpec, Wait, Continue from plumpy.workchains import if_, while_, return_, _PropagateReturn - from aiida.common.exceptions import MultipleObjectsError, NotExistent from aiida.common.extendeddicts import AttributeDict from aiida.common.lang import override +from aiida.common.utils import classproperty from aiida.orm.utils import load_node, load_workflow from aiida.utils.serialize import serialize_data, deserialize_data @@ -90,6 +90,17 @@ def on_run(self): super(WorkChain, self).on_run() self.calc.set_stepper_state_info(str(self._stepper)) + @classproperty + def exit_codes(self): + """ + Return the namespace of exit codes defined for this WorkChain through its ProcessSpec. + The namespace supports getitem and getattr operations with an ExitCode label to retrieve a specific code. + Additionally, the namespace can also be called with either the exit code integer status to retrieve it. + + :returns: ExitCodesNamespace of ExitCode named tuples + """ + return self.spec().exit_codes + def insert_awaitable(self, awaitable): """ Insert a awaitable that will cause the workchain to wait until the wait diff --git a/docs/source/concepts/processes.rst b/docs/source/concepts/processes.rst index 9083f2895a..5a3100d330 100644 --- a/docs/source/concepts/processes.rst +++ b/docs/source/concepts/processes.rst @@ -70,25 +70,26 @@ The ``Excepted`` state indicates that during execution an exception occurred tha The final option is the ``Finished`` state, which means that the process was successfully executed, and the execution was nominal. Note that this does not automatically mean that the result of the process can also considered to be successful, it just executed without any problems. -To distinghuis between a successful and a failed execution, we have introduced the 'finish status'. +To distinghuis between a successful and a failed execution, we have introduced the 'exit status'. This is another attribute that is stored in the node of the process and is an integer that can be set by the process. A zero means that the result of the process was successful, and a non-zero value indicates a failure. -All the calculation nodes used by the various processes are a sub class of :py:class:`~aiida.orm.implementation.general.calculation.AbstractCalculation`, which defines handy properties to query the process state and finish status +All the calculation nodes used by the various processes are a sub class of :py:class:`~aiida.orm.implementation.general.calculation.AbstractCalculation`, which defines handy properties to query the process state and exit status. =================== ============================================================================================ Method Explanation =================== ============================================================================================ ``process_state`` Returns the current process state -``finish_status`` Returns the finish status, or None if not set +``exit_status`` Returns the exit status, or None if not set +``exit_message`` Returns the exit message, or None if not set ``is_terminated`` Returns ``True`` if the process was either ``Killed``, ``Excepted`` or ``Finished`` ``is_killed`` Returns ``True`` if the process is ``Killed`` ``is_excepted`` Returns ``True`` if the process is ``Excepted`` ``is_finished`` Returns ``True`` if the process is ``Finished`` -``is_finished_ok`` Returns ``True`` if the process is ``Finished`` and the ``finish_status`` is equal to zero -``is_failed`` Returns ``True`` if the process is ``Finished`` and the ``finish_status`` is non-zero +``is_finished_ok`` Returns ``True`` if the process is ``Finished`` and the ``exit_status`` is equal to zero +``is_failed`` Returns ``True`` if the process is ``Finished`` and the ``exit_status`` is non-zero =================== ============================================================================================ -When you load a calculation node from the database, you can use these property methods to inquire about its state and finish status. +When you load a calculation node from the database, you can use these property methods to inquire about its state and exit status. .. _process_builder: diff --git a/docs/source/concepts/workflows.rst b/docs/source/concepts/workflows.rst index 6aff9cde12..090c1ee6bb 100644 --- a/docs/source/concepts/workflows.rst +++ b/docs/source/concepts/workflows.rst @@ -261,7 +261,7 @@ A typical example may look something like the following: Total results: 2 -The 'State' column is a concatenation of the ``process_state`` and the ``finish_status`` of the ``WorkCalculation``. +The 'State' column is a concatenation of the ``process_state`` and the ``exit_status`` of the ``WorkCalculation``. By default, the command will only show active items, i.e. ``WorkCalculations`` that have not yet reached a terminal state. If you want to also show the nodes in a terminal states, you can use the ``-a`` flag and call ``verdi work list -a``: @@ -278,7 +278,7 @@ If you want to also show the nodes in a terminal states, you can use the ``-a`` Total results: 4 For more information on the meaning of the 'state' column, please refer to the documentation of the :ref:`process state `. -The ``-s`` flag let's you query for specific process states, i.e. issuing ``verdi work list -s created`` will return: +The ``-S`` flag let's you query for specific process states, i.e. issuing ``verdi work list -S created`` will return: .. code-block:: bash @@ -289,7 +289,7 @@ The ``-s`` flag let's you query for specific process states, i.e. issuing ``verd Total results: 1 -To query for a specific finish status, one can use ``verdi work list -f 0``: +To query for a specific exit status, one can use ``verdi work list -E 0``: .. code-block:: bash @@ -365,7 +365,7 @@ An example output for a ``PwBaseWorkChain`` would look like the following: ctime 2018-04-08 21:18:50.850361+02:00 mtime 2018-04-08 21:18:50.850372+02:00 process state ProcessState.FINISHED - finish status 0 + exit status 0 code pw-v6.1 Inputs PK Type @@ -591,6 +591,42 @@ The actual implementation of the outline steps themselves is now trivial: The intention of this example is to show that with a well designed outline, a user only has to look at the outline to have a good idea *what* the workchain does and *how* it does it. One should not have to look at the implementation of the outline steps as all the important information is captured by the outline itself. +.. _exit_codes: + +Exit codes +^^^^^^^^^^ +Any ``WorkChain`` most likely will have one or multiple expected failure modes. +To clearly communicate to the caller what went wrong, the ``WorkChain`` supports setting its ``exit_status``. +This ``exit_status``, a positive integer, is an attribute of the calculation node and by convention, when it is zero means the process was successful, whereas any other value indicates failure. +This concept of an exit code, with a positive integer as the exit status, `is a common concept in programming `_ and a standard way for programs to communicate the result of their execution. + +Potential exit codes for the ``WorkChain`` can be defined through the ``ProcessSpec``, just like inputs and ouputs. +Any exit code consists of a positive non-zero integer, a string label to reference it and a more detailed description of the problem that triggers the exit code. +Consider the following example: + +.. code:: python + + spec.exit_code(418, 'ERROR_I_AM_A_TEAPOT', 'the workchain had an identity crisis') + +This defines an exit code for the ``WorkChain`` with exit status ``418`` and exit message ``the workchain had an identity crisis``. +The string ``ERROR_I_AM_A_TEAPOT`` is a label that the developer can use to reference this particular exit code somewhere in the ``WorkChain`` code itself. +A detailed explanation of how this is accomplished `will be explained in a later section `_. + +Whenever a ``WorkChain`` exits through a particular error code, the caller will be able to introspect it through the ``exit_status`` and ``exit_message`` attributes of the node. +Assume for example that we ran a ``WorkChain`` that threw the exit code described above, the caller would be able to do the following: + +.. code:: python + + in[1] workchain = load_node() + in[2] workchain.exit_status + out[2] 418 + in[2] workchain.exit_message + out[2] 'the workchain had an identity crisis' + +This is useful, because the caller can now programmatically, based on the ``exit_status``, decide how to proceed. +This is an infinitely more robust way of communcating specific errors to a non-human then parsing text based logs or reports (see the section on :ref:`reporting `). + + .. _reporting: Reporting @@ -702,13 +738,24 @@ The ``self.ctx.workchains`` now contains a list with the nodes of the completed Note that the use of ``append_`` is not just limited to the ``to_context`` method. You can also use it in exactly the same way with ``ToContext`` to append a process to a list in the context in multiple outline steps. +.. _aborting_and_exit_codes: + Aborting and exit codes ----------------------- At the end of every outline step, the return value will be inspected by the workflow engine. If a non-zero integer value is detected, the workflow engine will interpret this as an exit code and will stop the execution of the workchain, while setting its process state to ``Finished``. -In addition, the integer return value will be set as the ``finish_status`` of the workchain, which combined with the ``Finished`` process state will denote that the worchain is considered to be ``Failed``, as explained in the section on the :ref:`process state `. +In addition, the integer return value will be set as the ``exit_status`` of the workchain, which combined with the ``Finished`` process state will denote that the worchain is considered to be ``Failed``, as explained in the section on the :ref:`process state `. This is useful because it allows a workflow designer to easily exit from a workchain and use the return value to communicate programmatically the reason for the workchain stopping. -Consider the following example, where we launch a calculation and in the next step check whether it finished successfully, and if not we exit the workchain: + +We assume that you have read the `section on how to define exit code `_ through the process specification of the workchain. +Consider the following example workchain that defines such an exit code: + +.. code:: python + + spec.exit_code(400, 'ERROR_CALCULATION_FAILED', 'the child calculation did not finish successfully') + +Now imagine that in the outline, we launch a calculation and in the next step check whether it finished successfully. +In the event that the calculation did not finish successfully, the following snippet shows how you can retrieve the corresponding exit code and abort the ``WorkChain`` by returning it: .. code:: python @@ -720,56 +767,43 @@ Consider the following example, where we launch a calculation and in the next st def inspect_calculation(self): if not self.ctx.calculation.is_finished_ok: self.report('the calculation did not finish successfully, there is nothing we can do') - return 256 + return self.exit_codes.ERROR_CALCULATION_FAILED self.report('the calculation finished successfully') In the ``inspect_calculation`` outline, we retrieve the calculation that was submitted and added to the context in the previous step and check if it finished successfully through the property ``is_finished_ok``. -If this returns ``False``, in this example we simply fire a report message and return the integer ``256``. -Note that this value was randomly chosen for the example, but any non-zero integer will do. -A good practice would be to define constants in your workchain class with the various potential exit codes that can be returned in the workchains execution and use those instead. -For example: - -.. code:: python - - class SomeWorkChain(WorkChain): - - ERROR_CALCULATION_FAILED = 256 - - def inspect_calculation(self): - if not self.ctx.calculation.is_finished_ok: - self.report('the calculation did not finish successfully, there is nothing we can do') - return self.ERROR_CALCULATION_FAILED - -.. note:: +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.work.exit_code.ExitCode` named tuple, will cause the workchain to be aborted and the ``exit_status`` and ``exit_message`` to be set on the node, which were defined in the spec. - Make sure that you do not define these in the constructor of the ``WorkChain`` class, as that code will not be reexecuted when the workchain is persisted and subsequently loaded again, which happen for example if it is submitted to the daemon. - Instead, as in the example above, define these constants as class variables. - This will guarantee that they will always be defined, even when the workchain is loaded from a persisted state +.. 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. + Constructing your own ``ExitCode`` directly and returning that from the outline step will have exactly the same effect in terms of aborting the workchain 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 workchain. -The best part about this method of aborting a workchains execution, is that the finish status can now be used programmatically, by for example a parent workchain. +The best part about this method of aborting a workchains execution, is that the exit status can now be used programmatically, by for example a parent workchain. Imagine that a parent workchain submitted this workchain. After it has terminated its execution, the parent workchain will want to know what happened to the child workchain. As already noted in the :ref:`report` section, the report messages of the workchain should not be used. -The finish status, however, is a perfect way. -The parent workchain can easily request the finish status of the child workchain through the ``finish_status`` property, and based on its value determine how to continue. +The exit status, however, is a perfect way. +The parent workchain can easily request the exit status of the child workchain through the ``exit_status`` property, and based on its value determine how to proceed. Workfunction exit codes ^^^^^^^^^^^^^^^^^^^^^^^ -The method of setting the finish status for a ``WorkChain`` as explained in the previous section, by simply returning an integer from any of the outline steps, will not work of course for workfunctions, as there you can only return once and the return value has to be a database storable type. -To still be able to mark a workfunction as ``failed'' by letting it finish nominally, but setting a non-zero finish status, we provide a special exception class :py:class:`~aiida.work.exceptions.Exit`. -This exception can be constructed with an integer, to denote the desired finish status and when raised from a workfunction, workflow engine will mark the node as ``Finished`` and set the finish status to the value of the exception. +The method of setting the exit status for a ``WorkChain`` by returning an ``ExitCode``, as explained in the previous section, works almost exactly the same for ``workfunctions``. +The only difference is that for a workfunction, we do not have access to the convenience ``exit_codes`` property of then ``WorkChain``, but rather we have to import and return an ``ExitCode`` ourselves. +This named tuple can be constructed with an integer, to denote the desired exit status and an optional message, and when returned, the workflow engine will mark the node of the workfunction as ``Finished`` and set the exit status and message to the value of the tuple. Consider the following example: .. code:: python @workfunction def exiting_workfunction(): - from aiida.work import Exit - raise Exit(418) + from aiida.work import ExitCode + return ExitCode(418, 'I am a teapot') -The execution of the workfunction will be immediately terminated as soon as the exception is raised and the finish status will be set to ``418`` in this example. -Since no output nodes are returned, the ``FunctionCalculation`` node will have no outputs. +The execution of the workfunction 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. +Since no output nodes are returned, the ``FunctionCalculation`` node will have no outputs and the value returned from the function call will be an empty dictionary. Modular workflow design -----------------------