Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ docs/_build/

# Mac OS
.DS_Store

.venv
.envrc
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion pycobertura/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -124,6 +129,7 @@ def show(
output,
source,
source_prefix,
sort_by_uncovered_lines,
annotation_level,
annotation_title,
annotation_message,
Expand All @@ -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)
Expand Down
37 changes: 35 additions & 2 deletions pycobertura/reporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 = {
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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)

Expand Down
42 changes: 42 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import pytest
from click.testing import CliRunner
Expand Down Expand Up @@ -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("<tbody>") : result.output.index("</tbody>")
]
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

Expand Down
65 changes: 65 additions & 0 deletions tests/test_reporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,71 @@ def test_html_report__no_source_files_message():
</html>"""


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) == """\
<html>
<head>
<title>pycobertura report</title>
<meta charset="UTF-8">
</head>
<body>
<div class="container">
<h1>pycobertura report</h1>
<table class="u-full-width">
<thead>
<tr>
<th>Filename</th>
<th>Stmts</th>
<th>Miss</th>
<th>Cover</th>
<th>Missing</th>
</tr>
</thead>
<tbody>
<tr>
<td>dummy/dummy.py</td>
<td>4</td>
<td>2</td>
<td>50.00%</td>
<td>2, 5</td>
</tr>
<tr>
<td>dummy/__init__.py</td>
<td>0</td>
<td>0</td>
<td>100.00%</td>
<td></td>
</tr>
</tbody>
<tfoot>
<tr>
<td>TOTAL</td>
<td>4</td>
<td>2</td>
<td>50.00%</td>
<td></td>
</tr>
</tfoot>
</table>
<p>Rendering of source files was disabled.</p>
</div>
</body>
</html>"""


def test_text_report_delta__no_source():
from pycobertura.reporters import TextReporterDelta

Expand Down