Skip to content

Commit 599272a

Browse files
Merge branch 'exclude-lines-support'
2 parents 1c25163 + 59025ab commit 599272a

8 files changed

Lines changed: 863 additions & 6 deletions

File tree

src/slipcover/__main__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,16 @@ def main():
301301

302302

303303
omit_list = args.omit.split(',') if args.omit else None
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.
308+
exclude_lines = getattr(args, 'exclude_lines', None)
309+
exclude_also = getattr(args, 'exclude_also', None)
304310
sci = sc.Slipcover(immediate=args.immediate,
305311
d_miss_threshold=args.threshold, branch=args.branch,
306-
disassemble=args.dis, source=args.source, omit=omit_list)
312+
disassemble=args.dis, source=args.source, omit=omit_list,
313+
exclude_lines=exclude_lines, exclude_also=exclude_also)
307314

308315

309316
if not args.dont_wrap_pytest:
@@ -321,6 +328,18 @@ def main():
321328
os.environ["SLIPCOVER_SOURCE"] = source_str
322329
if args.omit:
323330
os.environ["SLIPCOVER_OMIT"] = args.omit
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)
324343

325344
if platform.system() != 'Windows':
326345
os.fork = fork_shim(sci)

src/slipcover/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ def _coerce_format(value):
144144
"xml-package-depth": int,
145145
"lcov-test-name": str,
146146
"lcov-comments": _coerce_comments,
147+
"exclude-lines": _coerce_comments,
148+
"exclude-also": _coerce_comments,
147149
}
148150

149151

src/slipcover/pytest_plugin.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ def _activate_worker():
6363
branch = os.environ.get("SLIPCOVER_BRANCH") == "1"
6464
source = os.environ.get("SLIPCOVER_SOURCE")
6565
omit = os.environ.get("SLIPCOVER_OMIT")
66+
# The var's mere presence -- not its truthiness -- distinguishes "not
67+
# configured, use defaults" (unset -> None) from "explicitly resolved"
68+
# (set, even to "" for exclude-lines = [] in config -> []) -- see
69+
# __main__.py.
70+
if "SLIPCOVER_EXCLUDE_LINES" in os.environ:
71+
raw = os.environ["SLIPCOVER_EXCLUDE_LINES"]
72+
exclude_lines = raw.split("\n") if raw else []
73+
else:
74+
exclude_lines = None
6675

6776
# Set up file matcher
6877
_file_matcher = sc.FileMatcher()
@@ -79,7 +88,7 @@ def _activate_worker():
7988

8089
# Create Slipcover instance
8190
source_list = [s.strip() for s in source.split(",")] if source else None
82-
_slipcover_instance = sc.Slipcover(branch=branch, source=source_list)
91+
_slipcover_instance = sc.Slipcover(branch=branch, source=source_list, exclude_lines=exclude_lines)
8392

8493
# Wrap pytest's assertion rewriter for instrumentation
8594
sc.wrap_pytest(_slipcover_instance, _file_matcher)

src/slipcover/slipcover.py

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import ast
34
import dis
5+
import re
46
import sys
57
import threading
68
import types
@@ -20,6 +22,17 @@
2022

2123
# FIXME provide __all__
2224

