Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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

2import argparse 

3import functools 

4import sys 

5import types 

6from typing import Generator 

7from typing import Tuple 

8from typing import Union 

9 

10from _pytest import outcomes 

11from _pytest._code import ExceptionInfo 

12from _pytest.compat import TYPE_CHECKING 

13from _pytest.config import Config 

14from _pytest.config import ConftestImportFailure 

15from _pytest.config import hookimpl 

16from _pytest.config import PytestPluginManager 

17from _pytest.config.argparsing import Parser 

18from _pytest.config.exceptions import UsageError 

19from _pytest.nodes import Node 

20from _pytest.reports import BaseReport 

21 

22if TYPE_CHECKING: 

23 from _pytest.capture import CaptureManager 

24 from _pytest.runner import CallInfo 

25 

26 

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

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

29 try: 

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

31 except ValueError as e: 

32 raise argparse.ArgumentTypeError( 

33 "{!r} is not in the format 'modname:classname'".format(value) 

34 ) from e 

35 return (modname, classname) 

36 

37 

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

39 group = parser.getgroup("general") 

40 group._addoption( 

41 "--pdb", 

42 dest="usepdb", 

43 action="store_true", 

44 help="start the interactive Python debugger on errors or KeyboardInterrupt.", 

45 ) 

46 group._addoption( 

47 "--pdbcls", 

48 dest="usepdb_cls", 

49 metavar="modulename:classname", 

50 type=_validate_usepdb_cls, 

51 help="start a custom interactive Python debugger on errors. " 

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

53 ) 

54 group._addoption( 

55 "--trace", 

56 dest="trace", 

57 action="store_true", 

58 help="Immediately break when running each test.", 

59 ) 

60 

61 

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

63 import pdb 

64 

65 if config.getvalue("trace"): 

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

67 if config.getvalue("usepdb"): 

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

69 

70 pytestPDB._saved.append( 

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

72 ) 

73 pdb.set_trace = pytestPDB.set_trace 

74 pytestPDB._pluginmanager = config.pluginmanager 

75 pytestPDB._config = config 

76 

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

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

79 def fin() -> None: 

80 ( 

81 pdb.set_trace, 

82 pytestPDB._pluginmanager, 

83 pytestPDB._config, 

84 ) = pytestPDB._saved.pop() 

85 

86 config._cleanup.append(fin) 

87 

88 

89class pytestPDB: 

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

91 

92 _pluginmanager = None # type: PytestPluginManager 

93 _config = None # type: Config 

94 _saved = [] # type: list 

95 _recursive_debug = 0 

96 _wrapped_pdb_cls = None 

97 

98 @classmethod 

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

100 if capman: 

101 return capman.is_capturing() 

102 return False 

103 

104 @classmethod 

105 def _import_pdb_cls(cls, capman: "CaptureManager"): 

106 if not cls._config: 

107 import pdb 

108 

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

110 return pdb.Pdb 

111 

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

113 

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

115 return cls._wrapped_pdb_cls[1] 

116 

117 if usepdb_cls: 

118 modname, classname = usepdb_cls 

119 

120 try: 

121 __import__(modname) 

122 mod = sys.modules[modname] 

123 

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

125 parts = classname.split(".") 

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

127 for part in parts[1:]: 

128 pdb_cls = getattr(pdb_cls, part) 

129 except Exception as exc: 

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

131 raise UsageError( 

132 "--pdbcls: could not import {!r}: {}".format(value, exc) 

133 ) from exc 

134 else: 

135 import pdb 

136 

137 pdb_cls = pdb.Pdb 

138 

139 wrapped_cls = cls._get_pdb_wrapper_class(pdb_cls, capman) 

140 cls._wrapped_pdb_cls = (usepdb_cls, wrapped_cls) 

141 return wrapped_cls 

142 

143 @classmethod 

144 def _get_pdb_wrapper_class(cls, pdb_cls, capman: "CaptureManager"): 

145 import _pytest.config 

146 

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

148 # inheritance like this. 

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

150 _pytest_capman = capman 

151 _continued = False 

152 

153 def do_debug(self, arg): 

154 cls._recursive_debug += 1 

155 ret = super().do_debug(arg) 

156 cls._recursive_debug -= 1 

157 return ret 

158 

159 def do_continue(self, arg): 

160 ret = super().do_continue(arg) 

161 if cls._recursive_debug == 0: 

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

163 tw.line() 

164 

165 capman = self._pytest_capman 

166 capturing = pytestPDB._is_capturing(capman) 

167 if capturing: 

168 if capturing == "global": 

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

170 else: 

171 tw.sep( 

172 ">", 

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

174 % capturing, 

175 ) 

176 capman.resume() 

177 else: 

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

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

180 self._continued = True 

181 return ret 

182 

183 do_c = do_cont = do_continue 

