Skip to content

Commit b465140

Browse files
authored
Implement a dry-run mode. (#290)
1 parent cf478a7 commit b465140

File tree

10 files changed

+408
-9
lines changed

10 files changed

+408
-9
lines changed

docs/source/_static/images/dry-run.svg

Lines changed: 132 additions & 0 deletions
Loading

docs/source/changes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and
99

1010
- {pull}`288` fixes pinning pybaum to v0.1.1 or a version that supports `tree_yield()`.
1111
- {pull}`289` shortens the task ids when using `pytask collect`. Fixes {issue}`286`.
12+
- {pull}`290` implements a dry-run with `pytask --dry-run` to see which tasks would be
13+
executed.
1214

1315
## 0.2.4 - 2022-06-28
1416

docs/source/tutorials/invoking_pytask.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,15 @@ To stop the build of the project after the first `n` failures use
7676
$ pytask -x | --stop-after-first-failure # Stop after the first failure
7777
$ pytask --max-failures 2 # Stop after the second failure
7878
```
79+
80+
### Performing a dry-run
81+
82+
If you want to see which tasks would be executed without executing them, you can do a
83+
dry-run.
84+
85+
```console
86+
$ pytask --dry-run
87+
```
88+
89+
```{image} /_static/images/dry-run.svg
90+
```

scripts/svgs/task_dry_run.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
import pytask
4+
5+
6+
@pytask.mark.produces("out.txt")
7+
def task_dry_run(produces):
8+
produces.write_text("This text file won't be produced in a dry-run.")
9+
10+
11+
if __name__ == "__main__":
12+
pytask.console.record = True
13+
pytask.main({"paths": __file__, "dry_run": True})
14+
pytask.console.save_svg("dry-run.svg", title="pytask")

src/_pytask/build.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def main(config_from_cli: dict[str, Any]) -> Session:
126126
"[dim]\\[default: True][/]"
127127
),
128128
)
129+
@click.option("--dry-run", type=bool, is_flag=True, help="Perform a dry-run.")
129130
def build(**config_from_cli: Any) -> NoReturn:
130131
"""Collect tasks, execute them and report the results.
131132

src/_pytask/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ def pytask_parse_config(
246246
config_from_file.get("sort_table", True)
247247
)
248248

249+
config["dry_run"] = config_from_cli.get("dry_run", False)
250+
249251

250252
@hookimpl
251253
def pytask_post_parse(config: dict[str, Any]) -> None:

src/_pytask/execute.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
from _pytask.exceptions import ExecutionError
2121
from _pytask.exceptions import NodeNotFoundError
2222
from _pytask.mark import Mark
23+
from _pytask.mark_utils import has_mark
2324
from _pytask.nodes import FilePathNode
2425
from _pytask.nodes import Task
2526
from _pytask.outcomes import count_outcomes
2627
from _pytask.outcomes import Exit
2728
from _pytask.outcomes import TaskOutcome
29+
from _pytask.outcomes import WouldBeExecuted
2830
from _pytask.report import ExecutionReport
2931
from _pytask.session import Session
3032
from _pytask.shared import get_first_non_none_value
@@ -151,19 +153,26 @@ def pytask_execute_task_setup(session: Session, task: Task) -> None:
151153
if isinstance(node, FilePathNode):
152154
node.path.parent.mkdir(parents=True, exist_ok=True)
153155

156+
would_be_executed = has_mark(task, "would_be_executed")
157+
if would_be_executed:
158+
raise WouldBeExecuted
159+
154160

155161
@hookimpl(trylast=True)
156-
def pytask_execute_task(task: Task) -> bool:
162+
def pytask_execute_task(session: Session, task: Task) -> bool:
157163
"""Execute task."""
158-
kwargs = {**task.kwargs}
164+
if session.config["dry_run"]:
165+
raise WouldBeExecuted()
166+
else:
167+
kwargs = {**task.kwargs}
159168

160-
func_arg_names = set(inspect.signature(task.function).parameters)
161-
for arg_name in ("depends_on", "produces"):
162-
if arg_name in func_arg_names:
163-
attribute = getattr(task, arg_name)
164-
kwargs[arg_name] = tree_map(lambda x: x.value, attribute)
169+
func_arg_names = set(inspect.signature(task.function).parameters)
170+
for arg_name in ("depends_on", "produces"):
171+
if arg_name in func_arg_names:
172+
attribute = getattr(task, arg_name)
173+
kwargs[arg_name] = tree_map(lambda x: x.value, attribute)
165174

166-
task.execute(**kwargs)
175+
task.execute(**kwargs)
167176
return True
168177

169178

@@ -201,6 +210,18 @@ def pytask_execute_task_process_report(
201210
task = report.task
202211
if report.outcome == TaskOutcome.SUCCESS:
203212
update_states_in_database(session.dag, task.name)
213+
elif report.exc_info and isinstance(report.exc_info[1], WouldBeExecuted):
214+
report.outcome = TaskOutcome.WOULD_BE_EXECUTED
215+
216+
for descending_task_name in descending_tasks(task.name, session.dag):
217+
descending_task = session.dag.nodes[descending_task_name]["task"]
218+
descending_task.markers.append(
219+
Mark(
220+
"would_be_executed",
221+
(),
222+
{"reason": f"Previous task {task.name!r} would be executed."},
223+
)
224+
)
204225
else:
205226
for descending_task_name in descending_tasks(task.name, session.dag):
206227
descending_task = session.dag.nodes[descending_task_name]["task"]

src/_pytask/outcomes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class TaskOutcome(Enum):
103103
SKIP = auto()
104104
SKIP_PREVIOUS_FAILED = auto()
105105
FAIL = auto()
106+
WOULD_BE_EXECUTED = auto()
106107

107108
@property
108109
def symbol(self) -> str:
@@ -114,6 +115,7 @@ def symbol(self) -> str:
114115
TaskOutcome.SKIP: "s",
115116
TaskOutcome.SKIP_PREVIOUS_FAILED: "F",
116117
TaskOutcome.FAIL: "F",
118+
TaskOutcome.WOULD_BE_EXECUTED: "w",
117119
}
118120
assert len(symbols) == len(TaskOutcome)
119121
return symbols[self]
@@ -128,6 +130,7 @@ def description(self) -> str:
128130
TaskOutcome.SKIP: "Skipped",
129131
TaskOutcome.SKIP_PREVIOUS_FAILED: "Skipped because previous failed",
130132
TaskOutcome.FAIL: "Failed",
133+
TaskOutcome.WOULD_BE_EXECUTED: "Would be executed",
131134
}
132135
assert len(descriptions) == len(TaskOutcome)
133136
return descriptions[self]
@@ -142,6 +145,7 @@ def style(self) -> str:
142145
TaskOutcome.SKIP: "skipped",
143146
TaskOutcome.SKIP_PREVIOUS_FAILED: "failed",
144147
TaskOutcome.FAIL: "failed",
148+
TaskOutcome.WOULD_BE_EXECUTED: "success",
145149
}
146150
assert len(styles) == len(TaskOutcome)
147151
return styles[self]
@@ -156,6 +160,7 @@ def style_textonly(self) -> str:
156160
TaskOutcome.SKIP: "skipped.textonly",
157161
TaskOutcome.SKIP_PREVIOUS_FAILED: "failed.textonly",
158162
TaskOutcome.FAIL: "failed.textonly",
163+
TaskOutcome.WOULD_BE_EXECUTED: "success.textonly",
159164
}
160165
assert len(styles_textonly) == len(TaskOutcome)
161166
return styles_textonly[self]
@@ -218,6 +223,10 @@ class Persisted(PytaskOutcome):
218223
"""Outcome if task should persist."""
219224

220225

226+
class WouldBeExecuted(PytaskOutcome):
227+
"""Outcome if a task would be executed."""
228+
229+
221230
class Exit(Exception):
222231
"""Raised for immediate program exits (no tracebacks/summaries)."""
223232

src/_pytask/skipping.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def pytask_parse_config(config: dict[str, Any]) -> None:
4949
@hookimpl
5050
def pytask_execute_task_setup(task: Task) -> None:
5151
"""Take a short-cut for skipped tasks during setup with an exception."""
52-
is_unchanged = has_mark(task, "skip_unchanged")
52+
is_unchanged = has_mark(task, "skip_unchanged") and not has_mark(
53+
task, "would_be_executed"
54+
)
5355
if is_unchanged:
5456
raise SkippedUnchanged
5557

tests/test_dry_run.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from __future__ import annotations
2+
3+
import textwrap
4+
5+
import pytest
6+
from pytask import cli
7+
from pytask import ExitCode
8+
9+
10+
@pytest.mark.end_to_end
11+
def test_dry_run(runner, tmp_path):
12+
source = """
13+
import pytask
14+
15+
@pytask.mark.produces("out.txt")
16+
def task_example(produces):
17+
produces.touch()
18+
"""
19+
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
20+
21+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
22+
23+
assert result.exit_code == ExitCode.OK
24+
assert "1 Would be executed" in result.output
25+
assert not tmp_path.joinpath("out.txt").exists()
26+
27+
28+
@pytest.mark.end_to_end
29+
def test_dry_run_w_subsequent_task(runner, tmp_path):
30+
"""Subsequent tasks would be executed if their previous task changed."""
31+
source = """
32+
import pytask
33+
34+
@pytask.mark.depends_on("out.txt")
35+
@pytask.mark.produces("out_2.txt")
36+
def task_example(produces):
37+
produces.touch()
38+
"""
39+
tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source))
40+
41+
source = """
42+
import pytask
43+
44+
@pytask.mark.produces("out.txt")
45+
def task_example(produces):
46+
produces.touch()
47+
"""
48+
tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source))
49+
50+
result = runner.invoke(cli, [tmp_path.as_posix()])
51+
52+
assert result.exit_code == ExitCode.OK
53+
assert "2 Succeeded" in result.output
54+
55+
tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source))
56+
57+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
58+
59+
assert result.exit_code == ExitCode.OK
60+
assert "2 Would be executed" in result.output
61+
62+
63+
@pytest.mark.end_to_end
64+
def test_dry_run_w_subsequent_skipped_task(runner, tmp_path):
65+
"""A skip is more important than a would be run."""
66+
source = """
67+
import pytask
68+
69+
@pytask.mark.produces("out.txt")
70+
def task_example(produces):
71+
produces.touch()
72+
"""
73+
tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source))
74+
75+
source = """
76+
import pytask
77+
78+
@pytask.mark.depends_on("out.txt")
79+
@pytask.mark.produces("out_2.txt")
80+
def task_example(produces):
81+
produces.touch()
82+
"""
83+
tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source))
84+
85+
result = runner.invoke(cli, [tmp_path.as_posix()])
86+
87+
assert result.exit_code == ExitCode.OK
88+
assert "2 Succeeded" in result.output
89+
90+
source = """
91+
import pytask
92+
93+
@pytask.mark.produces("out.txt")
94+
def task_example(produces):
95+
produces.touch()
96+
"""
97+
tmp_path.joinpath("task_example_first.py").write_text(textwrap.dedent(source))
98+
99+
source = """
100+
import pytask
101+
102+
@pytask.mark.skip
103+
@pytask.mark.depends_on("out.txt")
104+
@pytask.mark.produces("out_2.txt")
105+
def task_example(produces):
106+
produces.touch()
107+
"""
108+
tmp_path.joinpath("task_example_second.py").write_text(textwrap.dedent(source))
109+
110+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
111+
112+
assert result.exit_code == ExitCode.OK
113+
assert "1 Would be executed" in result.output
114+
assert "1 Skipped" in result.output
115+
116+
117+
@pytest.mark.end_to_end
118+
def test_dry_run_skip(runner, tmp_path):
119+
source = """
120+
import pytask
121+
122+
@pytask.mark.skip
123+
def task_example_skip(): ...
124+
125+
@pytask.mark.produces("out.txt")
126+
def task_example(produces):
127+
produces.touch()
128+
"""
129+
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
130+
131+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
132+
133+
assert result.exit_code == ExitCode.OK
134+
assert "1 Would be executed" in result.output
135+
assert "1 Skipped" in result.output
136+
assert not tmp_path.joinpath("out.txt").exists()
137+
138+
139+
@pytest.mark.end_to_end
140+
def test_dry_run_skip_all(runner, tmp_path):
141+
source = """
142+
import pytask
143+
144+
@pytask.mark.skip
145+
@pytask.mark.produces("out.txt")
146+
def task_example_skip(): ...
147+
148+
@pytask.mark.skip
149+
@pytask.mark.depends_on("out.txt")
150+
def task_example_skip_subsequent(): ...
151+
"""
152+
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
153+
154+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
155+
156+
assert result.exit_code == ExitCode.OK
157+
assert "2 Skipped" in result.output
158+
159+
160+
@pytest.mark.end_to_end
161+
def test_dry_run_skipped_successful(runner, tmp_path):
162+
source = """
163+
import pytask
164+
165+
@pytask.mark.produces("out.txt")
166+
def task_example(produces):
167+
produces.touch()
168+
"""
169+
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
170+
171+
result = runner.invoke(cli, [tmp_path.as_posix()])
172+
173+
assert result.exit_code == ExitCode.OK
174+
assert "1 Succeeded" in result.output
175+
176+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
177+
178+
assert result.exit_code == ExitCode.OK
179+
assert "1 Skipped because unchanged" in result.output
180+
181+
182+
@pytest.mark.end_to_end
183+
def test_dry_run_persisted(runner, tmp_path):
184+
source = """
185+
import pytask
186+
187+
@pytask.mark.persist
188+
@pytask.mark.produces("out.txt")
189+
def task_example(produces):
190+
produces.touch()
191+
"""
192+
tmp_path.joinpath("task_example.py").write_text(textwrap.dedent(source))
193+
194+
result = runner.invoke(cli, [tmp_path.as_posix()])
195+
196+
assert result.exit_code == ExitCode.OK
197+
assert "1 Succeeded" in result.output
198+
199+
tmp_path.joinpath("out.txt").write_text("Changed text file.")
200+
201+
result = runner.invoke(cli, ["--dry-run", tmp_path.as_posix()])
202+
203+
assert result.exit_code == ExitCode.OK
204+
assert "1 Persisted" in result.output

0 commit comments

Comments
 (0)