25+
# Default exclude_lines patterns, matching coverage.py's own zero-config
26+
# defaults (coverage/config.py's DEFAULT_EXCLUDE, copied verbatim for the
27+
# two reused here) so the most common idiom, `# pragma: no cover`, works
28+
# without any configuration. coverage.py's third default -- excluding
29+
# stub-like `def foo(): ...` one-liners -- is intentionally not included;
30+
# that's a distinct feature, not what issue #26 asks for.
31+
DEFAULT_EXCLUDE = [
32+
r"#\s*(pragma|PRAGMA)[:\s]?\s*(no|NO)\s*(cover|COVER)",
33+
r"if (typing\.)?TYPE_CHECKING:",
34+
]
35+
2336
# Counter.total() is new in 3.10
2437
if sys.version_info < (3,10):
2538
def counter_total(self: Counter) -> int:
@@ -367,13 +380,24 @@ class Slipcover:
367380
def __init__(self, immediate: bool = False,
368381
d_miss_threshold: int = 50, branch: bool = False,
369382
disassemble: bool = False, source: Optional[List[str]] = None,
370-
omit: Optional[List[str]] = None):
383+
omit: Optional[List[str]] = None,
384+
exclude_lines: Optional[List[str]] = None,
385+
exclude_also: Optional[List[str]] = None):
371386
self.immediate = immediate
372387
self.d_miss_threshold = d_miss_threshold
373388
self.branch = branch
374389
self.disassemble = disassemble
375390
self.source = source
376391
self.omit = omit
392+
# Matching coverage.py's exclude_lines: user-supplied patterns
393+
# replace DEFAULT_EXCLUDE, they don't add to it. None (not given at
394+
# all) means "use the defaults"; an explicit [] disables exclusion
395+
# entirely, including the defaults -- the natural way to express
396+
# "no exclusion" via [tool.slipcover] exclude-lines = [] in config.
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 [])]
377401

378402
# mutex protecting this state
379403
self.lock = threading.RLock()
@@ -700,6 +724,166 @@ def is_omitted(filepath: Path) -> bool:
700724
print(f"Warning: unable to include {filename}: {e}")
701725

702726

