Skip to content

Commit 59025ab

Browse files
Add exclude_also, additive on top of exclude_lines
[tool.slipcover] exclude-also (config-only, same as exclude-lines) adds patterns on top of whatever exclude-lines resolved to, matching coverage.py's own separate setting. The xdist controller resolves exclude_lines/exclude_also once and propagates the merged pattern list to workers as plain exclude_lines, so a worker never needs to redo that resolution itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5571072 commit 59025ab

6 files changed

Lines changed: 157 additions & 19 deletions

File tree

src/slipcover/__main__.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,14 +301,16 @@ def main():
301301

302302

303303
omit_list = args.omit.split(',') if args.omit else None
304-
# exclude_lines has no CLI flag -- [tool.slipcover] exclude-lines in
305-
# pyproject.toml is the only way to set it, so args won't have this
306-
# attribute at all unless apply_config() set it from a config file.
304+
# exclude_lines/exclude_also have no CLI flag -- [tool.slipcover]
305+
# exclude-lines/exclude-also in pyproject.toml are the only way to set
306+
# them, so args won't have these attributes at all unless apply_config()
307+
# set them from a config file.
307308
exclude_lines = getattr(args, 'exclude_lines', None)
309+
exclude_also = getattr(args, 'exclude_also', None)
308310
sci = sc.Slipcover(immediate=args.immediate,
309311
d_miss_threshold=args.threshold, branch=args.branch,
310312
disassemble=args.dis, source=args.source, omit=omit_list,
311-
exclude_lines=exclude_lines)
313+
exclude_lines=exclude_lines, exclude_also=exclude_also)
312314

313315

314316
if not args.dont_wrap_pytest:
@@ -326,13 +328,18 @@ def main():
326328
os.environ["SLIPCOVER_SOURCE"] = source_str
327329
if args.omit:
328330
os.environ["SLIPCOVER_OMIT"] = args.omit
329-
if exclude_lines is not None:
330-
# newline-joined, not comma-joined: regex patterns can contain
331-
# commas. Set even when empty (exclude-lines = [] in config):
332-
# the var's mere presence -- not its truthiness -- is what
333-
# tells a worker "this was resolved, don't fall back to
334-
# defaults" (see pytest_plugin.py's _activate_worker()).
335-
os.environ["SLIPCOVER_EXCLUDE_LINES"] = "\n".join(exclude_lines)
331+
if exclude_lines is not None or exclude_also:
332+
# Propagate the already-resolved pattern list (sci's own
333+
# compiled patterns, read back as strings) rather than the raw
334+
# exclude_lines/exclude_also, so a worker doesn't need to redo
335+
# the replace-then-add resolution itself -- it just gets the
336+
# final list as plain exclude_lines. Newline-joined, not
337+
# comma-joined: regex patterns can contain commas. Set even
338+
# when empty (exclude-lines = [] in config): the var's mere
339+
# presence -- not its truthiness -- is what tells a worker
340+
# "this was resolved, don't fall back to defaults" (see
341+
# pytest_plugin.py's _activate_worker()).
342+
os.environ["SLIPCOVER_EXCLUDE_LINES"] = "\n".join(p.pattern for p in sci._exclude_patterns)
336343

337344
if platform.system() != 'Windows':
338345
os.fork = fork_shim(sci)

src/slipcover/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def _coerce_format(value):
145145
"lcov-test-name": str,
146146
"lcov-comments": _coerce_comments,
147147
"exclude-lines": _coerce_comments,
148+
"exclude-also": _coerce_comments,
148149
}
149150

150151

