Skip to content

Instantly share code, notes, and snippets.

@ssokolow
Last active January 17, 2024 09:34
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ssokolow/f5219e4c8e4bddbba4d08101969445d1 to your computer and use it in GitHub Desktop.
Save ssokolow/f5219e4c8e4bddbba4d08101969445d1 to your computer and use it in GitHub Desktop.
Port and refactoring of gtkexcepthook.py for PyQt 5.x and Python 3.5+
#!/usr/bin/env python3
"""Qt5 Exception Handler for Python 3.5+
Additions copyright 2019, 2020 Stephan Sokolow
Demonstration::
python3 qtexcepthook.py [--report-button]
Usage::
import qtexcepthook
app = QApplication(sys.argv)
QtExceptHook().enable()
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 false
positive complaints from PyFlakes. (Borrowed naming convention from
:mod:`cgitb`)
Changes made since the port to PyQt 5.x:
- Heavily refactored for maintainability
- Replaced most of the code with a call to
:meth:`traceback.TracebackException.format` after updating the minimum
supported Python version to 3.5.
- Split out the email support into an easily-replaced callback
- Integrated :meth:`QCoreApplication.applicationName` into the example e-mails.
- Add a command-line parser to the demonstration code to make it easy to test
both with and without a reporting callback set.
"""
__author__ = "Stephan Sokolow; Filip Van Raemdonck; Gustavo J A M Carneiro"
__license__ = "Public Domain"
import getpass, socket, sys, traceback
from gettext import gettext as _
from smtplib import SMTP, SMTPException
from typing import Callable
# pylint: disable=no-name-in-module
from PyQt5.QtCore import (QCommandLineOption, QCommandLineParser, QEvent,
pyqtSlot)
from PyQt5.QtGui import QTextOption
from PyQt5.QtWidgets import QApplication, QMessageBox, QSizePolicy, QTextEdit
# pylint: disable=unused-import,wrong-import-order
from typing import Dict # noqa
from PyQt5.QtWidgets import QPushButton # noqa
#: The :meth:`QCoreApplication.translate` context for strings in this file
TR_ID = "excepthook"
def _tr(*args):
"""Helper to make :meth:`QCoreApplication.translate` more concise."""
return QApplication.translate(TR_ID, *args)
class ResizableMessageBox(QMessageBox): # pylint: disable=R0903
"""QMessageBox which allows the detailed text expander to be resized
and sets it to display non-wrapping monospace text.
Adapted from `Resizing a QMessageBox?
<https://www.qtcentre.org/threads/24888-Resizing-a-QMessageBox>`_ on
Qt Center.
.. todo:: For some reason, calling :meth:`QWidget.setMaximumSize` in this
causes KWin to misalign the titlebar's right button box under Kubuntu
16.04 LTS until the window is moved or resized.
"""
def __init__(self, *args, **kwargs):
super(ResizableMessageBox, self).__init__(*args, *kwargs)
self.setStyleSheet("QTextEdit { font-family: monospace; }")
def event(self, event):
"""Override :meth:`QWidget.event` to hook in our customizations *after*
Qt tries to force settings on us.
(Use :class:`LayoutRequest <QEvent>` because it only gets fired two to
four times under typical operation and happens at the right time.)
"""
res = QMessageBox.event(self, event)
# Skip events we don't care about
if event.type() != QEvent.LayoutRequest:
return res
# Only do all this stuff once the TextWidget is here
details = self.findChild(QTextEdit)
if details:
self.setSizeGripEnabled(True)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMaximumSize(16777215, 16777215)
details.setWordWrapMode(QTextOption.NoWrap)
details.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
details.setMaximumSize(16777215, 16777215)
return res
class QtExceptHook(object):
"""GUI exception hook for PyQt 5.x applications
:param reporting_cb: If provided, a :guilabel:`Report Bug...` button will
be added which will call ``reporting_cb`` when clicked.
"""
def __init__(self, reporting_cb: Callable[[str], None]=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._extra_info = ''
self._reporting_cb = reporting_cb
self._dialog = ResizableMessageBox(QMessageBox.Warning,
_tr("Bug Detected"),
_tr("<big><b>A programming error has been detected "
"during the execution of this program.</b></big>"))
secondary = _tr("It probably isn't fatal, but should be "
"reported to the developers nonetheless.")
if self._reporting_cb:
btn_report = self._dialog.addButton(_tr("Report Bug..."),
QMessageBox.ActionRole)
btn_report.clicked.disconnect()
btn_report.clicked.connect(self._cb_report_bug)
else:
btn_copy = self._dialog.addButton(_tr("Copy Traceback..."),
QMessageBox.ActionRole)
btn_copy.clicked.disconnect()
btn_copy.clicked.connect(self._cb_copy_to_clipboard)
secondary += _tr("\n\nPlease remember to include the "
"traceback from the Details expander.")
self._dialog.setInformativeText(secondary)
# Workaround for the X button not working when details are available
# Source: https://stackoverflow.com/a/32764190/435253
btn_close = self._dialog.addButton(QMessageBox.Close)
self._dialog.setEscapeButton(btn_close)
self.b_quit = self._dialog.addButton(_("Quit"), QMessageBox.RejectRole)
@pyqtSlot()
def _cb_copy_to_clipboard(self):
"""Qt slot for the :guilabel:`Copy Traceback...` button."""
QApplication.instance().clipboard().setText(
self._dialog.detailedText())
QMessageBox(QMessageBox.Information,
_tr("Traceback Copied"),
_tr("The traceback has now been copied to the clipboard.")
).exec_()
@pyqtSlot()
def _cb_report_bug(self):
"""Qt slot for the :guilabel:`Report Bug...` button. """
self._reporting_cb(self._dialog.detailedText())
def _excepthook(self, exc_type, exc_value, exc_traceback):
"""Replacement system exception handler callback"""
# Construct the text of the bug report
t_exception = traceback.TracebackException(
exc_type, exc_value, exc_traceback, capture_locals=True)
traceback_text = '\n'.join(t_exception.format())
if self._extra_info:
traceback_text += '\n{}'.format(self._extra_info)
# Store it in the dialog where *everything* will retrieve it from
self._dialog.setDetailedText(traceback_text)
# Show the dialog
self._dialog.exec_()
if self._dialog.clickedButton() == self.b_quit:
QApplication.instance().quit()
def enable(self):
"""Replace the default exception handler with this one"""
sys.excepthook = self._excepthook
def set_extra_info(self, text: str):
"""Set some arbitrary text to be appended to the traceback"""
self._extra_info = text or ''
def make_email_sender(from_address: str=None, smtp_host: str='localhost'
) -> Callable[[str], None]:
"""A factory function for building working examples of traceback-reporting
handlers for :class:`QtExceptHook`'s ``reporting_cb`` constructor argument.
:param from_address: A "From" address that the selected mail server will
allow the message to be sent from. Falls back to the current username
for use with ``localhost`` as the default ``smtp_host`` value.
:param smtp_host: The fully-qualified domain name of the SMTP server to
use for sending the message. A port other than 25 may be specified by
using the form ``host:port``.
.. note:: Must be called after creating a ``QApplication``.
"""
from_addr = from_address or getpass.getuser()
app_name = QApplication.instance().applicationName()
def send_email(traceback_text: str):
"""Send the traceback to the developer as an e-mail"""
message = ('From: buggy_application\n'
'To: bad_programmer\n'
'Subject: Exception feedback for "%s"\n\n'
'%s' % (app_name, traceback_text))
try:
smtp = SMTP()
smtp.connect(smtp_host)
smtp.sendmail(from_addr, (from_addr,), message)
smtp.quit()
except (socket.error, SMTPException):
QMessageBox(QMessageBox.Information,
_tr("SMTP Failure"),
_tr("An error was encountered while attempting to send "
"your bug report. Please submit it manually.")).exec_()
else:
QMessageBox(QMessageBox.Information,
_tr("Bug Reported"),
_tr("Your bug report was successfully sent.")).exec_()
return send_email
if __name__ == '__main__':
# Set up a mock application
app = QApplication(sys.argv)
app.setApplicationName("QtExceptHook Demo")
# Set up a simple command-line parser so no editing is needed to try both
# configurations
reportOption = QCommandLineOption('report-button',
_tr("Initialize the exception hook with a bug-reporting callback"))
parser = QCommandLineParser()
parser.addOption(reportOption)
parser.addHelpOption()
parser.process(app)
# Attach the exception handler
if parser.isSet(reportOption):
cb_mailer = make_email_sender()
ehook = QtExceptHook(cb_mailer)
else:
ehook = QtExceptHook()
ehook.enable()
ehook.set_extra_info("This is some extra info. Hello, world!")
# Set up some mock data
# pylint: disable=unused-variable,W0201,C0103,R0903
class X(object):
"""Mock data for testing"""
y = 'Test'
z = None
def __repr__(self):
""".. todo: Figure out how to avoid recursion with z"""
return "X<y={!r}, z=...>".format(self.y)
x = X()
x.z = x # type: ignore
w = ' e'
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