727+
# Statement types whose primary clause (`body`) must be bounded
728+
# separately from a trailing elif/else/except/finally clause: ast.If/
729+
# For/While/Try's own end_lineno reaches through ALL of those, so using
730+
# it directly for the "if"/"for"/"while"/"try" line's own span would
731+
# incorrectly sweep a sibling clause the pattern never matched. `elif`
732+
# needs no special handling: it's just a nested If inside orelse,
733+
# walked (and correctly bounded) like any other If. Built via getattr
734+
# since TryStar (3.11+) doesn't exist on every Python version
735+
# slipcover supports.
736+
_MULTI_CLAUSE_TYPES = tuple(t for t in (
737+
ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, getattr(ast, 'TryStar', None),
738+
) if t is not None)
739+
740+
def _compute_excluded_lines(self, source: str) -> set:
741+
"""Computes the set of 1-based line numbers excluded by self._exclude_patterns.
742+
743+
A match on a block's own header line excludes the whole block; a
744+
match on a decorator, or the decorated def/class line itself,
745+
excludes from the *first* decorator onward (matching coverage.py:
746+
verified against coverage/parser.py, which computes
747+
first_line = min(d.lineno for d in decorator_list) regardless of
748+
which decorator actually matched, and confirmed by running
749+
coverage.py against this exact scenario); any other match excludes
750+
just that line/statement.
751+
752+
Each candidate gets a (full_start, full_end, trigger_start,
753+
trigger_end): a match anywhere in [trigger_start, trigger_end]
754+
excludes the whole [full_start, full_end]. For a plain statement
755+
these coincide -- a match anywhere in a multi-line statement
756+
excludes that whole statement. For a block/decorator/clause header,
757+
the trigger is bounded to just the header (not the body), matching
758+
coverage.py's own check (which only looks through the header's
759+
closing colon, never into the body) -- so a match buried inside a
760+
block's body is never mistakenly attributed to the enclosing block;
761+
only its own, smaller, more specific statement claims it. Spans are
762+
tried smallest-full-range first, so the most specific match always
763+
wins.
764+
765+
A bare `else:`/`finally:` line has no dedicated AST node of its own
766+
to anchor on (`elif` doesn't need special handling: it's just a
767+
nested If, with its own real `lineno`). But the *gap* between where
768+
the preceding clause's body ends and the next clause's body begins
769+
-- both real AST positions -- can only ever contain that keyword
770+
itself, plus blank lines or comments, so it's used directly as the
771+
trigger region with no need to locate the keyword's exact line via
772+
source-text scanning. A match with no statement of its own and no
773+
such gap to claim it (e.g. a standalone comment) excludes just that
774+
single physical line, never whatever larger span happens to
775+
numerically contain it.
776+
"""
777+
lines = source.splitlines()
778+
matched = {
779+
i + 1 for i, text in enumerate(lines)
780+
if any(p.search(text) for p in self._exclude_patterns)
781+
}
782+
if not matched:
783+
return set()
784+
785+
try:
786+
tree = ast.parse(source)
787+
except SyntaxError:
788+
return matched # best-effort: at least exclude the matched lines themselves
789+
790+
spans = [] # (full_start, full_end, trigger_start, trigger_end)
791+
792+
for node in ast.walk(tree):
793+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
794+
first = node.decorator_list[0].lineno if node.decorator_list else node.lineno
795+
spans.append((first, node.end_lineno, first, node.lineno))
796+
continue # handled fully here -- skip the generic stmt case below
797+
798+
if isinstance(node, ast.ExceptHandler):
799+
spans.append((node.lineno, node.end_lineno, node.lineno, node.body[0].lineno - 1))
800+
801+
elif hasattr(ast, 'Match') and isinstance(node, ast.Match):
802+
for case in node.cases:
803+
spans.append((case.pattern.lineno, case.body[-1].end_lineno,
804+
case.pattern.lineno, case.body[0].lineno - 1))
805+
806+
if isinstance(node, Slipcover._MULTI_CLAUSE_TYPES) and node.orelse:
807+
gap_start = node.body[-1].end_lineno + 1
808+
gap_end = node.orelse[0].lineno - 1
809+
if gap_start <= gap_end:
810+
spans.append((gap_start, node.orelse[-1].end_lineno, gap_start, gap_end))
811+
812+
finalbody = getattr(node, 'finalbody', None)
813+
if finalbody:
814+
prev_end = (node.orelse[-1].end_lineno if node.orelse
815+
else node.handlers[-1].end_lineno if node.handlers
816+
else node.body[-1].end_lineno)
817+
gap_start = prev_end + 1
818+
gap_end = finalbody[0].lineno - 1
819+
if gap_start <= gap_end:
820+
spans.append((gap_start, finalbody[-1].end_lineno, gap_start, gap_end))
821+
822+
if isinstance(node, ast.stmt):
823+
body = getattr(node, 'body', None)
824+
if body:
825+
header_end = max(body[0].lineno - 1, node.lineno)
826+
if isinstance(node, Slipcover._MULTI_CLAUSE_TYPES):
827+
full_end = body[-1].end_lineno
828+
else:
829+
full_end = node.end_lineno or node.lineno
830+
spans.append((node.lineno, full_end, node.lineno, header_end))
831+
else:
832+
end = node.end_lineno or node.lineno
833+
spans.append((node.lineno, end, node.lineno, end))
834+
835+
# Smallest full-range first, so a matched line is always attributed
836+
# to its most specific enclosing construct.
837+
spans.sort(key=lambda s: s[1] - s[0])
838+
839+
excluded: set = set()
840+
claimed: set = set()
841+
for full_start, full_end, trig_start, trig_end in spans:
842+
trigger_lines = set(range(trig_start, trig_end + 1))
843+
if (matched & trigger_lines) - claimed:
844+
span_lines = set(range(full_start, full_end + 1))
845+
excluded.update(span_lines)
846+
claimed.update(matched & span_lines)
847+
848+
# any matched line nothing above accounts for (e.g. a standalone
849+
# comment line) is still excluded on its own.
850+
excluded.update(matched - claimed)
851+
return excluded
852+
853+
def _filter_excluded_lines(self, files: dict) -> None:
854+
"""Removes excluded lines, and any branch tuple originating from one,
855+
from the coverage data -- a decision point on an excluded line
856+
shouldn't leave stray executed/missing branch entries behind even
857+
though the line itself is gone."""
858+
source_cache: Dict[str, str] = {}
859+
860+
for fname, fdata in files.items():
861+
if fname not in source_cache:
862+
path = Path(fname)
863+
if not path.is_absolute():
864+
path = Path.cwd() / path
865+
try:
866+
source_cache[fname] = path.read_text()
867+
except OSError:
868+
source_cache[fname] = ""
869+
870+
source = source_cache[fname]
871+
if not source:
872+
continue
873+
874+
excluded = self._compute_excluded_lines(source)
875+
if not excluded:
876+
continue
877+
878+
fdata['executed_lines'] = [l for l in fdata['executed_lines'] if l not in excluded]
879+
fdata['missing_lines'] = [l for l in fdata['missing_lines'] if l not in excluded]
880+
881+
if 'executed_branches' in fdata:
882+
fdata['executed_branches'] = [b for b in fdata['executed_branches'] if b[0] not in excluded]
883+
if 'missing_branches' in fdata:
884+
fdata['missing_branches'] = [b for b in fdata['missing_branches'] if b[0] not in excluded]
885+
886+
703887
@staticmethod
704888
def _make_meta(branch_coverage: bool) -> dict:
705889
import datetime
@@ -755,6 +939,8 @@ def get_coverage(self):
755939

