Skip to content

Commit adbd779

Browse files
Merge pull request #73 from ownik/pytest_fail-under
Fix --fail-under with SystemExit raising from pytest or script
2 parents dd9fdbb + c6362d0 commit adbd779

5 files changed

Lines changed: 130 additions & 12 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
- name: install prereqs
4646
run: |
4747
python3 -m pip install -U pip # to avoid warnings
48-
python3 -m pip install pytest
48+
python3 -m pip install pytest pytest-xdist
4949
5050
- name: install Unix dependencies
5151
if: matrix.os != 'windows-latest'

src/slipcover/__main__.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def wrapper(*pargs, **kwargs):
4343
return wrapper
4444

4545

46-
def get_coverage(sci):
46+
def merged_coverage(sci):
4747
"""Combines this process' coverage with that of any previously forked children and xdist workers."""
4848
global input_tmpfiles, output_tmpfile
4949

@@ -100,7 +100,7 @@ def wrapper(*pargs, **kwargs):
100100
global output_tmpfile
101101

102102
if output_tmpfile:
103-
json.dump(get_coverage(sci), output_tmpfile)
103+
json.dump(merged_coverage(sci), output_tmpfile)
104104
output_tmpfile.flush()
105105

106106
original_exit(*pargs, **kwargs)
@@ -266,7 +266,7 @@ def printit(coverage, outfile):
266266
missing_width=args.missing_width)
267267

268268
if not args.silent:
269-
coverage = get_coverage(sci)
269+
coverage = merged_coverage(sci)
270270
if args.out:
271271
with open(args.out, "w") as outfile:
272272
printit(coverage, outfile)
@@ -275,6 +275,8 @@ def printit(coverage, outfile):
275275

276276
atexit.register(sci_atexit)
277277

278+
return_code = 0
279+
278280
if args.script:
279281
# python 'globals' for the script being executed
280282
script_globals: Dict[Any, Any] = dict()
@@ -300,20 +302,25 @@ def printit(coverage, outfile):
300302
code = sci.instrument(code)
301303

302304
with sc.ImportManager(sci, file_matcher):
303-
exec(code, script_globals)
304-
305+
try:
306+
exec(code, script_globals)
307+
except SystemExit as e:
308+
return_code = e.code if e.code is not None else 0
305309
else:
306310
import runpy
307311
sys.argv = [*args.module, *args.script_or_module_args]
308312
with sc.ImportManager(sci, file_matcher):
309-
runpy.run_module(*args.module, run_name='__main__', alter_sys=True)
313+
try:
314+
runpy.run_module(*args.module, run_name='__main__', alter_sys=True)
315+
except SystemExit as e:
316+
return_code = e.code if e.code is not None else 0
310317

311318
if args.fail_under:
312-
cov = sci.get_coverage()
319+
cov = merged_coverage(sci)
313320
if cov['summary']['percent_covered'] < args.fail_under:
314321
return 2
315322

316-
return 0
323+
return return_code
317324

318325

319326
if __name__ == "__main__":

tests/branch.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ def foo(x):
22
if x == 0:
33
x += 1
44
foo(0)
5+
6+
raise SystemExit()

tests/test_coverage.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import xml.etree.ElementTree as ET
77
from pathlib import Path
8+
from textwrap import dedent
89

910
import pytest
1011

@@ -694,14 +695,78 @@ def test_summary_in_output_zero_lines(do_branch):
694695

695696

696697
@pytest.mark.parametrize("json_flag", ["", "--json"])
697-
def test_fail_under(json_flag):
698+
def test_fail_under(tmp_path, json_flag):
698699
p = subprocess.run(f"{sys.executable} -m slipcover {json_flag} --fail-under 100 tests/branch.py".split(), check=False)
699700
assert 0 == p.returncode
700701

701-
p = subprocess.run(f"{sys.executable} -m slipcover {json_flag} --branch --fail-under 83 tests/branch.py".split(), check=False)
702+
p = subprocess.run(f"{sys.executable} -m slipcover {json_flag} --branch --fail-under 85 tests/branch.py".split(), check=False)
702703
assert 0 == p.returncode
703704

