Skip to content

Commit d5bab6e

Browse files
Merge pull request #70 from amartani/lcov-report
Implement LCOV format reporting
2 parents adbd779 + 84a499f commit d5bab6e

5 files changed

Lines changed: 381 additions & 1 deletion

File tree

src/slipcover/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .version import __version__
2-
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml
2+
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml, print_lcov
33
from .importer import FileMatcher, ImportManager, wrap_pytest, wrap_spec_from_file_location
44
from .fuzz import wrap_function

src/slipcover/__main__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ def merge_files(args, base_path):
131131
if args.xml:
132132
sc.print_xml(merged, source_paths=[str(base_path)], with_branches=args.branch,
133133
xml_package_depth=args.xml_package_depth, outfile=jf)
134+
elif args.lcov:
135+
sc.print_lcov(merged, with_branches=args.branch,
136+
test_name=args.lcov_test_name, comments=args.lcov_comments,
137+
outfile=jf)
134138
else:
135139
json.dump(merged, jf, indent=(4 if args.pretty_print else None))
136140

@@ -170,6 +174,9 @@ def main():
170174
"Controls which directories are identified as packages in the report. "
171175
"Directories deeper than this depth are not reported as packages. "
172176
"The default is that all directories are reported as packages."))
177+
ap.add_argument('--lcov', action='store_true', help="select LCOV output")
178+
ap.add_argument('--lcov-test-name', type=str, help="test name for LCOV TN: entries")
179+
ap.add_argument('--lcov-comment', action='append', dest='lcov_comments', help="add comment lines at the beginning of LCOV output (can be used multiple times)")
173180
ap.add_argument('--out', type=Path, help="specify output file name")
174181
ap.add_argument('--source', help="specify directories to cover")
175182
ap.add_argument('--omit', help="specify file(s) to omit")
@@ -261,6 +268,10 @@ def printit(coverage, outfile):
261268
elif args.xml:
262269
sc.print_xml(coverage, source_paths=[str(base_path)], with_branches=args.branch,
263270
xml_package_depth=args.xml_package_depth, outfile=outfile)
271+
elif args.lcov:
272+
sc.print_lcov(coverage, with_branches=args.branch,
273+
test_name=args.lcov_test_name, comments=args.lcov_comments,
274+
outfile=outfile)
264275
else:
265276
sc.print_coverage(coverage, outfile=outfile, skip_covered=args.skip_covered,
266277
missing_width=args.missing_width)

