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 12 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.

.. veresionadded:: 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
195 changes: 144 additions & 51 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,112 @@ 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 to print report data in text format"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstrings should always use triple-quote style, and complete English grammar.

# Prepare the formatting strings, header, and column sorting.
max_name = max([len(fr.relative_filename()) for (fr, analysis) in \
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need a backslash to continue a line if you are in unbalanced brackets (as you are here). Also, I prefer to have the closing bracket on a line of its own. Take a look through other code in the repo to get a sense of the style.

self.fr_analysis] + [5]) + 2
n = self.config.precision
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems odd to me to be looking at config.precision here, since you are getting all of the coverage totals as strings. You shouldn't need to think about the precision, just deal with the strings as they are, sort of how max_name is calculated.

max_n = max(n+6, 7)
h_form = dict(
Name="{:{name_len}}", Stmts="{:>7}", Miss="{:>7}",
Branch="{:>7}", BrPart="{:>7}", Cover="{:>{n}}",
Missing="{:>9}")
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)

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

column_order isn't used in this function (or in _report_markdown), so these lines can be deleted.


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
if 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)
return self.total.n_statements and self.total.pc_covered
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to return this from here, or from _report_markdown.


def _report_markdown(self, header, lines_values, total_line, end_lines):
"internal method to print report data in markdown format"
# Prepare the formatting strings, header, and column sorting.
max_name = max([len(fr.relative_filename().replace("_","\\_")) for\
(fr, analysis) in self.fr_analysis] + [9]) + 1
h_form = dict(
Name="| {:{name_len}}|", Stmts="{:>7} |", Miss="{:>7} |",
Branch="{:>7} |", BrPart="{:>7} |", Cover="{:>{n}} |",
Missing="{:>9} |")
n = self.config.precision
max_n = max(n+6, 7) + 4
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)

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

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
if total_line:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran a coverage report locally, and this line (and the similar one in _report_text) say the condition is always true. Is it true that these functions can never be called with an empty total_line, or do we just not have a test for that case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total line is always printed, if there is coverage data to report.

The report method raises an Exception before creating the total line, if there is no coverage data to report.

total_form = dict(
Name="| {:>{name_len}}** |", Stmts="{:>5}** |", Miss="{:>5}** |",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why the trailing stars are in the format string, but the leading stars are in the values on line 131?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was just to less code. If I inserted the stars like
"**" + str(value) + "**"
then without special if else case, the Cover entry would look like:
****
On the other hand, I already had to make an if else statement for the missing column, so I'll rewrite it.

Branch="{:>5}** |", BrPart="{:>5}** |", Cover="{:>{n}}%** |",
Missing="{:>9} |")
total_line_items = []
for item, value in zip(header, total_line):
if item == "Missing":
if value == '':
insert = value
else:
insert = "**" + value + "**"
total_line_items += total_form[item].format(\
insert, name_len=max_name-3)
else:
total_line_items += total_form[item].format(\
"**"+str(value), name_len=max_name-3, n=max_n-3)
total_row_str = "".join(total_line_items)
self.writeout(total_row_str)
for end_line in end_lines:
self.writeout(end_line)
return self.total.n_statements and self.total.pc_covered


def report(self, morfs, outfile=None):
"""Writes a report summarizing coverage statistics per module.

Expand All @@ -44,36 +150,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 +173,58 @@ 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)
lines_values.sort(key=lambda tup: (tup[sort_idx], tup[0]),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use a line-length of 100, so no need to wrap this line, and probably others in this file.

reverse=reverse)

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

# Write a TOTAL line if we had at least one file.
# calculate total if we had at least one file.
total_line = ()
if self.total.n_files > 0:
self.writeout(rule)
args = ("TOTAL", self.total.n_statements, self.total.n_missing)
total_line = ("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,)
total_line += (self.total.n_branches, self.total.n_partial_branches)
total_line += (self.total.pc_covered_str,)
if self.config.show_missing:
args += ("",)
self.writeout(fmt_coverage % args)
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.lower() == 'markdown':
self._report_markdown(header, lines_values, total_line, end_lines)
else:
self._report_text(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 @@ -512,6 +512,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 @@ -534,7 +535,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