From ec4e2cfc6e4953046ace6443775feda990a263e9 Mon Sep 17 00:00:00 2001 From: Vitali Tsimoshka Date: Tue, 26 Oct 2021 22:55:34 +0300 Subject: [PATCH] Update `ignore_fail` option to support non-zero exit status from sequence (#40) Allow two addition option value for `ingore_fail` option: - `return_zero`: works like true - `return_non_zero`: run all the subtasks regardless theirs exit status, but returns non-zero exit status if any of subtasks failed --- README.rst | 12 +++++-- poethepoet/task/base.py | 2 +- poethepoet/task/cmd.py | 4 ++- poethepoet/task/ref.py | 4 ++- poethepoet/task/script.py | 2 +- poethepoet/task/sequence.py | 28 +++++++++++++-- poethepoet/task/shell.py | 4 +-- tests/test_ignore_fail.py | 68 +++++++++++++++++++++++++++++++++++++ 8 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 tests/test_ignore_fail.py diff --git a/README.rst b/README.rst index 908c629b8..d4ddc916d 100644 --- a/README.rst +++ b/README.rst @@ -239,13 +239,21 @@ scripts (shell), and composite tasks (sequence). ] release.default_item_type = "script" - A failure (non-zero result) will result in the rest of the tasks in the sequence not being executed, unless the :toml:`ignore_fail` option is set on the task like so: + A failure (non-zero result) will result in the rest of the tasks in the sequence not being executed, unless the :toml:`ignore_fail` option is set on the task to :toml:`true` or :toml:`return_zero` like so: .. code-block:: toml [tool.poe.tasks] attempts.sequence = ["task1", "task2", "task3"] - attempts.ignore_fail = true + attempts.ignore_fail = "return_zero" + + If you want to run all the subtasks in the sequence but return non-zero result in the end of the sequnce if any of the subtasks have failed you can set :toml:`ignore_fail` option to the :toml:`return_non_zero` value like so: + + .. code-block:: toml + + [tool.poe.tasks] + attempts.sequence = ["task1", "task2", "task3"] + attempts.ignore_fail = "return_non_zero" Task level configuration ======================== diff --git a/poethepoet/task/base.py b/poethepoet/task/base.py index 7711a1505..9faec76db 100644 --- a/poethepoet/task/base.py +++ b/poethepoet/task/base.py @@ -63,7 +63,7 @@ class PoeTask(metaclass=MetaPoeTask): content: TaskContent options: Dict[str, Any] - __options__: Dict[str, Type] = {} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = {} __content_type__: Type = str __base_options: Dict[str, Union[Type, Tuple[Type, ...]]] = { "args": (dict, list), diff --git a/poethepoet/task/cmd.py b/poethepoet/task/cmd.py index c1d61549b..4b0770adc 100644 --- a/poethepoet/task/cmd.py +++ b/poethepoet/task/cmd.py @@ -6,7 +6,9 @@ MutableMapping, Sequence, Type, + Tuple, TYPE_CHECKING, + Union, ) from .base import PoeTask @@ -26,7 +28,7 @@ class CmdTask(PoeTask): content: str __key__ = "cmd" - __options__: Dict[str, Type] = {} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = {} def _handle_run( self, diff --git a/poethepoet/task/ref.py b/poethepoet/task/ref.py index e171e3b6e..21ad18043 100644 --- a/poethepoet/task/ref.py +++ b/poethepoet/task/ref.py @@ -5,7 +5,9 @@ Optional, Sequence, Type, + Tuple, TYPE_CHECKING, + Union, ) from .base import PoeTask @@ -24,7 +26,7 @@ class RefTask(PoeTask): content: str __key__ = "ref" - __options__: Dict[str, Type] = {} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = {} def _handle_run( self, diff --git a/poethepoet/task/script.py b/poethepoet/task/script.py index bc2295a92..273acb1b8 100644 --- a/poethepoet/task/script.py +++ b/poethepoet/task/script.py @@ -28,7 +28,7 @@ class ScriptTask(PoeTask): content: str __key__ = "script" - __options__: Dict[str, Type] = {} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = {} def _handle_run( self, diff --git a/poethepoet/task/sequence.py b/poethepoet/task/sequence.py index 8a1335c3f..dbc356972 100644 --- a/poethepoet/task/sequence.py +++ b/poethepoet/task/sequence.py @@ -29,7 +29,10 @@ class SequenceTask(PoeTask): __key__ = "sequence" __content_type__: Type = list - __options__: Dict[str, Type] = {"ignore_fail": bool, "default_item_type": str} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = { + "ignore_fail": (bool, str), + "default_item_type": str, + } def __init__( self, @@ -70,12 +73,21 @@ def _handle_run( # Indicate on the global context that there are multiple stages context.multistage = True + ignore_fail = self.options.get("ignore_fail") + non_zero_subtasks: List[str] = list() for subtask in self.subtasks: task_result = subtask.run(context=context, extra_args=tuple(), env=env) - if task_result and not self.options.get("ignore_fail"): + if task_result and not ignore_fail: raise ExecutionError( f"Sequence aborted after failed subtask {subtask.name!r}" ) + if task_result: + non_zero_subtasks.append(subtask.name) + + if non_zero_subtasks and ignore_fail == "return_non_zero": + raise ExecutionError( + f"Subtasks {', '.join(non_zero_subtasks)} returned non-zero exit status" + ) return 0 @classmethod @@ -90,4 +102,16 @@ def _validate_task_def( "Unsupported value for option `default_item_type` for task " f"{task_name!r}. Expected one of {cls.get_task_types(content_type=str)}" ) + ignore_fail = task_def.get("ignore_fail") + if ignore_fail is not None and ignore_fail not in ( + True, + False, + "return_zero", + "return_non_zero", + ): + return ( + "Unsupported value for option `ignore_fail` for task " + f'{task_name!r}. Expected one of (true, false, "return_zero", "return_non_zero")' + ) + return None diff --git a/poethepoet/task/shell.py b/poethepoet/task/shell.py index 1f110ec40..f16b7b65b 100644 --- a/poethepoet/task/shell.py +++ b/poethepoet/task/shell.py @@ -1,7 +1,7 @@ import os import shutil import subprocess -from typing import Dict, MutableMapping, Sequence, Type, TYPE_CHECKING +from typing import Dict, MutableMapping, Sequence, Type, Tuple, TYPE_CHECKING, Union from ..exceptions import PoeException from .base import PoeTask @@ -18,7 +18,7 @@ class ShellTask(PoeTask): content: str __key__ = "shell" - __options__: Dict[str, Type] = {} + __options__: Dict[str, Union[Type, Tuple[Type, ...]]] = {} def _handle_run( self, diff --git a/tests/test_ignore_fail.py b/tests/test_ignore_fail.py new file mode 100644 index 000000000..0580c8396 --- /dev/null +++ b/tests/test_ignore_fail.py @@ -0,0 +1,68 @@ +import pytest + + +@pytest.fixture +def generate_pyproject(tmp_path): + """Return function which generates pyproject.toml with a given ignore_fail value.""" + + def generator(ignore_fail): + project_tmpl = """ + [tool.poe.tasks] + task_1 = { shell = "echo 'task 1 error'; exit 1;" } + task_2 = { shell = "echo 'task 2 error'; exit 1;" } + task_3 = { shell = "echo 'task 3 success'; exit 0;" } + + [tool.poe.tasks.all_tasks] + sequence = ["task_1", "task_2", "task_3"] + """ + if isinstance(ignore_fail, bool) and ignore_fail: + project_tmpl += "\nignore_fail = true" + elif not isinstance(ignore_fail, bool): + project_tmpl += f'\nignore_fail = "{ignore_fail}"' + with open(tmp_path / "pyproject.toml", "w") as fp: + fp.write(project_tmpl) + + return tmp_path + + return generator + + +@pytest.mark.parametrize("fail_value", [True, "return_zero"]) +def test_full_ignore(generate_pyproject, run_poe, fail_value): + project_path = generate_pyproject(ignore_fail=fail_value) + result = run_poe("all_tasks", cwd=project_path) + assert result.code == 0, "Expected zero result" + assert "task 1 error" in result.capture, "Expected first task in log" + assert "task 2 error" in result.capture, "Expected second task in log" + assert "task 3 success" in result.capture, "Expected third task in log" + + +def test_without_ignore(generate_pyproject, run_poe): + project_path = generate_pyproject(ignore_fail=False) + result = run_poe("all_tasks", cwd=project_path) + assert result.code == 1, "Expected non-zero result" + assert "task 1 error" in result.capture, "Expected first task in log" + assert "task 2 error" not in result.capture, "Second task shouldn't run" + assert "task 3 success" not in result.capture, "Third task shouldn't run" + assert "Sequence aborted after failed subtask 'task_1'" in result.capture + + +def test_return_non_zero(generate_pyproject, run_poe): + project_path = generate_pyproject(ignore_fail="return_non_zero") + result = run_poe("all_tasks", cwd=project_path) + assert result.code == 1, "Expected non-zero result" + assert "task 1 error" in result.capture, "Expected first task in log" + assert "task 2 error" in result.capture, "Expected second task in log" + assert "task 3 success" in result.capture, "Expected third task in log" + assert "Subtasks task_1, task_2 returned non-zero exit status" in result.capture + + +def test_invalid_ingore_value(generate_pyproject, run_poe): + project_path = generate_pyproject(ignore_fail="invalid_value") + result = run_poe("all_tasks", cwd=project_path) + assert result.code == 1, "Expected non-zero result" + assert ( + "Unsupported value for option `ignore_fail` for task 'all_tasks'." + ' Expected one of (true, false, "return_zero", "return_non_zero")' + in result.capture + )