ssokolow/quicktile

View on GitHub
quicktile/gtkexcepthook.py

Summary

Maintainability
C
1 day
Test Coverage
#!/usr/bin/env python3
# pylint: disable=line-too-long
"""Graphical exception handler for PyGTK applications

Usage
-----

::

    import gtkexcepthook
    gtkexcepthook.enable(optional_reporting_callback)

Notes
-----

| ©2003 Gustavo J A M Carneiro gjc at inescporto.pt
| ©2004-2005 Filip Van Raemdonck
| ©2009, 2011, 2017, 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."

Contains changes merged back from `qtexcepthook.py`_, a Qt 5 port of
:file:`gtkexcepthook.py` by Stephan Sokolow ©2019.

Changes from Van Raemdonck version:
 - Ported to PyGI and GTK 3.x
 - Refactored code for maintainability and added MyPy type annotations
 - Switched from auto-enable to :any:`gtkexcepthook.enable` to silence PyFlakes
   false positives. (Borrowed naming convention from cgitb)
 - Split out traceback import to silence PyFlakes warning.
 - Started to resolve PyLint complaints

.. todo:: Finish polishing :mod:`quicktile.gtkexcepthook` up to meet my code
    formatting and clarity standards.
.. todo:: Confirm there isn't any other generally-applicable information that
       could be included in the :mod:`quicktile.gtkexcepthook` debugging dump.

.. _qtexcepthook.py: https://gist.github.com/ssokolow/f5219e4c8e4bddbba4d08101969445d1

API Documentation
-----------------
"""  # NOQA

__author__ = "Gustavo J A M Carneiro; Filip Van Daemdonck; Stephan Sokolow"
__authors__ = [
    "Gustavo J A M Carneiro",
    "Filip Van Daemdonck",
    "Stephan Sokolow"]
__license__ = "whatever you want"

# Silence PyLint being flat-out wrong about MyPy type annotations and
# complaining about my grouped imports
# pylint: disable=unsubscriptable-object
# pylint: disable=wrong-import-order

import enum, inspect, linecache, logging, pydoc, tokenize, keyword
import sys
from io import StringIO
from gettext import gettext as _
from pprint import pformat

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')

from gi.repository import Gdk, Gtk

# -- Type-Annotation Imports --
from typing import Any, Callable, Dict, Generator, Type, Tuple
from types import FrameType, TracebackType
# --

log = logging.getLogger(__name__)

# == Analyzer Backend ==


class Scope(enum.Enum):
    """The scope of a variable looked up by :any:`lookup`"""
    Builtin = 1
    Global = 2
    Local = 3
    NONE = None

    def __str__(self):
        """Override str() to return either the variant name or '?' for NONE"""
        if self.value is Scope.NONE:
            return '?'
        else:
            return str(self.name)[0].upper()


def lookup(name: str,
           frame: FrameType,
           local_vars: Dict[str, Any]
           ) -> Tuple[Scope, Any]:
    """Find the value for a given name in the given frame

    :param name: Name of the variable to look up.
    :param frame: A frame object originally retrieved via
        :any:`inspect.getinnerframes`.
    :param local_vars: A cached locals dict originally retrieved via
        :any:`inspect.getargvalues`.
    :returns: A tuple of a :any:`Scope` and the requested value.
    """
    if name in local_vars:
        return Scope.Local, local_vars[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: inspect.FrameInfo
) -> Generator[tokenize.TokenInfo, None, None]:
    """Generator which produces a lexical token stream from a frame record

    .. todo: Add MyPy type signature for :func:`tokenize_frame`

    """
    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: inspect.FrameInfo,
                local_vars: Dict[str, Any]) -> Dict[str, Any]:
    """Extract all the local variables from the given traceback frame using
    :func:`lookup`.

    :param frame_rec: A frame info object originally retrieved via
        :any:`inspect.getinnerframes`.
    :param local_vars: A cached locals dict originally retrieved via
        :any:`inspect.getargvalues`.
    :returns: A dict of the local variables.
    """
    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  # noqa pylint: disable=no-member
                t_str not in keyword.kwlist):
            if not name:
                assert not name and not scope  # nosec
                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:  # noqa pylint: disable=bare-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:  # pylint: disable=no-member
                break
    return all_vars


