1"""Interactive debugging with PDB, the Python Debugger.""" 2import argparse 3import functools 4import sys 5import types 6from typing import Any 7from typing import Callable 8from typing import Generator 9from typing import List 10from typing import Optional 11from typing import Tuple 12from typing import Union 13 14from _pytest import outcomes 15from _pytest._code import ExceptionInfo 16from _pytest.compat import TYPE_CHECKING 17from _pytest.config import Config 18from _pytest.config import ConftestImportFailure 19from _pytest.config import hookimpl 20from _pytest.config import PytestPluginManager 21from _pytest.config.argparsing import Parser 22from _pytest.config.exceptions import UsageError 23from _pytest.nodes import Node 24from _pytest.reports import BaseReport 25 26if TYPE_CHECKING: 27 from typing import Type 28 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 "{!r} is not in the format 'modname:classname'".format(value) 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="start a custom interactive Python debugger on errors. " 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._cleanup.append(fin) 93 94 95class pytestPDB: 96 """Pseudo PDB that defers to the real pdb.""" 97 98 _pluginmanager = None # type: Optional[PytestPluginManager] 99 _config = None # type: Config 100 _saved = ( 101 [] 102 ) # type: List[Tuple[Callable[..., None], Optional[PytestPluginManager], Config]] 103 _recursive_debug = 0 104 _wrapped_pdb_cls = None # type: Optional[Tuple[Type[Any], Type[Any]]] 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 "--pdbcls: could not import {!r}: {}".format(value, 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 tw = _pytest.config.create_terminal_writer(cls._config) 171 tw.line() 172 173 capman = self._pytest_capman 174 capturing = pytestPDB._is_capturing(capman) 175 if capturing: 176 if capturing == "global": 177 tw.sep(">", "PDB continue (IO-capturing resumed)") 178 else: 179 tw.sep( 180 ">", 181 "PDB continue (IO-capturing resumed for %s)" 182 % capturing, 183 ) 184 assert capman is not None 185 capman.resume() 186 else: 187 tw.sep(">", "PDB continue") 188 assert cls._pluginmanager is not None 189 cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self) 190 self._continued = True 191 return ret 192 193 do_c = do_cont = do_continue 194 195 def do_quit(self, arg): 196 """Raise Exit outcome when quit command is used in pdb. 197 198 This is a bit of a hack - it would be better if BdbQuit 199 could be handled, but this would require to wrap the 200 whole pytest run, and adjust the report etc. 201 """ 202 ret = super().do_quit(arg) 203 204 if cls._recursive_debug == 0: 205 outcomes.exit("Quitting debugger") 206 207 return ret 208 209 do_q = do_quit 210 do_exit = do_quit 211 212 def setup(self, f, tb): 213 """Suspend on setup(). 214 215 Needed after do_continue resumed, and entering another 216 breakpoint again. 217 """ 218 ret = super().setup(f, tb) 219 if not ret and self._continued: 220 # pdb.setup() returns True if the command wants to exit 221 # from the interaction: do not suspend capturing then. 222 if self._pytest_capman: 223 self._pytest_capman.suspend_global_capture(in_=True) 224 return ret 225 226 def get_stack(self, f, t): 227 stack, i = super().get_stack(f, t) 228 if f is None: 229 # Find last non-hidden frame. 230 i = max(0, len(stack) - 1) 231 while i and stack[i][0].f_locals.get("__tracebackhide__", False): 232 i -= 1 233 return stack, i 234 235 return PytestPdbWrapper 236 237 @classmethod 238 def _init_pdb(cls, method, *args, **kwargs): 239 """Initialize PDB debugging, dropping any IO capturing.""" 240 import _pytest.config 241 242 if cls._pluginmanager is None: 243 capman = None # type: Optional[CaptureManager] 244 else: 245 capman = cls._pluginmanager.getplugin("capturemanager") 246 if capman: 247 capman.suspend(in_=True) 248 249 if cls._config: 250 tw = _pytest.config.create_terminal_writer(cls._config) 251 tw.line() 252 253 if cls._recursive_debug == 0: 254 # Handle header similar to pdb.set_trace in py37+. 255 header = kwargs.pop("header", None) 256 if header is not None: 257 tw.sep(">", header) 258 else: 259 capturing = cls._is_capturing(capman) 260 if capturing == "global": 261 tw.sep(">", "PDB {} (IO-capturing turned off)".format(method)) 262 elif capturing: 263 tw.sep( 264 ">", 265 "PDB %s (IO-capturing turned off for %s)" 266 % (method, capturing), 267 ) 268 else: 269 tw.sep(">", "PDB {}".format(method)) 270 271 _pdb = cls._import_pdb_cls(capman)(**kwargs) 272 273 if cls._pluginmanager: 274 cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb) 275 return _pdb 276 277 @classmethod 278 def set_trace(cls, *args, **kwargs) -> None: 279 """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing.""" 280 frame = sys._getframe().f_back 281 _pdb = cls._init_pdb("set_trace", *args, **kwargs) 282 _pdb.set_trace(frame) 283 284 285class PdbInvoke: 286 def pytest_exception_interact( 287 self, node: Node, call: "CallInfo[Any]", report: BaseReport 288 ) -> None: 289 capman = node.config.pluginmanager.getplugin("capturemanager") 290 if capman: 291 capman.suspend_global_capture(in_=True) 292 out, err = capman.read_global_capture() 293 sys.stdout.write(out) 294 sys.stdout.write(err) 295 assert call.excinfo is not None 296 _enter_pdb(node, call.excinfo, report) 297 298 def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None: 299 tb = _postmortem_traceback(excinfo) 300 post_mortem(tb) 301 302 303class PdbTrace: 304 @hookimpl(hookwrapper=True) 305 def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]: 306 wrap_pytest_function_for_tracing(pyfuncitem) 307 yield 308 309 310def wrap_pytest_function_for_tracing(pyfuncitem): 311 """Change the Python function object of the given Function item by a 312 wrapper which actually enters pdb before calling the python function 313 itself, effectively leaving the user in the pdb prompt in the first 314 statement of the function.""" 315 _pdb = pytestPDB._init_pdb("runcall") 316 testfunction = pyfuncitem.obj 317 318 # we can't just return `partial(pdb.runcall, testfunction)` because (on 319 # python < 3.7.4) runcall's first param is `func`, which means we'd get 320 # an exception if one of the kwargs to testfunction was called `func`. 321 @functools.wraps(testfunction) 322 def wrapper(*args, **kwargs): 323 func = functools.partial(testfunction, *args, **kwargs) 324 _pdb.runcall(func) 325 326 pyfuncitem.obj = wrapper 327 328 329def maybe_wrap_pytest_function_for_tracing(pyfuncitem): 330 """Wrap the given pytestfunct item for tracing support if --trace was given in 331 the command line.""" 332 if pyfuncitem.config.getvalue("trace"): 333 wrap_pytest_function_for_tracing(pyfuncitem) 334 335 336def _enter_pdb( 337 node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport 338) -> BaseReport: 339 # XXX we re-use the TerminalReporter's terminalwriter 340 # because this seems to avoid some encoding related troubles 341 # for not completely clear reasons. 342 tw = node.config.pluginmanager.getplugin("terminalreporter")._tw 343 tw.line() 344 345 showcapture = node.config.option.showcapture 346 347 for sectionname, content in ( 348 ("stdout", rep.capstdout), 349 ("stderr", rep.capstderr), 350 ("log", rep.caplog), 351 ): 352 if showcapture in (sectionname, "all") and content: 353 tw.sep(">", "captured " + sectionname) 354 if content[-1:] == "\n": 355 content = content[:-1] 356 tw.line(content) 357 358 tw.sep(">", "traceback") 359 rep.toterminal(tw) 360 tw.sep(">", "entering PDB") 361 tb = _postmortem_traceback(excinfo) 362 rep._pdbshown = True # type: ignore[attr-defined] 363 post_mortem(tb) 364 return rep 365 366 367def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType: 368 from doctest import UnexpectedException 369 370 if isinstance(excinfo.value, UnexpectedException): 371 # A doctest.UnexpectedException is not useful for post_mortem. 372 # Use the underlying exception instead: 373 return excinfo.value.exc_info[2] 374 elif isinstance(excinfo.value, ConftestImportFailure): 375 # A config.ConftestImportFailure is not useful for post_mortem. 376 # Use the underlying exception instead: 377 return excinfo.value.excinfo[2] 378 else: 379 assert excinfo._excinfo is not None 380 return excinfo._excinfo[2] 381 382 383def post_mortem(t: types.TracebackType) -> None: 384 p = pytestPDB._init_pdb("post_mortem") 385 p.reset() 386 p.interaction(None, t) 387 if p.quitting: 388 outcomes.exit("Quitting debugger") 389