# 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 from typing import IO from typing import Iterable from typing import TYPE_CHECKING from coverage.exceptions import ConfigError from coverage.exceptions import 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 from coverage.results import 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))