From a27e9792c921ea4a46e116a2412f489ee1dfc612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Sat, 6 Dec 2025 14:16:54 +0100 Subject: [PATCH 1/3] Add --sort-html-by-uncovered-lines --- README.md | 7 +++++ pycobertura/cli.py | 12 +++++++- pycobertura/reporters.py | 31 +++++++++++++++++++ tests/test_cli.py | 21 +++++++++++++ tests/test_reporters.py | 65 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f1b6ccf..aa707bea 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,13 @@ whether lines were covered (green) or not (red). pycobertura show --format html --output coverage.html coverage.xml ``` +To focus on the riskiest files first, the HTML summary can be sorted by the +number of uncovered lines: + +```shell +pycobertura show --format html --sort-html-by-uncovered-lines --output coverage.html coverage.xml +``` + ![Example output of html formatted pycobertura show command](http://i.imgur.com/BYnXmAp.png) The following shows how to generate a JSON version of another coverage file. diff --git a/pycobertura/cli.py b/pycobertura/cli.py index f3d8f82e..7099034a 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -116,6 +116,11 @@ def get_exit_code(differ: CoberturaDiff, source): "the --source is a zip archive and the files were zipped under " "a directory prefix that is not part of the source.", ) +@click.option( + "--sort-html-by-uncovered-lines/--no-sort-html-by-uncovered-lines", + default=False, + help="Sort HTML output so files with the most uncovered lines appear first.", +) def show( cobertura_file, ignore_regex, @@ -124,6 +129,7 @@ def show( output, source, source_prefix, + sort_html_by_uncovered_lines, annotation_level, annotation_title, annotation_message, @@ -138,7 +144,11 @@ def show( filesystem=filesystem_factory(source, source_prefix=source_prefix), ) Reporter = reporters[format] - reporter = Reporter(cobertura, ignore_regex) + reporter_kwargs = {} + if format == "html": + reporter_kwargs["sort_by_uncovered_lines"] = sort_html_by_uncovered_lines + + reporter = Reporter(cobertura, ignore_regex, **reporter_kwargs) if format == "csv": report = reporter.generate(delimiter) diff --git a/pycobertura/reporters.py b/pycobertura/reporters.py index 8d3b1d6c..102d3dbb 100644 --- a/pycobertura/reporters.py +++ b/pycobertura/reporters.py @@ -177,10 +177,41 @@ def __init__(self, *args, **kwargs): self.no_file_sources_message = kwargs.pop( "no_file_sources_message", "Rendering of source files was disabled." ) + self.sort_by_uncovered_lines = kwargs.pop( + "sort_by_uncovered_lines", False + ) super(HtmlReporter, self).__init__(*args, **kwargs) + def _sort_summary_lines(self, summary_lines): + """ + Returns a copy of summary_lines with files sorted by uncovered lines + (Miss) descending; TOTAL row remains last. + """ + sorted_summary = {key: [] for key in summary_lines} + files_count = len(summary_lines["Filename"]) - 1 + sorted_indexes = sorted( + range(files_count), + key=lambda index: ( + -summary_lines["Miss"][index], + summary_lines["Filename"][index], + ), + ) + + for index in sorted_indexes: + for key in sorted_summary: + sorted_summary[key].append(summary_lines[key][index]) + + # keep TOTAL row untouched at the end + for key in sorted_summary: + sorted_summary[key].append(summary_lines[key][-1]) + + return sorted_summary + def generate(self): summary_lines = self.get_summary_lines() + if self.sort_by_uncovered_lines: + summary_lines = self._sort_summary_lines(summary_lines) + self.format_line_rates(summary_lines) self.format_missing_lines(summary_lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index d879a179..8bedee9f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -135,6 +135,27 @@ def test_show__format_html(): assert result.exit_code == ExitCodes.OK +def test_show__format_html__sorted_by_uncovered_lines(): + from pycobertura.cli import show, ExitCodes + + runner = CliRunner() + result = runner.invoke( + show, + [ + 'tests/dummy.original.xml', + '--format', + 'html', + '--sort-html-by-uncovered-lines', + ], + catch_exceptions=False, + ) + tbody = result.output[ + result.output.index("") : result.output.index("") + ] + assert tbody.index("dummy/dummy.py") < tbody.index("dummy/__init__.py") + assert result.exit_code == ExitCodes.OK + + def test_show__format_json(): from pycobertura.cli import show, ExitCodes diff --git a/tests/test_reporters.py b/tests/test_reporters.py index fce69d52..355bbca9 100644 --- a/tests/test_reporters.py +++ b/tests/test_reporters.py @@ -432,6 +432,71 @@ def test_html_report__no_source_files_message(): """ +def test_html_report__sorted_by_uncovered_lines(): + from pycobertura.reporters import HtmlReporter + + cobertura = make_cobertura('tests/dummy.original.xml') + report = HtmlReporter( + cobertura, + render_file_sources=False, + sort_by_uncovered_lines=True, + ) + html_output = report.generate() + + assert "normalize.css" in html_output + assert "Skeleton V2.0" in html_output + + assert remove_style_tag(html_output) == """\ + + + pycobertura report + + + +
+

pycobertura report

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilenameStmtsMissCoverMissing
dummy/dummy.py4250.00%2, 5
dummy/__init__.py00100.00%
TOTAL4250.00%
+

Rendering of source files was disabled.

+
+ +""" + + def test_text_report_delta__no_source(): from pycobertura.reporters import TextReporterDelta From bcd4674919770a202f0e22843410c1bcb34b078b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 11 Dec 2025 07:55:17 +0100 Subject: [PATCH 2/3] implement PR feedback: Sort via Reporter. --- .gitignore | 3 ++ README.md | 8 +++-- pycobertura/cli.py | 9 +++--- pycobertura/reporters.py | 66 +++++++++++++++++++++------------------- tests/test_cli.py | 23 +++++++++++++- 5 files changed, 68 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 77779038..179a690b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ docs/_build/ # Mac OS .DS_Store + +.venv +.envrc diff --git a/README.md b/README.md index aa707bea..71a4393e 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,15 @@ whether lines were covered (green) or not (red). pycobertura show --format html --output coverage.html coverage.xml ``` -To focus on the riskiest files first, the HTML summary can be sorted by the -number of uncovered lines: +To focus on the riskiest files first, the summary can be sorted by the +number of uncovered lines via the `--sort-by-uncovered-lines` flag: ```shell -pycobertura show --format html --sort-html-by-uncovered-lines --output coverage.html coverage.xml +pycobertura show --format html --sort-by-uncovered-lines --output coverage.html coverage.xml ``` +The `--sort-by-uncovered-lines` flag works with any of the supported formats. + ![Example output of html formatted pycobertura show command](http://i.imgur.com/BYnXmAp.png) The following shows how to generate a JSON version of another coverage file. diff --git a/pycobertura/cli.py b/pycobertura/cli.py index 7099034a..b5e9295d 100644 --- a/pycobertura/cli.py +++ b/pycobertura/cli.py @@ -117,9 +117,9 @@ def get_exit_code(differ: CoberturaDiff, source): "a directory prefix that is not part of the source.", ) @click.option( - "--sort-html-by-uncovered-lines/--no-sort-html-by-uncovered-lines", + "--sort-by-uncovered-lines/--no-sort-by-uncovered-lines", default=False, - help="Sort HTML output so files with the most uncovered lines appear first.", + help="Sort the summary so files with the most uncovered lines appear first.", ) def show( cobertura_file, @@ -129,7 +129,7 @@ def show( output, source, source_prefix, - sort_html_by_uncovered_lines, + sort_by_uncovered_lines, annotation_level, annotation_title, annotation_message, @@ -145,8 +145,7 @@ def show( ) Reporter = reporters[format] reporter_kwargs = {} - if format == "html": - reporter_kwargs["sort_by_uncovered_lines"] = sort_html_by_uncovered_lines + reporter_kwargs["sort_by_uncovered_lines"] = sort_by_uncovered_lines reporter = Reporter(cobertura, ignore_regex, **reporter_kwargs) diff --git a/pycobertura/reporters.py b/pycobertura/reporters.py index 102d3dbb..10f3b129 100644 --- a/pycobertura/reporters.py +++ b/pycobertura/reporters.py @@ -25,9 +25,15 @@ class Reporter: - def __init__(self, cobertura, ignore_regex=None): + def __init__( + self, + cobertura, + ignore_regex=None, + sort_by_uncovered_lines=False, + ): self.cobertura: Cobertura = cobertura self.ignore_regex = ignore_regex + self.sort_by_uncovered_lines = sort_by_uncovered_lines @staticmethod def format_line_rates(summary_lines): @@ -39,6 +45,32 @@ def format_missing_lines(summary_lines): for i, missing_lines in enumerate(summary_lines["Missing"]): summary_lines["Missing"][i] = stringify(missing_lines) + def _maybe_sort_summary_lines(self, summary_lines): + if not self.sort_by_uncovered_lines: + return summary_lines + return self._sort_summary_lines(summary_lines) + + @staticmethod + def _sort_summary_lines(summary_lines): + sorted_summary = {key: [] for key in summary_lines} + files_count = len(summary_lines["Filename"]) - 1 + sorted_indexes = sorted( + range(files_count), + key=lambda index: ( + -summary_lines["Miss"][index], + summary_lines["Filename"][index], + ), + ) + + for index in sorted_indexes: + for key in sorted_summary: + sorted_summary[key].append(summary_lines[key][index]) + + for key in sorted_summary: + sorted_summary[key].append(summary_lines[key][-1]) + + return sorted_summary + def get_summary_lines(self): filenames = self.cobertura.files(ignore_regex=self.ignore_regex) summary_lines = { @@ -73,7 +105,7 @@ def get_summary_lines(self): summary_lines["Cover"].append(total_rate) summary_lines["Missing"].append([]) - return summary_lines + return self._maybe_sort_summary_lines(summary_lines) def per_file_stats(self, summary_lines): """ @@ -177,40 +209,10 @@ def __init__(self, *args, **kwargs): self.no_file_sources_message = kwargs.pop( "no_file_sources_message", "Rendering of source files was disabled." ) - self.sort_by_uncovered_lines = kwargs.pop( - "sort_by_uncovered_lines", False - ) super(HtmlReporter, self).__init__(*args, **kwargs) - def _sort_summary_lines(self, summary_lines): - """ - Returns a copy of summary_lines with files sorted by uncovered lines - (Miss) descending; TOTAL row remains last. - """ - sorted_summary = {key: [] for key in summary_lines} - files_count = len(summary_lines["Filename"]) - 1 - sorted_indexes = sorted( - range(files_count), - key=lambda index: ( - -summary_lines["Miss"][index], - summary_lines["Filename"][index], - ), - ) - - for index in sorted_indexes: - for key in sorted_summary: - sorted_summary[key].append(summary_lines[key][index]) - - # keep TOTAL row untouched at the end - for key in sorted_summary: - sorted_summary[key].append(summary_lines[key][-1]) - - return sorted_summary - def generate(self): summary_lines = self.get_summary_lines() - if self.sort_by_uncovered_lines: - summary_lines = self._sort_summary_lines(summary_lines) self.format_line_rates(summary_lines) self.format_missing_lines(summary_lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bedee9f..01cba78a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import json import os import pytest from click.testing import CliRunner @@ -145,7 +146,7 @@ def test_show__format_html__sorted_by_uncovered_lines(): 'tests/dummy.original.xml', '--format', 'html', - '--sort-html-by-uncovered-lines', + '--sort-by-uncovered-lines', ], catch_exceptions=False, ) @@ -156,6 +157,26 @@ def test_show__format_html__sorted_by_uncovered_lines(): assert result.exit_code == ExitCodes.OK +def test_show__format_json__sorted_by_uncovered_lines(): + from pycobertura.cli import show, ExitCodes + + runner = CliRunner() + result = runner.invoke( + show, + [ + 'tests/dummy.original.xml', + '--format', + 'json', + '--sort-by-uncovered-lines', + ], + catch_exceptions=False, + ) + payload = json.loads(result.output) + + assert payload["files"][0]["Filename"] == "dummy/dummy.py" + assert result.exit_code == ExitCodes.OK + + def test_show__format_json(): from pycobertura.cli import show, ExitCodes From dab79b4f18b9c75bb179eaf282745b4d2c30bcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Fri, 12 Dec 2025 08:13:40 +0100 Subject: [PATCH 3/3] add entry to CHANGES --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index ab03d863..dc41db75 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## Unreleased +* Add `--sort-by-uncovered-lines` to sort output, so the file with the most missing lines is at the + top. Thanks @guettli + ## 4.1.0 (2025-04-13) * Improve `GitFileSystem` to support symbolic links by using `git cat-file