Coverage for coverage / lcovreport.py: 100.000%
90 statements
« prev ^ index » next coverage.py v7.12.1a0.dev1, created at 2025-11-30 17:57 +0000
« prev ^ index » next coverage.py v7.12.1a0.dev1, created at 2025-11-30 17:57 +0000
1# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt
4"""LCOV reporting for coverage.py."""
6from __future__ import annotations 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
8import base64 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
9import hashlib 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
10import sys 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
11from collections.abc import Iterable 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
12from typing import IO, TYPE_CHECKING 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
14from coverage.plugin import FileReporter 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
15from coverage.report_core import get_analysis_to_report 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
16from coverage.results import Analysis, AnalysisNarrower, Numbers 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
17from coverage.types import TMorf 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
19if TYPE_CHECKING: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
20 from coverage import Coverage
23def line_hash(line: str) -> str: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
24 """Produce a hash of a source line for use in the LCOV file."""
25 # The LCOV file format optionally allows each line to be MD5ed as a
26 # fingerprint of the file. This is not a security use. Some security
27 # scanners raise alarms about the use of MD5 here, but it is a false
28 # positive. This is not a security concern.
29 # The unusual encoding of the MD5 hash, as a base64 sequence with the
30 # trailing = signs stripped, is specified by the LCOV file format.
31 hashed = hashlib.md5(line.encode("utf-8"), usedforsecurity=False).digest() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
32 return base64.b64encode(hashed).decode("ascii").rstrip("=") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
35def lcov_lines( 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
36 analysis: Analysis,
37 lines: list[int],
38 source_lines: list[str],
39 outfile: IO[str],
40) -> None:
41 """Emit line coverage records for an analyzed file."""
42 hash_suffix = "" 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
43 for line in lines: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
44 if source_lines: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
45 hash_suffix = "," + line_hash(source_lines[line - 1]) 1abcdefghijklmnopqrstuvwxyzABCDEF5GHIJKL6MNOP8QRST$UVWX9YZ01234
46 # Q: can we get info about the number of times a statement is
47 # executed? If so, that should be recorded here.
48 hit = int(line not in analysis.missing) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
49 outfile.write(f"DA:{line},{hit}{hash_suffix}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
51 if analysis.numbers.n_statements > 0: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
52 outfile.write(f"LF:{analysis.numbers.n_statements}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
53 outfile.write(f"LH:{analysis.numbers.n_executed}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
56def lcov_functions( 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
57 fr: FileReporter,
58 file_analysis: Analysis,
59 outfile: IO[str],
60) -> None:
61 """Emit function coverage records for an analyzed file."""
62 # lcov 2.2 introduces a new format for function coverage records.
63 # We continue to generate the old format because we don't know what
64 # version of the lcov tools will be used to read this report.
66 # "and region.lines" below avoids a crash due to a bug in PyPy 3.8
67 # where, for whatever reason, when collecting data in --branch mode,
68 # top-level functions have an empty lines array. Instead we just don't
69 # emit function records for those.
71 # suppressions because of https://github.com/pylint-dev/pylint/issues/9923
72 functions = [ 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
73 (
74 min(region.start, min(region.lines)), # pylint: disable=nested-min-max
75 max(region.start, max(region.lines)), # pylint: disable=nested-min-max
76 region,
77 )
78 for region in fr.code_regions()
79 if region.kind == "function" and region.lines
80 ]
81 if not functions: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
82 return 1abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN7OPQRSTUVWXYZ01234
84 narrower = AnalysisNarrower(file_analysis) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
85 narrower.add_regions(r.lines for _, _, r in functions) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
87 functions.sort() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
88 functions_hit = 0 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
89 for first_line, last_line, region in functions: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
90 # A function counts as having been executed if any of it has been
91 # executed.
92 analysis = narrower.narrow(region.lines) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
93 hit = int(analysis.numbers.n_executed > 0) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
94 functions_hit += hit 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
96 outfile.write(f"FN:{first_line},{last_line},{region.name}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
97 outfile.write(f"FNDA:{hit},{region.name}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
99 outfile.write(f"FNF:{len(functions)}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
100 outfile.write(f"FNH:{functions_hit}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
103def lcov_arcs( 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
104 fr: FileReporter,
105 analysis: Analysis,
106 lines: list[int],
107 outfile: IO[str],
108) -> None:
109 """Emit branch coverage records for an analyzed file."""
110 branch_stats = analysis.branch_stats() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
111 executed_arcs = analysis.executed_branch_arcs() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
112 missing_arcs = analysis.missing_branch_arcs() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
114 for line in lines: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
115 if line not in branch_stats: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
116 continue 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
118 # This is only one of several possible ways to map our sets of executed
119 # and not-executed arcs to BRDA codes. It seems to produce reasonable
120 # results when fed through genhtml.
121 _, taken = branch_stats[line] 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
123 if taken == 0: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
124 # When _none_ of the out arcs from 'line' were executed,
125 # it can mean the line always raised an exception.
126 assert len(executed_arcs[line]) == 0 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
127 destinations = [(dst, "-") for dst in missing_arcs[line]] 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
128 else:
129 # Q: can we get counts of the number of times each arc was executed?
130 # branch_stats has "total" and "taken" counts for each branch,
131 # but it doesn't have "taken" broken down by destination.
132 destinations = [(dst, "1") for dst in executed_arcs[line]] 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QRSTUV%WXYZ01'234
133 destinations.extend((dst, "0") for dst in missing_arcs[line]) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
135 # Sort exit arcs after normal arcs. Exit arcs typically come from
136 # an if statement, at the end of a function, with no else clause.
137 # This structure reads like you're jumping to the end of the function
138 # when the conditional expression is false, so it should be presented
139 # as the second alternative for the branch, after the alternative that
140 # enters the if clause.
141 destinations.sort(key=lambda d: (d[0] < 0, d)) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
143 for dst, hit in destinations: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
144 branch = fr.arc_description(line, dst) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
145 outfile.write(f"BRDA:{line},0,{branch},{hit}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
147 # Summary of the branch coverage.
148 brf = sum(t for t, k in branch_stats.values()) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
149 brh = brf - sum(t - k for t, k in branch_stats.values()) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
150 if brf > 0: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
151 outfile.write(f"BRF:{brf}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
152 outfile.write(f"BRH:{brh}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
155class LcovReporter: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
156 """A reporter for writing LCOV coverage reports."""
158 report_type = "LCOV report" 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
160 def __init__(self, coverage: Coverage) -> None: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
161 self.coverage = coverage 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
162 self.config = coverage.config 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
163 self.total = Numbers(self.coverage.config.precision) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
165 def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str]) -> float: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
166 """Renders the full lcov report.
168 `morfs` is a list of modules or filenames
170 outfile is the file object to write the file into.
171 """
173 self.coverage.get_data() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
174 outfile = outfile or sys.stdout 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
176 # ensure file records are sorted by the _relative_ filename, not the full path
177 to_report = [ 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
178 (fr.relative_filename(), fr, analysis)
179 for fr, analysis in get_analysis_to_report(self.coverage, morfs)
180 ]
181 to_report.sort() 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
183 for fname, fr, analysis in to_report: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
184 self.total += analysis.numbers 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
185 self.lcov_file(fname, fr, analysis, outfile) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
187 return self.total.n_statements and self.total.pc_covered 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
189 def lcov_file( 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
190 self,
191 rel_fname: str,
192 fr: FileReporter,
193 analysis: Analysis,
194 outfile: IO[str],
195 ) -> None:
196 """Produces the lcov data for a single file.
198 This currently supports both line and branch coverage,
199 however function coverage is not supported.
200 """
202 if analysis.numbers.n_statements == 0: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
203 if self.config.skip_empty: 1abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234
204 return 1abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234
206 outfile.write(f"SF:{rel_fname}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
208 lines = sorted(analysis.statements) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
209 if self.config.lcov_line_checksums: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
210 source_lines = fr.source().splitlines() 1abcdefghijklmnopqrstuvwxyzABCDEF5GHIJKL6MNOP8QRST$UVWX9YZ01234
211 else:
212 source_lines = [] 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
214 lcov_lines(analysis, lines, source_lines, outfile) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
215 lcov_functions(fr, analysis, outfile) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
216 if analysis.has_arcs: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234
217 lcov_arcs(fr, analysis, lines, outfile) 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OPQR(STUV%WX9YZ)01'234
219 outfile.write("end_of_record\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234