This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,21 +1,21 @@
# mypy: allow-untyped-defs
# ruff: noqa: T100
"""Interactive debugging with PDB, the Python Debugger."""
from __future__ import annotations
import argparse
from collections.abc import Callable
from collections.abc import Generator
import functools
import sys
import types
from typing import Any
import unittest
from typing import Any
from typing import Callable
from typing import Generator
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
from _pytest import outcomes
from _pytest._code import ExceptionInfo
from _pytest.capture import CaptureManager
from _pytest.config import Config
from _pytest.config import ConftestImportFailure
from _pytest.config import hookimpl
@@ -24,10 +24,13 @@ from _pytest.config.argparsing import Parser
from _pytest.config.exceptions import UsageError
from _pytest.nodes import Node
from _pytest.reports import BaseReport
from _pytest.runner import CallInfo
if TYPE_CHECKING:
from _pytest.capture import CaptureManager
from _pytest.runner import CallInfo
def _validate_usepdb_cls(value: str) -> tuple[str, str]:
def _validate_usepdb_cls(value: str) -> Tuple[str, str]:
"""Validate syntax of --pdbcls option."""
try:
modname, classname = value.split(":")
@@ -40,13 +43,13 @@ def _validate_usepdb_cls(value: str) -> tuple[str, str]:
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general")
group.addoption(
group._addoption(
"--pdb",
dest="usepdb",
action="store_true",
help="Start the interactive Python debugger on errors or KeyboardInterrupt",
)
group.addoption(
group._addoption(
"--pdbcls",
dest="usepdb_cls",
metavar="modulename:classname",
@@ -54,7 +57,7 @@ def pytest_addoption(parser: Parser) -> None:
help="Specify a custom interactive Python debugger for use with --pdb."
"For example: --pdbcls=IPython.terminal.debugger:TerminalPdb",
)
group.addoption(
group._addoption(
"--trace",
dest="trace",
action="store_true",
@@ -92,22 +95,22 @@ def pytest_configure(config: Config) -> None:
class pytestPDB:
"""Pseudo PDB that defers to the real pdb."""
_pluginmanager: PytestPluginManager | None = None
_config: Config | None = None
_saved: list[
tuple[Callable[..., None], PytestPluginManager | None, Config | None]
_pluginmanager: Optional[PytestPluginManager] = None
_config: Optional[Config] = None
_saved: List[
Tuple[Callable[..., None], Optional[PytestPluginManager], Optional[Config]]
] = []
_recursive_debug = 0
_wrapped_pdb_cls: tuple[type[Any], type[Any]] | None = None
_wrapped_pdb_cls: Optional[Tuple[Type[Any], Type[Any]]] = None
@classmethod
def _is_capturing(cls, capman: CaptureManager | None) -> str | bool:
def _is_capturing(cls, capman: Optional["CaptureManager"]) -> Union[str, bool]:
if capman:
return capman.is_capturing()
return False
@classmethod
def _import_pdb_cls(cls, capman: CaptureManager | None):
def _import_pdb_cls(cls, capman: Optional["CaptureManager"]):
if not cls._config:
import pdb
@@ -146,10 +149,12 @@ class pytestPDB:
return wrapped_cls
@classmethod
def _get_pdb_wrapper_class(cls, pdb_cls, capman: CaptureManager | None):
def _get_pdb_wrapper_class(cls, pdb_cls, capman: Optional["CaptureManager"]):
import _pytest.config
class PytestPdbWrapper(pdb_cls):
# Type ignored because mypy doesn't support "dynamic"
# inheritance like this.
class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc]
_pytest_capman = capman
_continued = False
@@ -159,9 +164,6 @@ class pytestPDB:
cls._recursive_debug -= 1
return ret
if hasattr(pdb_cls, "do_debug"):
do_debug.__doc__ = pdb_cls.do_debug.__doc__
def do_continue(self, arg):
ret = super().do_continue(arg)
if cls._recursive_debug == 0:
@@ -177,7 +179,8 @@ class pytestPDB:
else:
tw.sep(
">",
f"PDB continue (IO-capturing resumed for {capturing})",
"PDB continue (IO-capturing resumed for %s)"
% capturing,
)
assert capman is not None
capman.resume()
@@ -188,17 +191,15 @@ class pytestPDB:
self._continued = True
return ret
if hasattr(pdb_cls, "do_continue"):
do_continue.__doc__ = pdb_cls.do_continue.__doc__
do_c = do_cont = do_continue
def do_quit(self, arg):
# Raise Exit outcome when quit command is used in pdb.
#
# This is a bit of a hack - it would be better if BdbQuit
# could be handled, but this would require to wrap the
# whole pytest run, and adjust the report etc.
"""Raise Exit outcome when quit command is used in pdb.
This is a bit of a hack - it would be better if BdbQuit
could be handled, but this would require to wrap the
whole pytest run, and adjust the report etc.
"""
ret = super().do_quit(arg)
if cls._recursive_debug == 0:
@@ -206,9 +207,6 @@ class pytestPDB:
return ret
if hasattr(pdb_cls, "do_quit"):
do_quit.__doc__ = pdb_cls.do_quit.__doc__
do_q = do_quit
do_exit = do_quit
@@ -243,7 +241,7 @@ class pytestPDB:
import _pytest.config
if cls._pluginmanager is None:
capman: CaptureManager | None = None
capman: Optional[CaptureManager] = None
else:
capman = cls._pluginmanager.getplugin("capturemanager")
if capman:
@@ -265,7 +263,8 @@ class pytestPDB:
elif capturing:
tw.sep(
">",
f"PDB {method} (IO-capturing turned off for {capturing})",
"PDB %s (IO-capturing turned off for %s)"
% (method, capturing),
)
else:
tw.sep(">", f"PDB {method}")
@@ -286,7 +285,7 @@ class pytestPDB:
class PdbInvoke:
def pytest_exception_interact(
self, node: Node, call: CallInfo[Any], report: BaseReport
self, node: Node, call: "CallInfo[Any]", report: BaseReport
) -> None:
capman = node.config.pluginmanager.getplugin("capturemanager")
if capman:
@@ -300,18 +299,18 @@ class PdbInvoke:
_enter_pdb(node, call.excinfo, report)
def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None:
exc_or_tb = _postmortem_exc_or_tb(excinfo)
post_mortem(exc_or_tb)
tb = _postmortem_traceback(excinfo)
post_mortem(tb)
class PdbTrace:
@hookimpl(wrapper=True)
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]:
@hookimpl(hookwrapper=True)
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]:
wrap_pytest_function_for_tracing(pyfuncitem)
return (yield)
yield
def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def wrap_pytest_function_for_tracing(pyfuncitem):
"""Change the Python function object of the given Function item by a
wrapper which actually enters pdb before calling the python function
itself, effectively leaving the user in the pdb prompt in the first
@@ -323,14 +322,14 @@ def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
# python < 3.7.4) runcall's first param is `func`, which means we'd get
# an exception if one of the kwargs to testfunction was called `func`.
@functools.wraps(testfunction)
def wrapper(*args, **kwargs) -> None:
def wrapper(*args, **kwargs):
func = functools.partial(testfunction, *args, **kwargs)
_pdb.runcall(func)
pyfuncitem.obj = wrapper
def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def maybe_wrap_pytest_function_for_tracing(pyfuncitem):
"""Wrap the given pytestfunct item for tracing support if --trace was given in
the command line."""
if pyfuncitem.config.getvalue("trace"):
@@ -340,7 +339,7 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def _enter_pdb(
node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport
) -> BaseReport:
# XXX we reuse the TerminalReporter's terminalwriter
# XXX we re-use the TerminalReporter's terminalwriter
# because this seems to avoid some encoding related troubles
# for not completely clear reasons.
tw = node.config.pluginmanager.getplugin("terminalreporter")._tw
@@ -362,46 +361,31 @@ def _enter_pdb(
tw.sep(">", "traceback")
rep.toterminal(tw)
tw.sep(">", "entering PDB")
tb_or_exc = _postmortem_exc_or_tb(excinfo)
tb = _postmortem_traceback(excinfo)
rep._pdbshown = True # type: ignore[attr-defined]
post_mortem(tb_or_exc)
post_mortem(tb)
return rep
def _postmortem_exc_or_tb(
excinfo: ExceptionInfo[BaseException],
) -> types.TracebackType | BaseException:
def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType:
from doctest import UnexpectedException
get_exc = sys.version_info >= (3, 13)
if isinstance(excinfo.value, UnexpectedException):
# A doctest.UnexpectedException is not useful for post_mortem.
# Use the underlying exception instead:
underlying_exc = excinfo.value
if get_exc:
return underlying_exc.exc_info[1]
return underlying_exc.exc_info[2]
return excinfo.value.exc_info[2]
elif isinstance(excinfo.value, ConftestImportFailure):
# A config.ConftestImportFailure is not useful for post_mortem.
# Use the underlying exception instead:
cause = excinfo.value.cause
if get_exc:
return cause
assert cause.__traceback__ is not None
return cause.__traceback__
return excinfo.value.excinfo[2]
else:
assert excinfo._excinfo is not None
if get_exc:
return excinfo._excinfo[1]
return excinfo._excinfo[2]
def post_mortem(tb_or_exc: types.TracebackType | BaseException) -> None:
def post_mortem(t: types.TracebackType) -> None:
p = pytestPDB._init_pdb("post_mortem")
p.reset()
p.interaction(None, tb_or_exc)
p.interaction(None, t)
if p.quitting:
outcomes.exit("Quitting debugger")