Coverage for /opt/homebrew/lib/python3.11/site-packages/_pytest/debugging.py: 30%

230 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

1"""Interactive debugging with PDB, the Python Debugger.""" 

2import argparse 

3import functools 

4import sys 

5import types 

6import unittest 

7from typing import Any 

8from typing import Callable 

9from typing import Generator 

10from typing import List 

11from typing import Optional 

12from typing import Tuple 

13from typing import Type 

14from typing import TYPE_CHECKING 

15from typing import Union 

16 

17from _pytest import outcomes 

18from _pytest._code import ExceptionInfo 

19from _pytest.config import Config 

20from _pytest.config import ConftestImportFailure 

21from _pytest.config import hookimpl 

22from _pytest.config import PytestPluginManager 

23from _pytest.config.argparsing import Parser 

24from _pytest.config.exceptions import UsageError 

25from _pytest.nodes import Node 

26from _pytest.reports import BaseReport 

27 

28if TYPE_CHECKING: 

29 from _pytest.capture import CaptureManager 

30 from _pytest.runner import CallInfo 

31 

32 

33def _validate_usepdb_cls(value: str) -> Tuple[str, str]: 

34 """Validate syntax of --pdbcls option.""" 

35 try: 

36 modname, classname = value.split(":") 

37 except ValueError as e: 

38 raise argparse.ArgumentTypeError( 

39 f"{value!r} is not in the format 'modname:classname'" 

40 ) from e 

41 return (modname, classname) 

42 

43 

44def pytest_addoption(parser: Parser) -> None: 

45 group = parser.getgroup("general") 

46 group._addoption( 

47 "--pdb", 

48 dest="usepdb", 

49 action="store_true", 

50 help="Start the interactive Python debugger on errors or KeyboardInterrupt", 

51 ) 

52 group._addoption( 

53 "--pdbcls", 

54 dest="usepdb_cls", 

55 metavar="modulename:classname", 

56 type=_validate_usepdb_cls, 

57 help="Specify a custom interactive Python debugger for use with --pdb." 

58 "For example: --pdbcls=IPython.terminal.debugger:TerminalPdb", 

59 ) 

60 group._addoption( 

61 "--trace", 

62 dest="trace", 

63 action="store_true", 

64 help="Immediately break when running each test", 

65 ) 

66 

67 

68def pytest_configure(config: Config) -> None: 

69 import pdb 

70 

71 if config.getvalue("trace"): 

72 config.pluginmanager.register(PdbTrace(), "pdbtrace") 

73 if config.getvalue("usepdb"): 

74 config.pluginmanager.register(PdbInvoke(), "pdbinvoke") 

75 

76 pytestPDB._saved.append( 

77 (pdb.set_trace, pytestPDB._pluginmanager, pytestPDB._config) 

78 ) 

79 pdb.set_trace = pytestPDB.set_trace 

80 pytestPDB._pluginmanager = config.pluginmanager 

81 pytestPDB._config = config 

82 

83 # NOTE: not using pytest_unconfigure, since it might get called although 

84 # pytest_configure was not (if another plugin raises UsageError). 

85 def fin() -> None: 

86 ( 

87 pdb.set_trace, 

88 pytestPDB._pluginmanager, 

89 pytestPDB._config, 

90 ) = pytestPDB._saved.pop() 

91 

92 config.add_cleanup(fin) 

93 

94 

95class pytestPDB: 

96 """Pseudo PDB that defers to the real pdb.""" 

97 

98 _pluginmanager: Optional[PytestPluginManager] = None 

99 _config: Optional[Config] = None 

100 _saved: List[ 

101 Tuple[Callable[..., None], Optional[PytestPluginManager], Optional[Config]] 

102 ] = [] 

103 _recursive_debug = 0 

104 _wrapped_pdb_cls: Optional[Tuple[Type[Any], Type[Any]]] = None 

105 

106 @classmethod 

107 def _is_capturing(cls, capman: Optional["CaptureManager"]) -> Union[str, bool]: 

108 if capman: 

109 return capman.is_capturing() 

110 return False 

111 

112 @classmethod 

113 def _import_pdb_cls(cls, capman: Optional["CaptureManager"]): 

114 if not cls._config: 

115 import pdb 

116 

117 # Happens when using pytest.set_trace outside of a test. 

118 return pdb.Pdb 

119 

120 usepdb_cls = cls._config.getvalue("usepdb_cls") 

121 

122 if cls._wrapped_pdb_cls and cls._wrapped_pdb_cls[0] == usepdb_cls: 

123 return cls._wrapped_pdb_cls[1] 

124 

125 if usepdb_cls: 

126 modname, classname = usepdb_cls 

127 

128 try: 

129 __import__(modname) 

130 mod = sys.modules[modname] 

