Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/slipcover/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .version import __version__
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml, print_lcov
from .importer import FileMatcher, ImportManager, wrap_pytest
from .fuzz import wrap_function
11 changes: 11 additions & 0 deletions src/slipcover/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ def merge_files(args, base_path):
if args.xml:
sc.print_xml(merged, source_paths=[str(base_path)], with_branches=args.branch,
xml_package_depth=args.xml_package_depth, outfile=jf)
elif args.lcov:
sc.print_lcov(merged, with_branches=args.branch,
test_name=args.lcov_test_name, comments=args.lcov_comments,
outfile=jf)
else:
json.dump(merged, jf, indent=(4 if args.pretty_print else None))

Expand Down Expand Up @@ -149,6 +153,9 @@ def main():
"Controls which directories are identified as packages in the report. "
"Directories deeper than this depth are not reported as packages. "
"The default is that all directories are reported as packages."))
ap.add_argument('--lcov', action='store_true', help="select LCOV output")
ap.add_argument('--lcov-test-name', type=str, help="test name for LCOV TN: entries")
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)")
ap.add_argument('--out', type=Path, help="specify output file name")
ap.add_argument('--source', help="specify directories to cover")
ap.add_argument('--omit', help="specify file(s) to omit")
Expand Down Expand Up @@ -227,6 +234,10 @@ def printit(coverage, outfile):
elif args.xml:
sc.print_xml(coverage, source_paths=[str(base_path)], with_branches=args.branch,
xml_package_depth=args.xml_package_depth, outfile=outfile)
elif args.lcov:
sc.print_lcov(coverage, with_branches=args.branch,
test_name=args.lcov_test_name, comments=args.lcov_comments,
outfile=outfile)
else:
sc.print_coverage(coverage, outfile=outfile, skip_covered=args.skip_covered,
missing_width=args.missing_width)
Expand Down
131 changes: 131 additions & 0 deletions src/slipcover/lcovreport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""LCOV reporting for slipcover"""

from __future__ import annotations

import sys
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple

if TYPE_CHECKING:
from typing import IO

from .schemas import Coverage, CoverageFile


def get_missing_branch_arcs(file_data: CoverageFile) -> Dict[int, List[int]]:
"""Return arcs that weren't executed from branch lines.

Returns {l1:[l2a,l2b,...], ...}

"""
mba: Dict[int, List[int]] = {}
for branch in file_data["missing_branches"]:
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct key access to missing_branches which is a NotRequired field. This function is only called when branch data exists, but for type safety and defensive programming, consider using .get(\"missing_branches\", []) to handle edge cases where the key might not be present.

Copilot uses AI. Check for mistakes.
mba.setdefault(branch[0], []).append(branch[1])

return mba


def get_branch_info(
file_data: CoverageFile, missing_arcs: Dict[int, List[int]]
) -> Dict[int, List[Tuple[int, bool]]]:
"""Get information about branches for LCOV format.

Returns a dict mapping line numbers to a list of (branch_dest, was_taken) tuples.

"""
all_branches = sorted(file_data["executed_branches"] + file_data["missing_branches"])
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct key access to NotRequired fields executed_branches and missing_branches. Similar to other functions, this should use .get() for safer access: file_data.get(\"executed_branches\", []) + file_data.get(\"missing_branches\", [])

Copilot uses AI. Check for mistakes.

# Group branches by their source line
branches_by_line: Dict[int, List[Tuple[int, bool]]] = defaultdict(list)

for branch in all_branches:
src_line, dest_line = branch
is_taken = branch not in file_data["missing_branches"]
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct key access to missing_branches. For consistency and safety, use .get(\"missing_branches\", []) instead.

Copilot uses AI. Check for mistakes.
branches_by_line[src_line].append((dest_line, is_taken))

return branches_by_line


class LcovReporter:
"""A reporter for writing LCOV-style coverage results."""

def __init__(
self,
coverage: Coverage,
with_branches: bool,
test_name: Optional[str] = None,
comments: Optional[List[str]] = None,
) -> None:
self.coverage = coverage
self.with_branches = with_branches
self.test_name = test_name
self.comments = comments or []

def report(self, outfile: IO[str] | None = None) -> None:
"""Generate an LCOV-compatible coverage report.

`outfile` is a file object to write the LCOV data to.

