222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
|
|
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
|
|
|
|
"""LCOV reporting for coverage.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import sys
|
|
|
|
from typing import IO, TYPE_CHECKING
|
|
from collections.abc import Iterable
|
|
|
|
from coverage.plugin import FileReporter
|
|
from coverage.report_core import get_analysis_to_report
|
|
from coverage.results import Analysis, Numbers
|
|
from coverage.types import TMorf
|
|
|
|
if TYPE_CHECKING:
|
|
from coverage import Coverage
|
|
|
|
|
|
def line_hash(line: str) -> str:
|
|
"""Produce a hash of a source line for use in the LCOV file."""
|
|
# The LCOV file format optionally allows each line to be MD5ed as a
|
|
# fingerprint of the file. This is not a security use. Some security
|
|
# scanners raise alarms about the use of MD5 here, but it is a false
|
|
# positive. This is not a security concern.
|
|
# The unusual encoding of the MD5 hash, as a base64 sequence with the
|
|
# trailing = signs stripped, is specified by the LCOV file format.
|
|
hashed = hashlib.md5(line.encode("utf-8"), usedforsecurity=False).digest()
|
|
return base64.b64encode(hashed).decode("ascii").rstrip("=")
|
|
|
|
|
|
def lcov_lines(
|
|
analysis: Analysis,
|
|
lines: list[int],
|
|
source_lines: list[str],
|
|
outfile: IO[str],
|
|
) -> None:
|
|
"""Emit line coverage records for an analyzed file."""
|
|
hash_suffix = ""
|
|
for line in lines:
|
|
if source_lines:
|
|
hash_suffix = "," + line_hash(source_lines[line-1])
|
|
# Q: can we get info about the number of times a statement is
|
|
# executed? If so, that should be recorded here.
|
|
hit = int(line not in analysis.missing)
|
|
outfile.write(f"DA:{line},{hit}{hash_suffix}\n")
|
|
|
|
if analysis.numbers.n_statements > 0:
|
|
outfile.write(f"LF:{analysis.numbers.n_statements}\n")
|
|
outfile.write(f"LH:{analysis.numbers.n_executed}\n")
|
|
|
|
|
|
def lcov_functions(
|
|
fr: FileReporter,
|
|
file_analysis: Analysis,
|
|
outfile: IO[str],
|
|
) -> None:
|
|
"""Emit function coverage records for an analyzed file."""
|
|
# lcov 2.2 introduces a new format for function coverage records.
|
|
# We continue to generate the old format because we don't know what
|
|
# version of the lcov tools will be used to read this report.
|
|
|
|
# "and region.lines" below avoids a crash due to a bug in PyPy 3.8
|
|
# where, for whatever reason, when collecting data in --branch mode,
|
|
# top-level functions have an empty lines array. Instead we just don't
|
|
# emit function records for those.
|
|
|
|
# suppressions because of https://github.com/pylint-dev/pylint/issues/9923
|
|
functions = [
|
|
(min(region.start, min(region.lines)), #pylint: disable=nested-min-max
|
|
max(region.start, max(region.lines)), #pylint: disable=nested-min-max
|
|
region)
|
|
for region in fr.code_regions()
|
|
if region.kind == "function" and region.lines
|
|
]
|
|
if not functions:
|
|
return
|
|
|
|
functions.sort()
|
|
functions_hit = 0
|
|
for first_line, last_line, region in functions:
|
|
# A function counts as having been executed if any of it has been
|
|
# executed.
|
|
analysis = file_analysis.narrow(region.lines)
|
|
hit = int(analysis.numbers.n_executed > 0)
|
|
functions_hit += hit
|
|
|
|
outfile.write(f"FN:{first_line},{last_line},{region.name}\n")
|
|
outfile.write(f"FNDA:{hit},{region.name}\n")
|
|
|
|
outfile.write(f"FNF:{len(functions)}\n")
|
|
outfile.write(f"FNH:{functions_hit}\n")
|
|
|
|
|
|
def lcov_arcs(
|
|
fr: FileReporter,
|
|
analysis: Analysis,
|
|
lines: list[int],
|
|
outfile: IO[str],
|
|
) -> None:
|
|
"""Emit branch coverage records for an analyzed file."""
|
|
branch_stats = analysis.branch_stats()
|
|
executed_arcs = analysis.executed_branch_arcs()
|
|
missing_arcs = analysis.missing_branch_arcs()
|
|
|
|
for line in lines:
|
|
if line not in branch_stats:
|
|
continue
|
|
|
|
# This is only one of several possible ways to map our sets of executed
|
|
# and not-executed arcs to BRDA codes. It seems to produce reasonable
|
|
# results when fed through genhtml.
|
|
_, taken = branch_stats[line]
|
|
|
|
if taken == 0:
|
|
# When _none_ of the out arcs from 'line' were executed,
|
|
# it can mean the line always raised an exception.
|
|
assert len(executed_arcs[line]) == 0
|
|
destinations = [
|
|
(dst, "-") for dst in missing_arcs[line]
|
|
]
|
|
else:
|
|
# Q: can we get counts of the number of times each arc was executed?
|
|
# branch_stats has "total" and "taken" counts for each branch,
|
|
# but it doesn't have "taken" broken down by destination.
|
|
destinations = [
|
|
(dst, "1") for dst in executed_arcs[line]
|
|
]
|
|
destinations.extend(
|
|
(dst, "0") for dst in missing_arcs[line]
|
|
)
|
|
|
|
# Sort exit arcs after normal arcs. Exit arcs typically come from
|
|
# an if statement, at the end of a function, with no else clause.
|
|
# This structure reads like you're jumping to the end of the function
|
|
# when the conditional expression is false, so it should be presented
|
|
# as the second alternative for the branch, after the alternative that
|
|
# enters the if clause.
|
|
destinations.sort(key=lambda d: (d[0] < 0, d))
|
|
|
|
for dst, hit in destinations:
|
|
branch = fr.arc_description(line, dst)
|
|
outfile.write(f"BRDA:{line},0,{branch},{hit}\n")
|
|
|
|
# Summary of the branch coverage.
|
|
brf = sum(t for t, k in branch_stats.values())
|
|
brh = brf - sum(t - k for t, k in branch_stats.values())
|
|
if brf > 0:
|
|
outfile.write(f"BRF:{brf}\n")
|
|
outfile.write(f"BRH:{brh}\n")
|
|
|
|
|
|
class LcovReporter:
|
|
"""A reporter for writing LCOV coverage reports."""
|
|
|
|
report_type = "LCOV report"
|
|
|
|
def __init__(self, coverage: Coverage) -> None:
|
|
self.coverage = coverage
|
|
self.config = coverage.config
|
|
self.total = Numbers(self.coverage.config.precision)
|
|
|
|
def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str]) -> float:
|
|
"""Renders the full lcov report.
|
|
|
|
`morfs` is a list of modules or filenames
|
|
|
|
outfile is the file object to write the file into.
|
|
"""
|
|
|
|
self.coverage.get_data()
|
|
outfile = outfile or sys.stdout
|
|
|
|
# ensure file records are sorted by the _relative_ filename, not the full path
|
|
to_report = [
|
|
(fr.relative_filename(), fr, analysis)
|
|
for fr, analysis in get_analysis_to_report(self.coverage, morfs)
|
|
]
|
|
to_report.sort()
|
|
|
|
for fname, fr, analysis in to_report:
|
|
self.total += analysis.numbers
|
|
self.lcov_file(fname, fr, analysis, outfile)
|
|
|
|
return self.total.n_statements and self.total.pc_covered
|
|
|
|
def lcov_file(
|
|
self,
|
|
rel_fname: str,
|
|
fr: FileReporter,
|
|
analysis: Analysis,
|
|
outfile: IO[str],
|
|
) -> None:
|
|
"""Produces the lcov data for a single file.
|
|
|
|
This currently supports both line and branch coverage,
|
|
however function coverage is not supported.
|
|
"""
|
|
|
|
if analysis.numbers.n_statements == 0:
|
|
if self.config.skip_empty:
|
|
return
|
|
|
|
outfile.write(f"SF:{rel_fname}\n")
|
|
|
|
lines = sorted(analysis.statements)
|
|
if self.config.lcov_line_checksums:
|
|
source_lines = fr.source().splitlines()
|
|
else:
|
|
source_lines = []
|
|
|
|
lcov_lines(analysis, lines, source_lines, outfile)
|
|
lcov_functions(fr, analysis, outfile)
|
|
if analysis.has_arcs:
|
|
lcov_arcs(fr, analysis, lines, outfile)
|
|
|
|
outfile.write("end_of_record\n")
|