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/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 diff --git a/README.md b/README.md index 2f1b6ccf..71a4393e 100644 --- a/README.md +++ b/README.md @@ -125,6 +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 summary can be sorted by the +number of uncovered lines via the `--sort-by-uncovered-lines` flag: + +```shell +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 f3d8f82e..b5e9295d 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-by-uncovered-lines/--no-sort-by-uncovered-lines", + default=False, + help="Sort the summary 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_by_uncovered_lines, annotation_level, annotation_title, annotation_message, @@ -138,7 +144,10 @@ def show( filesystem=filesystem_factory(source, source_prefix=source_prefix), ) Reporter = reporters[format] - reporter = Reporter(cobertura, ignore_regex) + reporter_kwargs = {} + reporter_kwargs["sort_by_uncovered_lines"] = sort_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..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): """ @@ -181,6 +213,7 @@ def __init__(self, *args, **kwargs): def generate(self): summary_lines = self.get_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..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 @@ -135,6 +136,47 @@ 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-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__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 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