Skip to content
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
132 changes: 132 additions & 0 deletions docs/source/_static/images/dry-run.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/source/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions docs/source/tutorials/invoking_pytask.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
14 changes: 14 additions & 0 deletions scripts/svgs/task_dry_run.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions src/_pytask/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/_pytask/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 29 additions & 8 deletions src/_pytask/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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"]
Expand Down
9 changes: 9 additions & 0 deletions src/_pytask/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class TaskOutcome(Enum):
SKIP = auto()
SKIP_PREVIOUS_FAILED = auto()
FAIL = auto()
WOULD_BE_EXECUTED = auto()

@property
def symbol(self) -> str:
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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)."""

Expand Down
4 changes: 3 additions & 1 deletion src/_pytask/skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
204 changes: 204 additions & 0 deletions tests/test_dry_run.py
Original file line number Diff line number Diff line change
@@ -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