Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/hypothesis/internal/scrutineer.py: 39%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

136 statements  

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 }