184 

185 def do_quit(self, arg): 

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

187 

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

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

190 whole pytest run, and adjust the report etc. 

191 """ 

192 ret = super().do_quit(arg) 

193 

194 if cls._recursive_debug == 0: 

195 outcomes.exit("Quitting debugger") 

196 

197 return ret 

198 

199 do_q = do_quit 

200 do_exit = do_quit 

201 

202 def setup(self, f, tb): 

203 """Suspend on setup(). 

204 

205 Needed after do_continue resumed, and entering another 

206 breakpoint again. 

207 """ 

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

209 if not ret and self._continued: 

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

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

212 if self._pytest_capman: 

213 self._pytest_capman.suspend_global_capture(in_=True) 

214 return ret 

215 

216 def get_stack(self, f, t): 

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

218 if f is None: 

219 # Find last non-hidden frame. 

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

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

222 i -= 1 

223 return stack, i 

224 

225 return PytestPdbWrapper 

226 

227 @classmethod 

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

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

230 import _pytest.config 

231 

232 if cls._pluginmanager is not None: 

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

234 else: 

235 capman = None 

236 if capman: 

237 capman.suspend(in_=True) 

238 

239 if cls._config: 

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

241 tw.line() 

242 

243 if cls._recursive_debug == 0: 

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

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

246 if header is not None: 

247 tw.sep(">", header) 

248 else: 

249 capturing = cls._is_capturing(capman) 

250 if capturing == "global": 

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

252 elif capturing: 

253 tw.sep( 

254 ">", 

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

256 % (method, capturing), 

257 ) 

258 else: 

259 tw.sep(">", "PDB {}".format(method)) 

260 

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

262 

263 if cls._pluginmanager: 

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

265 return _pdb 

266 

267 @classmethod 

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

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

270 frame = sys._getframe().f_back 

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

272 _pdb.set_trace(frame) 

273 

274 

275class PdbInvoke: 

276 def pytest_exception_interact( 

277 self, node: Node, call: "CallInfo", report: BaseReport 

278 ) -> None: 

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

280 if capman: 

281 capman.suspend_global_capture(in_=True) 

282 out, err = capman.read_global_capture() 

283 sys.stdout.write(out) 

284 sys.stdout.write(err) 

285 assert call.excinfo is not None 

286 _enter_pdb(node, call.excinfo, report) 

287 

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

289 tb = _postmortem_traceback(excinfo) 

290 post_mortem(tb) 

291 

292 

293class PdbTrace: 

294 @hookimpl(hookwrapper=True) 

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

296 wrap_pytest_function_for_tracing(pyfuncitem) 

297 yield 

298 

299 

300def wrap_pytest_function_for_tracing(pyfuncitem): 

301 """Changes the python function object of the given Function item by a wrapper which actually 

302 enters pdb before calling the python function itself, effectively leaving the user 

303 in the pdb prompt in the first statement of the function. 

304 """ 

305 _pdb = pytestPDB._init_pdb("runcall") 

306 testfunction = pyfuncitem.obj 

307 

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

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

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

311 @functools.wraps(testfunction) 

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

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

314 _pdb.runcall(func) 

315 

316 pyfuncitem.obj = wrapper 

317 

318 

319def maybe_wrap_pytest_function_for_tracing(pyfuncitem): 

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

321 the command line""" 

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

323 wrap_pytest_function_for_tracing(pyfuncitem) 

324 

325 

326def _enter_pdb( 

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

328) -> BaseReport: 

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

330 # because this seems to avoid some encoding related troubles 

331 # for not completely clear reasons. 

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

333 tw.line() 

334 

335 showcapture = node.config.option.showcapture 

336 

337 for sectionname, content in ( 

338 ("stdout", rep.capstdout), 

339 ("stderr", rep.capstderr), 

340 ("log", rep.caplog), 

341 ): 

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

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

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

345 content = content[:-1] 

346 tw.line(content) 

347 

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

349 rep.toterminal(tw) 

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

351 tb = _postmortem_traceback(excinfo) 

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

353 post_mortem(tb) 

354 return rep 

355 

356 

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

358 from doctest import UnexpectedException 

359 

360 if isinstance(excinfo.value, UnexpectedException): 

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

362 # Use the underlying exception instead: 

363 return excinfo.value.exc_info[2] 

364 elif isinstance(excinfo.value, ConftestImportFailure): 

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

366 # Use the underlying exception instead: 

367 return excinfo.value.excinfo[2] 

368 else: 

369 assert excinfo._excinfo is not None 

370 return excinfo._excinfo[2] 

371 

372 

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

374 p = pytestPDB._init_pdb("post_mortem") 

375 p.reset() 

376 p.interaction(None, t) 

377 if p.quitting: 

378 outcomes.exit("Quitting debugger")