756940
files[simp.simplify(f)] = f_files
757941

942+
self._filter_excluded_lines(files)
943+
758944
cov = {
759945
'meta': Slipcover._make_meta(self.branch),
760946
'files': files

tests/test_config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +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, exclude_also=None,
173174
)
174175
defaults.update(kwargs)
175176
return argparse.Namespace(**defaults)
@@ -195,6 +196,24 @@ def test_apply_config_lcov_comments_scalar_becomes_list():
195196
assert args.lcov_comments == ["just one"]
196197

197198

199+
def test_apply_config_exclude_lines():
200+
args = _make_args()
201+
apply_config({"exclude-lines": ["foo", "bar"]}, args)
202+
assert args.exclude_lines == ["foo", "bar"]
203+
204+
205+
def test_apply_config_exclude_lines_scalar_becomes_list():
206+
args = _make_args()
207+
apply_config({"exclude-lines": "just one"}, args)
208+
assert args.exclude_lines == ["just one"]
209+
210+
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+
198217
def test_apply_config_format_bad_value_raises():
199218
args = _make_args()
200219
with pytest.raises(ValueError, match="must be one of"):

tests/test_coverage.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,8 @@ def test_sigterm_top_level_writes_single_correct_report(tmp_path, monkeypatch):
18201820
script.write_text(dedent("""\
18211821
import time
18221822
x = 1
1823+
with open("started.txt", "w") as f:
1824+
f.write("1")
18231825
time.sleep(10)
18241826
y = 2 # must never execute -- process is killed during sleep
18251827
"""))
@@ -1829,9 +1831,21 @@ def test_sigterm_top_level_writes_single_correct_report(tmp_path, monkeypatch):
18291831
cwd=tmp_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
18301832
)
18311833

1832-
time.sleep(1) # let slipcover start up and reach the sleep
1834+
# Wait for the script to actually reach the sleep, rather than guessing
1835+
# a fixed duration: too short races the SIGTERM against slipcover's own
1836+
# startup, too long just wastes time -- polling for a concrete
1837+
# readiness marker adapts to whatever the environment actually needs.
1838+
started_file = tmp_path / "started.txt"
1839+
for _ in range(100): # up to ~5s
1840+
if started_file.exists():
1841+
break
1842+
time.sleep(0.05)
1843+
else:
1844+
proc.kill()
1845+
pytest.fail("script never started")
1846+
18331847
proc.send_signal(signal.SIGTERM)
1834-
stdout, stderr = proc.communicate(timeout=10)
1848+
stdout, stderr = proc.communicate(timeout=30)
18351849

18361850
assert proc.returncode == 0, f"stdout={stdout}\nstderr={stderr}"
18371851
# the report table's header appears exactly once per report -- the
@@ -1886,7 +1900,7 @@ def test_sigterm_forked_child_writes_partial_coverage_safely(tmp_path, monkeypat
18861900
child_pid = int(pid_file.read_text())
18871901
os.kill(child_pid, signal.SIGTERM)
18881902

1889-
stdout, stderr = proc.communicate(timeout=10)
1903+
stdout, stderr = proc.communicate(timeout=30)
18901904
assert proc.returncode == 0, f"stdout={stdout}\nstderr={stderr}"
18911905

18921906
cov = json.loads((tmp_path / "out.json").read_text())

0 commit comments

Comments
 (0)