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