131 

132 # Handle --pdbcls=pdb:pdb.Pdb (useful e.g. with pdbpp). 

133 parts = classname.split(".") 

134 pdb_cls = getattr(mod, parts[0]) 

135 for part in parts[1:]: 

136 pdb_cls = getattr(pdb_cls, part) 

137 except Exception as exc: 

138 value = ":".join((modname, classname)) 

139 raise UsageError( 

140 f"--pdbcls: could not import {value!r}: {exc}" 

141 ) from exc 

142 else: 

143 import pdb 

144 

145 pdb_cls = pdb.Pdb 

146 

147 wrapped_cls = cls._get_pdb_wrapper_class(pdb_cls, capman) 

148 cls._wrapped_pdb_cls = (usepdb_cls, wrapped_cls) 

149 return wrapped_cls 

150 

151 @classmethod 

152 def _get_pdb_wrapper_class(cls, pdb_cls, capman: Optional["CaptureManager"]): 

153 import _pytest.config 

154 

155 # Type ignored because mypy doesn't support "dynamic" 

156 # inheritance like this. 

157 class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc] 

158 _pytest_capman = capman 

159 _continued = False 

160 

161 def do_debug(self, arg): 

162 cls._recursive_debug += 1 

163 ret = super().do_debug(arg) 

164 cls._recursive_debug -= 1 

165 return ret 

166 

167 def do_continue(self, arg): 

168 ret = super().do_continue(arg) 

169 if cls._recursive_debug == 0: 

170 assert cls._config is not None 

171 tw = _pytest.config.create_terminal_writer(cls._config) 

172 tw.line() 

173 

174 capman = self._pytest_capman 

175 capturing = pytestPDB._is_capturing(capman) 

176 if capturing: 

177 if capturing == "global": 

178 tw.sep(">", "PDB continue (IO-capturing resumed)") 

179 else: 

180 tw.sep( 

181 ">", 

182 "PDB continue (IO-capturing resumed for %s)" 

183 % capturing, 

184 ) 

185 assert capman is not None 

186 capman.resume() 

187 else: 

188 tw.sep(">", "PDB continue") 

189 assert cls._pluginmanager is not None 

190 cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self) 

191 self._continued = True 

192 return ret 

193 

194 do_c = do_cont = do_continue 

195 

196 def do_quit(self, arg): 

197 """Raise Exit outcome when quit command is used in pdb. 

198 

199 This is a bit of a hack - it would be better if BdbQuit 

200 could be handled, but this would require to wrap the 

201 whole pytest run, and adjust the report etc. 

202 """ 

203 ret = super().do_quit(arg) 

204 

205 if cls._recursive_debug == 0: 

206 outcomes.exit("Quitting debugger") 

207 

208 return ret 

209 

210 do_q = do_quit 

211 do_exit = do_quit 

212 

213 def setup(self, f, tb): 

214 """Suspend on setup(). 

215 

216 Needed after do_continue resumed, and entering another 

217 breakpoint again. 

218 """ 

219 ret = super().setup(f, tb) 

220 if not ret and self._continued: 

221 # pdb.setup() returns True if the command wants to exit 

222 # from the interaction: do not suspend capturing then. 

223 if self._pytest_capman: 

224 self._pytest_capman.suspend_global_capture(in_=True) 

225 return ret 

226 

227 def get_stack(self, f, t): 

228 stack, i = super().get_stack(f, t) 

229 if f is None: 

230 # Find last non-hidden frame. 

231 i = max(0, len(stack) - 1) 

232 while i and stack[i][0].f_locals.get("__tracebackhide__", False): 

233 i -= 1 

234 return stack, i 

235 

236 return PytestPdbWrapper 

237 

238 @classmethod 

239 def _init_pdb(cls, method, *args, **kwargs): 

240 """Initialize PDB debugging, dropping any IO capturing.""" 

241 import _pytest.config 

242 

243 if cls._pluginmanager is None: 

244 capman: Optional[CaptureManager] = None 

245 else: 

246 capman = cls._pluginmanager.getplugin("capturemanager") 

247 if capman: 

248 capman.suspend(in_=True) 

249 

250 if cls._config: 

251 tw = _pytest.config.create_terminal_writer(cls._config) 

252 tw.line() 

253 

254 if cls._recursive_debug == 0: 

255 # Handle header similar to pdb.set_trace in py37+. 

256 header = kwargs.pop("header", None) 

257 if header is not None: 

258 tw.sep(">", header) 

259 else: 

260 capturing = cls._is_capturing(capman) 

261 if capturing == "global": 

262 tw.sep(">", f"PDB {method} (IO-capturing turned off)") 

263 elif capturing: 

264 tw.sep( 

265 ">", 

266 "PDB %s (IO-capturing turned off for %s)" 

267 % (method, capturing), 

268 ) 

