diff --git a/docs/source/_static/images/dry-run.svg b/docs/source/_static/images/dry-run.svg new file mode 100644 index 00000000..cb909827 --- /dev/null +++ b/docs/source/_static/images/dry-run.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pytask + + + + + + + + + + ───────────────────────────────────────────────── Start pytask session ───────────────────────────────────────────────── +Platform: win32 -- Python 3.10.4, pytask 0.2.5., pluggy 1.0.0 +Root: C:\Users\TobiasR\git\pytask +Collected 1 task. + + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ +Task                         Outcome +┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ +task_dry_run.py::task_dry_runw       +└───────────────────────────────┴─────────┘ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭──────────── Summary ─────────────╮ +1Collected tasks   +1Would be executed(100.0%) +╰──────────────────────────────────╯ +──────────────────────────────────────────────── Succeeded in 0 seconds ──────────────────────────────────────────────── + + + + diff --git a/docs/source/changes.md b/docs/source/changes.md index 38d719f8..a1f05cd1 100644 --- a/docs/source/changes.md +++ b/docs/source/changes.md @@ -9,6 +9,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and - {pull}`288` fixes pinning pybaum to v0.1.1 or a version that supports `tree_yield()`. - {pull}`289` shortens the task ids when using `pytask collect`. Fixes {issue}`286`. +- {pull}`290` implements a dry-run with `pytask --dry-run` to see which tasks would be + executed. ## 0.2.4 - 2022-06-28 diff --git a/docs/source/tutorials/invoking_pytask.md b/docs/source/tutorials/invoking_pytask.md index 864b0ec2..de4271c8 100644 --- a/docs/source/tutorials/invoking_pytask.md +++ b/docs/source/tutorials/invoking_pytask.md @@ -76,3 +76,15 @@ To stop the build of the project after the first `n` failures use $ pytask -x | --stop-after-first-failure # Stop after the first failure $ pytask --max-failures 2 # Stop after the second failure ``` + +### Performing a dry-run + +If you want to see which tasks would be executed without executing them, you can do a +dry-run. + +```console +$ pytask --dry-run +``` + +```{image} /_static/images/dry-run.svg +``` diff --git a/scripts/svgs/task_dry_run.py b/scripts/svgs/task_dry_run.py new file mode 100644 index 00000000..0c01672e --- /dev/null +++ b/scripts/svgs/task_dry_run.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pytask + + +@pytask.mark.produces("out.txt") +def task_dry_run(produces): + produces.write_text("This text file won't be produced in a dry-run.") + + +if __name__ == "__main__": + pytask.console.record = True + pytask.main({"paths": __file__, "dry_run": True}) + pytask.console.save_svg("dry-run.svg", title="pytask") diff --git a/src/_pytask/build.py b/src/_pytask/build.py index 62a60c02..3cb52a2b 100644 --- a/src/_pytask/build.py +++ b/src/_pytask/build.py @@ -126,6 +126,7 @@ def main(config_from_cli: dict[str, Any]) -> Session: "[dim]\\[default: True][/]" ), ) +@click.option("--dry-run", type=bool, is_flag=True, help="Perform a dry-run.") def build(**config_from_cli: Any) -> NoReturn: """Collect tasks, execute them and report the results. diff --git a/src/_pytask/config.py b/src/_pytask/config.py index ebae0b4d..bd361c6f 100644 --- a/src/_pytask/config.py +++ b/src/_pytask/config.py @@ -246,6 +246,8 @@ def pytask_parse_config( config_from_file.get("sort_table", True) ) + config["dry_run"] = config_from_cli.get("dry_run", False) + @hookimpl def pytask_post_parse(config: dict[str, Any]) -> None: diff --git a/src/_pytask/execute.py b/src/_pytask/execute.py index f7380fb7..dfcd1d51 100644 --- a/src/_pytask/execute.py +++ b/src/_pytask/execute.py @@ -20,11 +20,13 @@ from _pytask.exceptions import ExecutionError from _pytask.exceptions import NodeNotFoundError from _pytask.mark import Mark +from _pytask.mark_utils import has_mark from _pytask.nodes import FilePathNode from _pytask.nodes import Task from _pytask.outcomes import count_outcomes from _pytask.outcomes import Exit from _pytask.outcomes import TaskOutcome +from _pytask.outcomes import WouldBeExecuted from _pytask.report import ExecutionReport from _pytask.session import Session from _pytask.shared import get_first_non_none_value @@ -151,19 +153,26 @@ def pytask_execute_task_setup(session: Session, task: Task) -> None: if isinstance(node, FilePathNode): node.path.parent.mkdir(parents=True, exist_ok=True) + would_be_executed = has_mark(task, "would_be_executed") + if would_be_executed: + raise WouldBeExecuted + @hookimpl(trylast=True) -def pytask_execute_task(task: Task) -> bool: +def pytask_execute_task(session: Session, task: Task) -> bool: """Execute task.""" - kwargs = {**task.kwargs} + if session.config["dry_run"]: + raise WouldBeExecuted() + else: + kwargs = {**task.kwargs} - func_arg_names = set(inspect.signature(task.function).parameters) - for arg_name in ("depends_on", "produces"): - if arg_name in func_arg_names: - attribute = getattr(task, arg_name) - kwargs[arg_name] = tree_map(lambda x: x.value, attribute) + func_arg_names = set(inspect.signature(task.function).parameters) + for arg_name in ("depends_on", "produces"): + if arg_name in func_arg_names: + attribute = getattr(task, arg_name) + kwargs[arg_name] = tree_map(lambda x: x.value, attribute) - task.execute(**kwargs) + task.execute(**kwargs) return True @@ -201,6 +210,18 @@ def pytask_execute_task_process_report( task = report.task if report.outcome == TaskOutcome.SUCCESS: update_states_in_database(session.dag, task.name) + elif report.exc_info and isinstance(report.exc_info[1], WouldBeExecuted): + report.outcome = TaskOutcome.WOULD_BE_EXECUTED + + for descending_task_name in descending_tasks(task.name, session.dag): + descending_task = session.dag.nodes[descending_task_name]["task"] + descending_task.markers.append( + Mark( + "would_be_executed", + (), + {"reason": f"Previous task {task.name!r} would be executed."}, + ) + ) else: for descending_task_name in descending_tasks(task.name, session.dag): descending_task = session.dag.nodes[descending_task_name]["task"] diff --git a/src/_pytask/outcomes.py b/src/_pytask/outcomes.py index a9ab3480..788cbacb 100644 --- a/src/_pytask/outcomes.py +++ b/src/_pytask/outcomes.py @@ -103,6 +103,7 @@ class TaskOutcome(Enum): SKIP = auto() SKIP_PREVIOUS_FAILED = auto() FAIL = auto() + WOULD_BE_EXECUTED = auto() @property def symbol(self) -> str: @@ -114,6 +115,7 @@ def symbol(self) -> str: TaskOutcome.SKIP: "s", TaskOutcome.SKIP_PREVIOUS_FAILED: "F", TaskOutcome.FAIL: "F", + TaskOutcome.WOULD_BE_EXECUTED: "w", } assert len(symbols) == len(TaskOutcome) return symbols[self] @@ -128,6 +130,7 @@ def description(self) -> str: TaskOutcome.SKIP: "Skipped", TaskOutcome.SKIP_PREVIOUS_FAILED: "Skipped because previous failed", TaskOutcome.FAIL: "Failed", + TaskOutcome.WOULD_BE_EXECUTED: "Would be executed", } assert len(descriptions) == len(TaskOutcome) return descriptions[self] @@ -142,6 +145,7 @@ def style(self) -> str: TaskOutcome.SKIP: "skipped", TaskOutcome.SKIP_PREVIOUS_FAILED: "failed", TaskOutcome.FAIL: "failed", + TaskOutcome.WOULD_BE_EXECUTED: "success", } assert len(styles) == len(TaskOutcome) return styles[self] @@ -156,6 +160,7 @@ def style_textonly(self) -> str: TaskOutcome.SKIP: "skipped.textonly", TaskOutcome.SKIP_PREVIOUS_FAILED: "failed.textonly", TaskOutcome.FAIL: "failed.textonly", + TaskOutcome.WOULD_BE_EXECUTED: "success.textonly", } assert len(styles_textonly) == len(TaskOutcome) return styles_textonly[self] @@ -218,6 +223,10 @@ class Persisted(PytaskOutcome): """Outcome if task should persist.""" +class WouldBeExecuted(PytaskOutcome): + """Outcome if a task would be executed.""" + + class Exit(Exception): """Raised for immediate program exits (no tracebacks/summaries).""" diff --git a/src/_pytask/skipping.py b/src/_pytask/skipping.py index 83706818..539fe48c 100644 --- a/src/_pytask/skipping.py +++ b/src/_pytask/skipping.py @@ -49,7 +49,9 @@ def pytask_parse_config(config: dict[str, Any]) -> None: @hookimpl def pytask_execute_task_setup(task: Task) -> None: """Take a short-cut for skipped tasks during setup with an exception.""" - is_unchanged = has_mark(task, "skip_unchanged") + is_unchanged = has_mark(task, "skip_unchanged") and not has_mark( + task, "would_be_executed" + ) if is_unchanged: raise SkippedUnchanged diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py new file mode 100644 index 00000000..0fcb8f57 --- /dev/null +++ b/tests/test_dry_run.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import textwrap + +import pytest +from pytask import cli +from pytask import ExitCode + + +@pytest.mark.end_to_end +def test_dry_run(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Would be executed" in result.output + assert not tmp_path.joinpath("out.txt").exists() + + +@pytest.mark.end_to_end +def test_dry_run_w_subsequent_task(runner, tmp_path): + """Subsequent tasks would be executed if their previous task changed.""" + source = """ + import pytask + + @pytask.mark.depends_on("out.txt") + @pytask.mark.produces("out_2.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source)) + + source = """ + import pytask + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "2 Succeeded" in result.output + + tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "2 Would be executed" in result.output + + +@pytest.mark.end_to_end +def test_dry_run_w_subsequent_skipped_task(runner, tmp_path): + """A skip is more important than a would be run.""" + source = """ + import pytask + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source)) + + source = """ + import pytask + + @pytask.mark.depends_on("out.txt") + @pytask.mark.produces("out_2.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "2 Succeeded" in result.output + + source = """ + import pytask + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source)) + + source = """ + import pytask + + @pytask.mark.skip + @pytask.mark.depends_on("out.txt") + @pytask.mark.produces("out_2.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Would be executed" in result.output + assert "1 Skipped" in result.output + + +@pytest.mark.end_to_end +def test_dry_run_skip(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.skip + def task_example_skip(): ... + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Would be executed" in result.output + assert "1 Skipped" in result.output + assert not tmp_path.joinpath("out.txt").exists() + + +@pytest.mark.end_to_end +def test_dry_run_skip_all(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.skip + @pytask.mark.produces("out.txt") + def task_example_skip(): ... + + @pytask.mark.skip + @pytask.mark.depends_on("out.txt") + def task_example_skip_subsequent(): ... + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "2 Skipped" in result.output + + +@pytest.mark.end_to_end +def test_dry_run_skipped_successful(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Succeeded" in result.output + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Skipped because unchanged" in result.output + + +@pytest.mark.end_to_end +def test_dry_run_persisted(runner, tmp_path): + source = """ + import pytask + + @pytask.mark.persist + @pytask.mark.produces("out.txt") + def task_example(produces): + produces.touch() + """ + tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source)) + + result = runner.invoke(cli, [tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Succeeded" in result.output + + tmp_path.joinpath("out.txt").write_text("Changed text file.") + + result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()]) + + assert result.exit_code == ExitCode.OK + assert "1 Persisted" in result.output