src/slipcover/lcovreport.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""LCOV reporting for slipcover"""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from collections import defaultdict
7+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
8+
9+
if TYPE_CHECKING:
10+
from typing import IO
11+
12+
from .schemas import Coverage, CoverageFile
13+
14+
15+
def get_branch_info(
16+
file_data: CoverageFile,
17+
) -> Dict[int, List[Tuple[int, bool]]]:
18+
"""Get information about branches for LCOV format.
19+
20+
Returns a dict mapping line numbers to a list of (branch_dest, was_taken) tuples.
21+
22+
"""
23+
# Branches may arrive as lists (e.g. after a JSON round-trip via --merge)
24+
# rather than tuples, so normalize before hashing into a set.
25+
missing_branches = {tuple(b) for b in file_data.get("missing_branches", [])}
26+
all_branches = sorted(
27+
tuple(b) for b in file_data.get("executed_branches", []) + file_data.get("missing_branches", [])
28+
)
29+
30+
# Group branches by their source line
31+
branches_by_line: Dict[int, List[Tuple[int, bool]]] = defaultdict(list)
32+
33+
for branch in all_branches:
34+
src_line, dest_line = branch
35+
is_taken = branch not in missing_branches
36+
branches_by_line[src_line].append((dest_line, is_taken))
37+
38+
return branches_by_line
39+
40+
41+
class LcovReporter:
42+
"""A reporter for writing LCOV-style coverage results."""
43+
44+
def __init__(
45+
self,
46+
coverage: Coverage,
47+
with_branches: bool,
48+
test_name: Optional[str] = None,
49+
comments: Optional[List[str]] = None,
50+
) -> None:
51+
self.coverage = coverage
52+
self.with_branches = with_branches
53+
self.test_name = test_name
54+
self.comments = comments or []
55+
56+
def report(self, outfile: IO[str] | None = None) -> None:
57+
"""Generate an LCOV-compatible coverage report.
58+
59+
`outfile` is a file object to write the LCOV data to.
60+
61+
"""
62+
outfile = outfile or sys.stdout
63+
64+
for comment in self.comments:
65+
outfile.write(f"# {comment}\n")
66+
67+
for file_path, file_data in sorted(self.coverage["files"].items()):
68+
self._write_file_coverage(outfile, file_path, file_data)
69+
70+
def _write_file_coverage(
71+
self, outfile: IO[str], file_path: str, file_data: CoverageFile
72+
) -> None:
73+
"""Write LCOV coverage data for a single file."""
74+
75+
# TN: Test Name (optional)
76+
if self.test_name is not None:
77+
outfile.write(f"TN:{self.test_name}\n")
78+
79+
# SF: Source File
80+
sf_path = file_path.replace('\\', '/')
81+
outfile.write(f"SF:{sf_path}\n")
82+
83+
# Get all lines (both executed and missing)
84+
all_lines = sorted(file_data["executed_lines"] + file_data["missing_lines"])
85+
86+
# Write branch coverage data if enabled
87+
if self.with_branches and (file_data.get("executed_branches") or file_data.get("missing_branches")):
88+
branch_info = get_branch_info(file_data)
89+
90+
# BRDA: Branch data
91+
# Format: BRDA:<line number>,<block number>,<branch number>,<taken count or '-'>
92+
for line_num in sorted(branch_info.keys()):
93+
branches = branch_info[line_num]
94+
# Use line number as block number for simplicity
95+
block_num = 0
96+
for branch_num, (dest, is_taken) in enumerate(branches):
97+
taken_str = "1" if is_taken else "-"
98+
outfile.write(f"BRDA:{line_num},{block_num},{branch_num},{taken_str}\n")
99+
100+
# BRF: Branches Found
101+
total_branches = len(file_data.get("executed_branches", [])) + len(file_data.get("missing_branches", []))
102+
outfile.write(f"BRF:{total_branches}\n")
103+
104+
# BRH: Branches Hit
105+
branches_hit = len(file_data.get("executed_branches", []))
106+
outfile.write(f"BRH:{branches_hit}\n")
107+
108+
# DA: Line coverage data
109+
# Format: DA:<line number>,<execution count>
110+
for line in all_lines:
111+
hit_count = 1 if line in file_data["executed_lines"] else 0
112+
outfile.write(f"DA:{line},{hit_count}\n")
113+
114+
# LF: Lines Found (total instrumented lines)
115+
total_lines = len(all_lines)
116+
outfile.write(f"LF:{total_lines}\n")
117+
118+
# LH: Lines Hit (covered lines)
119+
lines_hit = len(file_data["executed_lines"])
120+
outfile.write(f"LH:{lines_hit}\n")
121+
122+
# end_of_record: End of record marker
123+
outfile.write("end_of_record\n")

src/slipcover/slipcover.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from . import branch as br
1717
from .version import __version__
1818
from .xmlreport import XmlReporter
19+
from .lcovreport import LcovReporter
1920

2021
# FIXME provide __all__
2122

@@ -164,6 +165,22 @@ def print_xml(
164165
).report(outfile=outfile)
165166

166167

168+
def print_lcov(
169+
coverage: Coverage,
170+
*,
171+
with_branches: bool = False,
172+
test_name: Optional[str] = None,
173+
comments: Optional[List[str]] = None,
174+
outfile=sys.stdout
175+
) -> None:
176+
LcovReporter(
177+
coverage=coverage,
178+
with_branches=with_branches,
179+
test_name=test_name,
180+
comments=comments,
181+
).report(outfile=outfile)
182+
183+
167184
def print_coverage(coverage, *, outfile=sys.stdout, missing_width=None, skip_covered=False) -> None:
168185
"""Prints coverage information for human consumption."""
169186
from tabulate import tabulate

0 commit comments

Comments
 (0)