Skip to content

Commit 2e01a9d

Browse files
tomwhitejeromekelleher
authored andcommitted
Print error messages not stacktraces.
Add bcftools validation tests for error conditions.
1 parent f6b2615 commit 2e01a9d

File tree

3 files changed

+91
-37
lines changed

3 files changed

+91
-37
lines changed

tests/test_bcftools_validation.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,34 @@
99
from .utils import assert_vcfs_close, vcz_path_cache
1010

1111

12-
def run_bcftools(args: str) -> str:
13-
"""Run bcftools (which must be on the PATH) and return stdout as a string."""
12+
def run_bcftools(args: str, expect_error=False) -> tuple[str, str]:
13+
"""
14+
Run bcftools (which must be on the PATH) and return stdout and stderr
15+
as a pair of strings.
16+
"""
1417
completed = subprocess.run(
15-
f"bcftools {args}", capture_output=True, check=True, shell=True
18+
f"bcftools {args}", capture_output=True, check=False, shell=True
1619
)
17-
return completed.stdout.decode("utf-8")
20+
if expect_error:
21+
assert completed.returncode != 0
22+
else:
23+
assert completed.returncode == 0
24+
return completed.stdout.decode("utf-8"), completed.stderr.decode("utf-8")
1825

1926

20-
def run_vcztools(args: str) -> str:
21-
"""Run run_vcztools and return stdout as a string."""
27+
def run_vcztools(args: str, expect_error=False) -> tuple[str, str]:
28+
"""Run run_vcztools and return stdout and stderr as a pair of strings."""
2229
runner = ct.CliRunner(mix_stderr=False)
2330
result = runner.invoke(
2431
cli.vcztools_main,
2532
args,
2633
catch_exceptions=False,
2734
)
28-
assert result.exit_code == 0
29-
return result.stdout
35+
if expect_error:
36+
assert result.exit_code != 0
37+
else:
38+
assert result.exit_code == 0
39+
return result.stdout, result.stderr
3040

3141

3242
# fmt: off
@@ -76,12 +86,12 @@ def test_vcf_output(tmp_path, args, vcf_file):
7686
original = pathlib.Path("tests/data/vcf") / vcf_file
7787
vcz = vcz_path_cache(original)
7888

79-
bcftools_out = run_bcftools(f"{args} {original}")
89+
bcftools_out, _ = run_bcftools(f"{args} {original}")
8090
bcftools_out_file = tmp_path.joinpath("bcftools_out.vcf")
8191
with open(bcftools_out_file, "w") as f:
8292
f.write(bcftools_out)
8393

84-
vcztools_out = run_vcztools(f"{args} {vcz}")
94+
vcztools_out, _ = run_vcztools(f"{args} {vcz}")
8595
vcztools_out_file = tmp_path.joinpath("vcztools_out.vcf")
8696
with open(vcztools_out_file, "w") as f:
8797
f.write(vcztools_out)
@@ -143,7 +153,26 @@ def test_output(tmp_path, args, vcf_name):
143153
vcf_path = pathlib.Path("tests/data/vcf") / vcf_name
144154
vcz_path = vcz_path_cache(vcf_path)
145155

146-
bcftools_output = run_bcftools(f"{args} {vcf_path}")
147-
vcztools_output = run_vcztools(f"{args} {vcz_path}")
156+
bcftools_output, _ = run_bcftools(f"{args} {vcf_path}")
157+
vcztools_output, _ = run_vcztools(f"{args} {vcz_path}")
148158

149159
assert vcztools_output == bcftools_output
160+
161+
162+
@pytest.mark.parametrize(
163+
("args", "vcf_name"),
164+
[
165+
("index -ns", "sample.vcf.gz"),
166+
("query -f '%POS\n' -i 'INFO/DP > 10' -e 'INFO/DP < 50'", "sample.vcf.gz"),
167+
("view -i 'INFO/DP > 10' -e 'INFO/DP < 50'", "sample.vcf.gz"),
168+
],
169+
)
170+
def test_error(tmp_path, args, vcf_name):
171+
vcf_path = pathlib.Path("tests/data/vcf") / vcf_name
172+
vcz_path = vcz_path_cache(vcf_path)
173+
174+
_, bcftools_error = run_bcftools(f"{args} {vcf_path}", expect_error=True)
175+
assert bcftools_error.startswith("Error:") or bcftools_error.startswith("[E::")
176+
177+
_, vcztools_error = run_vcztools(f"{args} {vcz_path}", expect_error=True)
178+
assert vcztools_error.startswith("Error:")

