Commits

Yuya Nishihara committed d7358d0

debugtools: add extension to help debugging of gc issue and infobar

If enabled, it adds "Debug" menu to the workbench. Currently it only
contains "InfoBar", "Widgets" and "GC" actions.

  • Participants
  • Parent commits 57b6ea3
  • Branches stable

Comments (0)

Files changed (5)

contrib/thgdebugtools/__init__.py

+# thgdebugtools - extension to add debug actions to TortoiseHg
+#
+# Copyright 2013 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+"""add debug actions to TortoiseHg GUI
+
+This extension adds "Debug" menu to the Workbench window.
+"""
+
+import sys
+
+def extsetup(ui):
+    if 'tortoisehg.hgqt.run' not in sys.modules:
+        return  # not a TortoiseHg
+
+    # now it's safe to load TortoiseHg-specific modules
+    import core
+    core.extsetup(ui)

contrib/thgdebugtools/core.py

+# core.py - top-level menus and hooks
+#
+# Copyright 2013 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+import gc
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+from mercurial import extensions
+from tortoisehg.hgqt import run, workbench
+
+import dbgutil, infobar, widgets
+
+class DebugMenuActions(dbgutil.BaseMenuActions):
+    """Set up top-level debug menu"""
+
+    def _setupMenu(self, menu):
+        if self._workbench():
+            m = menu.addMenu('&InfoBar')
+            infobar.InfoBarMenuActions(m, parent=self)
+            self._infoBarMenu = m
+            menu.aboutToShow.connect(self._updateInfoBarMenu)
+
+        m = menu.addMenu('&Widgets')
+        widgets.WidgetsMenuActions(m, parent=self)
+
+        menu.addSeparator()
+
+        a = menu.addAction('Run Full &Garbage Collection')
+        a.triggered.connect(self.runGc)
+
+        a = menu.addAction('')  # placeholder to show gc status
+        a.setEnabled(False)
+        self._gcStatusAction = a
+
+        a = menu.addAction('&Enable Garbage Collector')
+        a.setCheckable(True)
+        a.triggered.connect(self.setGcEnabled)
+        self._gcEnabledAction = a
+        menu.aboutToShow.connect(self._updateGcAction)
+
+    @pyqtSlot()
+    def _updateInfoBarMenu(self):
+        self._infoBarMenu.setEnabled(bool(self._repoWidget()))
+
+    @pyqtSlot()
+    def runGc(self):
+        found = gc.collect()
+        self._information('GC Result', 'Found %d unreachable objects' % found)
+
+    @property
+    def _gcTimer(self):
+        return run.qtrun._gc.timer
+
+    def isGcEnabled(self):
+        return self._gcTimer.isActive()
+
+    @pyqtSlot(bool)
+    def setGcEnabled(self, enabled):
+        if enabled:
+            self._gcTimer.start()
+        else:
+            self._gcTimer.stop()
+
+    @pyqtSlot()
+    def _updateGcAction(self):
+        self._gcStatusAction.setText('  count = %s'
+                                     % ', '.join(map(str, gc.get_count())))
+        self._gcEnabledAction.setChecked(self.isGcEnabled())
+
+def _workbenchrun(orig, ui, *pats, **opts):
+    dlg = orig(ui, *pats, **opts)
+    m = dlg.menuBar().addMenu('&Debug')
+    DebugMenuActions(m, parent=dlg)
+    return dlg
+
+def extsetup(ui):
+    extensions.wrapfunction(workbench, 'run', _workbenchrun)

contrib/thgdebugtools/dbgutil.py

+# dbgutil.py - common functions and classes
+#
+# Copyright 2013 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+from tortoisehg.hgqt import workbench
+
+class WidgetNotFound(Exception):
+    pass
+
+class BaseMenuActions(QObject):
+    """Common helper methods for debug menu actions"""
+
+    def __init__(self, menu, parent=None):
+        super(BaseMenuActions, self).__init__(parent)
+        self._setupMenu(menu)  # must be implemented by sub class
+
+    def _findParentWidget(self):
+        p = self.parent()
+        while p:
+            if isinstance(p, QWidget):
+                return p
+            p = p.parent()
+        raise WidgetNotFound('no parent widget exists')
+
+    def _parentWidget(self):
+        try:
+            return self._findParentWidget()
+        except WidgetNotFound:
+            pass
+
+    def _findWorkbench(self):
+        w = self._findParentWidget().window()
+        if isinstance(w, workbench.Workbench):
+            return w
+        raise WidgetNotFound('parent window is not a Workbench')
+
+    def _workbench(self):
+        try:
+            return self._findWorkbench()
+        except WidgetNotFound:
+            pass
+
+    def _findRepoWidget(self):
+        w = self._findWorkbench().repoTabsWidget.currentWidget()
+        if w:
+            return w
+        raise WidgetNotFound('no RepoWidget is open')
+
+    def _repoWidget(self):
+        try:
+            return self._findRepoWidget()
+        except WidgetNotFound:
+            pass
+
+    def _information(self, title, text):
+        return QMessageBox.information(self._parentWidget(), title, text)
+
+    def _getText(self, title, label, text=None):
+        newtext, ok = QInputDialog.getText(self._parentWidget(), title, label,
+                                           text=text or '')
+        if ok:
+            return unicode(newtext)
+
+    def _log(self, msg, label='ui.debug'):
+        try:
+            wb = self._findWorkbench()
+            wb.log.output(msg, label=label)
+        except WidgetNotFound:
+            pass

contrib/thgdebugtools/infobar.py

+# infobar.py - menu to show/hide infobar manually
+#
+# Copyright 2013 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+from mercurial import extensions, util
+from tortoisehg.hgqt import qtlib
+
+import dbgutil
+
+class InfoBarMenuActions(dbgutil.BaseMenuActions):
+    """Set up debug menu for RepoWidget's InfoBar"""
+
+    def _setupMenu(self, menu):
+        menu.triggered.connect(self._setInfoBarByAction)
+        clsnames = ['&StatusInfoBar', 'Command&ErrorInfoBar',
+                    '&ConfirmInfoBar']
+        for e in clsnames:
+            menu.addAction(e).setData(e.replace('&', ''))
+
+        menu.addSeparator()
+        a = menu.addAction('Cl&ear')
+        a.triggered.connect(self.clearInfoBar)
+        a = menu.addAction('Setup &Trace')
+        a.triggered.connect(self.setupInfoBarTrace)
+
+    @pyqtSlot(QAction)
+    def _setInfoBarByAction(self, action):
+        clsname = str(action.data().toString())
+        if not clsname:
+            return
+        self.setInfoBar(clsname)
+
+    def setInfoBar(self, clsname):
+        cls = getattr(qtlib, clsname)
+        msg = self._getText('Set InfoBar', 'Message',
+                            'The quick fox jumps over the lazy dog.')
+        if msg:
+            self._findRepoWidget().setInfoBar(cls, msg)
+
+    @pyqtSlot()
+    def clearInfoBar(self):
+        self._findRepoWidget().clearInfoBar()
+
+    @pyqtSlot()
+    def setupInfoBarTrace(self):
+        rw = self._findRepoWidget()
+        def setInfoBarWithTrace(orig, cls, *args, **kwargs):
+            w = orig(cls, *args, **kwargs)
+            if not w:
+                return
+            self._log('InfoBar %r created\n' % w)
+            if util.safehasattr(w, 'finished'):
+                w.finished.connect(self._logInfoBarFinished)
+            return w
+        extensions.wrapfunction(rw, 'setInfoBar', setInfoBarWithTrace)
+
+    @pyqtSlot(int)
+    def _logInfoBarFinished(self, result):
+        self._log('InfoBar %r finished with %d\n' % (self.sender(), result))