269 else: 

270 tw.sep(">", f"PDB {method}") 

271 

272 _pdb = cls._import_pdb_cls(capman)(**kwargs) 

273 

274 if cls._pluginmanager: 

275 cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb) 

276 return _pdb 

277 

278 @classmethod 

279 def set_trace(cls, *args, **kwargs) -> None: 

280 """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing.""" 

281 frame = sys._getframe().f_back 

282 _pdb = cls._init_pdb("set_trace", *args, **kwargs) 

283 _pdb.set_trace(frame) 

284 

285 

286class PdbInvoke: 

287 def pytest_exception_interact( 

288 self, node: Node, call: "CallInfo[Any]", report: BaseReport 

289 ) -> None: 

290 capman = node.config.pluginmanager.getplugin("capturemanager") 

291 if capman: 

292 capman.suspend_global_capture(in_=True) 

293 out, err = capman.read_global_capture() 

294 sys.stdout.write(out) 

295 sys.stdout.write(err) 

296 assert call.excinfo is not None 

297 

298 if not isinstance(call.excinfo.value, unittest.SkipTest): 

299 _enter_pdb(node, call.excinfo, report) 

300 

301 def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: 

302 tb = _postmortem_traceback(excinfo) 

303 post_mortem(tb) 

304 

305 

306class PdbTrace: 

307 @hookimpl(hookwrapper=True) 

308 def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: 

309 wrap_pytest_function_for_tracing(pyfuncitem) 

310 yield 

311 

312 

313def wrap_pytest_function_for_tracing(pyfuncitem): 

314 """Change the Python function object of the given Function item by a 

315 wrapper which actually enters pdb before calling the python function 

316 itself, effectively leaving the user in the pdb prompt in the first 

317 statement of the function.""" 

318 _pdb = pytestPDB._init_pdb("runcall") 

319 testfunction = pyfuncitem.obj 

320 

321 # we can't just return `partial(pdb.runcall, testfunction)` because (on 

322 # python < 3.7.4) runcall's first param is `func`, which means we'd get 

323 # an exception if one of the kwargs to testfunction was called `func`. 

324 @functools.wraps(testfunction) 

325 def wrapper(*args, **kwargs): 

326 func = functools.partial(testfunction, *args, **kwargs) 

327 _pdb.runcall(func) 

328 

329 pyfuncitem.obj = wrapper 

330 

331 

332def maybe_wrap_pytest_function_for_tracing(pyfuncitem): 

333 """Wrap the given pytestfunct item for tracing support if --trace was given in 

334 the command line.""" 

335 if pyfuncitem.config.getvalue("trace"): 

336 wrap_pytest_function_for_tracing(pyfuncitem) 

337 

338 

339def _enter_pdb( 

340 node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport 

341) -> BaseReport: 

342 # XXX we re-use the TerminalReporter's terminalwriter 

343 # because this seems to avoid some encoding related troubles 

344 # for not completely clear reasons. 

345 tw = node.config.pluginmanager.getplugin("terminalreporter")._tw 

346 tw.line() 

347 

348 showcapture = node.config.option.showcapture 

349 

350 for sectionname, content in ( 

351 ("stdout", rep.capstdout), 

352 ("stderr", rep.capstderr), 

353 ("log", rep.caplog), 

354 ): 

355 if showcapture in (sectionname, "all") and content: 

356 tw.sep(">", "captured " + sectionname) 

357 if content[-1:] == "\n": 

358 content = content[:-1] 

359 tw.line(content) 

360 

361 tw.sep(">", "traceback") 

362 rep.toterminal(tw) 

363 tw.sep(">", "entering PDB") 

364 tb = _postmortem_traceback(excinfo) 

365 rep._pdbshown = True # type: ignore[attr-defined] 

366 post_mortem(tb) 

367 return rep 

368 

369 

370def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType: 

371 from doctest import UnexpectedException 

372 

373 if isinstance(excinfo.value, UnexpectedException): 

374 # A doctest.UnexpectedException is not useful for post_mortem. 

375 # Use the underlying exception instead: 

376 return excinfo.value.exc_info[2] 

377 elif isinstance(excinfo.value, ConftestImportFailure): 

378 # A config.ConftestImportFailure is not useful for post_mortem. 

379 # Use the underlying exception instead: 

380 return excinfo.value.excinfo[2] 

381 else: 

382 assert excinfo._excinfo is not None 

383 return excinfo._excinfo[2] 

384 

385 

386def post_mortem(t: types.TracebackType) -> None: 

387 p = pytestPDB._init_pdb("post_mortem") 

388 p.reset() 

389 p.interaction(None, t) 

390 if p.quitting: 

391 outcomes.exit("Quitting debugger")