Skip to content

Commit

Permalink
feat: report terminal output in Markdown Table format #1418 (#1479)
Browse files Browse the repository at this point in the history
* refactoring normal reporting text output

* implemented markdown feature from #1418

* minor changes

* fixed text output

* fixed precision for text and markdown report format

* minor changes

* finished testing for markdown format feature

* fixed testing outside test_summary.py

* removed fixed-length widespace padding for tests

* removed whitespaces

* refactoring, fixing  docs, rewriting cmd args

* fixing code quality

* implementing requested changes

* doc fix

* test: add another test of correct report formatting

* fixed precision printing test

* style: adjust the formatting

Co-authored-by: Ned Batchelder <ned@nedbatchelder.com>
  • Loading branch information
stepeos and nedbat committed Nov 5, 2022
1 parent 27fd4a9 commit cf1efa8
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 92 deletions.
7 changes: 7 additions & 0 deletions coverage/cmdline.py
Expand Up @@ -96,6 +96,10 @@ class Opts:
'', '--fail-under', action='store', metavar="MIN", type="float",
help="Exit with a status of 2 if the total coverage is less than MIN.",
)
output_format = optparse.make_option(
'', '--format', action='store', metavar="FORMAT", dest="output_format",
help="Output format, either text (default) or markdown",
)
help = optparse.make_option(
'-h', '--help', action='store_true',
help="Get help on this command.",
Expand Down Expand Up @@ -245,6 +249,7 @@ def __init__(self, *args, **kwargs):
debug=None,
directory=None,
fail_under=None,
output_format=None,
help=None,
ignore_errors=None,
include=None,
Expand Down Expand Up @@ -482,6 +487,7 @@ def get_prog_name(self):
Opts.contexts,
Opts.input_datafile,
Opts.fail_under,
Opts.output_format,
Opts.ignore_errors,
Opts.include,
Opts.omit,
Expand Down Expand Up @@ -689,6 +695,7 @@ def command_line(self, argv):
skip_covered=options.skip_covered,
skip_empty=options.skip_empty,
sort=options.sort,
output_format=options.output_format,
**report_args
)
elif options.action == "annotate":
Expand Down
2 changes: 2 additions & 0 deletions coverage/config.py
Expand Up @@ -199,6 +199,7 @@ def __init__(self):
# Defaults for [report]
self.exclude_list = DEFAULT_EXCLUDE[:]
self.fail_under = 0.0
self.output_format = None
self.ignore_errors = False
self.report_include = None
self.report_omit = None
Expand Down Expand Up @@ -374,6 +375,7 @@ def copy(self):
# [report]
('exclude_list', 'report:exclude_lines', 'regexlist'),
('fail_under', 'report:fail_under', 'float'),
('output_format', 'report:output_format', 'boolean'),
('ignore_errors', 'report:ignore_errors', 'boolean'),
('partial_always_list', 'report:partial_branches_always', 'regexlist'),
('partial_list', 'report:partial_branches', 'regexlist'),
Expand Down
11 changes: 9 additions & 2 deletions coverage/control.py
Expand Up @@ -908,7 +908,8 @@ def _get_file_reporters(self, morfs=None):
def report(
self, morfs=None, show_missing=None, ignore_errors=None,
file=None, omit=None, include=None, skip_covered=None,
contexts=None, skip_empty=None, precision=None, sort=None
contexts=None, skip_empty=None, precision=None, sort=None,
output_format=None,
):
"""Write a textual summary report to `file`.
Expand All @@ -922,6 +923,9 @@ def report(
`file` is a file-like object, suitable for writing.
`output_format` provides options, to print eitehr as plain text, or as
markdown code
`include` is a list of file name patterns. Files that match will be
included in the report. Files matching `omit` will not be included in
the report.
Expand Down Expand Up @@ -953,13 +957,16 @@ def report(
.. versionadded:: 5.2
The `precision` parameter.
.. versionadded:: 6.6
The `format` parameter.
"""
with override_config(
self,
ignore_errors=ignore_errors, report_omit=omit, report_include=include,
show_missing=show_missing, skip_covered=skip_covered,
report_contexts=contexts, skip_empty=skip_empty, precision=precision,
sort=sort
sort=sort, output_format=output_format,
):
reporter = SummaryReporter(self)
return reporter.report(morfs, outfile=file)
Expand Down
205 changes: 151 additions & 54 deletions coverage/summary.py
Expand Up @@ -6,7 +6,7 @@
import sys

