| # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
| |
| """Summary reporting""" |
| |
| from __future__ import annotations |
| |
| import sys |
| |
| from typing import Any, IO, Iterable, TYPE_CHECKING |
| |
| from coverage.exceptions import ConfigError, NoDataError |
| from coverage.misc import human_sorted_items |
| from coverage.plugin import FileReporter |
| from coverage.report_core import get_analysis_to_report |
| from coverage.results import Analysis, Numbers |
| from coverage.types import TMorf |
| |
| if TYPE_CHECKING: |
| from coverage import Coverage |
| |
| |
| class SummaryReporter: |
| """A reporter for writing the summary report.""" |
| |
| def __init__(self, coverage: Coverage) -> None: |
| self.coverage = coverage |
| self.config = self.coverage.config |
| self.branches = coverage.get_data().has_arcs() |
| self.outfile: IO[str] | None = None |
| self.output_format = self.config.format or "text" |
| if self.output_format not in {"text", "markdown", "total"}: |
| raise ConfigError(f"Unknown report format choice: {self.output_format!r}") |
| self.fr_analysis: list[tuple[FileReporter, Analysis]] = [] |
| self.skipped_count = 0 |
| self.empty_count = 0 |
| self.total = Numbers(precision=self.config.precision) |
| |
| def write(self, line: str) -> None: |
| """Write a line to the output, adding a newline.""" |
| assert self.outfile is not None |
| self.outfile.write(line.rstrip()) |
| self.outfile.write("\n") |
| |
| def write_items(self, items: Iterable[str]) -> None: |
| """Write a list of strings, joined together.""" |
| self.write("".join(items)) |
| |
| def _report_text( |
| self, |
| header: list[str], |
| lines_values: list[list[Any]], |
| total_line: list[Any], |
| end_lines: list[str], |
| ) -> None: |
| """Internal method that prints report data in text format. |
| |
| `header` is a list with captions. |
| `lines_values` is list of lists of sortable values. |
| `total_line` is a list with values of the total line. |
| `end_lines` is a list of ending lines with information about skipped files. |
| |
| """ |
| # Prepare the formatting strings, header, and column sorting. |
| max_name = max([len(line[0]) for line in lines_values] + [5]) + 1 |
| max_n = max(len(total_line[header.index("Cover")]) + 2, len(" Cover")) + 1 |
| max_n = max([max_n] + [len(line[header.index("Cover")]) + 2 for line in lines_values]) |
| formats = dict( |
| Name="{:{name_len}}", |
| Stmts="{:>7}", |
| Miss="{:>7}", |
| Branch="{:>7}", |
| BrPart="{:>7}", |
| Cover="{:>{n}}", |
| Missing="{:>10}", |
| ) |
| header_items = [ |
| formats[item].format(item, name_len=max_name, n=max_n) |
| for item in header |
| ] |
| header_str = "".join(header_items) |
| rule = "-" * len(header_str) |
| |
| # Write the header |
| self.write(header_str) |
| self.write(rule) |
| |
| formats.update(dict(Cover="{:>{n}}%"), Missing=" {:9}") |
| for values in lines_values: |
| # build string with line values |
| line_items = [ |
| formats[item].format(str(value), |
| name_len=max_name, n=max_n-1) for item, value in zip(header, values) |
| ] |
| self.write_items(line_items) |
| |
| # Write a TOTAL line |
| if lines_values: |
| self.write(rule) |
| |
| line_items = [ |
| formats[item].format(str(value), |
| name_len=max_name, n=max_n-1) for item, value in zip(header, total_line) |
| ] |
| self.write_items(line_items) |
| |
| for end_line in end_lines: |
| self.write(end_line) |
| |
| def _report_markdown( |
| self, |
| header: list[str], |
| lines_values: list[list[Any]], |
| total_line: list[Any], |
| end_lines: list[str], |
| ) -> None: |
| """Internal method that prints report data in markdown format. |
| |
| `header` is a list with captions. |
| `lines_values` is a sorted list of lists containing coverage information. |
| `total_line` is a list with values of the total line. |
| `end_lines` is a list of ending lines with information about skipped files. |
| |
| """ |
| # Prepare the formatting strings, header, and column sorting. |
| max_name = max((len(line[0].replace("_", "\\_")) for line in lines_values), default=0) |
| max_name = max(max_name, len("**TOTAL**")) + 1 |
| formats = dict( |
| Name="| {:{name_len}}|", |
| Stmts="{:>9} |", |
| Miss="{:>9} |", |
| Branch="{:>9} |", |
| BrPart="{:>9} |", |
| Cover="{:>{n}} |", |
| Missing="{:>10} |", |
| ) |
| max_n = max(len(total_line[header.index("Cover")]) + 6, len(" Cover ")) |
| header_items = [formats[item].format(item, name_len=max_name, n=max_n) for item in header] |
| header_str = "".join(header_items) |
| rule_str = "|" + " ".join(["- |".rjust(len(header_items[0])-1, "-")] + |
| ["-: |".rjust(len(item)-1, "-") for item in header_items[1:]], |
| ) |
| |
| # Write the header |
| self.write(header_str) |
| self.write(rule_str) |
| |
| for values in lines_values: |
| # build string with line values |
| formats.update(dict(Cover="{:>{n}}% |")) |
| line_items = [ |
| formats[item].format(str(value).replace("_", "\\_"), name_len=max_name, n=max_n-1) |
| for item, value in zip(header, values) |
| ] |
| self.write_items(line_items) |
| |
| # Write the TOTAL line |
| formats.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |")) |
| total_line_items: list[str] = [] |
| for item, value in zip(header, total_line): |
| if value == "": |
| insert = value |
| elif item == "Cover": |
| insert = f" **{value}%**" |
| else: |
| insert = f" **{value}**" |
| total_line_items += formats[item].format(insert, name_len=max_name, n=max_n) |
| self.write_items(total_line_items) |
| for end_line in end_lines: |
| self.write(end_line) |
| |
| def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str] | None = None) -> float: |
| """Writes a report summarizing coverage statistics per module. |
| |
| `outfile` is a text-mode file object to write the summary to. |
| |
| """ |
| self.outfile = outfile or sys.stdout |
| |
| self.coverage.get_data().set_query_contexts(self.config.report_contexts) |
| for fr, analysis in get_analysis_to_report(self.coverage, morfs): |
| self.report_one_file(fr, analysis) |
| |
| if not self.total.n_files and not self.skipped_count: |
| raise NoDataError("No data to report.") |
| |
| if self.output_format == "total": |
| self.write(self.total.pc_covered_str) |
| else: |
| self.tabular_report() |
| |
| return self.total.pc_covered |
| |
| def tabular_report(self) -> None: |
| """Writes tabular report formats.""" |
| # Prepare the header line and column sorting. |
| header = ["Name", "Stmts", "Miss"] |
| if self.branches: |
| header += ["Branch", "BrPart"] |
| header += ["Cover"] |
| if self.config.show_missing: |
| header += ["Missing"] |
| |
| column_order = dict(name=0, stmts=1, miss=2, cover=-1) |
| if self.branches: |
| column_order.update(dict(branch=3, brpart=4)) |
| |
| # `lines_values` is list of lists of sortable values. |
| lines_values = [] |
| |
| for (fr, analysis) in self.fr_analysis: |
| nums = analysis.numbers |
| |
| args = [fr.relative_filename(), nums.n_statements, nums.n_missing] |
| if self.branches: |
| args += [nums.n_branches, nums.n_partial_branches] |
| args += [nums.pc_covered_str] |
| if self.config.show_missing: |
| args += [analysis.missing_formatted(branches=True)] |
| args += [nums.pc_covered] |
| lines_values.append(args) |
| |
| # Line sorting. |
| sort_option = (self.config.sort or "name").lower() |
| reverse = False |
| if sort_option[0] == "-": |
| reverse = True |
| sort_option = sort_option[1:] |
| elif sort_option[0] == "+": |
| sort_option = sort_option[1:] |
| sort_idx = column_order.get(sort_option) |
| if sort_idx is None: |
| raise ConfigError(f"Invalid sorting option: {self.config.sort!r}") |
| if sort_option == "name": |
| lines_values = human_sorted_items(lines_values, reverse=reverse) |
| else: |
| lines_values.sort( |
| key=lambda line: (line[sort_idx], line[0]), |
| reverse=reverse, |
| ) |
| |
| # Calculate total if we had at least one file. |
| total_line = ["TOTAL", self.total.n_statements, self.total.n_missing] |
| if self.branches: |
| total_line += [self.total.n_branches, self.total.n_partial_branches] |
| total_line += [self.total.pc_covered_str] |
| if self.config.show_missing: |
| total_line += [""] |
| |
| # Create other final lines. |
| end_lines = [] |
| if self.config.skip_covered and self.skipped_count: |
| file_suffix = "s" if self.skipped_count>1 else "" |
| end_lines.append( |
| f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage.", |
| ) |
| if self.config.skip_empty and self.empty_count: |
| file_suffix = "s" if self.empty_count > 1 else "" |
| end_lines.append(f"\n{self.empty_count} empty file{file_suffix} skipped.") |
| |
| if self.output_format == "markdown": |
| formatter = self._report_markdown |
| else: |
| formatter = self._report_text |
| formatter(header, lines_values, total_line, end_lines) |
| |
| def report_one_file(self, fr: FileReporter, analysis: Analysis) -> None: |
| """Report on just one file, the callback from report().""" |
| nums = analysis.numbers |
| self.total += nums |
| |
| no_missing_lines = (nums.n_missing == 0) |
| no_missing_branches = (nums.n_partial_branches == 0) |
| if self.config.skip_covered and no_missing_lines and no_missing_branches: |
| # Don't report on 100% files. |
| self.skipped_count += 1 |
| elif self.config.skip_empty and nums.n_statements == 0: |
| # Don't report on empty files. |
| self.empty_count += 1 |
| else: |
| self.fr_analysis.append((fr, analysis)) |