|
| 1 | +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| 2 | +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
| 3 | + |
| 4 | +""" |
| 5 | +$ python warn_executed.py <coverage_data_file> <config_file> |
| 6 | +
|
| 7 | +Find lines that were excluded by "warn-executed" regex patterns |
| 8 | +but were actually executed according to coverage data. |
| 9 | +
|
| 10 | +The config_file is a TOML file with "warn-executed" and "warn-not-partial" |
| 11 | +patterns like: |
| 12 | +
|
| 13 | + warn-executed = [ |
| 14 | + "pragma: no cover", |
| 15 | + "# debug", |
| 16 | + "raise NotImplemented", |
| 17 | + ] |
| 18 | +
|
| 19 | + warn-not-partial = [ |
| 20 | + "if TYPE_CHECKING:", |
| 21 | + ] |
| 22 | +
|
| 23 | +These should be patterns that you excluded as lines or partial branches. |
| 24 | +
|
| 25 | +Warning: this program uses internal undocumented private classes from |
| 26 | +coverage.py. This is an unsupported proof-of-concept. |
| 27 | +
|
| 28 | +I wrote a blog post about this: |
| 29 | +https://nedbatchelder.com/blog/202508/finding_unneeded_pragmas.html |
| 30 | +
|
| 31 | +""" |
| 32 | + |
| 33 | +import linecache |
| 34 | +import os |
| 35 | +import sys |
| 36 | +import tomllib |
| 37 | + |
| 38 | +from coverage.parser import PythonParser |
| 39 | +from coverage.sqldata import CoverageData |
| 40 | +from coverage.results import Analysis |
| 41 | + |
| 42 | + |
| 43 | +def read_warn_patterns(config_file: str) -> tuple[list[str], list[str]]: |
| 44 | + """Read "warn-executed" and "warn-not-partial" patterns from a TOML config file.""" |
| 45 | + with open(config_file, "rb") as f: |
| 46 | + config = tomllib.load(f) |
| 47 | + |
| 48 | + warn_executed = [] |
| 49 | + warn_not_partial = [] |
| 50 | + |
| 51 | + if "warn-executed" in config: |
| 52 | + warn_executed.extend(config["warn-executed"]) |
| 53 | + if "warn-not-partial" in config: |
| 54 | + warn_not_partial.extend(config["warn-not-partial"]) |
| 55 | + |
| 56 | + return warn_executed, warn_not_partial |
| 57 | + |
| 58 | + |
| 59 | +def find_executed_excluded_lines( |
| 60 | + source_file: str, |
| 61 | + coverage_data: CoverageData, |
| 62 | + warn_patterns: list[str], |
| 63 | +) -> set[int]: |
| 64 | + """ |
| 65 | + Find lines that match warn-executed patterns but were actually executed. |
| 66 | +
|
| 67 | + Args: |
| 68 | + source_file: Path to the Python source file to analyze |
| 69 | + coverage_data: The coverage data object |
| 70 | + warn_patterns: List of regex patterns that should warn if executed |
| 71 | +
|
| 72 | + Returns: |
| 73 | + Set of executed line numbers that matched any pattern |
| 74 | + """ |
| 75 | + executed_lines = coverage_data.lines(source_file) |
| 76 | + if executed_lines is None: |
| 77 | + return set() |
| 78 | + |
| 79 | + executed_lines = set(executed_lines) |
| 80 | + |
| 81 | + try: |
| 82 | + with open(source_file, "r", encoding="utf-8") as f: |
| 83 | + source_text = f.read() |
| 84 | + except Exception: |
| 85 | + return set() |
| 86 | + |
| 87 | + parser = PythonParser(text=source_text, filename=source_file) |
| 88 | + parser.parse_source() |
| 89 | + |
| 90 | + all_executed_excluded = set() |
| 91 | + for pattern in warn_patterns: |
| 92 | + matched_lines = parser.lines_matching(pattern) |
| 93 | + all_executed_excluded.update(matched_lines & executed_lines) |
| 94 | + |
| 95 | + return all_executed_excluded |
| 96 | + |
| 97 | + |
| 98 | +def find_not_partial_lines( |
| 99 | + source_file: str, |
| 100 | + coverage_data: CoverageData, |
| 101 | + warn_patterns: list[str], |
| 102 | +) -> set[int]: |
| 103 | + """ |
| 104 | + Find lines that match warn-not-partial patterns but had both code paths executed. |
| 105 | +
|
| 106 | + Args: |
| 107 | + source_file: Path to the Python source file to analyze |
| 108 | + coverage_data: The coverage data object |
| 109 | + warn_patterns: List of regex patterns for lines expected to be partial |
| 110 | +
|
| 111 | + Returns: |
| 112 | + Set of line numbers that matched patterns but weren't partial |
| 113 | + """ |
| 114 | + if not coverage_data.has_arcs(): |
| 115 | + return set() |
| 116 | + |
| 117 | + all_arcs = coverage_data.arcs(source_file) |
| 118 | + if all_arcs is None: |
| 119 | + return set() |
| 120 | + |
| 121 | + try: |
| 122 | + with open(source_file, "r", encoding="utf-8") as f: |
| 123 | + source_text = f.read() |
| 124 | + except Exception: |
| 125 | + return set() |
| 126 | + |
| 127 | + parser = PythonParser(text=source_text, filename=source_file) |
| 128 | + parser.parse_source() |
| 129 | + |
| 130 | + all_possible_arcs = set(parser.arcs()) |
| 131 | + executed_arcs = set(all_arcs) |
| 132 | + |
| 133 | + # Lines with some missing arcs are partial branches |
| 134 | + partial_lines = set() |
| 135 | + for start_line in {arc[0] for arc in all_possible_arcs if arc[0] > 0}: |
| 136 | + possible_from_line = {arc for arc in all_possible_arcs if arc[0] == start_line} |
| 137 | + executed_from_line = {arc for arc in executed_arcs if arc[0] == start_line} |
| 138 | + if executed_from_line and possible_from_line != executed_from_line: |
| 139 | + partial_lines.add(start_line) |
| 140 | + |
| 141 | + all_not_partial = set() |
| 142 | + for pattern in warn_patterns: |
| 143 | + matched_lines = parser.lines_matching(pattern) |
| 144 | + not_partial = matched_lines - partial_lines |
| 145 | + all_not_partial.update(not_partial) |
| 146 | + |
| 147 | + return all_not_partial |
| 148 | + |
| 149 | + |
| 150 | +def analyze_warnings(coverage_file: str, config_file: str) -> dict[str, set[int]]: |
| 151 | + """ |
| 152 | + Find lines that match warn-executed or warn-not-partial patterns. |
| 153 | +
|
| 154 | + Args: |
| 155 | + coverage_file: Path to the coverage data file (.coverage) |
| 156 | + config_file: Path to TOML config file with warning patterns |
| 157 | +
|
| 158 | + Returns: |
| 159 | + Dictionary mapping filenames to sets of problematic line numbers |
| 160 | + """ |
| 161 | + warn_executed_patterns, warn_not_partial_patterns = read_warn_patterns(config_file) |
| 162 | + |
| 163 | + if not warn_executed_patterns and not warn_not_partial_patterns: |
| 164 | + return {} |
| 165 | + |
| 166 | + coverage_data = CoverageData(coverage_file) |
| 167 | + coverage_data.read() |
| 168 | + |
| 169 | + measured_files = sorted(coverage_data.measured_files()) |
| 170 | + |
| 171 | + all_results = {} |
| 172 | + for source_file in measured_files: |
| 173 | + problem_lines = set() |
| 174 | + |
| 175 | + if warn_executed_patterns: |
| 176 | + executed_excluded = find_executed_excluded_lines( |
| 177 | + source_file, |
| 178 | + coverage_data, |
| 179 | + warn_executed_patterns, |
| 180 | + ) |
| 181 | + problem_lines.update(executed_excluded) |
| 182 | + |
| 183 | + if warn_not_partial_patterns: |
| 184 | + not_partial = find_not_partial_lines( |
| 185 | + source_file, |
| 186 | + coverage_data, |
| 187 | + warn_not_partial_patterns, |
| 188 | + ) |
| 189 | + problem_lines.update(not_partial) |
| 190 | + |
| 191 | + if problem_lines: |
| 192 | + all_results[source_file] = problem_lines |
| 193 | + |
| 194 | + return all_results |
| 195 | + |
| 196 | + |
| 197 | +def main(): |
| 198 | + if len(sys.argv) != 3: |
| 199 | + print(__doc__.rstrip()) |
| 200 | + return 1 |
| 201 | + |
| 202 | + coverage_file, config_file = sys.argv[1:] |
| 203 | + results = analyze_warnings(coverage_file, config_file) |
| 204 | + |
| 205 | + for source_file in sorted(results.keys()): |
| 206 | + problem_lines = results[source_file] |
| 207 | + for line_num in sorted(problem_lines): |
| 208 | + line_text = linecache.getline(source_file, line_num).rstrip() |
| 209 | + print(f"{source_file}:{line_num}: {line_text}") |
| 210 | + |
| 211 | + |
| 212 | +if __name__ == "__main__": |
| 213 | + sys.exit(main()) |
0 commit comments