from coverage.exceptions import ConfigError, NoDataError
from coverage.misc import human_sorted_items
from coverage.misc import human_key
from coverage.report import get_analysis_to_report
from coverage.results import Numbers

Expand All @@ -30,6 +30,119 @@ def writeout(self, line):
self.outfile.write(line.rstrip())
self.outfile.write("\n")

def _report_text(self, header, lines_values, total_line, end_lines):
"""Internal method that prints report data in text format.
`header` is a tuple with captions.
`lines_values` is list of tuples of sortable values.
`total_line` is a tuple with values of the total line.
`end_lines` is a tuple 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])
h_form = dict(
Name="{:{name_len}}",
Stmts="{:>7}",
Miss="{:>7}",
Branch="{:>7}",
BrPart="{:>7}",
Cover="{:>{n}}",
Missing="{:>10}",
)
header_items = [
h_form[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.writeout(header_str)
self.writeout(rule)

h_form.update(dict(Cover="{:>{n}}%"), Missing=" {:9}")
for values in lines_values:
# build string with line values
line_items = [
h_form[item].format(str(value),
name_len=max_name, n=max_n-1) for item, value in zip(header, values)
]
text = "".join(line_items)
self.writeout(text)

# Write a TOTAL line
self.writeout(rule)
line_items = [
h_form[item].format(str(value),
name_len=max_name, n=max_n-1) for item, value in zip(header, total_line)
]
text = "".join(line_items)
self.writeout(text)

for end_line in end_lines:
self.writeout(end_line)

def _report_markdown(self, header, lines_values, total_line, end_lines):
"""Internal method that prints report data in markdown format.
`header` is a tuple with captions.
`lines_values` is a sorted list of tuples containing coverage information.
`total_line` is a tuple with values of the total line.
`end_lines` is a tuple 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] + [9])
max_name += 1
h_form = 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 = [h_form[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.writeout(header_str)
self.writeout(rule_str)

for values in lines_values:
# build string with line values
h_form.update(dict(Cover="{:>{n}}% |"))
line_items = [
h_form[item].format(str(value).replace("_", "\\_"),
name_len=max_name, n=max_n-1) for item, value in zip(header, values)
]
text = "".join(line_items)
self.writeout(text)

# Write the TOTAL line
h_form.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |"))
total_line_items = []
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 += h_form[item].format(insert, name_len=max_name, n=max_n)
total_row_str = "".join(total_line_items)
self.writeout(total_row_str)
for end_line in end_lines:
self.writeout(end_line)

def report(self, morfs, outfile=None):
"""Writes a report summarizing coverage statistics per module.
Expand All @@ -44,36 +157,19 @@ def report(self, morfs, outfile=None):
self.report_one_file(fr, analysis)

# Prepare the formatting strings, header, and column sorting.
max_name = max([len(fr.relative_filename()) for (fr, analysis) in self.fr_analysis] + [5])
fmt_name = "%%- %ds " % max_name
fmt_skip_covered = "\n%s file%s skipped due to complete coverage."
fmt_skip_empty = "\n%s empty file%s skipped."

header = (fmt_name % "Name") + " Stmts Miss"
fmt_coverage = fmt_name + "%6d %6d"
header = ("Name", "Stmts", "Miss",)
if self.branches:
header += " Branch BrPart"
fmt_coverage += " %6d %6d"
width100 = Numbers(precision=self.config.precision).pc_str_width()
header += "%*s" % (width100+4, "Cover")
fmt_coverage += "%%%ds%%%%" % (width100+3,)
header += ("Branch", "BrPart",)
header += ("Cover",)
if self.config.show_missing:
header += " Missing"
fmt_coverage += " %s"
rule = "-" * len(header)
header += ("Missing",)

column_order = dict(name=0, stmts=1, miss=2, cover=-1)
if self.branches:
column_order.update(dict(branch=3, brpart=4))

# Write the header
self.writeout(header)
self.writeout(rule)

# `lines` is a list of pairs, (line text, line values). The line text
# is a string that will be printed, and line values is a tuple of
# sortable values.
lines = []
# `lines_values` is list of tuples of sortable values.
lines_values = []

for (fr, analysis) in self.fr_analysis:
nums = analysis.numbers
Expand All @@ -84,54 +180,55 @@ def report(self, morfs, outfile=None):
args += (nums.pc_covered_str,)
if self.config.show_missing:
args += (analysis.missing_formatted(branches=True),)
text = fmt_coverage % args
# Add numeric percent coverage so that sorting makes sense.
args += (nums.pc_covered,)
lines.append((text, args))
lines_values.append(args)

# Sort the lines and write them out.
# 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 = human_sorted_items(lines, reverse=reverse)
lines_values.sort(key=lambda tup: (human_key(tup[0]), tup[1]), reverse=reverse)
else:
position = column_order.get(sort_option)
if position is None:
raise ConfigError(f"Invalid sorting option: {self.config.sort!r}")
lines.sort(key=lambda l: (l[1][position], l[0]), reverse=reverse)

for line in lines:
self.writeout(line[0])

# Write a TOTAL line if we had at least one file.
if self.total.n_files > 0:
self.writeout(rule)
args = ("TOTAL", self.total.n_statements, self.total.n_missing)
if self.branches:
args += (self.total.n_branches, self.total.n_partial_branches)
args += (self.total.pc_covered_str,)
if self.config.show_missing:
args += ("",)
self.writeout(fmt_coverage % args)
lines_values.sort(key=lambda tup: (tup[sort_idx], tup[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 += ("",)

# Write other final lines.
# create other final lines
end_lines = []
if not self.total.n_files and not self.skipped_count:
raise NoDataError("No data to report.")

if self.config.skip_covered and self.skipped_count:
self.writeout(
fmt_skip_covered % (self.skipped_count, 's' if self.skipped_count > 1 else '')
file_suffix = 's' if self.skipped_count>1 else ''
fmt_skip_covered = (
f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage."
)
end_lines.append(fmt_skip_covered)
if self.config.skip_empty and self.empty_count:
self.writeout(
fmt_skip_empty % (self.empty_count, 's' if self.empty_count > 1 else '')
)
file_suffix = 's' if self.empty_count>1 else ''
fmt_skip_empty = f"\n{self.empty_count} empty file{file_suffix} skipped."
end_lines.append(fmt_skip_empty)

text_format = self.config.output_format or "text"
if text_format == "markdown":
formatter = self._report_markdown
else:
formatter = self._report_text
formatter(header, lines_values, total_line, end_lines)

return self.total.n_statements and self.total.pc_covered

Expand Down
3 changes: 2 additions & 1 deletion doc/cmd.rst
Expand Up @@ -518,6 +518,7 @@ as a percentage.
file. Defaults to '.coverage'. [env: COVERAGE_FILE]
--fail-under=MIN Exit with a status of 2 if the total coverage is less
than MIN.
--format=FORMAT Output format, either text (default) or markdown
-i, --ignore-errors Ignore errors while reading source files.
--include=PAT1,PAT2,...
Include only files whose paths match one of these
Expand All @@ -540,7 +541,7 @@ as a percentage.
--rcfile=RCFILE Specify configuration file. By default '.coveragerc',
'setup.cfg', 'tox.ini', and 'pyproject.toml' are
tried. [env: COVERAGE_RCFILE]
.. [[[end]]] (checksum: 2f8dde61bab2f44fbfe837aeae87dfd2)
.. [[[end]]] (checksum: 8c671de502a388159689082d906f786a)
The ``-m`` flag also shows the line numbers of missing statements::

Expand Down
2 changes: 1 addition & 1 deletion tests/test_cmdline.py
Expand Up @@ -44,7 +44,7 @@ class BaseCmdLineTest(CoverageTest):
_defaults.Coverage().report(
ignore_errors=None, include=None, omit=None, morfs=[],
show_missing=None, skip_covered=None, contexts=None, skip_empty=None, precision=None,
sort=None,
sort=None, output_format=None,
)
_defaults.Coverage().xml_report(
ignore_errors=None, include=None, omit=None, morfs=[], outfile=None,
Expand Down

0 comments on commit cf1efa8

Please sign in to comment.