|
1 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
|
2 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
|
3 |
|
4 """Results of coverage measurement.""" |
|
5 |
|
6 import collections |
|
7 |
|
8 from coverage.debug import SimpleReprMixin |
|
9 from coverage.exceptions import ConfigError |
|
10 from coverage.misc import contract, nice_pair |
|
11 |
|
12 |
|
13 class Analysis: |
|
14 """The results of analyzing a FileReporter.""" |
|
15 |
|
16 def __init__(self, data, precision, file_reporter, file_mapper): |
|
17 self.data = data |
|
18 self.file_reporter = file_reporter |
|
19 self.filename = file_mapper(self.file_reporter.filename) |
|
20 self.statements = self.file_reporter.lines() |
|
21 self.excluded = self.file_reporter.excluded_lines() |
|
22 |
|
23 # Identify missing statements. |
|
24 executed = self.data.lines(self.filename) or [] |
|
25 executed = self.file_reporter.translate_lines(executed) |
|
26 self.executed = executed |
|
27 self.missing = self.statements - self.executed |
|
28 |
|
29 if self.data.has_arcs(): |
|
30 self._arc_possibilities = sorted(self.file_reporter.arcs()) |
|
31 self.exit_counts = self.file_reporter.exit_counts() |
|
32 self.no_branch = self.file_reporter.no_branch_lines() |
|
33 n_branches = self._total_branches() |
|
34 mba = self.missing_branch_arcs() |
|
35 n_partial_branches = sum(len(v) for k,v in mba.items() if k not in self.missing) |
|
36 n_missing_branches = sum(len(v) for k,v in mba.items()) |
|
37 else: |
|
38 self._arc_possibilities = [] |
|
39 self.exit_counts = {} |
|
40 self.no_branch = set() |
|
41 n_branches = n_partial_branches = n_missing_branches = 0 |
|
42 |
|
43 self.numbers = Numbers( |
|
44 precision=precision, |
|
45 n_files=1, |
|
46 n_statements=len(self.statements), |
|
47 n_excluded=len(self.excluded), |
|
48 n_missing=len(self.missing), |
|
49 n_branches=n_branches, |
|
50 n_partial_branches=n_partial_branches, |
|
51 n_missing_branches=n_missing_branches, |
|
52 ) |
|
53 |
|
54 def missing_formatted(self, branches=False): |
|
55 """The missing line numbers, formatted nicely. |
|
56 |
|
57 Returns a string like "1-2, 5-11, 13-14". |
|
58 |
|
59 If `branches` is true, includes the missing branch arcs also. |
|
60 |
|
61 """ |
|
62 if branches and self.has_arcs(): |
|
63 arcs = self.missing_branch_arcs().items() |
|
64 else: |
|
65 arcs = None |
|
66 |
|
67 return format_lines(self.statements, self.missing, arcs=arcs) |
|
68 |
|
69 def has_arcs(self): |
|
70 """Were arcs measured in this result?""" |
|
71 return self.data.has_arcs() |
|
72 |
|
73 @contract(returns='list(tuple(int, int))') |
|
74 def arc_possibilities(self): |
|
75 """Returns a sorted list of the arcs in the code.""" |
|
76 return self._arc_possibilities |
|
77 |
|
78 @contract(returns='list(tuple(int, int))') |
|
79 def arcs_executed(self): |
|
80 """Returns a sorted list of the arcs actually executed in the code.""" |
|
81 executed = self.data.arcs(self.filename) or [] |
|
82 executed = self.file_reporter.translate_arcs(executed) |
|
83 return sorted(executed) |
|
84 |
|
85 @contract(returns='list(tuple(int, int))') |
|
86 def arcs_missing(self): |
|
87 """Returns a sorted list of the unexecuted arcs in the code.""" |
|
88 possible = self.arc_possibilities() |
|
89 executed = self.arcs_executed() |
|
90 missing = ( |
|
91 p for p in possible |
|
92 if p not in executed |
|
93 and p[0] not in self.no_branch |
|
94 and p[1] not in self.excluded |
|
95 ) |
|
96 return sorted(missing) |
|
97 |
|
98 @contract(returns='list(tuple(int, int))') |
|
99 def arcs_unpredicted(self): |
|
100 """Returns a sorted list of the executed arcs missing from the code.""" |
|
101 possible = self.arc_possibilities() |
|
102 executed = self.arcs_executed() |
|
103 # Exclude arcs here which connect a line to itself. They can occur |
|
104 # in executed data in some cases. This is where they can cause |
|
105 # trouble, and here is where it's the least burden to remove them. |
|
106 # Also, generators can somehow cause arcs from "enter" to "exit", so |
|
107 # make sure we have at least one positive value. |
|
108 unpredicted = ( |
|
109 e for e in executed |
|
110 if e not in possible |
|
111 and e[0] != e[1] |
|
112 and (e[0] > 0 or e[1] > 0) |
|
113 ) |
|
114 return sorted(unpredicted) |
|
115 |
|
116 def _branch_lines(self): |
|
117 """Returns a list of line numbers that have more than one exit.""" |
|
118 return [l1 for l1,count in self.exit_counts.items() if count > 1] |
|
119 |
|
120 def _total_branches(self): |
|
121 """How many total branches are there?""" |
|
122 return sum(count for count in self.exit_counts.values() if count > 1) |
|
123 |
|
124 @contract(returns='dict(int: list(int))') |
|
125 def missing_branch_arcs(self): |
|
126 """Return arcs that weren't executed from branch lines. |
|
127 |
|
128 Returns {l1:[l2a,l2b,...], ...} |
|
129 |
|
130 """ |
|
131 missing = self.arcs_missing() |
|
132 branch_lines = set(self._branch_lines()) |
|
133 mba = collections.defaultdict(list) |
|
134 for l1, l2 in missing: |
|
135 if l1 in branch_lines: |
|
136 mba[l1].append(l2) |
|
137 return mba |
|
138 |
|
139 @contract(returns='dict(int: list(int))') |
|
140 def executed_branch_arcs(self): |
|
141 """Return arcs that were executed from branch lines. |
|
142 |
|
143 Returns {l1:[l2a,l2b,...], ...} |
|
144 |
|
145 """ |
|
146 executed = self.arcs_executed() |
|
147 branch_lines = set(self._branch_lines()) |
|
148 eba = collections.defaultdict(list) |
|
149 for l1, l2 in executed: |
|
150 if l1 in branch_lines: |
|
151 eba[l1].append(l2) |
|
152 return eba |
|
153 |
|
154 @contract(returns='dict(int: tuple(int, int))') |
|
155 def branch_stats(self): |
|
156 """Get stats about branches. |
|
157 |
|
158 Returns a dict mapping line numbers to a tuple: |
|
159 (total_exits, taken_exits). |
|
160 """ |
|
161 |
|
162 missing_arcs = self.missing_branch_arcs() |
|
163 stats = {} |
|
164 for lnum in self._branch_lines(): |
|
165 exits = self.exit_counts[lnum] |
|
166 missing = len(missing_arcs[lnum]) |
|
167 stats[lnum] = (exits, exits - missing) |
|
168 return stats |
|
169 |
|
170 |
|
171 class Numbers(SimpleReprMixin): |
|
172 """The numerical results of measuring coverage. |
|
173 |
|
174 This holds the basic statistics from `Analysis`, and is used to roll |
|
175 up statistics across files. |
|
176 |
|
177 """ |
|
178 |
|
179 def __init__(self, |
|
180 precision=0, |
|
181 n_files=0, n_statements=0, n_excluded=0, n_missing=0, |
|
182 n_branches=0, n_partial_branches=0, n_missing_branches=0 |
|
183 ): |
|
184 assert 0 <= precision < 10 |
|
185 self._precision = precision |
|
186 self._near0 = 1.0 / 10**precision |
|
187 self._near100 = 100.0 - self._near0 |
|
188 self.n_files = n_files |
|
189 self.n_statements = n_statements |
|
190 self.n_excluded = n_excluded |
|
191 self.n_missing = n_missing |
|
192 self.n_branches = n_branches |
|
193 self.n_partial_branches = n_partial_branches |
|
194 self.n_missing_branches = n_missing_branches |
|
195 |
|
196 def init_args(self): |
|
197 """Return a list for __init__(*args) to recreate this object.""" |
|
198 return [ |
|
199 self._precision, |
|
200 self.n_files, self.n_statements, self.n_excluded, self.n_missing, |
|
201 self.n_branches, self.n_partial_branches, self.n_missing_branches, |
|
202 ] |
|
203 |
|
204 @property |
|
205 def n_executed(self): |
|
206 """Returns the number of executed statements.""" |
|
207 return self.n_statements - self.n_missing |
|
208 |
|
209 @property |
|
210 def n_executed_branches(self): |
|
211 """Returns the number of executed branches.""" |
|
212 return self.n_branches - self.n_missing_branches |
|
213 |
|
214 @property |
|
215 def pc_covered(self): |
|
216 """Returns a single percentage value for coverage.""" |
|
217 if self.n_statements > 0: |
|
218 numerator, denominator = self.ratio_covered |
|
219 pc_cov = (100.0 * numerator) / denominator |
|
220 else: |
|
221 pc_cov = 100.0 |
|
222 return pc_cov |
|
223 |
|
224 @property |
|
225 def pc_covered_str(self): |
|
226 """Returns the percent covered, as a string, without a percent sign. |
|
227 |
|
228 Note that "0" is only returned when the value is truly zero, and "100" |
|
229 is only returned when the value is truly 100. Rounding can never |
|
230 result in either "0" or "100". |
|
231 |
|
232 """ |
|
233 return self.display_covered(self.pc_covered) |
|
234 |
|
235 def display_covered(self, pc): |
|
236 """Return a displayable total percentage, as a string. |
|
237 |
|
238 Note that "0" is only returned when the value is truly zero, and "100" |
|
239 is only returned when the value is truly 100. Rounding can never |
|
240 result in either "0" or "100". |
|
241 |
|
242 """ |
|
243 if 0 < pc < self._near0: |
|
244 pc = self._near0 |
|
245 elif self._near100 < pc < 100: |
|
246 pc = self._near100 |
|
247 else: |
|
248 pc = round(pc, self._precision) |
|
249 return "%.*f" % (self._precision, pc) |
|
250 |
|
251 def pc_str_width(self): |
|
252 """How many characters wide can pc_covered_str be?""" |
|
253 width = 3 # "100" |
|
254 if self._precision > 0: |
|
255 width += 1 + self._precision |
|
256 return width |
|
257 |
|
258 @property |
|
259 def ratio_covered(self): |
|
260 """Return a numerator and denominator for the coverage ratio.""" |
|
261 numerator = self.n_executed + self.n_executed_branches |
|
262 denominator = self.n_statements + self.n_branches |
|
263 return numerator, denominator |
|
264 |
|
265 def __add__(self, other): |
|
266 nums = Numbers(precision=self._precision) |
|
267 nums.n_files = self.n_files + other.n_files |
|
268 nums.n_statements = self.n_statements + other.n_statements |
|
269 nums.n_excluded = self.n_excluded + other.n_excluded |
|
270 nums.n_missing = self.n_missing + other.n_missing |
|
271 nums.n_branches = self.n_branches + other.n_branches |
|
272 nums.n_partial_branches = ( |
|
273 self.n_partial_branches + other.n_partial_branches |
|
274 ) |
|
275 nums.n_missing_branches = ( |
|
276 self.n_missing_branches + other.n_missing_branches |
|
277 ) |
|
278 return nums |
|
279 |
|
280 def __radd__(self, other): |
|
281 # Implementing 0+Numbers allows us to sum() a list of Numbers. |
|
282 assert other == 0 # we only ever call it this way. |
|
283 return self |
|
284 |
|
285 |
|
286 def _line_ranges(statements, lines): |
|
287 """Produce a list of ranges for `format_lines`.""" |
|
288 statements = sorted(statements) |
|
289 lines = sorted(lines) |
|
290 |
|
291 pairs = [] |
|
292 start = None |
|
293 lidx = 0 |
|
294 for stmt in statements: |
|
295 if lidx >= len(lines): |
|
296 break |
|
297 if stmt == lines[lidx]: |
|
298 lidx += 1 |
|
299 if not start: |
|
300 start = stmt |
|
301 end = stmt |
|
302 elif start: |
|
303 pairs.append((start, end)) |
|
304 start = None |
|
305 if start: |
|
306 pairs.append((start, end)) |
|
307 return pairs |
|
308 |
|
309 |
|
310 def format_lines(statements, lines, arcs=None): |
|
311 """Nicely format a list of line numbers. |
|
312 |
|
313 Format a list of line numbers for printing by coalescing groups of lines as |
|
314 long as the lines represent consecutive statements. This will coalesce |
|
315 even if there are gaps between statements. |
|
316 |
|
317 For example, if `statements` is [1,2,3,4,5,10,11,12,13,14] and |
|
318 `lines` is [1,2,5,10,11,13,14] then the result will be "1-2, 5-11, 13-14". |
|
319 |
|
320 Both `lines` and `statements` can be any iterable. All of the elements of |
|
321 `lines` must be in `statements`, and all of the values must be positive |
|
322 integers. |
|
323 |
|
324 If `arcs` is provided, they are (start,[end,end,end]) pairs that will be |
|
325 included in the output as long as start isn't in `lines`. |
|
326 |
|
327 """ |
|
328 line_items = [(pair[0], nice_pair(pair)) for pair in _line_ranges(statements, lines)] |
|
329 if arcs: |
|
330 line_exits = sorted(arcs) |
|
331 for line, exits in line_exits: |
|
332 for ex in sorted(exits): |
|
333 if line not in lines and ex not in lines: |
|
334 dest = (ex if ex > 0 else "exit") |
|
335 line_items.append((line, f"{line}->{dest}")) |
|
336 |
|
337 ret = ', '.join(t[-1] for t in sorted(line_items)) |
|
338 return ret |
|
339 |
|
340 |
|
341 @contract(total='number', fail_under='number', precision=int, returns=bool) |
|
342 def should_fail_under(total, fail_under, precision): |
|
343 """Determine if a total should fail due to fail-under. |
|
344 |
|
345 `total` is a float, the coverage measurement total. `fail_under` is the |
|
346 fail_under setting to compare with. `precision` is the number of digits |
|
347 to consider after the decimal point. |
|
348 |
|
349 Returns True if the total should fail. |
|
350 |
|
351 """ |
|
352 # We can never achieve higher than 100% coverage, or less than zero. |
|
353 if not (0 <= fail_under <= 100.0): |
|
354 msg = f"fail_under={fail_under} is invalid. Must be between 0 and 100." |
|
355 raise ConfigError(msg) |
|
356 |
|
357 # Special case for fail_under=100, it must really be 100. |
|
358 if fail_under == 100.0 and total != 100.0: |
|
359 return True |
|
360 |
|
361 return round(total, precision) < fail_under |