Skip to content

Commit 21ba23a

Browse files
authored
Add -x/--stop-after-first-failure and --max-failures for early exits. (#45)
1 parent 7f8959e commit 21ba23a

File tree

10 files changed

+124
-5
lines changed

10 files changed

+124
-5
lines changed

.conda/meta.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ test:
3939
- pytest
4040
source_files:
4141
- tox.ini
42+
- src
4243
- tests
4344
commands:
4445
- pytask --help

docs/changes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ all releases are available on `Anaconda.org <https://anaconda.org/pytask/pytask>
99
0.0.11 - 2020-xx-xx
1010
-------------------
1111

12+
- :gh:`45` adds the option to stop execution after a number of tasks has failed. Closes
13+
:gh:`44`.
1214
- :gh:`47` reduce node names in error messages while resolving dependencies.
1315
- :gh:`50` implements correct usage of singular and plural in collection logs.
1416

docs/explanations/pluggy.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
pluggy and the Plugin Architecture
44
==================================
55

6-
pluggy ([1]_, [2]_) is at the heart of pytask and enables its plugin system. The
6+
pluggy ([1]_, [2]_, [3]_) is at the heart of pytask and enables its plugin system. The
77
mechanism to achieve extensibility is called :term:`hooking`.
88

99
At certain points, pytask, or more generally the host, implements entry-points which are
@@ -32,3 +32,6 @@ Thus, it is the host's responsibility to design the entry-points in such a way t
3232
3333
.. [2] `A talk by Floris Bruynooghe about pluggy and pytest
3434
<https://youtu.be/zZsNPDfOoHU>`_.
35+
36+
.. [3] `An introduction to pluggy by Kracekumar Ramaraju
37+
<https://kracekumar.com/post/build_plugins_with_pluggy>`
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
How to invoke pytask
2+
====================
3+
4+
Entry-points
5+
------------
6+
7+
There are two entry-points to invoke pytask.
8+
9+
1. Use command line interface with
10+
11+
.. code-block:: console
12+
13+
$ pytask
14+
15+
Use the following flags to learn more about pytask and its configuration.
16+
17+
.. code-block:: console
18+
19+
$ pytask --version
20+
$ pytask -h | --help
21+
22+
2. Invoke pytask programmatically with
23+
24+
.. code-block:: python
25+
26+
import pytask
27+
28+
29+
session = pytask.main({"paths": ...})
30+
31+
Pass command line arguments with their long name and hyphens replaced by underscores
32+
as keys of the dictionary.
33+
34+
35+
Stopping after the first (or N) failures
36+
----------------------------------------
37+
38+
To stop the build of the project after the first (N) failures use
39+
40+
.. code-block:: console
41+
42+
$ pytask -x | --stop-after-first-failure # Stop after the first failure
43+
$ pytask --max-failures 2 # Stop after the second failure

docs/tutorials/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ organize and start your own project.
1919
how_to_collect
2020
how_to_make_tasks_persist
2121
how_to_capture
22+
how_to_invoke_pytask

src/_pytask/build.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ def main(config_from_cli):
9090
default=None,
9191
help="Trace all function calls in the plugin framework. [default: False]",
9292
)
93+
@click.option(
94+
"-x",
95+
"--stop-after-first-failure",
96+
is_flag=True,
97+
default=None,
98+
help="Stop after the first failure.",
99+
)
100+
@click.option("--max-failures", default=None, help="Stop after some failures.")
93101
def build(**config_from_cli):
94102
"""Collect and execute tasks and report the results.
95103

src/_pytask/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ def pytask_parse_config(config, config_from_cli, config_from_file):
149149
)
150150
)
151151

152+
config["stop_after_first_failure"] = get_first_non_none_value(
153+
config_from_cli,
154+
config_from_file,
155+
key="stop_after_first_failure",
156+
default=False,
157+
callback=convert_truthy_or_falsy_to_bool,
158+
)
159+
if config["stop_after_first_failure"]:
160+
config["max_failures"] = 1
161+
else:
162+
config["max_failures"] = get_first_non_none_value(
163+
config_from_cli,
164+
config_from_file,
165+
key="max_failures",
166+
default=float("inf"),
167+
callback=lambda x: x if x is None else int(x),
168+
)
169+
152170

153171
@hookimpl
154172
def pytask_post_parse(config):

src/_pytask/execute.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def pytask_execute(session):
2323
"""Execute tasks."""
2424
session.hook.pytask_execute_log_start(session=session)
2525
session.scheduler = session.hook.pytask_execute_create_scheduler(session=session)
26-
session.execution_reports = session.hook.pytask_execute_build(session=session)
26+
session.hook.pytask_execute_build(session=session)
2727
session.hook.pytask_execute_log_end(
2828
session=session, reports=session.execution_reports
2929
)
@@ -49,12 +49,13 @@ def pytask_execute_create_scheduler(session):
4949
@hookimpl
5050
def pytask_execute_build(session):
5151
"""Execute tasks."""
52-
reports = []
5352
for task in session.scheduler:
5453
report = session.hook.pytask_execute_task_protocol(session=session, task=task)
55-
reports.append(report)
54+
session.execution_reports.append(report)
55+
if session.should_stop:
56+
return True
5657

57-
return reports
58+
return True
5859

5960

6061
@hookimpl
@@ -138,6 +139,11 @@ def pytask_execute_task_process_report(session, report):
138139
{"reason": f"Previous task '{task.name}' failed."},
139140
)
140141
)
142+
143+
session.n_tests_failed += 1
144+
if session.n_tests_failed >= session.config["max_failures"]:
145+
session.should_stop = True
146+
141147
return True
142148

143149

src/_pytask/session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ class Session:
3535
execution_start = attr.ib(default=None)
3636
execution_end = attr.ib(default=None)
3737

38+
n_tests_failed = attr.ib(default=0)
39+
"""Optional[int]: Number of tests which have failed."""
40+
should_stop = attr.ib(default=False)
41+
"""Optional[bool]: Indicates whether the session should be stopped."""
42+
3843
@classmethod
3944
def from_config(cls, config):
4045
"""Construct the class from a config."""

tests/test_execute.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,35 @@ def task_dummy(depends_on, produces):
198198

199199
session = main({"paths": tmp_path})
200200
assert session.exit_code == 0
201+
202+
203+
@pytest.mark.parametrize("n_failures", [1, 2, 3])
204+
def test_execution_stops_after_n_failures(tmp_path, n_failures):
205+
source = """
206+
def task_1(): raise Exception
207+
def task_2(): raise Exception
208+
def task_3(): raise Exception
209+
"""
210+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
211+
212+
session = main({"paths": tmp_path, "max_failures": n_failures})
213+
214+
assert len(session.tasks) == 3
215+
assert len(session.execution_reports) == n_failures
216+
217+
218+
@pytest.mark.parametrize("stop_after_first_failure", [False, True])
219+
def test_execution_stop_after_first_failure(tmp_path, stop_after_first_failure):
220+
source = """
221+
def task_1(): raise Exception
222+
def task_2(): raise Exception
223+
def task_3(): raise Exception
224+
"""
225+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
226+
227+
session = main(
228+
{"paths": tmp_path, "stop_after_first_failure": stop_after_first_failure}
229+
)
230+
231+
assert len(session.tasks) == 3
232+
assert len(session.execution_reports) == 1 if stop_after_first_failure else 3

0 commit comments

Comments
 (0)