Skip to content

Commit cf1efa8

Browse files
stepeosnedbat
andauthored
feat: report terminal output in Markdown Table format #1418 (#1479)
* 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 <[email protected]>
1 parent 27fd4a9 commit cf1efa8

File tree

7 files changed

+263
-92
lines changed

7 files changed

+263
-92
lines changed

coverage/cmdline.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ class Opts:
9696
'', '--fail-under', action='store', metavar="MIN", type="float",
9797
help="Exit with a status of 2 if the total coverage is less than MIN.",
9898
)
99+
output_format = optparse.make_option(
100+
'', '--format', action='store', metavar="FORMAT", dest="output_format",
101+
help="Output format, either text (default) or markdown",
102+
)
99103
help = optparse.make_option(
100104
'-h', '--help', action='store_true',
101105
help="Get help on this command.",
@@ -245,6 +249,7 @@ def __init__(self, *args, **kwargs):
245249
debug=None,
246250
directory=None,
247251
fail_under=None,
252+
output_format=None,
248253
help=None,
249254
ignore_errors=None,
250255
include=None,
@@ -482,6 +487,7 @@ def get_prog_name(self):
482487
Opts.contexts,
483488
Opts.input_datafile,
484489
Opts.fail_under,
490+
Opts.output_format,
485491
Opts.ignore_errors,
486492
Opts.include,
487493
Opts.omit,
@@ -689,6 +695,7 @@ def command_line(self, argv):
689695
skip_covered=options.skip_covered,
690696
skip_empty=options.skip_empty,
691697
sort=options.sort,
698+
output_format=options.output_format,
692699
**report_args
693700
)
694701
elif options.action == "annotate":

coverage/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def __init__(self):
199199
# Defaults for [report]
200200
self.exclude_list = DEFAULT_EXCLUDE[:]
201201
self.fail_under = 0.0
202+
self.output_format = None
202203
self.ignore_errors = False
203204
self.report_include = None
204205
self.report_omit = None
@@ -374,6 +375,7 @@ def copy(self):
374375
# [report]
375376
('exclude_list', 'report:exclude_lines', 'regexlist'),
376377
('fail_under', 'report:fail_under', 'float'),
378+
('output_format', 'report:output_format', 'boolean'),
377379
('ignore_errors', 'report:ignore_errors', 'boolean'),
378380
('partial_always_list', 'report:partial_branches_always', 'regexlist'),
379381
('partial_list', 'report:partial_branches', 'regexlist'),

coverage/control.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,8 @@ def _get_file_reporters(self, morfs=None):
908908
def report(
909909
self, morfs=None, show_missing=None, ignore_errors=None,
910910
file=None, omit=None, include=None, skip_covered=None,
911-
contexts=None, skip_empty=None, precision=None, sort=None
911+
contexts=None, skip_empty=None, precision=None, sort=None,
912+
output_format=None,
912913
):
913914
"""Write a textual summary report to `file`.
914915
@@ -922,6 +923,9 @@ def report(
922923
923924
`file` is a file-like object, suitable for writing.
924925
926+
`output_format` provides options, to print eitehr as plain text, or as
927+
markdown code
928+
925929
`include` is a list of file name patterns. Files that match will be
926930
included in the report. Files matching `omit` will not be included in
927931
the report.
@@ -953,13 +957,16 @@ def report(
953957
.. versionadded:: 5.2
954958
The `precision` parameter.
955959
960+
.. versionadded:: 6.6
961+
The `format` parameter.
962+
956963
"""
957964
with override_config(
958965
self,
959966
ignore_errors=ignore_errors, report_omit=omit, report_include=include,
960967
show_missing=show_missing, skip_covered=skip_covered,
961968
report_contexts=contexts, skip_empty=skip_empty, precision=precision,
962-
sort=sort
969+
sort=sort, output_format=output_format,
963970
):
964971
reporter = SummaryReporter(self)
965972
return reporter.report(morfs, outfile=file)

coverage/summary.py

