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.

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