Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Port and refactoring of gtkexcepthook.py for PyQt 5.x and Python 3.4+
#!/usr/bin/env python3
"""Qt5 Exception Handler for Python 3.4+
(c) 2019 Stephan Sokolow
Instructions:
import qtexcepthook
app = QApplication([])
QtExceptHook(app).enable()
(See the example code at the bottom for more advanced use)
Adapted from gtkexcepthook.py:
(c) 2003 Gustavo J A M Carneiro gjc at inescporto.pt
2004-2005 Filip Van Raemdonck
2009, 2011, 2019 Stephan Sokolow
http://www.daa.com.au/pipermail/pygtk/2003-August/005775.html
Message-ID: <1062087716.1196.5.camel@emperor.homelinux.net>
"The license is whatever you want."
Changes made to the Van Raemdonck version before porting off GTK+ 2.x:
- Switched from auto-enable to gtkexcepthook.enable() to silence PyFlakes false
positives. (Borrowed naming convention from cgitb)
- Split out traceback import to silence PyFlakes warning.
Changes made since the port to PyQt 5.x:
- Heavily refactored for maintainability
- Reworked the traceback formatting to be more readable
- Split out the email support into an easily-replaced callback
- Integrated Qt's QApplication.applicationName() into the example e-mails.
@todo: Finish refactoring this to meet my code formatting and clarity standards
@todo: Confirm there isn't any other generally-applicable information that
could be included in the debugging dump.
@todo: Once I bump the minimum supported version up to 3.5, try throwing out
all the traceback-building machinery and replacing it with a use of
TracebackException(..., capture_locals=True)
"""
__author__ = "Stephan Sokolow; Filip Van Raemdonck; Gustavo J A M Carneiro"
__license__ = "Public Domain"
import enum, inspect, linecache, logging, pydoc, textwrap, tokenize, keyword
import sys
import traceback
from io import StringIO
from gettext import gettext as _
from pprint import pformat
from smtplib import SMTP
from PyQt5.QtWidgets import ( # pylint: disable=no-name-in-module
QApplication, QMessageBox)
TR_ID = "excepthook"
log = logging.getLogger(__name__)
class Scope(enum.Enum):
"""The scope of a variable looked up by ``lookup``"""
Builtin = 1
Global = 2
Local = 3
NONE = None
def __str__(self):
if self.value is Scope.NONE:
return '?'
else:
return str(self.name)[0].upper()
def lookup(name, frame, lcls):
"""Find the value for a given name in the given frame"""
if name in lcls:
return Scope.Local, lcls[name]
elif name in frame.f_globals:
return Scope.Global, frame.f_globals[name]
elif '__builtins__' in frame.f_globals:
builtins = frame.f_globals['__builtins__']
if isinstance(builtins, dict):
if name in builtins:
return Scope.Builtin, builtins[name]
elif hasattr(builtins, name):
return Scope.Builtin, getattr(builtins, name)
return Scope.NONE, None
def tokenize_frame(frame_rec):
"""Generator which produces a lexical token stream from a frame record"""
fname, lineno = frame_rec[1:3]
lineno_mut = [lineno]
def readline(*args):
"""Callback to work around tokenize.generate_tokens's API"""
if args:
log.debug("readline with args: %r", args)
try:
return linecache.getline(fname, lineno_mut[0])
finally:
lineno_mut[0] += 1
for token_tup in tokenize.generate_tokens(readline):
yield token_tup
def gather_vars(frame_rec, local_vars):
frame = frame_rec[0]
all_vars, prev, name, scope = {}, None, '', None
for token_tuple in tokenize_frame(frame_rec):
t_type, t_str = token_tuple[0:2]
if t_type == tokenize.NAME and t_str not in keyword.kwlist:
if not name:
assert not name and not scope
scope, val = lookup(t_str, frame, local_vars)
name = t_str
elif name[-1] == '.':
try:
val = getattr(prev, t_str)
except AttributeError:
# XXX skip the rest of this identifier only
break
name += t_str
try:
if val:
prev = val
except:
log.debug(' found %s name %s val %s in %s for token %s',
scope, name, val, prev, t_str)
elif t_str == '.':
if prev:
name += '.'
else:
if name:
all_vars[name] = (scope, prev)
prev, name, scope = None, '', None
if t_type == tokenize.NEWLINE:
break
return all_vars
# ---
def analyse(exctyp, value, tracebk, context_lines=3, max_width=80):
"""Generate a traceback, including the contents of variables"""
trace = StringIO()
frame_records = inspect.getinnerframes(tracebk, context_lines)
frame_wrapper = textwrap.TextWrapper(width=max_width,
initial_indent='\n ', subsequent_indent=' ' * 4)
trace.write('Traceback (most recent call last):')
for frame_rec in frame_records:
frame, fname, lineno, funcname, context, _cindex = frame_rec
args_tuple = inspect.getargvalues(frame)
all_vars = gather_vars(frame_rec, args_tuple[3])
trace_frame = 'File {!r}, line {:d}, {}{}'.format(
fname, lineno, funcname, inspect.formatargvalues(*args_tuple,
formatvalue=lambda v: '=' + pydoc.text.repr(v)))
trace.write(frame_wrapper.fill(trace_frame) + '\n')
trace.write(''.join([' ' + x.replace('\t', ' ')
for x in filter(lambda a: a.strip(), context)]))
if all_vars:
trace.write(' Variables (B=Builtin, G=Global, L=Local):\n')
for key, (scope, val) in all_vars.items():
wrapper = textwrap.TextWrapper(width=max_width,
initial_indent=' - {:>12} ({}): '.format(
key, str(scope)[0].upper()),
subsequent_indent=' ' * 7)
trace.write(wrapper.fill(pformat(val)) + '\n')
trace.write('%s: %s' % (exctyp.__name__, value))
return trace
class QtExceptHook(object):
"""GUI exception hook for PyQt 5.x applications"""
_btns = None
_extra_info = None
_reporting_cb = None
def __init__(self, qapp, reporting_cb=None):
"""Initialize as much as possible on application startup.
(Maximize the chance that a bug in the exception handling system will
show up as early as possible.)
"""
self._app = qapp
self._btns = {}
self._reporting_cb = reporting_cb
self._dialog = QMessageBox(QMessageBox.Warning,
self._app.translate(TR_ID, "Bug Detected"),
self._app.translate(TR_ID, "<big><b>A programming error has been "
"detected during the execution of this program.</b></big>"))
secondary = self._app.translate(TR_ID, "It probably isn't fatal, but"
"should be reported to the developers nonetheless.")
if self._reporting_cb:
self._btns['report'] = self._dialog.addButton(
self._app.translate(TR_ID, "Report Bug..."), QMessageBox.AcceptRole)
else:
self._btns['copy'] = self._dialog.addButton(
self._app.translate(TR_ID, "Copy Traceback..."),
QMessageBox.ActionRole)
secondary += self._app.translate(TR_ID, "\n\nPlease remember to "
"include the traceback from the Details expander.")
self._dialog.setInformativeText(secondary)
self._dialog.setStyleSheet("QTextEdit { font-family: monospace; }")
# TODO: Connect to buttonClicked, then use findChildren to set a
# higher minimumHeight on the details pane.
self._btns['close'] = self._dialog.addButton(QMessageBox.Close)
self._btns['quit'] = self._dialog.addButton(_("Quit"),
QMessageBox.RejectRole)
# TODO: Test to verify that none of my changes to initialize things
# once and then reuse them will cause problems if the program
# produces multiple exception dialogs in a single run.
def _format_traceback(self, exctyp, value, tracebk):
"""Do all the work necessary to reliably pretty-print the traceback"""
try:
trace = analyse(exctyp, value, tracebk)
except Exception: # pylint: disable=broad-except
# Provide a more robust but less detailed fallback in case a bug
# exists within the code I revised from gtkexcepthook.
trace = StringIO()
traceback.print_exception(exctyp, value, tracebk, None, trace)
trace.write("\nAn exception was also encountered while attempting "
"to generate a more detailed traceback:\n\n")
traceback.print_exc(None, trace)
if self._extra_info:
trace.write('\n\n{}'.format(self._extra_info))
return trace
def _info(self, exctyp, value, tracebk):
"""Replacement system exception handler callback"""
trace = self._format_traceback(exctyp, value, tracebk)
self._dialog.setDetailedText(trace.getvalue())
# TODO: Figure out how to keep QMessageBox from closing when the
# Report/Copy buttons are clicked. Having the dialog go away and come
# back could confuse users and I don't want to rebuild the whole dialog
# from parts for such a small thing if I can avoid it.
while True:
self._dialog.exec_()
resp = self._dialog.clickedButton()
if resp == self._btns.get('report'):
self._reporting_cb(trace)
elif resp == self._btns.get('copy'):
self._app.clipboard().setText(trace.getvalue())
QMessageBox(QMessageBox.Information,
self._app.translate(TR_ID, "Traceback Copied"),
self._app.translate(TR_ID, "The traceback has now been "
"copied to the clipboard.")).exec_()
elif resp == self._btns.get('quit'):
self._app.quit()
return
elif resp == self._btns.get('close'):
return
def enable(self):
"""Attach the exception handler"""
sys.excepthook = self._info
def set_extra_info(self, text):
"""Set/update some arbitrary text to be included with the traceback"""
self._extra_info = text
def make_email_sender(app_name, from_addr, smtp_host='localhost'):
"""A working example of how to build a traceback-reporting handler"""
def send_email(traceback):
"""Send the traceback to the developer as an e-mail"""
# TODO: prettyprint, deal with problems sending feedback, etc.
message = ('From: buggy_application\n'
'To: bad_programmer\n'
'Subject: Exception feedback for "%s"\n\n'
'%s' % (app_name, traceback.getvalue()))
smtp = SMTP()
smtp.connect(smtp_host)
smtp.sendmail(from_addr, (from_addr,), message)
smtp.quit()
return send_email
if __name__ == '__main__':
# Set up a mock application
logging.basicConfig(level=logging.DEBUG,
format='%(levelname)s %(message)s')
app = QApplication([])
# Attach the exception handler
ehook = QtExceptHook(app)
ehook.enable()
ehook.set_extra_info("Hello, world!")
# The following is how you do it with a reporting callback
# cb_mailer = make_email_sender(app.applicationName(), 'ssokolow')
# QtExceptHook(app, cb_mailer).enable()
# Set up some mock data
# pylint: disable=unused-variable,W0201,C0103,R0903
class X(object):
"""Mock data for testing"""
x = X()
x.y = 'Test'
x.z = x
w = ' e'
# FIXME: Global variables aren't being pretty-printed
def testfunc(testarg):
"""Function to demonstrate traceback"""
a = 'foo' # NOQA
raise Exception(testarg.z.y + w)
# Raise an exception
testfunc(x)
# vim: set sw=4 sts=4 expandtab :
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.