src/slipcover/slipcover.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ def __init__(self, immediate: bool = False,
381381
d_miss_threshold: int = 50, branch: bool = False,
382382
disassemble: bool = False, source: Optional[List[str]] = None,
383383
omit: Optional[List[str]] = None,
384-
exclude_lines: Optional[List[str]] = None):
384+
exclude_lines: Optional[List[str]] = None,
385+
exclude_also: Optional[List[str]] = None):
385386
self.immediate = immediate
386387
self.d_miss_threshold = d_miss_threshold
387388
self.branch = branch
@@ -393,7 +394,10 @@ def __init__(self, immediate: bool = False,
393394
# all) means "use the defaults"; an explicit [] disables exclusion
394395
# entirely, including the defaults -- the natural way to express
395396
# "no exclusion" via [tool.slipcover] exclude-lines = [] in config.
396-
self._exclude_patterns = [re.compile(p) for p in (DEFAULT_EXCLUDE if exclude_lines is None else exclude_lines)]
397+
# exclude_also, matching coverage.py's own separate setting, is
398+
# always additive to whatever that resolves to.
399+
base = DEFAULT_EXCLUDE if exclude_lines is None else exclude_lines
400+
self._exclude_patterns = [re.compile(p) for p in list(base) + list(exclude_also or [])]
397401

398402
# mutex protecting this state
399403
self.lock = threading.RLock()

tests/test_config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def _make_args(**kwargs):
170170
immediate=False, skip_covered=False, fail_under=0,
171171
threshold=50, missing_width=80, silent=False, dis=False,
172172
debug=False, dont_wrap_pytest=False, sigterm=False,
173-
exclude_lines=None,
173+
exclude_lines=None, exclude_also=None,
174174
)
175175
defaults.update(kwargs)
176176
return argparse.Namespace(**defaults)
@@ -208,6 +208,12 @@ def test_apply_config_exclude_lines_scalar_becomes_list():
208208
assert args.exclude_lines == ["just one"]
209209

210210

211+
def test_apply_config_exclude_also():
212+
args = _make_args()
213+
apply_config({"exclude-also": ["foo", "bar"]}, args)
214+
assert args.exclude_also == ["foo", "bar"]
215+
216+
211217
def test_apply_config_format_bad_value_raises():
212218
args = _make_args()
213219
with pytest.raises(ValueError, match="must be one of"):

tests/test_exclude_lines.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import slipcover.slipcover as sc
2020

2121

22-
def _run(tmp_path, source, *, branch=False, exclude_lines=None):
22+
def _run(tmp_path, source, *, branch=False, exclude_lines=None, exclude_also=None):
2323
"""Compiles, instruments, and runs a real on-disk module, returning its
2424
file coverage dict. A real file (not an in-memory filename) is needed
2525
since exclusion matching reads the source text from disk."""
@@ -30,7 +30,7 @@ def _run(tmp_path, source, *, branch=False, exclude_lines=None):
3030
if branch:
3131
t = br.preinstrument(t)
3232

33-
sci = sc.Slipcover(branch=branch, exclude_lines=exclude_lines)
33+
sci = sc.Slipcover(branch=branch, exclude_lines=exclude_lines, exclude_also=exclude_also)
3434
code = compile(t, str(code_path), "exec")
3535
code = sci.instrument(code)
3636

@@ -112,9 +112,7 @@ def test_default_type_checking_block_excluded(tmp_path):
112112