704-
p = subprocess.run(f"{sys.executable} -m slipcover {json_flag} --branch --fail-under 84 tests/branch.py".split(), check=False)
705+
p = subprocess.run(f"{sys.executable} -m slipcover {json_flag} --branch --fail-under 86 tests/branch.py".split(), check=False)
706+
assert 2 == p.returncode
707+
708+
p = subprocess.run(f"{sys.executable} -m slipcover --branch --fail-under 93 -m pytest tests/pyt.py".split(), check=False)
709+
assert 0 == p.returncode
710+
711+
p = subprocess.run(f"{sys.executable} -m slipcover --branch --fail-under 94 -m pytest tests/pyt.py".split(), check=False)
712+
assert 2 == p.returncode
713+
714+
715+
def test_fail_under_precedence_with_nonzero_exit(tmp_path):
716+
"""When the script/pytest run itself fails (nonzero SystemExit) AND
717+
coverage is below the fail-under threshold, coverage failure (RC 2)
718+
takes precedence. But when coverage is fine, the run's own nonzero
719+
exit code must be preserved, not silently replaced with 0.
720+
"""
721+
script = tmp_path / "script.py"
722+
script.write_text(dedent("""\
723+
def foo(x):
724+
if x:
725+
return 1
726+
return 2
727+
foo(0)
728+
raise SystemExit(3)
729+
"""))
730+
731+
# coverage is fine (line 3 "return 1" never runs, but threshold is low) --
732+
# the script's own exit code (3) must be preserved
733+
p = subprocess.run(f"{sys.executable} -m slipcover --fail-under 1 {script}".split(), check=False)
734+
assert 3 == p.returncode
735+
736+
# coverage is below threshold -- fail-under (2) must override the
737+
# script's own exit code
738+
p = subprocess.run(f"{sys.executable} -m slipcover --fail-under 100 {script}".split(), check=False)
739+
assert 2 == p.returncode
740+
741+
742+
def test_fail_under_precedence_with_failing_pytest_run(tmp_path):
743+
"""Same precedence check as test_fail_under_precedence_with_nonzero_exit,
744+
but through the `-m pytest` path with a genuinely failing test (pytest's
745+
own SystemExit(1)), rather than a script raising SystemExit directly.
746+
"""
747+
test_file = tmp_path / "test_mod.py"
748+
test_file.write_text(dedent("""\
749+
def foo(x):
750+
if x:
751+
return 1
752+
return 2
753+
754+
def test_fail():
755+
assert foo(0) == 2
756+
assert False
757+
"""))
758+
759+
# coverage is fine -- pytest's own failure exit code (1) must be preserved
760+
p = subprocess.run(
761+
[sys.executable, '-m', 'slipcover', '--fail-under', '1', '-m', 'pytest', test_file.name],
762+
cwd=str(tmp_path), check=False)
763+
assert 1 == p.returncode
764+
765+
# coverage is below threshold -- fail-under (2) must override pytest's
766+
# own exit code
767+
p = subprocess.run(
768+
[sys.executable, '-m', 'slipcover', '--fail-under', '100', '-m', 'pytest', test_file.name],
769+
cwd=str(tmp_path), check=False)
705770
assert 2 == p.returncode
706771

707772

tests/test_xdist.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import json
66
from pathlib import Path
7+
from textwrap import dedent
78

89
import pytest
910

@@ -216,3 +217,46 @@ def test_xdist_four_workers(tmp_path):
216217
# All lines should still be covered with more workers
217218
assert [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 13, 14] == file_cov['executed_lines']
218219
assert [] == file_cov['missing_lines']
220+
221+
222+
def test_xdist_fail_under_uses_merged_coverage(tmp_path):
223+
"""--fail-under must be checked against the merged (all-workers) coverage,
224+
not just the coordinator process' own view. Under xdist, actual test code
225+
runs only in worker subprocesses -- the coordinator's own local coverage
226+
has no files at all, which trivially (and silently) reports as 100%,
227+
defeating --fail-under entirely if the local, unmerged view is used.
228+
"""
229+
test_file = tmp_path / "test_partial.py"
230+
test_file.write_text(dedent("""\
231+
def foo(x):
232+
if x:
233+
return 1
234+
return 2
235+
236+
def bar(y):
237+
if y:
238+
return 3
239+
return 4
240+
241+
def test_foo():
242+
assert foo(0) == 2
243+
244+
def test_bar():
245+
assert bar(0) == 4
246+
"""))
247+
248+
# real merged coverage is 10/12 = 83.3% (the "return 1"/"return 3"
249+
# branches are never taken) -- comfortably above 50, so this must pass.
250+
# The coordinator's own local, unmerged view would incorrectly see 0%
251+
# (it discovers the file via cwd-matching but never executes it itself,
252+
# since actual test execution happens only in the xdist workers), which
253+
# would incorrectly fail this same check.
254+
result = subprocess.run(
255+
[sys.executable, '-m', 'slipcover', '--fail-under', '50',
256+
'-m', 'pytest', '-n', '2', test_file.name],
257+
cwd=str(tmp_path),
258+
capture_output=True,
259+
text=True
260+
)
261+
262+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"

0 commit comments

Comments
 (0)