|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import ast |
3 | 4 | import dis |
| 5 | +import re |
4 | 6 | import sys |
5 | 7 | import threading |
6 | 8 | import types |
|
20 | 22 |
|
21 | 23 | # FIXME provide __all__ |
22 | 24 |
|
| 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 | + |
23 | 36 | # Counter.total() is new in 3.10 |
24 | 37 | if sys.version_info < (3,10): |
25 | 38 | def counter_total(self: Counter) -> int: |
@@ -367,13 +380,24 @@ class Slipcover: |
367 | 380 | def __init__(self, immediate: bool = False, |
368 | 381 | d_miss_threshold: int = 50, branch: bool = False, |
369 | 382 | 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): |
371 | 386 | self.immediate = immediate |
372 | 387 | self.d_miss_threshold = d_miss_threshold |
373 | 388 | self.branch = branch |
374 | 389 | self.disassemble = disassemble |
375 | 390 | self.source = source |
376 | 391 | 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 [])] |
377 | 401 |
|
378 | 402 | # mutex protecting this state |
379 | 403 | self.lock = threading.RLock() |
@@ -700,6 +724,166 @@ def is_omitted(filepath: Path) -> bool: |
700 | 724 | print(f"Warning: unable to include {filename}: {e}") |
701 | 725 |
|
702 | 726 |
|
| 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 | + |
703 | 887 | @staticmethod |
704 | 888 | def _make_meta(branch_coverage: bool) -> dict: |
705 | 889 | import datetime |
@@ -755,6 +939,8 @@ def get_coverage(self): |
755 | 939 |
|
756 | 940 | files[simp.simplify(f)] = f_files |
757 | 941 |
|
| 942 | + self._filter_excluded_lines(files) |
| 943 | + |
758 | 944 | cov = { |
759 | 945 | 'meta': Slipcover._make_meta(self.branch), |
760 | 946 | 'files': files |
|
0 commit comments