420 lines
14 KiB
Python
420 lines
14 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
|
||
|
|
||
|
"""Results of coverage measurement."""
|
||
|
|
||
|
from __future__ import annotations
|
||
|
|
||
|
import collections
|
||
|
import dataclasses
|
||
|
|
||
|
from collections.abc import Container, Iterable
|
||
|
from typing import TYPE_CHECKING
|
||
|
|
||
|
from coverage.exceptions import ConfigError
|
||
|
from coverage.misc import nice_pair
|
||
|
from coverage.types import TArc, TLineNo
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from coverage.data import CoverageData
|
||
|
from coverage.plugin import FileReporter
|
||
|
|
||
|
|
||
|
def analysis_from_file_reporter(
|
||
|
data: CoverageData,
|
||
|
precision: int,
|
||
|
file_reporter: FileReporter,
|
||
|
filename: str,
|
||
|
) -> Analysis:
|
||
|
"""Create an Analysis from a FileReporter."""
|
||
|
has_arcs = data.has_arcs()
|
||
|
statements = file_reporter.lines()
|
||
|
excluded = file_reporter.excluded_lines()
|
||
|
executed = file_reporter.translate_lines(data.lines(filename) or [])
|
||
|
|
||
|
if has_arcs:
|
||
|
arc_possibilities_set = file_reporter.arcs()
|
||
|
arcs: Iterable[TArc] = data.arcs(filename) or []
|
||
|
arcs = file_reporter.translate_arcs(arcs)
|
||
|
|
||
|
# Reduce the set of arcs to the ones that could be branches.
|
||
|
dests = collections.defaultdict(set)
|
||
|
for fromno, tono in arc_possibilities_set:
|
||
|
dests[fromno].add(tono)
|
||
|
single_dests = {
|
||
|
fromno: list(tonos)[0]
|
||
|
for fromno, tonos in dests.items()
|
||
|
if len(tonos) == 1
|
||
|
}
|
||
|
new_arcs = set()
|
||
|
for fromno, tono in arcs:
|
||
|
if fromno != tono:
|
||
|
new_arcs.add((fromno, tono))
|
||
|
else:
|
||
|
if fromno in single_dests:
|
||
|
new_arcs.add((fromno, single_dests[fromno]))
|
||
|
|
||
|
arcs_executed_set = file_reporter.translate_arcs(new_arcs)
|
||
|
exit_counts = file_reporter.exit_counts()
|
||
|
no_branch = file_reporter.no_branch_lines()
|
||
|
else:
|
||
|
arc_possibilities_set = set()
|
||
|
arcs_executed_set = set()
|
||
|
exit_counts = {}
|
||
|
no_branch = set()
|
||
|
|
||
|
return Analysis(
|
||
|
precision=precision,
|
||
|
filename=filename,
|
||
|
has_arcs=has_arcs,
|
||
|
statements=statements,
|
||
|
excluded=excluded,
|
||
|
executed=executed,
|
||
|
arc_possibilities_set=arc_possibilities_set,
|
||
|
arcs_executed_set=arcs_executed_set,
|
||
|
exit_counts=exit_counts,
|
||
|
no_branch=no_branch,
|
||
|
)
|
||
|
|
||
|
|
||
|
@dataclasses.dataclass
|
||
|
class Analysis:
|
||
|
"""The results of analyzing a FileReporter."""
|
||
|
|
||
|
precision: int
|
||
|
filename: str
|
||
|
has_arcs: bool
|
||
|
statements: set[TLineNo]
|
||
|
excluded: set[TLineNo]
|
||
|
executed: set[TLineNo]
|
||
|
arc_possibilities_set: set[TArc]
|
||
|
arcs_executed_set: set[TArc]
|
||
|
exit_counts: dict[TLineNo, int]
|
||
|
no_branch: set[TLineNo]
|
||
|
|
||
|
def __post_init__(self) -> None:
|
||
|
self.arc_possibilities = sorted(self.arc_possibilities_set)
|
||
|
self.arcs_executed = sorted(self.arcs_executed_set)
|
||
|
self.missing = self.statements - self.executed
|
||
|
|
||
|
if self.has_arcs:
|
||
|
n_branches = self._total_branches()
|
||
|
mba = self.missing_branch_arcs()
|
||
|
n_partial_branches = sum(len(v) for k,v in mba.items() if k not in self.missing)
|
||
|
n_missing_branches = sum(len(v) for k,v in mba.items())
|
||
|
else:
|
||
|
n_branches = n_partial_branches = n_missing_branches = 0
|
||
|
|
||
|
self.numbers = Numbers(
|
||
|
precision=self.precision,
|
||
|
n_files=1,
|
||
|
n_statements=len(self.statements),
|
||
|
n_excluded=len(self.excluded),
|
||
|
n_missing=len(self.missing),
|
||
|
n_branches=n_branches,
|
||
|
n_partial_branches=n_partial_branches,
|
||
|
n_missing_branches=n_missing_branches,
|
||
|
)
|
||
|
|
||
|
def narrow(self, lines: Container[TLineNo]) -> Analysis:
|
||
|
"""Create a narrowed Analysis.
|
||
|
|
||
|
The current analysis is copied to make a new one that only considers
|
||
|
the lines in `lines`.
|
||
|
"""
|
||
|
|
||
|
statements = {lno for lno in self.statements if lno in lines}
|
||
|
excluded = {lno for lno in self.excluded if lno in lines}
|
||
|
executed = {lno for lno in self.executed if lno in lines}
|
||
|
|
||
|
if self.has_arcs:
|
||
|
arc_possibilities_set = {
|
||
|
(a, b) for a, b in self.arc_possibilities_set
|
||
|
if a in lines or b in lines
|
||
|
}
|
||
|
arcs_executed_set = {
|
||
|
(a, b) for a, b in self.arcs_executed_set
|
||
|
if a in lines or b in lines
|
||
|
}
|
||
|
exit_counts = {
|
||
|
lno: num for lno, num in self.exit_counts.items()
|
||
|
if lno in lines
|
||
|
}
|
||
|
no_branch = {lno for lno in self.no_branch if lno in lines}
|
||
|
else:
|
||
|
arc_possibilities_set = set()
|
||
|
arcs_executed_set = set()
|
||
|
exit_counts = {}
|
||
|
no_branch = set()
|
||
|
|
||
|
return Analysis(
|
||
|
precision=self.precision,
|
||
|
filename=self.filename,
|
||
|
has_arcs=self.has_arcs,
|
||
|
statements=statements,
|
||
|
excluded=excluded,
|
||
|
executed=executed,
|
||
|
arc_possibilities_set=arc_possibilities_set,
|
||
|
arcs_executed_set=arcs_executed_set,
|
||
|
exit_counts=exit_counts,
|
||
|
no_branch=no_branch,
|
||
|
)
|
||
|
|
||
|
def missing_formatted(self, branches: bool = False) -> str:
|
||
|
"""The missing line numbers, formatted nicely.
|
||
|
|
||
|
Returns a string like "1-2, 5-11, 13-14".
|
||
|
|
||
|
If `branches` is true, includes the missing branch arcs also.
|
||
|
|
||
|
"""
|
||
|
if branches and self.has_arcs:
|
||
|
arcs = self.missing_branch_arcs().items()
|
||
|
else:
|
||
|
arcs = None
|
||
|
|
||
|
return format_lines(self.statements, self.missing, arcs=arcs)
|
||
|
|
||
|
def arcs_missing(self) -> list[TArc]:
|
||
|
"""Returns a sorted list of the un-executed arcs in the code."""
|
||
|
missing = (
|
||
|
p for p in self.arc_possibilities
|
||
|
if p not in self.arcs_executed_set
|
||
|
and p[0] not in self.no_branch
|
||
|
and p[1] not in self.excluded
|
||
|
)
|
||
|
return sorted(missing)
|
||
|
|
||
|
def _branch_lines(self) -> list[TLineNo]:
|
||
|
"""Returns a list of line numbers that have more than one exit."""
|
||
|
return [l1 for l1,count in self.exit_counts.items() if count > 1]
|
||
|
|
||
|
def _total_branches(self) -> int:
|
||
|
"""How many total branches are there?"""
|
||
|
return sum(count for count in self.exit_counts.values() if count > 1)
|
||
|
|
||
|
def missing_branch_arcs(self) -> dict[TLineNo, list[TLineNo]]:
|
||
|
"""Return arcs that weren't executed from branch lines.
|
||
|
|
||
|
Returns {l1:[l2a,l2b,...], ...}
|
||
|
|
||
|
"""
|
||
|
missing = self.arcs_missing()
|
||
|
branch_lines = set(self._branch_lines())
|
||
|
mba = collections.defaultdict(list)
|
||
|
for l1, l2 in missing:
|
||
|
assert l1 != l2, f"In {self.filename}, didn't expect {l1} == {l2}"
|
||
|
if l1 in branch_lines:
|
||
|
mba[l1].append(l2)
|
||
|
return mba
|
||
|
|
||
|
def executed_branch_arcs(self) -> dict[TLineNo, list[TLineNo]]:
|
||
|
"""Return arcs that were executed from branch lines.
|
||
|
|
||
|
Only include ones that we considered possible.
|
||
|
|
||
|
Returns {l1:[l2a,l2b,...], ...}
|
||
|
|
||
|
"""
|
||
|
branch_lines = set(self._branch_lines())
|
||
|
eba = collections.defaultdict(list)
|
||
|
for l1, l2 in self.arcs_executed:
|
||
|
assert l1 != l2, f"Oops: Didn't think this could happen: {l1 = }, {l2 = }"
|
||
|
if (l1, l2) not in self.arc_possibilities_set:
|
||
|
continue
|
||
|
if l1 in branch_lines:
|
||
|
eba[l1].append(l2)
|
||
|
return eba
|
||
|
|
||
|
def branch_stats(self) -> dict[TLineNo, tuple[int, int]]:
|
||
|
"""Get stats about branches.
|
||
|
|
||
|
Returns a dict mapping line numbers to a tuple:
|
||
|
(total_exits, taken_exits).
|
||
|
|
||
|
"""
|
||
|
|
||
|
missing_arcs = self.missing_branch_arcs()
|
||
|
stats = {}
|
||
|
for lnum in self._branch_lines():
|
||
|
exits = self.exit_counts[lnum]
|
||
|
missing = len(missing_arcs[lnum])
|
||
|
stats[lnum] = (exits, exits - missing)
|
||
|
return stats
|
||
|
|
||
|
|
||
|
@dataclasses.dataclass
|
||
|
class Numbers:
|
||
|
"""The numerical results of measuring coverage.
|
||
|
|
||
|
This holds the basic statistics from `Analysis`, and is used to roll
|
||
|
up statistics across files.
|
||
|
|
||
|
"""
|
||
|
|
||
|
precision: int = 0
|
||
|
n_files: int = 0
|
||
|
n_statements: int = 0
|
||
|
n_excluded: int = 0
|
||
|
n_missing: int = 0
|
||
|
n_branches: int = 0
|
||
|
n_partial_branches: int = 0
|
||
|
n_missing_branches: int = 0
|
||
|
|
||
|
@property
|
||
|
def n_executed(self) -> int:
|
||
|
"""Returns the number of executed statements."""
|
||
|
return self.n_statements - self.n_missing
|
||
|
|
||
|
@property
|
||
|
def n_executed_branches(self) -> int:
|
||
|
"""Returns the number of executed branches."""
|
||
|
return self.n_branches - self.n_missing_branches
|
||
|
|
||
|
@property
|
||
|
def pc_covered(self) -> float:
|
||
|
"""Returns a single percentage value for coverage."""
|
||
|
if self.n_statements > 0:
|
||
|
numerator, denominator = self.ratio_covered
|
||
|
pc_cov = (100.0 * numerator) / denominator
|
||
|
else:
|
||
|
pc_cov = 100.0
|
||
|
return pc_cov
|
||
|
|
||
|
@property
|
||
|
def pc_covered_str(self) -> str:
|
||
|
"""Returns the percent covered, as a string, without a percent sign.
|
||
|
|
||
|
Note that "0" is only returned when the value is truly zero, and "100"
|
||
|
is only returned when the value is truly 100. Rounding can never
|
||
|
result in either "0" or "100".
|
||
|
|
||
|
"""
|
||
|
return display_covered(self.pc_covered, self.precision)
|
||
|
|
||
|
@property
|
||
|
def ratio_covered(self) -> tuple[int, int]:
|
||
|
"""Return a numerator and denominator for the coverage ratio."""
|
||
|
numerator = self.n_executed + self.n_executed_branches
|
||
|
denominator = self.n_statements + self.n_branches
|
||
|
return numerator, denominator
|
||
|
|
||
|
def __add__(self, other: Numbers) -> Numbers:
|
||
|
return Numbers(
|
||
|
self.precision,
|
||
|
self.n_files + other.n_files,
|
||
|
self.n_statements + other.n_statements,
|
||
|
self.n_excluded + other.n_excluded,
|
||
|
self.n_missing + other.n_missing,
|
||
|
self.n_branches + other.n_branches,
|
||
|
self.n_partial_branches + other.n_partial_branches,
|
||
|
self.n_missing_branches + other.n_missing_branches,
|
||
|
)
|
||
|
|
||
|
def __radd__(self, other: int) -> Numbers:
|
||
|
# Implementing 0+Numbers allows us to sum() a list of Numbers.
|
||
|
assert other == 0 # we only ever call it this way.
|
||
|
return self
|
||
|
|
||
|
|
||
|
def display_covered(pc: float, precision: int) -> str:
|
||
|
"""Return a displayable total percentage, as a string.
|
||
|
|
||
|
Note that "0" is only returned when the value is truly zero, and "100"
|
||
|
is only returned when the value is truly 100. Rounding can never
|
||
|
result in either "0" or "100".
|
||
|
|
||
|
"""
|
||
|
near0 = 1.0 / 10 ** precision
|
||
|
if 0 < pc < near0:
|
||
|
pc = near0
|
||
|
elif (100.0 - near0) < pc < 100:
|
||
|
pc = 100.0 - near0
|
||
|
else:
|
||
|
pc = round(pc, precision)
|
||
|
return "%.*f" % (precision, pc)
|
||
|
|
||
|
|
||
|
def _line_ranges(
|
||
|
statements: Iterable[TLineNo],
|
||
|
lines: Iterable[TLineNo],
|
||
|
) -> list[tuple[TLineNo, TLineNo]]:
|
||
|
"""Produce a list of ranges for `format_lines`."""
|
||
|
statements = sorted(statements)
|
||
|
lines = sorted(lines)
|
||
|
|
||
|
pairs = []
|
||
|
start: TLineNo | None = None
|
||
|
lidx = 0
|
||
|
for stmt in statements:
|
||
|
if lidx >= len(lines):
|
||
|
break
|
||
|
if stmt == lines[lidx]:
|
||
|
lidx += 1
|
||
|
if not start:
|
||
|
start = stmt
|
||
|
end = stmt
|
||
|
elif start:
|
||
|
pairs.append((start, end))
|
||
|
start = None
|
||
|
if start:
|
||
|
pairs.append((start, end))
|
||
|
return pairs
|
||
|
|
||
|
|
||
|
def format_lines(
|
||
|
statements: Iterable[TLineNo],
|
||
|
lines: Iterable[TLineNo],
|
||
|
arcs: Iterable[tuple[TLineNo, list[TLineNo]]] | None = None,
|
||
|
) -> str:
|
||
|
"""Nicely format a list of line numbers.
|
||
|
|
||
|
Format a list of line numbers for printing by coalescing groups of lines as
|
||
|
long as the lines represent consecutive statements. This will coalesce
|
||
|
even if there are gaps between statements.
|
||
|
|
||
|
For example, if `statements` is [1,2,3,4,5,10,11,12,13,14] and
|
||
|
`lines` is [1,2,5,10,11,13,14] then the result will be "1-2, 5-11, 13-14".
|
||
|
|
||
|
Both `lines` and `statements` can be any iterable. All of the elements of
|
||
|
`lines` must be in `statements`, and all of the values must be positive
|
||
|
integers.
|
||
|
|
||
|
If `arcs` is provided, they are (start,[end,end,end]) pairs that will be
|
||
|
included in the output as long as start isn't in `lines`.
|
||
|
|
||
|
"""
|
||
|
line_items = [(pair[0], nice_pair(pair)) for pair in _line_ranges(statements, lines)]
|
||
|
if arcs is not None:
|
||
|
line_exits = sorted(arcs)
|
||
|
for line, exits in line_exits:
|
||
|
for ex in sorted(exits):
|
||
|
if line not in lines and ex not in lines:
|
||
|
dest = (ex if ex > 0 else "exit")
|
||
|
line_items.append((line, f"{line}->{dest}"))
|
||
|
|
||
|
ret = ", ".join(t[-1] for t in sorted(line_items))
|
||
|
return ret
|
||
|
|
||
|
|
||
|
def should_fail_under(total: float, fail_under: float, precision: int) -> bool:
|
||
|
"""Determine if a total should fail due to fail-under.
|
||
|
|
||
|
`total` is a float, the coverage measurement total. `fail_under` is the
|
||
|
fail_under setting to compare with. `precision` is the number of digits
|
||
|
to consider after the decimal point.
|
||
|
|
||
|
Returns True if the total should fail.
|
||
|
|
||
|
"""
|
||
|
# We can never achieve higher than 100% coverage, or less than zero.
|
||
|
if not (0 <= fail_under <= 100.0):
|
||
|
msg = f"fail_under={fail_under} is invalid. Must be between 0 and 100."
|
||
|
raise ConfigError(msg)
|
||
|
|
||
|
# Special case for fail_under=100, it must really be 100.
|
||
|
if fail_under == 100.0 and total != 100.0:
|
||
|
return True
|
||
|
|
||
|
return round(total, precision) < fail_under
|