1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
|
# 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")
|