Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support report terminal output in Markdown Table format #1418 #1479

Merged
merged 21 commits into from Nov 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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