"""
outfile = outfile or sys.stdout

for comment in self.comments:
outfile.write(f"# {comment}\n")

for file_path, file_data in sorted(self.coverage["files"].items()):
self._write_file_coverage(outfile, file_path, file_data)

def _write_file_coverage(
self, outfile: IO[str], file_path: str, file_data: CoverageFile
) -> None:
"""Write LCOV coverage data for a single file."""

# TN: Test Name (optional)
if self.test_name is not None:
outfile.write(f"TN:{self.test_name}\n")

# SF: Source File
outfile.write(f"SF:{file_path}\n")

# Get all lines (both executed and missing)
all_lines = sorted(file_data["executed_lines"] + file_data["missing_lines"])

# Write branch coverage data if enabled
if self.with_branches and (file_data["executed_branches"] or file_data["missing_branches"]):
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition directly accesses executed_branches and missing_branches keys which are NotRequired fields in the CoverageFile TypedDict. While this works when branch coverage is enabled (as these keys are always present), it could raise a KeyError if coverage data was generated without branch coverage but LCOV output is requested with --branch. Consider using .get() for safer access: file_data.get(\"executed_branches\", []) or checking key existence first.

Copilot uses AI. Check for mistakes.
missing_arcs = get_missing_branch_arcs(file_data)
branch_info = get_branch_info(file_data, missing_arcs)

# BRDA: Branch data
# Format: BRDA:<line number>,<block number>,<branch number>,<taken count or '-'>
for line_num in sorted(branch_info.keys()):
branches = branch_info[line_num]
# Use line number as block number for simplicity
block_num = 0
for branch_num, (dest, is_taken) in enumerate(branches):
taken_str = "1" if is_taken else "-"
outfile.write(f"BRDA:{line_num},{block_num},{branch_num},{taken_str}\n")

# BRF: Branches Found
total_branches = len(file_data["executed_branches"]) + len(file_data["missing_branches"])
outfile.write(f"BRF:{total_branches}\n")

# BRH: Branches Hit
branches_hit = len(file_data["executed_branches"])
Comment on lines +109 to +113
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct access to executed_branches and missing_branches keys. While protected by the condition on line 94, using .get() would be more defensive: len(file_data.get(\"executed_branches\", [])) and len(file_data.get(\"missing_branches\", []))

Copilot uses AI. Check for mistakes.
outfile.write(f"BRH:{branches_hit}\n")

# DA: Line coverage data
# Format: DA:<line number>,<execution count>
for line in all_lines:
hit_count = 1 if line in file_data["executed_lines"] else 0
outfile.write(f"DA:{line},{hit_count}\n")

# LF: Lines Found (total instrumented lines)
total_lines = len(all_lines)
outfile.write(f"LF:{total_lines}\n")

# LH: Lines Hit (covered lines)
lines_hit = len(file_data["executed_lines"])
outfile.write(f"LH:{lines_hit}\n")

# end_of_record: End of record marker
outfile.write("end_of_record\n")
17 changes: 17 additions & 0 deletions src/slipcover/slipcover.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from . import branch as br
from .version import __version__
from .xmlreport import XmlReporter
from .lcovreport import LcovReporter

# FIXME provide __all__

Expand Down Expand Up @@ -115,6 +116,22 @@ def print_xml(
).report(outfile=outfile)


def print_lcov(
coverage: Coverage,
*,
with_branches: bool = False,
test_name: Optional[str] = None,
comments: Optional[List[str]] = None,
outfile=sys.stdout
) -> None:
LcovReporter(
coverage=coverage,
with_branches=with_branches,
test_name=test_name,
comments=comments,
).report(outfile=outfile)


def print_coverage(coverage, *, outfile=sys.stdout, missing_width=None, skip_covered=False) -> None:
"""Prints coverage information for human consumption."""
from tabulate import tabulate
Expand Down
112 changes: 112 additions & 0 deletions tests/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1423,3 +1423,115 @@ def test_xml_flag_with_branches_and_pytest(tmp_path):
assert lines[11].get('branch') is None
assert lines[11].get('condition-coverage') is None
assert lines[11].get('missing-branches') is None


def test_lcov_flag(cov_merge_fixture: Path):
p = subprocess.run([sys.executable, '-m', 'slipcover', '--lcov', '--out', "out.lcov", "t.py"], check=True)
assert 0 == p.returncode

lcov_text = (cov_merge_fixture / 'out.lcov').read_text(encoding='utf8')
lines = lcov_text.strip().split('\n')

# Check basic structure
assert 'SF:t.py' in lines[0]

# Parse line coverage data
da_lines = [line for line in lines if line.startswith('DA:')]
assert len(da_lines) == 7 # 7 total lines

# Check specific line coverage
assert 'DA:1,1' in da_lines # Line 1 is executed
assert 'DA:3,1' in da_lines # Line 3 is executed
assert 'DA:4,1' in da_lines # Line 4 is executed
assert 'DA:6,0' in da_lines # Line 6 is not executed
assert 'DA:8,1' in da_lines # Line 8 is executed
assert 'DA:9,0' in da_lines # Line 9 is not executed
assert 'DA:11,1' in da_lines # Line 11 is executed

# Check summary
assert 'LF:7' in lines # 7 lines found
assert 'LH:5' in lines # 5 lines hit
assert 'end_of_record' in lines


def test_lcov_flag_with_branches(cov_merge_fixture: Path):
p = subprocess.run([sys.executable, '-m', 'slipcover', '--branch', '--lcov', '--out', "out.lcov", "t.py"], check=True)
assert 0 == p.returncode

lcov_text = (cov_merge_fixture / 'out.lcov').read_text(encoding='utf8')
lines = lcov_text.strip().split('\n')

# Check basic structure
assert 'SF:t.py' in lines[0]

# Parse branch coverage data
brda_lines = [line for line in lines if line.startswith('BRDA:')]
assert len(brda_lines) == 4 # 4 total branches (2 from each if statement)

# Check branch coverage summary
assert 'BRF:4' in lines # 4 branches found
assert 'BRH:2' in lines # 2 branches hit

# Parse line coverage data
da_lines = [line for line in lines if line.startswith('DA:')]
assert len(da_lines) == 7 # 7 total lines

# Check specific line coverage
assert 'DA:1,1' in da_lines # Line 1 is executed
assert 'DA:3,1' in da_lines # Line 3 is executed
assert 'DA:4,1' in da_lines # Line 4 is executed
assert 'DA:6,0' in da_lines # Line 6 is not executed
assert 'DA:8,1' in da_lines # Line 8 is executed
assert 'DA:9,0' in da_lines # Line 9 is not executed
assert 'DA:11,1' in da_lines # Line 11 is executed

# Check summary
assert 'LF:7' in lines # 7 lines found
assert 'LH:5' in lines # 5 lines hit
assert 'end_of_record' in lines


def test_lcov_flag_with_test_name(cov_merge_fixture: Path):
p = subprocess.run([sys.executable, '-m', 'slipcover', '--lcov', '--lcov-test-name', 'MyTestSuite',
'--out', "out.lcov", "t.py"], check=True)
assert 0 == p.returncode

lcov_text = (cov_merge_fixture / 'out.lcov').read_text(encoding='utf8')
lines = lcov_text.strip().split('\n')

# Check that TN: is present with the test name
assert 'TN:MyTestSuite' in lines[0]
assert 'SF:t.py' in lines[1]


def test_lcov_flag_with_comments(cov_merge_fixture: Path):
p = subprocess.run([sys.executable, '-m', 'slipcover', '--lcov',
'--lcov-comment', 'Generated by slipcover',
'--lcov-comment', 'Test run on 2025-01-01',
'--out', "out.lcov", "t.py"], check=True)
assert 0 == p.returncode

lcov_text = (cov_merge_fixture / 'out.lcov').read_text(encoding='utf8')
lines = lcov_text.strip().split('\n')

# Check that comments are present at the beginning
assert '# Generated by slipcover' == lines[0]
assert '# Test run on 2025-01-01' == lines[1]
assert 'SF:t.py' in lines[2]


def test_lcov_flag_with_test_name_and_comments(cov_merge_fixture: Path):
p = subprocess.run([sys.executable, '-m', 'slipcover', '--lcov',
'--lcov-test-name', 'IntegrationTest',
'--lcov-comment', 'Coverage report',
'--out', "out.lcov", "t.py"], check=True)
assert 0 == p.returncode

lcov_text = (cov_merge_fixture / 'out.lcov').read_text(encoding='utf8')
lines = lcov_text.strip().split('\n')

# Check that comment is present at the beginning
assert '# Coverage report' == lines[0]
# Check that TN: is present with the test name
assert 'TN:IntegrationTest' in lines[1]
assert 'SF:t.py' in lines[2]
Loading