Skip to content

Commit b5aa10b

Browse files
authored
Commands return correct exit codes. (#26)
1 parent 09efb6a commit b5aa10b

14 files changed

+115
-17
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ repos:
88
- id: check-yaml
99
exclude: meta.yaml
1010
- id: debug-statements
11-
exclude: (debugging\.py|build\.py)
11+
exclude: (debugging\.py|build\.py|clean\.py|mark/__init__\.py)
1212
- id: end-of-file-fixer
1313
- repo: https://github.com/asottile/pyupgrade
1414
rev: v2.7.2

docs/changes.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ all releases are available on `Anaconda.org <https://anaconda.org/pytask/pytask>
1010
------------------
1111

1212
- :gh:`25` allows to customize the names of the task files.
13+
- :gh:`26` makes commands return the correct exit codes.
1314
- :gh:`27` implements the ``pytask_collect_task_teardown`` hook specification to perform
1415
checks after a task is collected.
1516

src/_pytask/build.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
import click
77
from _pytask.config import hookimpl
8-
from _pytask.database import create_database
98
from _pytask.enums import ExitCode
109
from _pytask.exceptions import CollectionError
10+
from _pytask.exceptions import ConfigurationError
1111
from _pytask.exceptions import ExecutionError
1212
from _pytask.exceptions import ResolvingDependenciesError
1313
from _pytask.pluginmanager import get_plugin_manager
@@ -49,16 +49,16 @@ def main(config_from_cli):
4949

5050
config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli)
5151

52-
create_database(**config["database"])
53-
5452
session = Session.from_config(config)
55-
session.exit_code = ExitCode.OK
5653

57-
except Exception:
54+
except (ConfigurationError, Exception):
5855
traceback.print_exception(*sys.exc_info())
5956
session = Session({}, None)
6057
session.exit_code = ExitCode.CONFIGURATION_FAILED
6158

59+
if config_from_cli.get("pdb"):
60+
pdb.post_mortem()
61+
6262
else:
6363
try:
6464
session.hook.pytask_log_session_header(session=session)

src/_pytask/clean.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Add a command to clean the project from files unknown to pytask."""
22
import itertools
3+
import pdb
34
import shutil
45
import sys
56
import traceback
@@ -71,15 +72,16 @@ def clean(**config_from_cli):
7172
pm.hook.pytask_add_hooks(pm=pm)
7273

7374
config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli)
74-
7575
session = Session.from_config(config)
76-
session.exit_code = ExitCode.OK
7776

7877
except Exception:
7978
traceback.print_exception(*sys.exc_info())
8079
session = Session({}, None)
8180
session.exit_code = ExitCode.CONFIGURATION_FAILED
8281

82+
if config_from_cli.get("pdb"):
83+
pdb.post_mortem()
84+
8385
else:
8486
try:
8587
session.hook.pytask_log_session_header(session=session)
@@ -119,7 +121,7 @@ def clean(**config_from_cli):
119121
traceback.print_exception(*sys.exc_info())
120122
session.exit_code = ExitCode.FAILED
121123

122-
return session
124+
sys.exit(session.exit_code)
123125

124126

125127
def _collect_all_paths_known_to_pytask(session):

src/_pytask/config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import click
1111
import pluggy
12+
from _pytask.shared import convert_truthy_or_falsy_to_bool
1213
from _pytask.shared import get_first_non_none_value
1314
from _pytask.shared import parse_paths
1415
from _pytask.shared import parse_value_or_multiline_option
@@ -112,7 +113,7 @@ def pytask_parse_config(config, config_from_cli, config_from_file):
112113
config_from_file,
113114
key="debug_pytask",
114115
default=False,
115-
callback=bool,
116+
callback=convert_truthy_or_falsy_to_bool,
116117
)
117118
if config["debug_pytask"]:
118119
config["pm"].trace.root.setwriter(click.echo)

src/_pytask/database.py

+5
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,8 @@ def pytask_parse_config(config, config_from_cli, config_from_file):
120120
"create_db": config["database_create_db"],
121121
"create_tables": config["database_create_tables"],
122122
}
123+
124+
125+
@hookimpl
126+
def pytask_post_parse(config):
127+
create_database(**config["database"])

src/_pytask/enums.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"""Enumerations for pytask."""
12
import enum
23

34

src/_pytask/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class NodeNotCollectedError(PytaskError):
1010
"""Exception for nodes which could not be collected."""
1111

1212

13+
class ConfigurationError(PytaskError):
14+
"""Exception during the configuration."""
15+
16+
1317
class CollectionError(PytaskError):
1418
"""Exception during collection."""
1519

src/_pytask/mark/__init__.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pdb
12
import sys
23
import textwrap
34
import traceback
@@ -50,9 +51,10 @@ def markers(**config_from_cli):
5051
pm.hook.pytask_add_hooks(pm=pm)
5152

5253
config = pm.hook.pytask_configure(pm=pm, config_from_cli=config_from_cli)
53-
5454
session = Session.from_config(config)
55-
session.exit_code = ExitCode.OK
55+
56+
if config_from_cli.get("pdb"):
57+
pdb.post_mortem()
5658

5759
except Exception:
5860
traceback.print_exception(*sys.exc_info())
@@ -68,7 +70,7 @@ def markers(**config_from_cli):
6870
)
6971
click.echo("")
7072

71-
return session
73+
sys.exit(session.exit_code)
7274

7375

7476
@hookimpl

src/_pytask/session.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import attr
2+
from _pytask.enums import ExitCode
23

34

45
@attr.s
@@ -27,6 +28,7 @@ class Session:
2728
"""
2829
execution_reports = attr.ib(factory=list)
2930
"""Optional[List[pytask.report.ExecutionReport]]: Reports for executed tasks."""
31+
exit_code = attr.ib(default=ExitCode.OK)
3032

3133
@classmethod
3234
def from_config(cls, config):

src/_pytask/shared.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"""Functions which are used across various modules."""
12
import glob
23
from collections.abc import Iterable
34
from pathlib import Path
@@ -71,13 +72,10 @@ def get_first_non_none_value(*configs, key, default=None, callback=None):
7172
--------
7273
>>> get_first_non_none_value({"a": None}, {"a": 1}, key="a")
7374
1
74-
7575
>>> get_first_non_none_value({"a": None}, {"a": None}, key="a", default="default")
7676
'default'
77-
7877
>>> get_first_non_none_value({}, {}, key="a", default="default")
7978
'default'
80-
8179
>>> get_first_non_none_value({"a": None}, {"a": "b"}, key="a")
8280
'b'
8381
@@ -88,6 +86,7 @@ def get_first_non_none_value(*configs, key, default=None, callback=None):
8886

8987

9088
def parse_value_or_multiline_option(value):
89+
"""Parse option which can hold a single value or values separated by new lines."""
9190
if value in ["none", "None", None, ""]:
9291
value = None
9392
elif isinstance(value, str) and "\n" in value:
@@ -107,6 +106,6 @@ def convert_truthy_or_falsy_to_bool(x):
107106
out = None
108107
else:
109108
raise ValueError(
110-
f"Input {x} is neither truthy (True, true, 1) or falsy (False, false, 0)."
109+
f"Input '{x}' is neither truthy (True, true, 1) or falsy (False, false, 0)."
111110
)
112111
return out

tests/test_build.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import textwrap
2+
3+
import pytest
4+
from pytask import cli
5+
6+
7+
@pytest.mark.end_to_end
8+
def test_execution_failed(runner, tmp_path):
9+
source = """
10+
def task_dummy():
11+
raise Exception
12+
"""
13+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
14+
15+
result = runner.invoke(cli, [tmp_path.as_posix()])
16+
assert result.exit_code == 1
17+
18+
19+
@pytest.mark.end_to_end
20+
def test_configuration_failed(runner, tmp_path):
21+
result = runner.invoke(cli, [tmp_path.joinpath("non_existent_path").as_posix()])
22+
assert result.exit_code == 2
23+
24+
25+
@pytest.mark.end_to_end
26+
def test_collection_failed(runner, tmp_path):
27+
source = """
28+
raise Exception
29+
"""
30+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
31+
32+
result = runner.invoke(cli, [tmp_path.as_posix()])
33+
assert result.exit_code == 3
34+
35+
36+
@pytest.mark.end_to_end
37+
def test_resolving_dependencies_failed(runner, tmp_path):
38+
source = """
39+
import pytask
40+
41+
@pytask.mark.depends_on("in.txt")
42+
@pytask.mark.produces("out.txt")
43+
def task_dummy_1():
44+
pass
45+
46+
@pytask.mark.depends_on("out.txt")
47+
@pytask.mark.produces("in.txt")
48+
def task_dummy_2():
49+
pass
50+
"""
51+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
52+
53+
result = runner.invoke(cli, [tmp_path.as_posix()])
54+
assert result.exit_code == 4

tests/test_clean.py

+19
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,22 @@ def test_clean_interactive_w_directories(sample_project_path, runner):
107107
assert "to_be_deleted_file_2.txt" not in result.output
108108
assert "to_be_deleted_folder_1" in result.output
109109
assert not sample_project_path.joinpath("to_be_deleted_folder_1").exists()
110+
111+
112+
@pytest.mark.end_to_end
113+
def test_configuration_failed(runner, tmp_path):
114+
result = runner.invoke(
115+
cli, ["clean", tmp_path.joinpath("non_existent_path").as_posix()]
116+
)
117+
assert result.exit_code == 2
118+
119+
120+
@pytest.mark.end_to_end
121+
def test_collection_failed(runner, tmp_path):
122+
source = """
123+
raise Exception
124+
"""
125+
tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source))
126+
127+
result = runner.invoke(cli, ["clean", tmp_path.as_posix()])
128+
assert result.exit_code == 3

tests/test_mark.py

+8
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,11 @@ def task_func(arg):
272272

273273
err = capsys.readouterr().err
274274
assert expected_error in err
275+
276+
277+
@pytest.mark.end_to_end
278+
def test_configuration_failed(runner, tmp_path):
279+
result = runner.invoke(
280+
cli, ["markers", "-c", tmp_path.joinpath("non_existent_path").as_posix()]
281+
)
282+
assert result.exit_code == 2

0 commit comments

Comments
 (0)