Sharp edges

This approach will crash your program. One way or another. I don’t know how. Just be careful!

You can monkey-patch the Exception type itself, kind of — you can’t patch the object, since it’s immutable. Instead, you need to create a new class that mirrors it with your PDB hook inside, then patch that class into the place of Exception in the builtin module.

Then, you’ll also need to do the same for the entire exception tree, doing it module-by-module, in order to catch all types of exceptions which may already be imported and live objects in the process.

There’s a few things important to note when using this approach:

  • You are NOT in a proper pdb.pm() / pdb.post_mortem() frame here! You are in a pdb.set_trace() that just happened to land within Exception.__new__.
    • But you absolutely should act like you are!
  • Typically, the first thing you should do is up to go up one frame to where the exception is actually being thrown.
  • Don’t try to continue execution — you’ll just throw the exception, unwind the stack, and everything dies.
  • I don’t know how to safely undo this. Once you’re done debugging, just let the program die and relaunch the whole process.
#!python
# -*- coding: utf-8 -*-
 
"""
Super exception hook 9000.
"""
 
import builtins
import pdb
import re
import sys
import traceback
import warnings
 
import testmod
 
_ORIGINAL_EXCEPTION = Exception
_DONT_PATCH = [ "StopIteration", "MemoryError", "PatchedExc", "PatchedSub",
               "BdbQuit", "Warning" ]
 
def stdlib_antiexc_guards():
    indent = "    "
    for i in range(10):
        re.compile(f"(?m)^{indent*i}")
    sys.ps1 = ">>> "
    sys.ps2 = "... "
    warnings.simplefilter("ignore", (SyntaxWarning, DeprecationWarning))
    traceback._extract_caret_anchors_from_line_segment = lambda s: None
 
class PatchedExc(_ORIGINAL_EXCEPTION):
 
    def __new__(cls, *args):
        inst = _ORIGINAL_EXCEPTION.__new__(cls, *args)
        print("In patched __new__")
        pdb.set_trace()
        return inst
 
def install_hook_subclasses(cls, level):
    indent = "    " * level
    indentm1 = "    " * (level - 1)
 
    subs = cls.__subclasses__()
 
    if len(subs):
        print(f"{indentm1}recurse {cls}")
 
    for sub in subs:
 
        if sub.__name__ in _DONT_PATCH:
            print(f"{indent}skip {sub.__name__}; blacklist")
            continue
        if sub.__module__ not in sys.modules:
            print(f"{indent}skip {sub.__name__}; not in sys.modules")
            continue
 
        orig_bases = sub.__bases__
        new_bases = []
        for orig in orig_bases:
            mod = sys.modules[orig.__module__]
            new = getattr(mod, orig.__name__)
            new_bases.append(new)
 
        class PatchedSub(*new_bases[:-1], PatchedExc):
            __name__ = sub.__name__
            __qualname__ = sub.__qualname__
            __module__ = sub.__module__
            pass
        
        namespace = sys.modules[sub.__module__]
        namespace.__setattr__(sub.__name__, PatchedSub)
        print(f"{indent}patch {sub.__module__}.{sub.__name__}")
 
        install_hook_subclasses(sub, level + 1)
 
def install_hook():
    stdlib_antiexc_guards()
    builtins.Exception = PatchedExc
    install_hook_subclasses(_ORIGINAL_EXCEPTION, 1)
 
 
def main():
    install_hook()
    print('\nInstalled patched exceptions\n')
    testmod.main()
 
if __name__ == '__main__':
    main()

Technical notes (“wtf was that??“):

  1. _DONT_PATCH:
    • Exceptions you don’t want to patch. Add names to this list if you want to skip custom types.
    • At minimum, keep StopIteration, PatchedExc, PatchedSub, BdbQuit, and Warning.
  2. install_hook()
    • Main entry point to install hooks. This is what you should call in your “I would like to set debugger hooks, please” code.
  3. install_hook_subclasses
    • Recurses the exception hierarchy, patching subclasses of Exception and overwriting them in their respective modules. This is the most important part.
  4. stdlib_antiexc_guards
    • This function monkey-patches various standard library pieces to prevent low-level internal try/catch loops from tripping an exception. Because we’re patching all exceptions, even the caught ones, any try/catch loop that occurs needs to not meet the error conditions.
    • This function has to be called before any exception patching takes place — hence the first thing in the install_hook() function.
    • If your module uses a lot of regular expressions, you may need to increase the re._MAXCACHE (default 512) and re._MAXCACHE2 (default 256) sizes.

You can test this out with the accompanying testmod.py:

#!python
# -*- coding: utf-8 -*-
 
"""
Test module for catching exceptions.
"""
 
import random
import sys
import traceback
 
class CustomException(ValueError):
    pass
 
def foo():
    guessme = random.randint(1, 10)
    try:
        raise CustomException("Didn't catch at all!")
    except Exception:
        print("Hit normal exception handler in try-catch")
 
def bar():
    raise CustomException("Unhandled exception")
 
def ovrd_excepthook(typ, value, tb):
    traceback.print_tb(tb)
    import pdb; pdb.set_trace()
    print("Hit sys.excepthook")
 
def install_excepthook():
    sys.excepthook = ovrd_excepthook
 
def uninstall_excepthook():
    sys.excepthook = sys.__excepthook__
 
def main():
    print("Installing excepthook")
    install_excepthook()
    print("Caught exc with excepthook set")
    foo()
    print("Uninstalling excepthook")
    uninstall_excepthook()
    print("Caught exc with excepthook unset")
    foo()
    print("Uncaught exc with excepthook unset")
    bar()
    print("Done")
 
if __name__ == '__main__':
    main()

See also

I got this idea from this SO answer: https://stackoverflow.com/a/1029619 . Thanks, Cosmin Stejerean!