|  | # 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 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 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 | 
|  |  | 
|  |  | 
|  | TRegionLines = frozenset[TLineNo] | 
|  |  | 
|  |  | 
|  | class AnalysisNarrower: | 
|  | """ | 
|  | For reducing an `Analysis` to a subset of its lines. | 
|  |  | 
|  | Originally this was a simpler method on Analysis, but that led to quadratic | 
|  | behavior.  This class does the bulk of the work up-front to provide the | 
|  | same results in linear time. | 
|  |  | 
|  | Create an AnalysisNarrower from an Analysis, bulk-add region lines to it | 
|  | with `add_regions`, then individually request new narrowed Analysis objects | 
|  | for each region with `narrow`.  Doing most of the work in limited calls to | 
|  | `add_regions` lets us avoid poor performance. | 
|  | """ | 
|  |  | 
|  | # In this class, regions are represented by a frozenset of their lines. | 
|  |  | 
|  | def __init__(self, analysis: Analysis) -> None: | 
|  | self.analysis = analysis | 
|  | self.region2arc_possibilities: dict[TRegionLines, set[TArc]] = collections.defaultdict(set) | 
|  | self.region2arc_executed: dict[TRegionLines, set[TArc]] = collections.defaultdict(set) | 
|  | self.region2exit_counts: dict[TRegionLines, dict[TLineNo, int]] = collections.defaultdict( | 
|  | dict | 
|  | ) | 
|  |  | 
|  | def add_regions(self, liness: Iterable[set[TLineNo]]) -> None: | 
|  | """ | 
|  | Pre-process a number of sets of line numbers.  Later calls to `narrow` | 
|  | with one of these sets will provide a narrowed Analysis. | 
|  | """ | 
|  | if self.analysis.has_arcs: | 
|  | line2region: dict[TLineNo, TRegionLines] = {} | 
|  |  | 
|  | for lines in liness: | 
|  | fzlines = frozenset(lines) | 
|  | for line in lines: | 
|  | line2region[line] = fzlines | 
|  |  | 
|  | def collect_arcs( | 
|  | arc_set: set[TArc], | 
|  | region2arcs: dict[TRegionLines, set[TArc]], | 
|  | ) -> None: | 
|  | for a, b in arc_set: | 
|  | if r := line2region.get(a): | 
|  | region2arcs[r].add((a, b)) | 
|  | if r := line2region.get(b): | 
|  | region2arcs[r].add((a, b)) | 
|  |  | 
|  | collect_arcs(self.analysis.arc_possibilities_set, self.region2arc_possibilities) | 
|  | collect_arcs(self.analysis.arcs_executed_set, self.region2arc_executed) | 
|  |  | 
|  | for lno, num in self.analysis.exit_counts.items(): | 
|  | if r := line2region.get(lno): | 
|  | self.region2exit_counts[r][lno] = num | 
|  |  | 
|  | def narrow(self, lines: set[TLineNo]) -> Analysis: | 
|  | """Create a narrowed Analysis. | 
|  |  | 
|  | The current analysis is copied to make a new one that only considers | 
|  | the lines in `lines`. | 
|  | """ | 
|  |  | 
|  | # Technically, the set intersections in this method are still O(N**2) | 
|  | # since this method is called N times, but they're very fast and moving | 
|  | # them to `add_regions` won't avoid the quadratic time. | 
|  |  | 
|  | statements = self.analysis.statements & lines | 
|  | excluded = self.analysis.excluded & lines | 
|  | executed = self.analysis.executed & lines | 
|  |  | 
|  | if self.analysis.has_arcs: | 
|  | fzlines = frozenset(lines) | 
|  | arc_possibilities_set = self.region2arc_possibilities[fzlines] | 
|  | arcs_executed_set = self.region2arc_executed[fzlines] | 
|  | exit_counts = self.region2exit_counts[fzlines] | 
|  | no_branch = self.analysis.no_branch & lines | 
|  | else: | 
|  | arc_possibilities_set = set() | 
|  | arcs_executed_set = set() | 
|  | exit_counts = {} | 
|  | no_branch = set() | 
|  |  | 
|  | return Analysis( | 
|  | precision=self.analysis.precision, | 
|  | filename=self.analysis.filename, | 
|  | has_arcs=self.analysis.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 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"{pc:.{precision}f}" | 
|  |  | 
|  |  | 
|  | 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 |