PyQt excepthook

Today I learned the hard way that exception tracebacks are sometimes cut off by PyQt. Say you have a Python function f() that calls a Qt function g() which in turn ends up calling another Python function of yours, h(). If an error happens in h(), you'd expect to get the following traceback:

Traceback (most recent call last):
  File ..., line ..., in f()
  File ..., line ..., in g()
  File ..., line ..., in h()

Instead, you only get:

Traceback (most recent call last):
  File ..., line ..., in g()
  File ..., line ..., in h()

The fact that f() is missing can sometimes make debugging very difficult.

The following code can be used to reproduce the problem:

from PyQt5.QtWidgets import *
from PyQt5.QtCore import Qt

class Window(QWidget):
    def __init__(self):
        b1 = QPushButton('1')
        b2 = QPushButton('2')
        layout = QVBoxLayout(self)
    def f1(self, _):
    def f2(self, _):
    def inputMethodQuery(self, query):
        if query == Qt.ImCursorPosition:
            return super().inputMethodQuery(query) # Call 'g()'
    def h(self):
        raise Exception()

app = QApplication([])
window = Window()

When you run it, you get the following app:

PyQt sample app, with two buttons labelled 1 and 2

Clicking on any of the two buttons results in the error below. Note how we don't get any indication as to which button was clicked:

Traceback (most recent call last):
  File "<stdin>", line 19, in inputMethodQuery
  File ""<stdin>", line 21, in h

If however you also type in the following code:

import sys
import traceback

from collections import namedtuple

def excepthook(exc_type, exc_value, exc_tb):
    enriched_tb = _add_missing_frames(exc_tb) if exc_tb else exc_tb
	# Note: sys.__excepthook__(...) would not work here.
	# We need to use print_exception(...):
    traceback.print_exception(exc_type, exc_value, enriched_tb)

def _add_missing_frames(tb):
    result = fake_tb(tb.tb_frame, tb.tb_lasti, tb.tb_lineno, tb.tb_next)
    frame = tb.tb_frame.f_back
    while frame:
        result = fake_tb(frame, frame.f_lasti, frame.f_lineno, result)
        frame = frame.f_back
    return result

fake_tb = namedtuple(
    'fake_tb', ('tb_frame', 'tb_lasti', 'tb_lineno', 'tb_next')

sys.excepthook = excepthook

Then you get:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 13, in f1
  File "<stdin>", line 18, in inputMethodQuery
  File "<stdin>", line 19, in inputMethodQuery
  File "<stdin>", line 21, in h

Note how the extra output indicates which button was clicked.

This requires further explanation. The reason why Qt's inputMethodQuery(...) was used is that it is a virtual function. This lets us override it from Python. When you look at Qt's source code, you find that inputMethodQuery(ImAnchorPosition) results in a recursive call to inputMethodQuery(ImCursorPosition). So, if you think of ImAnchorPosition as our g(), then the code mirrors the structure f() -> g() -> h() described above.

When an exception occurs in Python, sys.excepthook(...) is called with an exc_tb parameter. This parameter contains the information for each of the lines in the Tracebacks shown above. The reason why the first version of our code did not include f() in the traceback was that it did not appear in exc_tb.

To fix the problem, our additional excepthook code above creates a "fake" traceback that includes the missing entries. Fortunately, the necessary information is available in the .tb_frame property of the original traceback. Finally, the default sys.__excepthook__(...) does not work with our fake data, so we need to call traceback.print_exception(...) instead.

If you found this helpful, then you will likely also be interested in fbs. It's a framework that deals with common problems to let you create PyQt apps in minutes, not months. At the time of this writing, it has 1400 stars on GitHub. Check it out!

Michael started fman in 2016, convinced that we deserve a better file manager. fman's launch in 2017 was a huge success. But despite full-time work, it only makes $350 per month. The goal for 2018 is to fix this.