tests/test_cli.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import pathlib
2-
import re
32
from unittest import mock
43

54
import click.testing as ct
@@ -17,7 +16,7 @@ def vcz_path():
1716

1817

1918
def test_version_header(vcz_path):
20-
output = run_vcztools(f"view {vcz_path}")
19+
output, _ = run_vcztools(f"view {vcz_path}")
2120
assert output.find("##vcztools_viewCommand=") >= 0
2221
assert output.find("Date=") >= 0
2322

@@ -26,20 +25,22 @@ class TestOutput:
2625
def test_view_unsupported_output(self, tmp_path, vcz_path):
2726
bad_output = tmp_path / "output.vcf.gz"
2827

29-
with pytest.raises(
30-
ValueError,
31-
match=re.escape(
32-
"Only uncompressed VCF output supported, suffix .gz not allowed"
33-
),
34-
):
35-
run_vcztools(f"view --no-version {vcz_path} -o {bad_output}")
28+
_, vcztools_error = run_vcztools(
29+
f"view --no-version {vcz_path} -o {bad_output}", expect_error=True
30+
)
31+
assert (
32+
"Only uncompressed VCF output supported, suffix .gz not allowed"
33+
in vcztools_error
34+
)
3635

3736
@pytest.mark.parametrize("suffix", ["gz", "bgz", "bcf"])
3837
def test_view_unsupported_output_suffix(self, tmp_path, vcz_path, suffix):
3938
bad_output = tmp_path / f"output.vcf.{suffix}"
4039

41-
with pytest.raises(ValueError, match=f".{suffix} not allowed"):
42-
run_vcztools(f"view --no-version {vcz_path} -o {bad_output}")
40+
_, vcztools_error = run_vcztools(
41+
f"view --no-version {vcz_path} -o {bad_output}", expect_error=True
42+
)
43+
assert f".{suffix} not allowed" in vcztools_error
4344

4445
def test_view_good_path(self, tmp_path, vcz_path):
4546
output_path = tmp_path / "tmp.vcf"
@@ -78,12 +79,16 @@ def test_view_write_pipe(self, tmp_path, vcz_path):
7879

7980
def test_excluding_and_including_samples(vcz_path):
8081
samples_file_path = pathlib.Path("tests/data/txt/samples.txt")
81-
error_message = re.escape("vcztools does not support combining -s and -S")
82+
error_message = "vcztools does not support combining -s and -S"
8283

83-
with pytest.raises(AssertionError, match=error_message):
84-
run_vcztools(f"view {vcz_path} -s NA00001 -S ^{samples_file_path}")
85-
with pytest.raises(AssertionError, match=error_message):
86-
run_vcztools(f"view {vcz_path} -s ^NA00001 -S {samples_file_path}")
84+
_, vcztools_error = run_vcztools(
85+
f"view {vcz_path} -s NA00001 -S ^{samples_file_path}", expect_error=True
86+
)
87+
assert error_message in vcztools_error
88+
_, vcztools_error = run_vcztools(
89+
f"view {vcz_path} -s ^NA00001 -S {samples_file_path}", expect_error=True
90+
)
91+
assert error_message in vcztools_error
8792

8893

8994
@mock.patch("sys.exit")
@@ -104,7 +109,7 @@ def test_format_required(self, vcz_path):
104109
f"query {vcz_path} ",
105110
catch_exceptions=False,
106111
)
107-
assert result.exit_code == 2
112+
assert result.exit_code != 0
108113
assert len(result.stdout) == 0
109114
assert len(result.stderr) > 0
110115

@@ -115,34 +120,34 @@ def test_path_required(self):
115120
"query --format=POS ",
116121
catch_exceptions=False,
117122
)
118-
assert result.exit_code == 2
123+
assert result.exit_code != 0
119124
assert len(result.stdout) == 0
120125
assert len(result.stderr) > 0
121126

