1# -*- coding: utf-8 -*-
2""" interactive debugging with PDB, the Python Debugger. """
3from __future__ import absolute_import
4from __future__ import division
5from __future__ import print_function
6
7import argparse
8import pdb
9import sys
10from doctest import UnexpectedException
11
12from _pytest import outcomes
13from _pytest.config import hookimpl
14from _pytest.config.exceptions import UsageError
15
16
17def _validate_usepdb_cls(value):
18    """Validate syntax of --pdbcls option."""
19    try:
20        modname, classname = value.split(":")
21    except ValueError:
22        raise argparse.ArgumentTypeError(
23            "{!r} is not in the format 'modname:classname'".format(value)
24        )
25    return (modname, classname)
26
27
28def pytest_addoption(parser):
29    group = parser.getgroup("general")
30    group._addoption(
31        "--pdb",
32        dest="usepdb",
33        action="store_true",
34        help="start the interactive Python debugger on errors or KeyboardInterrupt.",
35    )
36    group._addoption(
37        "--pdbcls",
38        dest="usepdb_cls",
39        metavar="modulename:classname",
40        type=_validate_usepdb_cls,
41        help="start a custom interactive Python debugger on errors. "
42        "For example: --pdbcls=IPython.terminal.debugger:TerminalPdb",
43    )
44    group._addoption(
45        "--trace",
46        dest="trace",
47        action="store_true",
48        help="Immediately break when running each test.",
49    )
50
51
52def pytest_configure(config):
53    if config.getvalue("trace"):
54        config.pluginmanager.register(PdbTrace(), "pdbtrace")
55    if config.getvalue("usepdb"):
56        config.pluginmanager.register(PdbInvoke(), "pdbinvoke")
57
58    pytestPDB._saved.append(
59        (pdb.set_trace, pytestPDB._pluginmanager, pytestPDB._config)
60    )
61    pdb.set_trace = pytestPDB.set_trace
62    pytestPDB._pluginmanager = config.pluginmanager
63    pytestPDB._config = config
64
65    # NOTE: not using pytest_unconfigure, since it might get called although
66    #       pytest_configure was not (if another plugin raises UsageError).
67    def fin():
68        (
69            pdb.set_trace,
70            pytestPDB._pluginmanager,
71            pytestPDB._config,
72        ) = pytestPDB._saved.pop()
73
74    config._cleanup.append(fin)
75
76
77class pytestPDB(object):
78    """ Pseudo PDB that defers to the real pdb. """
79
80    _pluginmanager = None
81    _config = None
82    _saved = []
83    _recursive_debug = 0
84    _wrapped_pdb_cls = None
85
86    @classmethod
87    def _is_capturing(cls, capman):
88        if capman:
89            return capman.is_capturing()
90        return False
91
92    @classmethod
93    def _import_pdb_cls(cls, capman):
94        if not cls._config:
95            # Happens when using pytest.set_trace outside of a test.
96            return pdb.Pdb
97
98        usepdb_cls = cls._config.getvalue("usepdb_cls")
99
100        if cls._wrapped_pdb_cls and cls._wrapped_pdb_cls[0] == usepdb_cls:
101            return cls._wrapped_pdb_cls[1]
102
103        if usepdb_cls:
104            modname, classname = usepdb_cls
105
106            try:
107                __import__(modname)
108                mod = sys.modules[modname]
109
110                # Handle --pdbcls=pdb:pdb.Pdb (useful e.g. with pdbpp).
111                parts = classname.split(".")
112                pdb_cls = getattr(mod, parts[0])
113                for part in parts[1:]:
114                    pdb_cls = getattr(pdb_cls, part)
115            except Exception as exc:
116                value = ":".join((modname, classname))
117                raise UsageError(
118                    "--pdbcls: could not import {!r}: {}".format(value, exc)
119                )
120        else:
121            pdb_cls = pdb.Pdb
122
123        wrapped_cls = cls._get_pdb_wrapper_class(pdb_cls, capman)
124        cls._wrapped_pdb_cls = (usepdb_cls, wrapped_cls)
125        return wrapped_cls
126
127    @classmethod
128    def _get_pdb_wrapper_class(cls, pdb_cls, capman):
129        import _pytest.config
130
131        class PytestPdbWrapper(pdb_cls, object):
132            _pytest_capman = capman
133            _continued = False
134
135            def do_debug(self, arg):
136                cls._recursive_debug += 1
137                ret = super(PytestPdbWrapper, self).do_debug(arg)
138                cls._recursive_debug -= 1
139                return ret
140
141            def do_continue(self, arg):
142                ret = super(PytestPdbWrapper, self).do_continue(arg)
143                if cls._recursive_debug == 0:
144                    tw = _pytest.config.create_terminal_writer(cls._config)
145                    tw.line()
146
147                    capman = self._pytest_capman
148                    capturing = pytestPDB._is_capturing(capman)
149                    if capturing:
150                        if capturing == "global":
151                            tw.sep(">", "PDB continue (IO-capturing resumed)")
152                        else:
153                            tw.sep(
154                                ">",
155                                "PDB continue (IO-capturing resumed for %s)"
156                                % capturing,
157                            )
158                        capman.resume()
159                    else:
160                        tw.sep(">", "PDB continue")
161                cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self)
162                self._continued = True
163                return ret
164
165            do_c = do_cont = do_continue
166
167            def do_quit(self, arg):
168                """Raise Exit outcome when quit command is used in pdb.
169
170                This is a bit of a hack - it would be better if BdbQuit
171                could be handled, but this would require to wrap the
172                whole pytest run, and adjust the report etc.
173                """
174                ret = super(PytestPdbWrapper, self).do_quit(arg)
175
176                if cls._recursive_debug == 0:
177                    outcomes.exit("Quitting debugger")
178
179                return ret
180
181            do_q = do_quit
182            do_exit = do_quit
183
184            def setup(self, f, tb):
185                """Suspend on setup().
186
187                Needed after do_continue resumed, and entering another
188                breakpoint again.
189                """
190                ret = super(PytestPdbWrapper, self).setup(f, tb)
191                if not ret and self._continued:
192                    # pdb.setup() returns True if the command wants to exit
193                    # from the interaction: do not suspend capturing then.
194                    if self._pytest_capman:
195                        self._pytest_capman.suspend_global_capture(in_=True)
196                return ret
197
198            def get_stack(self, f, t):
199                stack, i = super(PytestPdbWrapper, self).get_stack(f, t)
200                if f is None:
201                    # Find last non-hidden frame.
202                    i = max(0, len(stack) - 1)
203                    while i and stack[i][0].f_locals.get("__tracebackhide__", False):
204                        i -= 1
205                return stack, i
206
207        return PytestPdbWrapper
208
209    @classmethod
210    def _init_pdb(cls, method, *args, **kwargs):
211        """ Initialize PDB debugging, dropping any IO capturing. """
212        import _pytest.config
213
214        if cls._pluginmanager is not None:
215            capman = cls._pluginmanager.getplugin("capturemanager")
216        else:
217            capman = None
218        if capman:
219            capman.suspend(in_=True)
220
221        if cls._config:
222            tw = _pytest.config.create_terminal_writer(cls._config)
223            tw.line()
224
225            if cls._recursive_debug == 0:
226                # Handle header similar to pdb.set_trace in py37+.
227                header = kwargs.pop("header", None)
228                if header is not None:
229                    tw.sep(">", header)
230                else:
231                    capturing = cls._is_capturing(capman)
232                    if capturing == "global":
233                        tw.sep(">", "PDB %s (IO-capturing turned off)" % (method,))
234                    elif capturing:
235                        tw.sep(
236                            ">",
237                            "PDB %s (IO-capturing turned off for %s)"
238                            % (method, capturing),
239                        )
240                    else:
241                        tw.sep(">", "PDB %s" % (method,))
242
243        _pdb = cls._import_pdb_cls(capman)(**kwargs)
244
245        if cls._pluginmanager:
246            cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb)
247        return _pdb
248
249    @classmethod
250    def set_trace(cls, *args, **kwargs):
251        """Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing."""
252        frame = sys._getframe().f_back
253        _pdb = cls._init_pdb("set_trace", *args, **kwargs)
254        _pdb.set_trace(frame)
255
256
257class PdbInvoke(object):
258    def pytest_exception_interact(self, node, call, report):
259        capman = node.config.pluginmanager.getplugin("capturemanager")
260        if capman:
261            capman.suspend_global_capture(in_=True)
262            out, err = capman.read_global_capture()
263            sys.stdout.write(out)
264            sys.stdout.write(err)
265        _enter_pdb(node, call.excinfo, report)
266
267    def pytest_internalerror(self, excrepr, excinfo):
268        tb = _postmortem_traceback(excinfo)
269        post_mortem(tb)
270
271
272class PdbTrace(object):
273    @hookimpl(hookwrapper=True)
274    def pytest_pyfunc_call(self, pyfuncitem):
275        _test_pytest_function(pyfuncitem)
276        yield
277
278
279def _test_pytest_function(pyfuncitem):
280    _pdb = pytestPDB._init_pdb("runcall")
281    testfunction = pyfuncitem.obj
282    pyfuncitem.obj = _pdb.runcall
283    if "func" in pyfuncitem._fixtureinfo.argnames:  # pragma: no branch
284        raise ValueError("--trace can't be used with a fixture named func!")
285    pyfuncitem.funcargs["func"] = testfunction
286    new_list = list(pyfuncitem._fixtureinfo.argnames)
287    new_list.append("func")
288    pyfuncitem._fixtureinfo.argnames = tuple(new_list)
289
290
291def _enter_pdb(node, excinfo, rep):
292    # XXX we re-use the TerminalReporter's terminalwriter
293    # because this seems to avoid some encoding related troubles
294    # for not completely clear reasons.
295    tw = node.config.pluginmanager.getplugin("terminalreporter")._tw
296    tw.line()
297
298    showcapture = node.config.option.showcapture
299
300    for sectionname, content in (
301        ("stdout", rep.capstdout),
302        ("stderr", rep.capstderr),
303        ("log", rep.caplog),
304    ):
305        if showcapture in (sectionname, "all") and content:
306            tw.sep(">", "captured " + sectionname)
307            if content[-1:] == "\n":
308                content = content[:-1]
309            tw.line(content)
310
311    tw.sep(">", "traceback")
312    rep.toterminal(tw)
313    tw.sep(">", "entering PDB")
314    tb = _postmortem_traceback(excinfo)
315    rep._pdbshown = True
316    post_mortem(tb)
317    return rep
318
319
320def _postmortem_traceback(excinfo):
321    if isinstance(excinfo.value, UnexpectedException):
322        # A doctest.UnexpectedException is not useful for post_mortem.
323        # Use the underlying exception instead:
324        return excinfo.value.exc_info[2]
325    else:
326        return excinfo._excinfo[2]
327
328
329def post_mortem(t):
330    p = pytestPDB._init_pdb("post_mortem")
331    p.reset()
332    p.interaction(None, t)
333    if p.quitting:
334        outcomes.exit("Quitting debugger")
335