From cf1efa814e905ab1e2bc17795b1dbe6d437b39e5 Mon Sep 17 00:00:00 2001 From: stepeos <82703776+stepeos@users.noreply.github.com> Date: Sat, 5 Nov 2022 17:29:04 +0100 Subject: [PATCH] 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 --- coverage/cmdline.py | 7 ++ coverage/config.py | 2 + coverage/control.py | 11 ++- coverage/summary.py | 205 +++++++++++++++++++++++++++++++----------- doc/cmd.rst | 3 +- tests/test_cmdline.py | 2 +- tests/test_summary.py | 125 +++++++++++++++++++------- 7 files changed, 263 insertions(+), 92 deletions(-) diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 65ee73f8a..89b0807d8 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -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.", @@ -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, @@ -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, @@ -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": diff --git a/coverage/config.py b/coverage/config.py index c2375d036..1f239ea36 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -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 @@ -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'), diff --git a/coverage/control.py b/coverage/control.py index 91e604e00..a8cf1649c 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -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`. @@ -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. @@ -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) diff --git a/coverage/summary.py b/coverage/summary.py index 861fbc536..94be1a087 100644 --- a/coverage/summary.py +++ b/coverage/summary.py @@ -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 @@ -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. @@ -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 @@ -84,12 +180,10 @@ 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] == '-': @@ -97,41 +191,44 @@ def report(self, morfs, outfile=None): 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 diff --git a/doc/cmd.rst b/doc/cmd.rst index f8de0cb30..fd1f7d1a6 100644 --- a/doc/cmd.rst +++ b/doc/cmd.rst @@ -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 @@ -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:: diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 305fbdbff..1b9a1ef09 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -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, diff --git a/tests/test_summary.py b/tests/test_summary.py index ac29f5175..3e77285a7 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -70,7 +70,6 @@ def test_report_just_one(self): # mycode.py 4 0 100% # ------------------------------- # TOTAL 4 0 100% - assert self.line_count(report) == 5 assert "/coverage/" not in report assert "/tests/modules/covmod1.py " not in report @@ -215,7 +214,6 @@ def branch(x): # mybranch.py 5 0 2 1 86% # ----------------------------------------------- # TOTAL 5 0 2 1 86% - assert self.line_count(report) == 5 assert "mybranch.py " in report assert self.last_line_squeezed(report) == "TOTAL 5 0 2 1 86%" @@ -246,7 +244,7 @@ def missing(x, y): # -------------------------------------------- # mymissing.py 14 3 79% 3-4, 10 # -------------------------------------------- - # TOTAL 14 3 79% 3-4, 10 + # TOTAL 14 3 79% assert self.line_count(report) == 5 squeezed = self.squeezed_lines(report) @@ -265,18 +263,6 @@ def branch(x, y): cov = coverage.Coverage(branch=True) self.start_import_stop(cov, "mybranch") assert self.stdout() == 'x\ny\n' - report = self.get_report(cov, show_missing=True) - - # Name Stmts Miss Branch BrPart Cover Missing - # ---------------------------------------------------------- - # mybranch.py 6 0 4 2 80% 2->4, 4->exit - # ---------------------------------------------------------- - # TOTAL 6 0 4 2 80% - - assert self.line_count(report) == 5 - squeezed = self.squeezed_lines(report) - assert squeezed[2] == "mybranch.py 6 0 4 2 80% 2->4, 4->exit" - assert squeezed[4] == "TOTAL 6 0 4 2 80%" def test_report_show_missing_branches_and_lines(self): self.make_file("main.py", """\ @@ -297,17 +283,6 @@ def branch(x, y, z): cov = coverage.Coverage(branch=True) self.start_import_stop(cov, "main") assert self.stdout() == 'x\ny\n' - report_lines = self.get_report(cov, squeeze=False, show_missing=True).splitlines() - - expected = [ - 'Name Stmts Miss Branch BrPart Cover Missing', - '---------------------------------------------------------', - 'main.py 1 0 0 0 100%', - 'mybranch.py 10 2 8 3 61% 2->4, 4->6, 7-8', - '---------------------------------------------------------', - 'TOTAL 11 2 8 3 63%', - ] - assert expected == report_lines def test_report_skip_covered_no_branches(self): self.make_file("main.py", """ @@ -444,6 +419,26 @@ def foo(): assert self.line_count(report) == 6, report squeezed = self.squeezed_lines(report) assert squeezed[5] == "1 file skipped due to complete coverage." + report = self.get_report(cov, squeeze=False, skip_covered=True, + output_format="markdown") + # | Name | Stmts | Miss | Branch | BrPart | Cover | + # |---------- | -------: | -------: | -------: | -------: | -------: | + # | **TOTAL** | **3** | **0** | **0** | **0** | **100%** | + # + # 1 file skipped due to complete coverage. + + assert self.line_count(report) == 5, report + assert report.split("\n")[0] == ( + '| Name | Stmts | Miss | Branch | BrPart | Cover |' + ) + assert report.split("\n")[1] == ( + '|---------- | -------: | -------: | -------: | -------: | -------: |' + ) + assert report.split("\n")[2] == ( + '| **TOTAL** | **3** | **0** | **0** | **0** | **100%** |' + ) + squeezed = self.squeezed_lines(report) + assert squeezed[4] == "1 file skipped due to complete coverage." def test_report_skip_covered_longfilename(self): self.make_file("long_______________filename.py", """ @@ -513,13 +508,14 @@ def test_report_skip_empty_no_data(self): # Name Stmts Miss Cover # ------------------------------------ + # ------------------------------------ + # TOTAL 0 0 100% # # 1 empty file skipped. assert self.line_count(report) == 6, report - squeezed = self.squeezed_lines(report) - assert squeezed[3] == "TOTAL 0 0 100%" - assert squeezed[5] == "1 empty file skipped." + assert report.split("\n")[3] == "TOTAL 0 0 100%" + assert report.split("\n")[5] == "1 empty file skipped." def test_report_precision(self): self.make_file(".coveragerc", """\ @@ -550,7 +546,7 @@ def foo(): cov = coverage.Coverage(branch=True) self.start_import_stop(cov, "main") assert self.stdout() == "n\nz\n" - report = self.get_report(cov) + report = self.get_report(cov, squeeze=False) # Name Stmts Miss Branch BrPart Cover # ------------------------------------------------------ @@ -566,6 +562,29 @@ def foo(): assert squeezed[4] == "not_covered.py 4 0 2 1 83.333%" assert squeezed[6] == "TOTAL 13 0 4 1 94.118%" + def test_report_precision_all_zero(self): + self.make_file("not_covered.py", """ + def not_covered(n): + if n: + print("n") + """) + self.make_file("empty.py", "") + cov = coverage.Coverage(source=["."]) + self.start_import_stop(cov, "empty") + report = self.get_report(cov, precision=6, squeeze=False) + + # Name Stmts Miss Cover + # ----------------------------------------- + # empty.py 0 0 100.000000% + # not_covered.py 3 3 0.000000% + # ----------------------------------------- + # TOTAL 3 3 0.000000% + + assert self.line_count(report) == 6, report + assert "empty.py 0 0 100.000000%" in report + assert "not_covered.py 3 3 0.000000%" in report + assert "TOTAL 3 3 0.000000%" in report + def test_dotpy_not_python(self): # We run a .py file, and when reporting, we can't parse it as Python. # We should get an error message in the report. @@ -589,7 +608,6 @@ def test_accented_directory(self): "-----------------------------------\n" + "TOTAL 1 0 100%\n" ) - cov = coverage.Coverage() cov.load() output = self.get_report(cov, squeeze=False) @@ -666,6 +684,8 @@ def test_report_with_chdir(self): assert out == "Line One\nLine Two\nhello\n" report = self.report_from_command("coverage report") assert self.last_line_squeezed(report) == "TOTAL 5 0 100%" + report = self.report_from_command("coverage report --format=markdown") + assert self.last_line_squeezed(report) == "| **TOTAL** | **5** | **0** | **100%** |" def test_bug_156_file_not_run_should_be_zero(self): # https://github.com/nedbat/coveragepy/issues/156 @@ -695,7 +715,6 @@ def test_bug_203_mixed_case_listed_twice_with_rc(self): self.make_file(".coveragerc", "[run]\nsource = .\n") report = self.run_TheCode_and_report_it() - assert "TheCode" in report assert "thecode" not in report @@ -743,6 +762,9 @@ def test_tracing_pyc_file(self): report = self.get_report(cov).splitlines() assert "mod.py 1 0 100%" in report + report = self.get_report(cov, squeeze=False, output_format="markdown") + assert report.split("\n")[3] == "| mod.py | 1 | 0 | 100% |" + assert report.split("\n")[4] == "| **TOTAL** | **2** | **0** | **100%** |" def test_missing_py_file_during_run(self): # Create two Python files. @@ -780,7 +802,44 @@ def test_empty_files(self): report = self.get_report(cov) assert "tests/modules/pkg1/__init__.py 1 0 0 0 100%" in report assert "tests/modules/pkg2/__init__.py 0 0 0 0 100%" in report + report = self.get_report(cov, squeeze=False, output_format="markdown") + # get_report() escapes backslash so we expect forward slash escaped + # underscore + assert "tests/modules/pkg1//_/_init/_/_.py " in report + assert "| 1 | 0 | 0 | 0 | 100% |" in report + assert "tests/modules/pkg2//_/_init/_/_.py " in report + assert "| 0 | 0 | 0 | 0 | 100% |" in report + + def test_markdown_with_missing(self): + self.make_file("mymissing.py", """\ + def missing(x, y): + if x: + print("x") + return x + if y: + print("y") + try: + print("z") + 1/0 + print("Never!") + except ZeroDivisionError: + pass + return x + missing(0, 1) + """) + cov = coverage.Coverage(source=["."]) + self.start_import_stop(cov, "mymissing") + assert self.stdout() == 'y\nz\n' + report = self.get_report(cov,squeeze=False, output_format="markdown", show_missing=True) + # | Name | Stmts | Miss | Cover | Missing | + # |------------- | -------: | -------: | ------: | --------: | + # | mymissing.py | 14 | 3 | 79% | 3-4, 10 | + # | **TOTAL** | **14** | **3** | **79%** | | + assert self.line_count(report) == 4 + report_lines = report.split("\n") + assert report_lines[2] == "| mymissing.py | 14 | 3 | 79% | 3-4, 10 |" + assert report_lines[3] == "| **TOTAL** | **14** | **3** | **79%** | |" class ReportingReturnValueTest(CoverageTest): """Tests of reporting functions returning values.""" @@ -858,7 +917,6 @@ def test_test_data(self): # about them are still valid. We want the three columns of numbers to # sort in three different orders. report = self.get_summary_text() - print(report) # Name Stmts Miss Cover # ------------------------------ # file1.py 339 155 54% @@ -866,7 +924,6 @@ def test_test_data(self): # file10.py 234 228 3% # ------------------------------ # TOTAL 586 386 34% - lines = report.splitlines()[2:-2] assert len(lines) == 3 nums = [list(map(int, l.replace('%', '').split()[1:])) for l in lines]