Python: debug unraisable exceptions with Rich

A pleiosaur, an ancient exception because it’s not a dinosaur.

Take this Python class:

class Widget:
    def __del__(self):
        1 / 0

It implements a __del__ method to customize when the object is deleted. The division by zero there will raise a ZeroDivisionError, a quick way to fail.

But let’s try triggering that error by adding the following:

w = Widget()
del w
print("Continued...")

When you run it, you’ll see:

$ python example.py
Exception ignored in: <function Widget.__del__ at 0x104a31d00>
Traceback (most recent call last):
  File "/.../example.py", line 3, in __del__
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero
Continued...

The exception is displayed with a strange “Exception ignored in” prefix. Then, the program continues as if no error occurred, printing “Continued”.

So, what’s going on?

Well, the __del__ method is a special case. Python does not allow any exceptions within this method to propagate. Instead, it only logs them as “unraisable”.

Python has this behaviour because an object’s deletion can happen at any time. Without an explicit del statement, the garbage collector deletes objects when they’re no longer referenced. Since the garbage collector can run whenever, even during shutdown, raising an exception there is unsafe.

Exceptions in other places are also unraisable, for example, when a generator catches the GeneratorExit exception. Python doesn’t have an official list of cases, but you can see them all by searching the source for PyErr_WriteUnraisable, which is the C function to log an unraisable exception.

Python logs unraisable exceptions through sys.unraisablehook(). You can swap this hook to change what is displayed. For example, pytest uses it to accumulate unraisable exceptions in its “warnings summary”:

$ pytest test_example.py
========================= test session starts =========================
...
collected 1 item

example.py .                                                    [100%]

========================== warnings summary ===========================
example.py::test_widget
  /.../unraisableexception.py:85: PytestUnraisableExceptionWarning: Exception ignored in: <function Widget.__del__ at 0x1052cc180>

  Traceback (most recent call last):
    File "/.../test_example.py", line 3, in __del__
      1 / 0
      ~~^~~
  ZeroDivisionError: division by zero

    warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================== 1 passed, 1 warning in 0.00s =====================

Debugging unraisable exceptions can be tricky. The stack trace is minimal, stopping with the method that Python called, such as __del__.

One tool that I’ve found useful is Rich, a terminal colourization library with a module for printing tracebacks. This can show the values of local variables for each frame in the stack. Here’s how you can define a custom sys.unraisablehook() that displays the traceback with Rich:

import sys

import rich
from rich.traceback import Traceback


def unraisablehook(unraisable):
    rich.print(
        Traceback.from_exception(
            unraisable.exc_type,
            unraisable.exc_value,
            unraisable.exc_traceback,
            show_locals=True,
        )
    )


sys.unraisablehook = unraisablehook

Let’s use it with a slightly modified example, with x and y local variables:

class Widget:
    def __del__(self):
        x = 1
        y = 0
        x / y


w = Widget()
del w
print("Continued...")

Running this now, we get:

Rich showing the unraisable exception with local variables.

The “locals” box shows pretty-printed values of the variables in that frame. In a more complex example, this extra information can be valuable.

Fin

May your exceptions be debuggable, even if they be unraisable,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: