|
1 | 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
|
2 | 2 | # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
|
3 | 3 |
|
4 |
| -"""Reporter foundation for coverage.py.""" |
| 4 | +"""Summary reporting""" |
5 | 5 |
|
6 | 6 | from __future__ import annotations
|
7 | 7 |
|
8 | 8 | import sys
|
9 | 9 |
|
10 |
| -from typing import Callable, Iterable, Iterator, IO, Optional, Tuple, TYPE_CHECKING |
| 10 | +from typing import Any, IO, Iterable, List, Optional, Tuple, TYPE_CHECKING |
11 | 11 |
|
12 |
| -from coverage.exceptions import NoDataError, NotPython |
13 |
| -from coverage.files import prep_patterns, GlobMatcher |
14 |
| -from coverage.misc import ensure_dir_for_file, file_be_gone |
| 12 | +from coverage.exceptions import ConfigError, NoDataError |
| 13 | +from coverage.misc import human_sorted_items |
15 | 14 | from coverage.plugin import FileReporter
|
16 |
| -from coverage.results import Analysis |
17 |
| -from coverage.types import Protocol, TMorf |
| 15 | +from coverage.report_core import get_analysis_to_report |
| 16 | +from coverage.results import Analysis, Numbers |
| 17 | +from coverage.types import TMorf |
18 | 18 |
|
19 | 19 | if TYPE_CHECKING:
|
20 | 20 | from coverage import Coverage
|
21 | 21 |
|
22 | 22 |
|
23 |
| -class Reporter(Protocol): |
24 |
| - """What we expect of reporters.""" |
25 |
| - |
26 |
| - report_type: str |
27 |
| - |
28 |
| - def report(self, morfs: Optional[Iterable[TMorf]], outfile: IO[str]) -> float: |
29 |
| - """Generate a report of `morfs`, written to `outfile`.""" |
30 |
| - |
31 |
| - |
32 |
| -def render_report( |
33 |
| - output_path: str, |
34 |
| - reporter: Reporter, |
35 |
| - morfs: Optional[Iterable[TMorf]], |
36 |
| - msgfn: Callable[[str], None], |
37 |
| -) -> float: |
38 |
| - """Run a one-file report generator, managing the output file. |
39 |
| -
|
40 |
| - This function ensures the output file is ready to be written to. Then writes |
41 |
| - the report to it. Then closes the file and cleans up. |
42 |
| -
|
43 |
| - """ |
44 |
| - file_to_close = None |
45 |
| - delete_file = False |
46 |
| - |
47 |
| - if output_path == "-": |
48 |
| - outfile = sys.stdout |
49 |
| - else: |
50 |
| - # Ensure that the output directory is created; done here because this |
51 |
| - # report pre-opens the output file. HtmlReporter does this on its own |
52 |
| - # because its task is more complex, being multiple files. |
53 |
| - ensure_dir_for_file(output_path) |
54 |
| - outfile = open(output_path, "w", encoding="utf-8") |
55 |
| - file_to_close = outfile |
56 |
| - delete_file = True |
57 |
| - |
58 |
| - try: |
59 |
| - ret = reporter.report(morfs, outfile=outfile) |
60 |
| - if file_to_close is not None: |
61 |
| - msgfn(f"Wrote {reporter.report_type} to {output_path}") |
62 |
| - delete_file = False |
63 |
| - return ret |
64 |
| - finally: |
65 |
| - if file_to_close is not None: |
66 |
| - file_to_close.close() |
67 |
| - if delete_file: |
68 |
| - file_be_gone(output_path) # pragma: part covered (doesn't return) |
69 |
| - |
70 |
| - |
71 |
| -def get_analysis_to_report( |
72 |
| - coverage: Coverage, |
73 |
| - morfs: Optional[Iterable[TMorf]], |
74 |
| -) -> Iterator[Tuple[FileReporter, Analysis]]: |
75 |
| - """Get the files to report on. |
76 |
| -
|
77 |
| - For each morf in `morfs`, if it should be reported on (based on the omit |
78 |
| - and include configuration options), yield a pair, the `FileReporter` and |
79 |
| - `Analysis` for the morf. |
80 |
| -
|
81 |
| - """ |
82 |
| - file_reporters = coverage._get_file_reporters(morfs) |
83 |
| - config = coverage.config |
84 |
| - |
85 |
| - if config.report_include: |
86 |
| - matcher = GlobMatcher(prep_patterns(config.report_include), "report_include") |
87 |
| - file_reporters = [fr for fr in file_reporters if matcher.match(fr.filename)] |
88 |
| - |
89 |
| - if config.report_omit: |
90 |
| - matcher = GlobMatcher(prep_patterns(config.report_omit), "report_omit") |
91 |
| - file_reporters = [fr for fr in file_reporters if not matcher.match(fr.filename)] |
92 |
| - |
93 |
| - if not file_reporters: |
94 |
| - raise NoDataError("No data to report.") |
95 |
| - |
96 |
| - for fr in sorted(file_reporters): |
97 |
| - try: |
98 |
| - analysis = coverage._analyze(fr) |
99 |
| - except NotPython: |
100 |
| - # Only report errors for .py files, and only if we didn't |
101 |
| - # explicitly suppress those errors. |
102 |
| - # NotPython is only raised by PythonFileReporter, which has a |
103 |
| - # should_be_python() method. |
104 |
| - if fr.should_be_python(): # type: ignore[attr-defined] |
105 |
| - if config.ignore_errors: |
106 |
| - msg = f"Couldn't parse Python file '{fr.filename}'" |
107 |
| - coverage._warn(msg, slug="couldnt-parse") |
108 |
| - else: |
109 |
| - raise |
110 |
| - except Exception as exc: |
111 |
| - if config.ignore_errors: |
112 |
| - msg = f"Couldn't parse '{fr.filename}': {exc}".rstrip() |
113 |
| - coverage._warn(msg, slug="couldnt-parse") |
| 23 | +class SummaryReporter: |
| 24 | + """A reporter for writing the summary report.""" |
| 25 | + |
| 26 | + def __init__(self, coverage: Coverage) -> None: |
| 27 | + self.coverage = coverage |
| 28 | + self.config = self.coverage.config |
| 29 | + self.branches = coverage.get_data().has_arcs() |
| 30 | + self.outfile: Optional[IO[str]] = None |
| 31 | + self.output_format = self.config.format or "text" |
| 32 | + if self.output_format not in {"text", "markdown", "total"}: |
| 33 | + raise ConfigError(f"Unknown report format choice: {self.output_format!r}") |
| 34 | + self.fr_analysis: List[Tuple[FileReporter, Analysis]] = [] |
| 35 | + self.skipped_count = 0 |
| 36 | + self.empty_count = 0 |
| 37 | + self.total = Numbers(precision=self.config.precision) |
| 38 | + |
| 39 | + def write(self, line: str) -> None: |
| 40 | + """Write a line to the output, adding a newline.""" |
| 41 | + assert self.outfile is not None |
| 42 | + self.outfile.write(line.rstrip()) |
| 43 | + self.outfile.write("\n") |
| 44 | + |
| 45 | + def write_items(self, items: Iterable[str]) -> None: |
| 46 | + """Write a list of strings, joined together.""" |
| 47 | + self.write("".join(items)) |
| 48 | + |
| 49 | + def _report_text( |
| 50 | + self, |
| 51 | + header: List[str], |
| 52 | + lines_values: List[List[Any]], |
| 53 | + total_line: List[Any], |
| 54 | + end_lines: List[str], |
| 55 | + ) -> None: |
| 56 | + """Internal method that prints report data in text format. |
| 57 | +
|
| 58 | + `header` is a list with captions. |
| 59 | + `lines_values` is list of lists of sortable values. |
| 60 | + `total_line` is a list with values of the total line. |
| 61 | + `end_lines` is a list of ending lines with information about skipped files. |
| 62 | +
|
| 63 | + """ |
| 64 | + # Prepare the formatting strings, header, and column sorting. |
| 65 | + max_name = max([len(line[0]) for line in lines_values] + [5]) + 1 |
| 66 | + max_n = max(len(total_line[header.index("Cover")]) + 2, len(" Cover")) + 1 |
| 67 | + max_n = max([max_n] + [len(line[header.index("Cover")]) + 2 for line in lines_values]) |
| 68 | + formats = dict( |
| 69 | + Name="{:{name_len}}", |
| 70 | + Stmts="{:>7}", |
| 71 | + Miss="{:>7}", |
| 72 | + Branch="{:>7}", |
| 73 | + BrPart="{:>7}", |
| 74 | + Cover="{:>{n}}", |
| 75 | + Missing="{:>10}", |
| 76 | + ) |
| 77 | + header_items = [ |
| 78 | + formats[item].format(item, name_len=max_name, n=max_n) |
| 79 | + for item in header |
| 80 | + ] |
| 81 | + header_str = "".join(header_items) |
| 82 | + rule = "-" * len(header_str) |
| 83 | + |
| 84 | + # Write the header |
| 85 | + self.write(header_str) |
| 86 | + self.write(rule) |
| 87 | + |
| 88 | + formats.update(dict(Cover="{:>{n}}%"), Missing=" {:9}") |
| 89 | + for values in lines_values: |
| 90 | + # build string with line values |
| 91 | + line_items = [ |
| 92 | + formats[item].format(str(value), |
| 93 | + name_len=max_name, n=max_n-1) for item, value in zip(header, values) |
| 94 | + ] |
| 95 | + self.write_items(line_items) |
| 96 | + |
| 97 | + # Write a TOTAL line |
| 98 | + if lines_values: |
| 99 | + self.write(rule) |
| 100 | + |
| 101 | + line_items = [ |
| 102 | + formats[item].format(str(value), |
| 103 | + name_len=max_name, n=max_n-1) for item, value in zip(header, total_line) |
| 104 | + ] |
| 105 | + self.write_items(line_items) |
| 106 | + |
| 107 | + for end_line in end_lines: |
| 108 | + self.write(end_line) |
| 109 | + |
| 110 | + def _report_markdown( |
| 111 | + self, |
| 112 | + header: List[str], |
| 113 | + lines_values: List[List[Any]], |
| 114 | + total_line: List[Any], |
| 115 | + end_lines: List[str], |
| 116 | + ) -> None: |
| 117 | + """Internal method that prints report data in markdown format. |
| 118 | +
|
| 119 | + `header` is a list with captions. |
| 120 | + `lines_values` is a sorted list of lists containing coverage information. |
| 121 | + `total_line` is a list with values of the total line. |
| 122 | + `end_lines` is a list of ending lines with information about skipped files. |
| 123 | +
|
| 124 | + """ |
| 125 | + # Prepare the formatting strings, header, and column sorting. |
| 126 | + max_name = max((len(line[0].replace("_", "\\_")) for line in lines_values), default=0) |
| 127 | + max_name = max(max_name, len("**TOTAL**")) + 1 |
| 128 | + formats = dict( |
| 129 | + Name="| {:{name_len}}|", |
| 130 | + Stmts="{:>9} |", |
| 131 | + Miss="{:>9} |", |
| 132 | + Branch="{:>9} |", |
| 133 | + BrPart="{:>9} |", |
| 134 | + Cover="{:>{n}} |", |
| 135 | + Missing="{:>10} |", |
| 136 | + ) |
| 137 | + max_n = max(len(total_line[header.index("Cover")]) + 6, len(" Cover ")) |
| 138 | + header_items = [formats[item].format(item, name_len=max_name, n=max_n) for item in header] |
| 139 | + header_str = "".join(header_items) |
| 140 | + rule_str = "|" + " ".join(["- |".rjust(len(header_items[0])-1, "-")] + |
| 141 | + ["-: |".rjust(len(item)-1, "-") for item in header_items[1:]] |
| 142 | + ) |
| 143 | + |
| 144 | + # Write the header |
| 145 | + self.write(header_str) |
| 146 | + self.write(rule_str) |
| 147 | + |
| 148 | + for values in lines_values: |
| 149 | + # build string with line values |
| 150 | + formats.update(dict(Cover="{:>{n}}% |")) |
| 151 | + line_items = [ |
| 152 | + formats[item].format(str(value).replace("_", "\\_"), name_len=max_name, n=max_n-1) |
| 153 | + for item, value in zip(header, values) |
| 154 | + ] |
| 155 | + self.write_items(line_items) |
| 156 | + |
| 157 | + # Write the TOTAL line |
| 158 | + formats.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |")) |
| 159 | + total_line_items: List[str] = [] |
| 160 | + for item, value in zip(header, total_line): |
| 161 | + if value == "": |
| 162 | + insert = value |
| 163 | + elif item == "Cover": |
| 164 | + insert = f" **{value}%**" |
114 | 165 | else:
|
115 |
| - raise |
| 166 | + insert = f" **{value}**" |
| 167 | + total_line_items += formats[item].format(insert, name_len=max_name, n=max_n) |
| 168 | + self.write_items(total_line_items) |
| 169 | + for end_line in end_lines: |
| 170 | + self.write(end_line) |
| 171 | + |
| 172 | + def report(self, morfs: Optional[Iterable[TMorf]], outfile: Optional[IO[str]] = None) -> float: |
| 173 | + """Writes a report summarizing coverage statistics per module. |
| 174 | +
|
| 175 | + `outfile` is a text-mode file object to write the summary to. |
| 176 | +
|
| 177 | + """ |
| 178 | + self.outfile = outfile or sys.stdout |
| 179 | + |
| 180 | + self.coverage.get_data().set_query_contexts(self.config.report_contexts) |
| 181 | + for fr, analysis in get_analysis_to_report(self.coverage, morfs): |
| 182 | + self.report_one_file(fr, analysis) |
| 183 | + |
| 184 | + if not self.total.n_files and not self.skipped_count: |
| 185 | + raise NoDataError("No data to report.") |
| 186 | + |
| 187 | + if self.output_format == "total": |
| 188 | + self.write(self.total.pc_covered_str) |
| 189 | + else: |
| 190 | + self.tabular_report() |
| 191 | + |
| 192 | + return self.total.pc_covered |
| 193 | + |
| 194 | + def tabular_report(self) -> None: |
| 195 | + """Writes tabular report formats.""" |
| 196 | + # Prepare the header line and column sorting. |
| 197 | + header = ["Name", "Stmts", "Miss"] |
| 198 | + if self.branches: |
| 199 | + header += ["Branch", "BrPart"] |
| 200 | + header += ["Cover"] |
| 201 | + if self.config.show_missing: |
| 202 | + header += ["Missing"] |
| 203 | + |
| 204 | + column_order = dict(name=0, stmts=1, miss=2, cover=-1) |
| 205 | + if self.branches: |
| 206 | + column_order.update(dict(branch=3, brpart=4)) |
| 207 | + |
| 208 | + # `lines_values` is list of lists of sortable values. |
| 209 | + lines_values = [] |
| 210 | + |
| 211 | + for (fr, analysis) in self.fr_analysis: |
| 212 | + nums = analysis.numbers |
| 213 | + |
| 214 | + args = [fr.relative_filename(), nums.n_statements, nums.n_missing] |
| 215 | + if self.branches: |
| 216 | + args += [nums.n_branches, nums.n_partial_branches] |
| 217 | + args += [nums.pc_covered_str] |
| 218 | + if self.config.show_missing: |
| 219 | + args += [analysis.missing_formatted(branches=True)] |
| 220 | + args += [nums.pc_covered] |
| 221 | + lines_values.append(args) |
| 222 | + |
| 223 | + # Line sorting. |
| 224 | + sort_option = (self.config.sort or "name").lower() |
| 225 | + reverse = False |
| 226 | + if sort_option[0] == "-": |
| 227 | + reverse = True |
| 228 | + sort_option = sort_option[1:] |
| 229 | + elif sort_option[0] == "+": |
| 230 | + sort_option = sort_option[1:] |
| 231 | + sort_idx = column_order.get(sort_option) |
| 232 | + if sort_idx is None: |
| 233 | + raise ConfigError(f"Invalid sorting option: {self.config.sort!r}") |
| 234 | + if sort_option == "name": |
| 235 | + lines_values = human_sorted_items(lines_values, reverse=reverse) |
| 236 | + else: |
| 237 | + lines_values.sort( |
| 238 | + key=lambda line: (line[sort_idx], line[0]), # type: ignore[index] |
| 239 | + reverse=reverse, |
| 240 | + ) |
| 241 | + |
| 242 | + # Calculate total if we had at least one file. |
| 243 | + total_line = ["TOTAL", self.total.n_statements, self.total.n_missing] |
| 244 | + if self.branches: |
| 245 | + total_line += [self.total.n_branches, self.total.n_partial_branches] |
| 246 | + total_line += [self.total.pc_covered_str] |
| 247 | + if self.config.show_missing: |
| 248 | + total_line += [""] |
| 249 | + |
| 250 | + # Create other final lines. |
| 251 | + end_lines = [] |
| 252 | + if self.config.skip_covered and self.skipped_count: |
| 253 | + file_suffix = "s" if self.skipped_count>1 else "" |
| 254 | + end_lines.append( |
| 255 | + f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage." |
| 256 | + ) |
| 257 | + if self.config.skip_empty and self.empty_count: |
| 258 | + file_suffix = "s" if self.empty_count > 1 else "" |
| 259 | + end_lines.append(f"\n{self.empty_count} empty file{file_suffix} skipped.") |
| 260 | + |
| 261 | + if self.output_format == "markdown": |
| 262 | + formatter = self._report_markdown |
| 263 | + else: |
| 264 | + formatter = self._report_text |
| 265 | + formatter(header, lines_values, total_line, end_lines) |
| 266 | + |
| 267 | + def report_one_file(self, fr: FileReporter, analysis: Analysis) -> None: |
| 268 | + """Report on just one file, the callback from report().""" |
| 269 | + nums = analysis.numbers |
| 270 | + self.total += nums |
| 271 | + |
| 272 | + no_missing_lines = (nums.n_missing == 0) |
| 273 | + no_missing_branches = (nums.n_partial_branches == 0) |
| 274 | + if self.config.skip_covered and no_missing_lines and no_missing_branches: |
| 275 | + # Don't report on 100% files. |
| 276 | + self.skipped_count += 1 |
| 277 | + elif self.config.skip_empty and nums.n_statements == 0: |
| 278 | + # Don't report on empty files. |
| 279 | + self.empty_count += 1 |
116 | 280 | else:
|
117 |
| - yield (fr, analysis) |
| 281 | + self.fr_analysis.append((fr, analysis)) |
0 commit comments