diff --git a/devtools/debug.py b/devtools/debug.py index 9438595..d7e471c 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -2,7 +2,7 @@ import sys from collections.abc import Generator from types import FrameType -from typing import Any, TextIO +from typing import Any, TextIO, cast from .ansi import sformat from .prettier import PrettyFormat @@ -133,9 +133,19 @@ def format(self, *args: Any, frame_depth_: int = 2, **kwargs: Any) -> DebugOutpu return self._process(args, kwargs, frame_depth_) def breakpoint(self) -> None: + """ + Start the IPython debugger if IPython is installed, otherwise `pdb`. + """ import pdb # noqa: T100 - pdb.Pdb(skip=['devtools.*']).set_trace() + pdb_class: type[pdb.Pdb] + try: + from IPython.terminal.debugger import TerminalPdb # pyright: ignore[reportMissingImports, reportUnknownVariableType] + except ImportError: + pdb_class = pdb.Pdb + else: + pdb_class = cast('type[pdb.Pdb]', TerminalPdb) + pdb_class(skip=['devtools.*']).set_trace() def timer(self, name: str | None = None, *, verbose: bool = True, file: TextIO | None = None, dp: int = 3) -> Timer: return Timer(name=name, verbose=verbose, file=file, dp=dp) diff --git a/docs/usage.md b/docs/usage.md index 05a0978..91cf289 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -48,7 +48,7 @@ The debug namespace includes a number of other useful functions: * `debug.format()` same as calling `debug()` but returns a `DebugOutput` rather than printing the output * `debug.timer()` returns an instance of *devtool's* `Timer` class suitable for timing code execution -* `debug.breakpoint()` introduces a breakpoint using `pdb` +* `debug.breakpoint()` introduces a breakpoint using IPython's debugger if IPython is installed, otherwise `pdb` ```py --8<-- "examples/other.py" diff --git a/tests/test_main.py b/tests/test_main.py index b32ca8f..deab352 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import re import sys +import types from collections.abc import Generator from pathlib import Path from subprocess import run @@ -248,13 +249,36 @@ def test_inspect_error(mocker: MockerFixture): assert str(v) == ":0 (error parsing code, call stack too shallow)\n 'x' (str) len=1" -def test_breakpoint(mocker: MockerFixture): - # not much else we can do here +def test_breakpoint_pdb(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + # a `None` entry in sys.modules makes the import raise ImportError whether or not IPython is installed + monkeypatch.setitem(sys.modules, 'IPython.terminal.debugger', None) mocked_set_trace = mocker.patch('pdb.Pdb.set_trace') debug.breakpoint() assert mocked_set_trace.called +def test_breakpoint_ipython(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + ipython_set_trace = mocker.MagicMock() + constructed_with: list[list[str]] = [] + + class FakeTerminalPdb: + def __init__(self, skip: list[str]) -> None: + constructed_with.append(skip) + + set_trace = ipython_set_trace + + fake_module = types.ModuleType('IPython.terminal.debugger') + fake_module.TerminalPdb = FakeTerminalPdb # pyright: ignore[reportAttributeAccessIssue] + monkeypatch.setitem(sys.modules, 'IPython.terminal.debugger', fake_module) + mocked_pdb_set_trace = mocker.patch('pdb.Pdb.set_trace') + + debug.breakpoint() + + assert ipython_set_trace.called + assert constructed_with == [['devtools.*']] + assert not mocked_pdb_set_trace.called + + @pytest.mark.xfail( sys.platform == 'win32' and sys.version_info >= (3, 9), reason='see https://github.com/alexmojaki/executing/issues/27',