1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Copyright the Hypothesis Authors.
5# Individual contributors are listed in AUTHORS.rst and the git log.
6#
7# This Source Code Form is subject to the terms of the Mozilla Public License,
8# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9# obtain one at https://mozilla.org/MPL/2.0/.
10
11import functools
12import os
13import re
14import subprocess
15import sys
16import sysconfig
17import types
18from collections import defaultdict
19from collections.abc import Iterable
20from enum import IntEnum
21from functools import lru_cache, reduce
22from os import sep
23from pathlib import Path
24from typing import Self, TypeAlias
25
26from hypothesis._settings import Phase, Verbosity
27from hypothesis.internal.compat import PYPY
28from hypothesis.internal.escalation import is_hypothesis_file
29
30Location: TypeAlias = tuple[str, int]
31Branch: TypeAlias = tuple[Location | None, Location]
32Trace: TypeAlias = frozenset[Branch]
33
34
35@functools.cache
36def should_trace_file(fname: str) -> bool: # pragma: no cover
37 # fname.startswith("<") indicates runtime code-generation via compile,
38 # e.g. compile("def ...", "<string>", "exec") in e.g. attrs methods.
39 return not (is_hypothesis_file(fname) or fname.startswith("<"))
40
41
42# where possible, we'll use 3.12's new sys.monitoring module for low-overhead
43# coverage instrumentation; on older python versions we'll use sys.settrace.
44# tool_id = 1 is designated for coverage, but we intentionally choose a
45# non-reserved tool id so we can co-exist with coverage tools.
46MONITORING_TOOL_ID = 3
47if hasattr(sys, "monitoring"): # pragma: no branch # always true on Python >= 3.12
48 MONITORING_EVENTS = {sys.monitoring.events.LINE: "trace_line"}
49
50
51class Tracer:
52 """A super-simple branch coverage tracer."""
53
54 __slots__ = (
55 "_branches",
56 "_previous_location",
57 "_should_trace",
58 "_tried_and_failed_to_trace",
59 )
60
61 def __init__(self, *, should_trace: bool) -> None:
62 self._branches: set[Branch] = set()
63 self._previous_location: Location | None = None
64 self._tried_and_failed_to_trace = False
65 self._should_trace = should_trace and self.can_trace()
66
67 @staticmethod
68 def can_trace() -> bool:
69 if PYPY:
70 return False
71 if hasattr(sys, "monitoring"):
72 return sys.monitoring.get_tool(MONITORING_TOOL_ID) is None
73 return sys.gettrace() is None # pragma: no cover # only on Python < 3.12
74
75 @property
76 def branches(self) -> Trace:
77 return frozenset(self._branches)
78
79 def trace(self, frame, event, arg): # pragma: no cover # only on Python < 3.12
80 try:
81 if event == "call":
82 return self.trace
83 elif event == "line":
84 fname = frame.f_code.co_filename
85 if should_trace_file(fname):
86 current_location = (fname, frame.f_lineno)
87 self._branches.add((self._previous_location, current_location))
88 self._previous_location = current_location
89 except RecursionError:
90 pass
91
92 def trace_line(
93 self, code: types.CodeType, line_number: int
94 ) -> None: # pragma: no cover
95 # sys.monitoring callbacks do not fire for other monitoring tools, so coverage.py
96 # can't see this function.
97 fname = code.co_filename
98 if not should_trace_file(fname):
99 # this function is only called on 3.12+, but we want to avoid an
100 # assertion to that effect for performance.
101 return sys.monitoring.DISABLE # type: ignore
102
103 current_location = (fname, line_number)
104 self._branches.add((self._previous_location, current_location))
105 self._previous_location = current_location
106
107 def __enter__(self) -> "Self":
108 self._tried_and_failed_to_trace = False
109
110 if not self._should_trace:
111 return self
112
113 if not hasattr(sys, "monitoring"): # pragma: no cover # only on Python < 3.12
114 sys.settrace(self.trace)
115 return self
116
117 try:
118 sys.monitoring.use_tool_id(MONITORING_TOOL_ID, "scrutineer")
119 except ValueError: # pragma: no cover
120 # another thread may have registered a tool for MONITORING_TOOL_ID
121 # since we checked in can_trace; this is a rare race condition.
122 self._tried_and_failed_to_trace = True
123 return self
124
125 for event, callback_name in MONITORING_EVENTS.items():
126 sys.monitoring.set_events(MONITORING_TOOL_ID, event)
127 callback = getattr(self, callback_name)
128 sys.monitoring.register_callback(MONITORING_TOOL_ID, event, callback)
129
130 return self
131
132 def __exit__(self, *args, **kwargs):
133 if not self._should_trace:
134 return
135
136 if not hasattr(sys, "monitoring"): # pragma: no cover # only on Python < 3.12
137 sys.settrace(None)
138 return
139
140 if self._tried_and_failed_to_trace:
141 return # pragma: no cover # only true after the race in __enter__ above
142
143 sys.monitoring.free_tool_id(MONITORING_TOOL_ID)
144 for event in MONITORING_EVENTS:
145 sys.monitoring.register_callback(MONITORING_TOOL_ID, event, None)
146
147
148UNHELPFUL_LOCATIONS = (
149 # Note: The list is post-processed, so use plain "/" for separator here.
150 # Quite rarely, the first always-failing line is in Pytest's internals.
151 "/_pytest/**",
152 "/pluggy/_*.py",
153 # used by pytest for failure formatting in the terminal.
154 # seen: pygments/lexer.py, pygments/formatters/, pygments/filter.py.
155 "/pygments/*",
156 "/conftest.py",
157 # syrupy registers a pytest_assertrepr_compare hook, which only runs when
158 # assertions fail — making it appear as always-failing-never-passing.
159 "/syrupy/__init__.py",
160)
161
162
163def _glob_to_re(locs: Iterable[str]) -> str:
164 """Translate a list of glob patterns to a combined regular expression.
165 Only the * and ** wildcards are supported, and patterns including special
166 characters will only work by chance."""
167 # fnmatch.translate is not an option since its "*" consumes path sep
168 return "|".join(
169 loc.replace(".", re.escape("."))
170 .replace("**", r".+")
171 .replace("*", r"[^/]+")
172 .replace("/", re.escape(sep))
173 + r"\Z" # right anchored
174 for loc in locs
175 )
176
177
178def get_explaining_locations(traces):
179 # Traces is a dict[interesting_origin | None, set[frozenset[tuple[str, int]]]]
180 # Each trace in the set might later become a Counter instead of frozenset.
181 if not traces:
182 return {}
183
184 unions = {origin: set().union(*values) for origin, values in traces.items()}
185 seen_passing = {None}.union(*unions.pop(None, set()))
186
187 always_failing_never_passing = {
188 origin: reduce(set.intersection, [set().union(*v) for v in values])
189 - seen_passing
190 for origin, values in traces.items()
191 if origin is not None
192 }
193
194 # Build the observed parts of the control-flow graph for each origin
195 cf_graphs = {origin: defaultdict(set) for origin in unions}
196 for origin, seen_arcs in unions.items():
197 for src, dst in seen_arcs:
198 cf_graphs[origin][src].add(dst)
199 assert cf_graphs[origin][None], "Expected start node with >=1 successor"
200
201 # For each origin, our explanation is the always_failing_never_passing lines
202 # which are reachable from the start node (None) without passing through another
203 # AFNP line. So here's a whatever-first search with early stopping:
204 explanations = defaultdict(set)
205 for origin in unions:
206 queue = {None}
207 seen = set()
208 while queue:
209 assert queue.isdisjoint(seen), f"Intersection: {queue & seen}"
210 src = queue.pop()
211 seen.add(src)
212 if src in always_failing_never_passing[origin]:
213 explanations[origin].add(src)
214 else:
215 queue.update(cf_graphs[origin][src] - seen)
216
217 # The last step is to filter out explanations that we know would be uninformative.
218 # When this is the first AFNP location, we conclude that Scrutineer missed the
219 # real divergence (earlier in the trace) and drop that unhelpful explanation.
220 filter_regex = re.compile(_glob_to_re(UNHELPFUL_LOCATIONS))
221 return {
222 origin: {
223 loc
224 for loc in afnp_locs
225 if not filter_regex.search(loc[0])
226 # In addition to UNHELPFUL_LOCATIONS, we drop all stdlib locations.
227 and ModuleLocation.from_path(loc[0]) is not ModuleLocation.STDLIB
228 }
229 for origin, afnp_locs in explanations.items()
230 }
231
232
233# see e.g. https://docs.python.org/3/library/sysconfig.html#posix-user
234# for examples of these path schemes
235def _stdlib_dirs():
236 return {
237 Path(sysconfig.get_path("platstdlib")).resolve(),
238 Path(sysconfig.get_path("stdlib")).resolve(),
239 # Under Pyodide and other embedded pythons, the stdlib is imported from
240 # a zipfile on sys.path, which sysconfig doesn't report.
241 *(Path(p) for p in sys.path if p.endswith(".zip")),
242 }
243
244
245STDLIB_DIRS = _stdlib_dirs()
246SITE_PACKAGES_DIRS = {
247 Path(sysconfig.get_path("purelib")).resolve(),
248 Path(sysconfig.get_path("platlib")).resolve(),
249}
250
251EXPLANATION_STUB = (
252 "Explanation:",
253 " These lines were always and only run by failing test cases:",
254)
255
256
257class ModuleLocation(IntEnum):
258 LOCAL = 0
259 SITE_PACKAGES = 1
260 STDLIB = 2
261
262 @classmethod
263 @lru_cache(1024)
264 def from_path(cls, path: str) -> "ModuleLocation":
265 resolved = Path(path).resolve()
266 # site-packages may be a subdir of stdlib or platlib, so it's important to
267 # check is_relative_to for this before the stdlib.
268 if any(resolved.is_relative_to(p) for p in SITE_PACKAGES_DIRS):
269 return cls.SITE_PACKAGES
270 if any(resolved.is_relative_to(p) for p in STDLIB_DIRS):
271 return cls.STDLIB
272 return cls.LOCAL
273
274
275# show local files first, then site-packages, then stdlib
276def _sort_key(path: str, lineno: int) -> tuple[int, str, int]:
277 return (ModuleLocation.from_path(path), path, lineno)
278
279
280def make_report(explanations, *, cap_lines_at=5):
281 report = defaultdict(list)
282 for origin, locations in explanations.items():
283 locations = list(locations)
284 locations.sort(key=lambda v: _sort_key(v[0], v[1]))
285 report_lines = [f" {fname}:{lineno}" for fname, lineno in locations]
286 if len(report_lines) > cap_lines_at + 1:
287 msg = " (and {} more with settings.verbosity >= verbose)"
288 report_lines[cap_lines_at:] = [msg.format(len(report_lines[cap_lines_at:]))]
289 if report_lines: # We might have filtered out every location as uninformative.
290 report[origin] = list(EXPLANATION_STUB) + report_lines
291 return report
292
293
294def explanatory_lines(traces, settings):
295 if Phase.explain in settings.phases and sys.gettrace() and not traces:
296 return defaultdict(list)
297 # Return human-readable report lines summarising the traces
298 explanations = get_explaining_locations(traces)
299 max_lines = 5 if settings.verbosity <= Verbosity.normal else float("inf")
300 return make_report(explanations, cap_lines_at=max_lines)
301
302
303# beware the code below; we're using some heuristics to make a nicer report...
304
305
306@functools.lru_cache
307def _get_git_repo_root() -> Path:
308 try:
309 where = subprocess.run(
310 ["git", "rev-parse", "--show-toplevel"],
311 check=True,
312 timeout=10,
313 capture_output=True,
314 text=True,
315 encoding="utf-8",
316 ).stdout.strip()
317 except Exception: # pragma: no cover
318 return Path().absolute().parents[-1]
319 else:
320 return Path(where)
321
322
323def tractable_coverage_report(trace: Trace) -> dict[str, list[int]]:
324 """Report a simple coverage map which is (probably most) of the user's code."""
325 coverage: dict = {}
326 t = dict(trace)
327 for file, line in set(t.keys()).union(t.values()) - {None}: # type: ignore
328 coverage.setdefault(file, set()).add(line)
329 stdlib_fragment = f"{os.sep}lib{os.sep}python3.{sys.version_info.minor}{os.sep}"
330 return {
331 k: sorted(v)
332 for k, v in coverage.items()
333 if stdlib_fragment not in k
334 and (p := Path(k)).is_relative_to(_get_git_repo_root())
335 and "site-packages" not in p.parts
336 }