Lines changed: 151 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77

88
from coverage.exceptions import ConfigError, NoDataError
9-
from coverage.misc import human_sorted_items
9+
from coverage.misc import human_key
1010
from coverage.report import get_analysis_to_report
1111
from coverage.results import Numbers
1212

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

33+
def _report_text(self, header, lines_values, total_line, end_lines):
34+
"""Internal method that prints report data in text format.
35+
36+
`header` is a tuple with captions.
37+
`lines_values` is list of tuples of sortable values.
38+
`total_line` is a tuple with values of the total line.
39+
`end_lines` is a tuple of ending lines with information about skipped files.
40+
41+
"""
42+
# Prepare the formatting strings, header, and column sorting.
43+
max_name = max([len(line[0]) for line in lines_values] + [5]) + 1
44+
max_n = max(len(total_line[header.index("Cover")]) + 2, len(" Cover")) + 1
45+
max_n = max([max_n] + [len(line[header.index("Cover")]) + 2 for line in lines_values])
46+
h_form = dict(
47+
Name="{:{name_len}}",
48+
Stmts="{:>7}",
49+
Miss="{:>7}",
50+
Branch="{:>7}",
51+
BrPart="{:>7}",
52+
Cover="{:>{n}}",
53+
Missing="{:>10}",
54+
)
55+
header_items = [
56+
h_form[item].format(item, name_len=max_name, n=max_n)
57+
for item in header
58+
]
59+
header_str = "".join(header_items)
60+
rule = "-" * len(header_str)
61+
62+
# Write the header
63+
self.writeout(header_str)
64+
self.writeout(rule)
65+
66+
h_form.update(dict(Cover="{:>{n}}%"), Missing=" {:9}")
67+
for values in lines_values:
68+
# build string with line values
69+
line_items = [
70+
h_form[item].format(str(value),
71+
name_len=max_name, n=max_n-1) for item, value in zip(header, values)
72+
]
73+
text = "".join(line_items)
74+
self.writeout(text)
75+
76+
# Write a TOTAL line
77+
self.writeout(rule)
78+
line_items = [
79+
h_form[item].format(str(value),
80+
name_len=max_name, n=max_n-1) for item, value in zip(header, total_line)
81+
]
82+
text = "".join(line_items)
83+
self.writeout(text)
84+
85+
for end_line in end_lines:
86+
self.writeout(end_line)
87+
88+
def _report_markdown(self, header, lines_values, total_line, end_lines):
89+
"""Internal method that prints report data in markdown format.
90+
91+
`header` is a tuple with captions.
92+
`lines_values` is a sorted list of tuples containing coverage information.
93+
`total_line` is a tuple with values of the total line.
94+
`end_lines` is a tuple of ending lines with information about skipped files.
95+
96+
"""
97+
# Prepare the formatting strings, header, and column sorting.
98+
max_name = max([len(line[0].replace("_", "\\_")) for line in lines_values] + [9])
99+
max_name += 1
100+
h_form = dict(
101+
Name="| {:{name_len}}|",
102+
Stmts="{:>9} |",
103+
Miss="{:>9} |",
104+
Branch="{:>9} |",
105+
BrPart="{:>9} |",
106+
Cover="{:>{n}} |",
107+
Missing="{:>10} |",
108+
)
109+
max_n = max(len(total_line[header.index("Cover")]) + 6, len(" Cover "))
110+
header_items = [h_form[item].format(item, name_len=max_name, n=max_n) for item in header]
111+
header_str = "".join(header_items)
112+
rule_str = "|" + " ".join(["- |".rjust(len(header_items[0])-1, '-')] +
113+
["-: |".rjust(len(item)-1, '-') for item in header_items[1:]]
114+
)
115+
116+
# Write the header
117+
self.writeout(header_str)
118+
self.writeout(rule_str)
119+
120+
for values in lines_values:
121+
# build string with line values
122+
h_form.update(dict(Cover="{:>{n}}% |"))
123+
line_items = [
124+
h_form[item].format(str(value).replace("_", "\\_"),
125+
name_len=max_name, n=max_n-1) for item, value in zip(header, values)
126+
]
127+
text = "".join(line_items)
128+
self.writeout(text)
129+
130+
# Write the TOTAL line
131+
h_form.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |"))
132+
total_line_items = []
133+
for item, value in zip(header, total_line):
134+
if value == '':
135+
insert = value
136+
elif item == "Cover":
137+
insert = f" **{value}%**"
138+
else:
139+
insert = f" **{value}**"
140+
total_line_items += h_form[item].format(insert, name_len=max_name, n=max_n)
141+
total_row_str = "".join(total_line_items)
142+
self.writeout(total_row_str)
143+
for end_line in end_lines:
144+
self.writeout(end_line)
145+
33146
def report(self, morfs, outfile=None):
34147
"""Writes a report summarizing coverage statistics per module.
35148
@@ -44,36 +157,19 @@ def report(self, morfs, outfile=None):
44157
self.report_one_file(fr, analysis)
45158

46159
# Prepare the formatting strings, header, and column sorting.
47-
max_name = max([len(fr.relative_filename()) for (fr, analysis) in self.fr_analysis] + [5])
48-
fmt_name = "%%- %ds " % max_name
49-
fmt_skip_covered = "\n%s file%s skipped due to complete coverage."
50-
fmt_skip_empty = "\n%s empty file%s skipped."
51-
52-
header = (fmt_name % "Name") + " Stmts Miss"
53-
fmt_coverage = fmt_name + "%6d %6d"
160+
header = ("Name", "Stmts", "Miss",)
54161
if self.branches:
55-
header += " Branch BrPart"
56-
fmt_coverage += " %6d %6d"
57-
width100 = Numbers(precision=self.config.precision).pc_str_width()
58-
header += "%*s" % (width100+4, "Cover")
59-
fmt_coverage += "%%%ds%%%%" % (width100+3,)
162+
header += ("Branch", "BrPart",)
163+
header += ("Cover",)
60164
if self.config.show_missing:
61-
header += " Missing"
62-
fmt_coverage += " %s"
63-
rule = "-" * len(header)
165+
header += ("Missing",)
64166

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

69-
# Write the header
70-
self.writeout(header)
71-
self.writeout(rule)
72-
73-
# `lines` is a list of pairs, (line text, line values). The line text
74-
# is a string that will be printed, and line values is a tuple of
75-
# sortable values.
76-
lines = []
171+
# `lines_values` is list of tuples of sortable values.
172+
lines_values = []
77173

78174
for (fr, analysis) in self.fr_analysis:
79175
nums = analysis.numbers
@@ -84,54 +180,55 @@ def report(self, morfs, outfile=None):
84180
args += (nums.pc_covered_str,)
85181
if self.config.show_missing:
86182
args += (analysis.missing_formatted(branches=True),)
87-
text = fmt_coverage % args
88-
# Add numeric percent coverage so that sorting makes sense.
89183
args += (nums.pc_covered,)
90-
lines.append((text, args))
184+
lines_values.append(args)
91185

92-
# Sort the lines and write them out.
186+
# line-sorting.
93187
sort_option = (self.config.sort or "name").lower()
94188
reverse = False
95189
if sort_option[0] == '-':
96190
reverse = True
97191
sort_option = sort_option[1:]
98192
elif sort_option[0] == '+':
99193
sort_option = sort_option[1:]
100-
194+
sort_idx = column_order.get(sort_option)
195+
if sort_idx is None:
196+
raise ConfigError(f"Invalid sorting option: {self.config.sort!r}")
101197
if sort_option == "name":
102-
lines = human_sorted_items(lines, reverse=reverse)
198+
lines_values.sort(key=lambda tup: (human_key(tup[0]), tup[1]), reverse=reverse)
103199
else:
104-
position = column_order.get(sort_option)
105-
if position is None:
106-
raise ConfigError(f"Invalid sorting option: {self.config.sort!r}")
107-
lines.sort(key=lambda l: (l[1][position], l[0]), reverse=reverse)
108-
109-
for line in lines:
110-
self.writeout(line[0])
111-
112-
# Write a TOTAL line if we had at least one file.
113-
if self.total.n_files > 0:
114-
self.writeout(rule)
115-
args = ("TOTAL", self.total.n_statements, self.total.n_missing)
116-
if self.branches:
117-
args += (self.total.n_branches, self.total.n_partial_branches)
118-
args += (self.total.pc_covered_str,)
119-
if self.config.show_missing:
120-
args += ("",)
121-
self.writeout(fmt_coverage % args)
200+
lines_values.sort(key=lambda tup: (tup[sort_idx], tup[0]), reverse=reverse)
201+
202+
# calculate total if we had at least one file.
203+
total_line = ("TOTAL", self.total.n_statements, self.total.n_missing)
204+
if self.branches:
205+
total_line += (self.total.n_branches, self.total.n_partial_branches)
206+
total_line += (self.total.pc_covered_str,)
207+
if self.config.show_missing:
208+
total_line += ("",)
122209

123-
# Write other final lines.
210+
# create other final lines
211+
end_lines = []
124212
if not self.total.n_files and not self.skipped_count:
125213
raise NoDataError("No data to report.")
126214

127215
if self.config.skip_covered and self.skipped_count:
128-
self.writeout(
129-
fmt_skip_covered % (self.skipped_count, 's' if self.skipped_count > 1 else '')
216+
file_suffix = 's' if self.skipped_count>1 else ''
217+
fmt_skip_covered = (
218+
f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage."
130219
)
220+
end_lines.append(fmt_skip_covered)
131221
if self.config.skip_empty and self.empty_count:
132-
self.writeout(
133-
fmt_skip_empty % (self.empty_count, 's' if self.empty_count > 1 else '')
134-
)
222+
file_suffix = 's' if self.empty_count>1 else ''
223+
fmt_skip_empty = f"\n{self.empty_count} empty file{file_suffix} skipped."
224+
end_lines.append(fmt_skip_empty)
225+
226+
text_format = self.config.output_format or "text"
227+
if text_format == "markdown":
228+
formatter = self._report_markdown
229+
else:
230+
formatter = self._report_text
231+
formatter(header, lines_values, total_line, end_lines)
135232

136233
return self.total.n_statements and self.total.pc_covered
137234

doc/cmd.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ as a percentage.
518518
file. Defaults to '.coverage'. [env: COVERAGE_FILE]
519519
--fail-under=MIN Exit with a status of 2 if the total coverage is less
520520
than MIN.
521+
--format=FORMAT Output format, either text (default) or markdown
521522
-i, --ignore-errors Ignore errors while reading source files.
522523
--include=PAT1,PAT2,...
523524
Include only files whose paths match one of these
@@ -540,7 +541,7 @@ as a percentage.
540541
--rcfile=RCFILE Specify configuration file. By default '.coveragerc',
541542
'setup.cfg', 'tox.ini', and 'pyproject.toml' are
542543
tried. [env: COVERAGE_RCFILE]
543-
.. [[[end]]] (checksum: 2f8dde61bab2f44fbfe837aeae87dfd2)
544+
.. [[[end]]] (checksum: 8c671de502a388159689082d906f786a)
544545
545546
The ``-m`` flag also shows the line numbers of missing statements::
546547

tests/test_cmdline.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class BaseCmdLineTest(CoverageTest):
4444
_defaults.Coverage().report(
4545
ignore_errors=None, include=None, omit=None, morfs=[],
4646
show_missing=None, skip_covered=None, contexts=None, skip_empty=None, precision=None,
47-
sort=None,
47+
sort=None, output_format=None,
4848
)
4949
_defaults.Coverage().xml_report(
5050
ignore_errors=None, include=None, omit=None, morfs=[], outfile=None,

0 commit comments

Comments
 (0)