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