113113
def test_custom_pattern_replaces_defaults(tmp_path):
114114
"""Matches coverage.py's exclude_lines setting exactly: providing custom
115-
patterns replaces the built-in defaults, it doesn't add to them. (A
116-
future exclude_also, matching coverage.py's own separate additive
117-
setting, could add the additive behavior back later if wanted.)"""
115+
patterns replaces the built-in defaults, it doesn't add to them."""
118116
cov = _run(tmp_path, """\
119117
def foo(x):
120118
if x < 0: # pragma: no cover
@@ -135,6 +133,53 @@ def foo(x):
135133
assert 6 in cov['executed_lines']
136134

137135

136+
def test_exclude_also_adds_to_defaults(tmp_path):
137+
"""Matches coverage.py's exclude_also: unlike exclude_lines, it's always
138+
additive to whatever's currently active -- here, the untouched
139+
defaults."""
140+
cov = _run(tmp_path, """\
141+
def foo(x):
142+
if x < 0: # pragma: no cover
143+
return 1
144+
if x < 0: # custom-also
145+
return 2
146+
return 3
147+
148+
foo(1)
149+
""", exclude_also=["custom-also"])
150+
151+
# both the default pragma and the exclude_also pattern apply
152+
for ln in (2, 3, 4, 5):
153+
assert ln not in cov['executed_lines'] and ln not in cov['missing_lines']
154+
assert 6 in cov['executed_lines']
155+
156+
157+
def test_exclude_also_adds_to_custom_exclude_lines(tmp_path):
158+
"""exclude_also adds on top of exclude_lines too, once exclude_lines has
159+
already replaced the defaults -- so the (now inactive) default pragma
160+
still doesn't apply, but both custom patterns do."""
161+
cov = _run(tmp_path, """\
162+
def foo(x):
163+
if x < 0: # pragma: no cover
164+
return 1
165+
if x < 0: # custom-a
166+
return 2
167+
if x < 0: # custom-b
168+
return 3
169+
return 4
170+
171+
foo(1)
172+
""", exclude_lines=["custom-a"], exclude_also=["custom-b"])
173+
174+
# the default pragma is still inactive (exclude_lines replaced it)
175+
assert 2 in cov['executed_lines']
176+
assert 3 in cov['missing_lines']
177+
# both custom-a and custom-b apply
178+
for ln in (4, 5, 6, 7):
179+
assert ln not in cov['executed_lines'] and ln not in cov['missing_lines']
180+
assert 8 in cov['executed_lines']
181+
182+
138183
def test_single_line_match_excludes_only_that_line(tmp_path):
139184
cov = _run(tmp_path, """\
140185
x = 1
@@ -440,3 +485,29 @@ def test_cli_exclude_lines_empty_config_disables_defaults(tmp_path, monkeypatch)
440485
keys = [k for k in cov['files'] if 'script.py' in k]
441486
assert keys, f"script.py not in coverage: {list(cov['files'].keys())}"
442487
assert 3 in cov['files'][keys[0]]['executed_lines']
488+
489+
490+
def test_cli_exclude_also_config_applied(tmp_path, monkeypatch):
491+
"""[tool.slipcover] exclude-also adds a pattern on top of the untouched
492+
defaults, confirmed end-to-end, not just at the apply_config() level."""
493+
monkeypatch.chdir(tmp_path)
494+
(tmp_path / "pyproject.toml").write_text(
495+
'[tool.slipcover]\n'
496+
'exclude-also = ["custom-nocov"]\n'
497+
)
498+
(tmp_path / "script.py").write_text(
499+
"def foo(x):\n"
500+
" if x < 0: # custom-nocov\n"
501+
" return 1\n"
502+
" return 2\n"
503+
"foo(1)\n"
504+
)
505+
506+
p = subprocess.run([sys.executable, '-m', 'slipcover', '--json', 'script.py'],
507+
capture_output=True, text=True)
508+
assert p.returncode == 0, f"stderr: {p.stderr}"
509+
510+
cov = json.loads(p.stdout)
511+
keys = [k for k in cov['files'] if 'script.py' in k]
512+
assert keys, f"script.py not in coverage: {list(cov['files'].keys())}"
513+
assert 3 not in cov['files'][keys[0]]['missing_lines']

tests/test_xdist.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,52 @@ def test_foo():
538538
# line 3 ("return 1") is genuinely dead code (foo(1) never takes the
539539
# excluded branch) and would show up as missing without propagation.
540540
assert 3 not in cov['files'][keys[0]]['missing_lines']
541+
542+
543+
def test_xdist_exclude_also_propagation(tmp_path, monkeypatch):
544+
"""exclude-also's resolution (default patterns + the extra pattern) must
545+
reach xdist workers as one already-merged list, not require the worker
546+
to redo the replace-then-add itself."""
547+
monkeypatch.chdir(tmp_path)
548+
549+
(tmp_path / "pyproject.toml").write_text(
550+
'[tool.slipcover]\n'
551+
'exclude-also = ["custom-nocov"]\n'
552+
)
553+
554+
module_file = tmp_path / "target.py"
555+
module_file.write_text(dedent("""\
556+
def foo(x):
557+
if x < 0: # pragma: no cover
558+
return 1
559+
if x < 0: # custom-nocov
560+
return 2
561+
return 3
562+
"""))
563+
564+
test_file = tmp_path / "test_it.py"
565+
test_file.write_text(dedent("""\
566+
from target import foo
567+
568+
def test_foo():
569+
assert foo(1) == 3
570+
"""))
571+
572+
out = tmp_path / "out.json"
573+
result = subprocess.run(
574+
[sys.executable, '-m', 'slipcover', '--json', '--out', str(out),
575+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
576+
capture_output=True, text=True
577+
)
578+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
579+
580+
with out.open() as f:
581+
cov = json.load(f)
582+
check_summaries(cov)
583+
584+
keys = [k for k in cov['files'] if 'target.py' in k]
585+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
586+
# both the default pragma (line 2/3) and the exclude-also pattern
587+
# (line 4/5) must be excluded, neither showing up as missing.
588+
for ln in (3, 5):
589+
assert ln not in cov['files'][keys[0]]['missing_lines']

0 commit comments

Comments
 (0)