contrib/thgdebugtools/widgets.py

+# widgets.py - menu to find invisible widgets and gc issues
+#
+# Copyright 2013 Yuya Nishihara <yuya@tcha.org>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2, incorporated herein by reference.
+
+import cgi, gc, pprint, re, weakref
+
+from PyQt4.QtCore import *
+from PyQt4.QtGui import *
+
+import dbgutil
+
+def invisibleWindows():
+    """List of invisible top-level widgets excluding menus"""
+    return [w for w in QApplication.topLevelWidgets()
+            if w.isHidden() and not isinstance(w, QMenu)]
+
+def orphanedWidgets():
+    """List of invisible widgets of no parent"""
+    return [w for w in QApplication.allWidgets()
+            if (not w.parent() and w.isHidden()
+                and not isinstance(w, QDesktopWidget))]
+
+def zombieWidgets():
+    """List of possibly-deleted widgets but referenced from Python"""
+    referenced = set(w for w in gc.get_objects() if isinstance(w, QWidget))
+    return referenced - set(QApplication.allWidgets())
+
+class WidgetsMenuActions(dbgutil.BaseMenuActions):
+    """Set up menu to find unused widgets"""
+
+    def _setupMenu(self, menu):
+        findtypes = [
+            ('&Invisible Windows', invisibleWindows, self.showWidget),
+            ('&Orphaned Widgets',  orphanedWidgets,  self.showWidget),
+            ('&Zombie Widgets',    zombieWidgets,    self.openGcInfoOfWidget),
+            ]
+        for name, collect, action in findtypes:
+            m = menu.addMenu(name)
+            m.menuAction().setStatusTip(collect.__doc__ or '')
+            f = WidgetsFinder(m, collect, parent=self)
+            f.triggered.connect(action)
+
+        menu.addSeparator()
+
+        a = menu.addAction('&GC Info of Active Window')
+        a.triggered.connect(self.openGcInfoOfActiveWindow)
+        self._gcInfoDialog = None
+
+    @pyqtSlot(object)
+    def showWidget(self, w):
+        w.show()
+        w.raise_()
+        w.activateWindow()
+
+    def _openGcInfoDialog(self):
+        if self._gcInfoDialog:
+            dlg = self._gcInfoDialog
+        else:
+            dlg = self._gcInfoDialog = GcInfoDialog()
+        dlg.show()
+        dlg.raise_()
+        dlg.activateWindow()
+        return dlg
+
+    @pyqtSlot(object)
+    def openGcInfoOfWidget(self, w):
+        dlg = self._openGcInfoDialog()
+        dlg.update(w)
+
+    @pyqtSlot()
+    def openGcInfoOfActiveWindow(self):
+        dlg = self._openGcInfoDialog()
+        dlg.update(QApplication.activeWindow())
+
+class WidgetsFinder(QObject):
+    # not QWidget because C++ part may be deleted
+    triggered = pyqtSignal(object)
+
+    def __init__(self, menu, collect, parent=None):
+        super(WidgetsFinder, self).__init__(parent)
+        self._menu = menu
+        self._menu.aboutToShow.connect(self.rebuild)
+        self._menu.triggered.connect(self._emitTriggered)
+        self._collect = collect
+        self._refreshTimer = QTimer(self, interval=100)
+        self._refreshTimer.timeout.connect(self.refresh)
+        self._menu.aboutToShow.connect(self._refreshTimer.start)
+        self._menu.aboutToHide.connect(self._refreshTimer.stop)
+
+    @pyqtSlot()
+    def rebuild(self):
+        widgets = self._collect()
+        self._menu.clear()
+        if not widgets:
+            self._menu.addAction('(none)').setEnabled(False)
+            return
+        for i, w in enumerate(sorted(widgets, key=repr)):
+            s = re.sub(r'^(tortoisehg\.hgqt|PyQt4\.QtGui)\.', '',
+                       repr(w)[1:-1])
+            s = s.replace(' object at ', ' at ')
+            if i < 10:
+                s = '&%d %s' % ((i + 1) % 10, s)
+            a = self._menu.addAction(s)
+            a.setData(weakref.ref(w))
+
+    @pyqtSlot()
+    def refresh(self):
+        for a in self._menu.actions():
+            wref = a.data().toPyObject()
+            if not wref:
+                continue
+            w = wref()
+            a.setEnabled(bool(w))
+
+    @pyqtSlot(QAction)
+    def _emitTriggered(self, action):
+        wref = action.data().toPyObject()
+        w = wref()
+        if w:
+            self.triggered.emit(w)
+
+class GcInfoDialog(QDialog):
+
+    def __init__(self, parent=None):
+        super(GcInfoDialog, self).__init__(parent)
+        self.setLayout(QVBoxLayout(self))
+        self._infoEdit = QTextBrowser(self)
+        self.layout().addWidget(self._infoEdit)
+        self._followActiveCheck = QCheckBox('&Follow active window', self)
+        self._followActiveCheck.setChecked(True)
+        self.layout().addWidget(self._followActiveCheck)
+
+        self._buttonBox = bbox = QDialogButtonBox(self)
+        self.layout().addWidget(bbox)
+        b = bbox.addButton('&Show Widget', QDialogButtonBox.ActionRole)
+        b.clicked.connect(self.showWidget)
+        b = bbox.addButton('&Destroy', QDialogButtonBox.ResetRole)
+        b.clicked.connect(self.deleteWidget)
+        b.setAutoDefault(False)
+
+        self._targetWidgetRef = None
+        QApplication.instance().focusChanged.connect(self._updateByFocusChange)
+        self._updateButtons()
+        self.resize(600, 400)
+
+    def targetWidget(self):
+        if not self._targetWidgetRef:
+            return
+        return self._targetWidgetRef()
+
+    @pyqtSlot()
+    def showWidget(self):
+        w = self.targetWidget()
+        if not w:
+            self._updateButtons()
+            return
+        w.show()
+        w.raise_()
+        w.activateWindow()
+
+    @pyqtSlot()
+    def deleteWidget(self):
+        w = self.targetWidget()
+        if not w:
+            self._updateButtons()
+            return
+        w.deleteLater()
+
+    @pyqtSlot(QWidget, QWidget)
+    def _updateByFocusChange(self, old, now):
+        if (not self._followActiveCheck.isChecked()
+            or not old or not now or old.window() is now.window()
+            or now.window() is self):
+            return
+        self.update(now.window())
+
+    def update(self, w):
+        if not w:
+            self._targetWidgetRef = None
+            self._updateButtons()
+            return
+        referrers = gc.get_referrers(w)
+        self.setWindowTitle('GC Info - %r' % w)
+        self._infoEdit.clear()
+        self._infoEdit.append('<h1>Referrers</h1>')
+        self._infoEdit.append('<pre>%s</pre>'
+                              % cgi.escape(pprint.pformat(referrers)))
+        del referrers
+        self._targetWidgetRef = weakref.ref(w)
+        self._updateButtons()
+
+    @pyqtSlot()
+    def _updateButtons(self):
+        self._buttonBox.setEnabled(bool(self.targetWidget()))