122127
def test_list(self, vcz_path):
123-
result = run_vcztools(f"query -l {vcz_path}")
128+
result, _ = run_vcztools(f"query -l {vcz_path}")
124129
assert list(result.splitlines()) == ["NA00001", "NA00002", "NA00003"]
125130

126131
def test_list_ignores_output(self, vcz_path, tmp_path):
127132
output = tmp_path / "tmp.txt"
128-
result = run_vcztools(f"query -l {vcz_path} -o {output}")
133+
result, _ = run_vcztools(f"query -l {vcz_path} -o {output}")
129134
assert list(result.splitlines()) == ["NA00001", "NA00002", "NA00003"]
130135
assert not output.exists()
131136

132137
def test_output(self, vcz_path, tmp_path):
133138
output = tmp_path / "tmp.txt"
134-
result = run_vcztools(f"query -f '%POS\n' {vcz_path} -o {output}")
139+
result, _ = run_vcztools(f"query -f '%POS\n' {vcz_path} -o {output}")
135140
assert list(result.splitlines()) == []
136141
assert output.exists()
137142

138143

139144
class TestIndex:
140145
def test_stats(self, vcz_path):
141-
result = run_vcztools(f"index -s {vcz_path}")
146+
result, _ = run_vcztools(f"index -s {vcz_path}")
142147
assert list(result.splitlines()) == ["19\t.\t2", "20\t.\t6", "X\t.\t1"]
143148

144149
def test_nrecords(self, vcz_path):
145-
result = run_vcztools(f"index -n {vcz_path}")
150+
result, _ = run_vcztools(f"index -n {vcz_path}")
146151
assert list(result.splitlines()) == ["9"]
147152

148153
def test_stats_and_nrecords(self, vcz_path):
@@ -152,7 +157,7 @@ def test_stats_and_nrecords(self, vcz_path):
152157
f"index -ns {vcz_path}",
153158
catch_exceptions=False,
154159
)
155-
assert result.exit_code == 2
160+
assert result.exit_code != 0
156161
assert len(result.stdout) == 0
157162
assert len(result.stderr) > 0
158163
assert "Expected only one of --stats or --nrecords options" in result.stderr
@@ -164,7 +169,7 @@ def test_no_stats_or_nrecords(self, vcz_path):
164169
f"index {vcz_path}",
165170
catch_exceptions=False,
166171
)
167-
assert result.exit_code == 2
172+
assert result.exit_code != 0
168173
assert len(result.stdout) == 0
169174
assert len(result.stderr) > 0
170175
assert "Error: Building region indexes is not supported" in result.stderr

vcztools/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import os
33
import sys
4+
from functools import wraps
45

56
import click
67

@@ -28,6 +29,22 @@ def handle_broken_pipe(output):
2829
sys.exit(1) # Python exits with error code 1 on EPIPE
2930

3031

32+
def handle_exception(func):
33+
"""
34+
Handle application exceptions by converting to a ClickException,
35+
so the message is written to stderr and a non-zero exit code is set.
36+
"""
37+
38+
@wraps(func)
39+
def wrapper(*args, **kwargs):
40+
try:
41+
return func(*args, **kwargs)
42+
except Exception as e:
43+
raise click.ClickException(e) from e
44+
45+
return wrapper
46+
47+
3148
include = click.option(
3249
"-i", "--include", type=str, help="Filter expression to include variant sites."
3350
)
@@ -66,6 +83,7 @@ def list_commands(self, ctx):
6683
is_flag=True,
6784
help="Print per contig stats.",
6885
)
86+
@handle_exception
6987
def index(path, nrecords, stats):
7088
if nrecords and stats:
7189
raise click.UsageError("Expected only one of --stats or --nrecords options")
@@ -95,6 +113,7 @@ def index(path, nrecords, stats):
95113
)
96114
@include
97115
@exclude
116+
@handle_exception
98117
def query(path, output, list_samples, format, include, exclude):
99118
if list_samples:
100119
# bcftools query -l ignores the --output option and always writes to stdout
@@ -177,6 +196,7 @@ def query(path, output, list_samples, format, include, exclude):
177196
)
178197
@include
179198
@exclude
199+
@handle_exception
180200
def view(
181201
path,
182202
output,

0 commit comments

Comments
 (0)