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

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 

3 

4"""LCOV reporting for coverage.py.""" 

5 

6from __future__ import annotations 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

7 

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

13 

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

18 

19if TYPE_CHECKING: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

20 from coverage import Coverage 

21 

22 

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

33 

34 

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

50 

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

54 

55 

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. 

65 

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. 

70 

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

83 

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

86 

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

95 

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

98 

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

101 

102 

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

113 

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

117 

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

122 

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

134 

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

142 

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

146 

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

153 

154 

155class LcovReporter: 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

156 """A reporter for writing LCOV coverage reports.""" 

157 

158 report_type = "LCOV report" 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

159 

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

164 

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. 

167 

168 `morfs` is a list of modules or filenames 

169 

170 outfile is the file object to write the file into. 

171 """ 

172 

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

175 

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

182 

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

186 

187 return self.total.n_statements and self.total.pc_covered 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

188 

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. 

197 

198 This currently supports both line and branch coverage, 

199 however function coverage is not supported. 

200 """ 

201 

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

205 

206 outfile.write(f"SF:{rel_fname}\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234

207 

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

213 

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

218 

219 outfile.write("end_of_record\n") 1abcdefghijklmnopqrstuvwxyzABCDEF5GH!IJ#KL6MN7OP8QR(ST$UV%WX9YZ)01'234