def analyse(exctyp: Type[BaseException],
            value: BaseException,
            tracebk: TracebackType,
            context_lines: int = 3,
            ) -> StringIO:
    """Generate a traceback, including the contents of variables in each
    stack frame.

    :param exctyp: Used for class name.
    :param value: Used for exception message.
    :param tracebk: Used for everything else.
    :param context_lines: See the ``context`` argument to
        :any:`inspect.getinnerframes`
    :returns: The formatted traceback
    """
    trace = StringIO()
    frame_records = inspect.getinnerframes(tracebk, context_lines)

    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])

        def formatvalue(v):
            return "=" + pydoc.text.repr(v)
        pretty_spec = inspect.formatargvalues(*args_tuple,
            formatvalue=formatvalue)
        trace_frame = 'File {!r}, line {:d}, {}{}'.format(
            fname, lineno, funcname, pretty_spec)

        trace.write('\n' + trace_frame + '\n')
        trace.write(''.join(['    ' + x.replace('\t', '  ')
            for x in context or [] if x.strip()]))

        if all_vars:
            trace.write('    Variables (B=Builtin, G=Global, L=Local):\n')
            for key, (scope, val) in all_vars.items():
                trace.write('     - {:>12} ({}): {}'.format(
                    key, str(scope)[0].upper(),
                    '\n'.join('       {}'.format(x) for x in
                        pformat(val).split('\n')) + '\n'))

    trace.write('%s: %s' % (exctyp.__name__, value))
    return trace

# == GTK+ Frontend ==


class ExceptionHandler(object):
    """GTK-based graphical exception handler

    :param reporting_cb: A callback to be exposed via a :guilabel:`Report...`
        button which will receive the formatted traceback as a string.
    """

    def __init__(self, reporting_cb: Callable[[str], None] = None) -> None:
        self.reporting_cb = reporting_cb

    def make_info_dialog(self) -> Gtk.MessageDialog:
        """Initialize and return the top-level dialog"""
        dialog = Gtk.MessageDialog(transient_for=None, flags=0,
                                   message_type=Gtk.MessageType.WARNING,
                                   buttons=Gtk.ButtonsType.NONE)

        dialog.set_title(_("Bug Detected"))
        dialog.set_markup(_("<big><b>A programming error has been detected "
            "during the execution of this program.</b></big>"))

        secondary = _("It probably isn't fatal, but should be reported to "
            "the developers nonetheless.")

        if self.reporting_cb:
            dialog.add_button(_("Report..."), 3)
        else:
            secondary += _("\n\nPlease remember to include the contents of "
                           "the Details dialog.")
        dialog.format_secondary_text(secondary)

        dialog.add_button(_("Details..."), 2)
        dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
        dialog.add_button(Gtk.STOCK_QUIT, 1)

        return dialog

    @staticmethod
    def make_details_dialog(parent: Gtk.MessageDialog, text: str
                            ) -> Gtk.MessageDialog:
        """Initialize and return the details dialog

        :param parent: A reference to the dialog from :any:`make_info_dialog`.
        :param text: The contents of the formatted traceback.
        """

        details = Gtk.Dialog(title=_("Bug Details"), transient_for=parent,
                             modal=True, destroy_with_parent=True)
        details.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)

        textview = Gtk.TextView()
        textview.show()
        textview.set_editable(False)
        textview.set_monospace(True)

        swin = Gtk.ScrolledWindow.new()
        swin.show()
        swin.add(textview)
        details.vbox.pack_start(swin, True, True, 2)  # pylint: disable=E1101
        textbuffer = textview.get_buffer()
        textbuffer.set_text(text)

        # Set the default size to just over 60% of the screen's dimensions
        screen = Gdk.Screen.get_default()
        monitor = screen.get_monitor_at_window(parent.get_window())
        area = screen.get_monitor_geometry(monitor)
        width, height = area.width // 1.6, area.height // 1.6
        details.set_default_size(int(width), int(height))

        return details

    def __call__(self,
            exctyp: Type[BaseException],
            value: BaseException,
            tback: TracebackType):
        """Custom :any:`sys.excepthook` callback which displays a GTK dialog"""

        cached_tb = None
        dialog = self.make_info_dialog()
        while True:
            resp = dialog.run()

            if resp == 3 and self.reporting_cb:
                if cached_tb is None:
                    cached_tb = analyse(exctyp, value, tback).getvalue()
                self.reporting_cb(cached_tb)
            elif resp == 2:
                if cached_tb is None:
                    cached_tb = analyse(exctyp, value, tback).getvalue()
                details = self.make_details_dialog(dialog, cached_tb)
                details.run()
                details.destroy()
            elif resp == 1 and Gtk.main_level() > 0:
                Gtk.main_quit()

            # Only the "Details" dialog loops back when closed
            if resp != 2:
                break

        dialog.destroy()


def enable(reporting_cb: Callable[[str], None] = None):
    """Call this to set gtkexcepthook as the default exception handler

    :param reporting_cb: If provided, this callback will be exposed in the
        dialog as a :guilabel:`Report...` button.

        The function will receive the same formatted traceback visible via
        the :guilabel:`Details...` button.
    """

    # MyPy disabled pending a release of the fix to #797
    sys.excepthook = ExceptionHandler(reporting_cb)  # type: ignore


if __name__ == '__main__':
    class TestFodder(object):  # pylint: disable=too-few-public-methods
        """Just something interesting to show in the augmented traceback"""
        y = 'Test'

        def __init__(self):
            self.z = self  # pylint: disable=invalid-name
    x = TestFodder()
    w = ' e'

    enable()
    raise Exception(x.z.y + w)

# vim: set sw=4 sts=4 :