Qt program

Eric Holscher, one of the creators of Read The Docs, recently posted about the importance of a documentation culture in Open Source development, and about things that could be done to encourage this.

He makes some good points, and Read The Docs is a very nice looking showcase for documentation.

Writing good documentation is difficult enough at the best of times, and one practical problem that I face when working on Sphinx documentation is that I often feel I have to break away from composing it to building it, to see how it looks - because the look of it on the page will determine how I want to refine it.

What I’ve tended to do is work iteratively by making some changes to the ReST sources, invoking make html and refreshing the browser to show how the built documentation looks.

This is OK, but does break the flow more than a little (for me, anyway, but I can’t believe I’m the only one).

I had the idea that it would be nice to streamline the process somewhat, so that all I would need to do is to save the changed ReST source – the building and browser refresh would be automatically done, and if I had the editor and browser windows open next to each other in tiled fashion, I could achieve a sort of WYSIWYG effect with the changes appearing in the browser a second or two after I saved any changes.

I decided to experiment with this idea, and needed a browser which I could easily control (to get it to refresh on-demand). I decided to use Roberto Alsina’s 128-line browser, which is based on QtWebKit and PyQt.

Roberto posted his browser code almost a year ago, and I knew I’d find a use for it one day :-)

The code (MIT licensed) is available from here. As it’s a single file standalone script, I haven’t considered putting it on PyPI – it’s probably easier to download it to a $HOME/bin or similar location, then you can invoke it in the docs directory of your project, run your editor, position the browser and editor windows suitably, and you’re ready to go!

#!/usr/bin/env python
#
# Copyright (C) 2012 Vinay Sajip. Licensed under the MIT license.
#
# Based on Roberto Alsina's 128-line web browser, see
#
# http://lateral.netmanagers.com.ar/weblog/posts/BB948.html
#
import json
import os
import subprocess
import sys
import tempfile
from urllib import pathname2url

import sip
sip.setapi("QString", 2)
sip.setapi("QVariant", 2)

from PyQt4 import QtGui,QtCore,QtWebKit, QtNetwork

settings = QtCore.QSettings("Vinay Sajip", "DocWatch")

class Watcher(QtCore.QThread):
        """
        A watcher which looks for source file changes, builds the documentation,
        and notifies the browser to refresh its contents
        """
        def run(self):
                self._stop = False
                watch_command = 'inotifywait -rq -e close_write --exclude \'"*.html"\' .'.split()
                make_command = 'make html'.split()
                while not self._stop:
                        # Perhaps should put notifier access in a mutex - not bothering yet
                        self.notifier = subprocess.Popen(watch_command)
                        self.notifier.wait()
                        if self._stop:
                                break
                        subprocess.call(make_command)
                        # Refresh the UI ...
                        self.parent().changed.emit()

        def stop(self):
                self._stop = True
                # Perhaps should put notifier access in a mutex - not bothering for now
                if self.notifier.poll() is None:    # not yet terminated ...
                        self.notifier.terminate()

class MainWindow(QtGui.QMainWindow):
        """
        A browser intended for viewing HTML documentation generated by Sphinx.
        """
        changed = QtCore.pyqtSignal()

        def __init__(self, url):
                QtGui.QMainWindow.__init__(self)
                self.sb=self.statusBar()

                self.pbar = QtGui.QProgressBar()
                self.pbar.setMaximumWidth(120)
                self.wb=QtWebKit.QWebView(loadProgress = self.pbar.setValue, loadFinished = self.pbar.hide, loadStarted = self.pbar.show, titleChanged = self.setWindowTitle)
                self.setCentralWidget(self.wb)

                self.tb=self.addToolBar("Main Toolbar")
                for a in (QtWebKit.QWebPage.Back, QtWebKit.QWebPage.Forward, QtWebKit.QWebPage.Reload):
                        self.tb.addAction(self.wb.pageAction(a))

                self.url = QtGui.QLineEdit(returnPressed = lambda:self.wb.setUrl(QtCore.QUrl.fromUserInput(self.url.text())))
                self.tb.addWidget(self.url)

                self.wb.urlChanged.connect(lambda u: self.url.setText(u.toString()))
                self.wb.urlChanged.connect(lambda: self.url.setCompleter(QtGui.QCompleter(QtCore.QStringList([QtCore.QString(i.url().toString()) for i in self.wb.history().items()]), caseSensitivity = QtCore.Qt.CaseInsensitive)))

                self.wb.statusBarMessage.connect(self.sb.showMessage)
                self.wb.page().linkHovered.connect(lambda l: self.sb.showMessage(l, 3000))

                self.search = QtGui.QLineEdit(returnPressed = lambda: self.wb.findText(self.search.text()))
                self.search.hide()
                self.showSearch = QtGui.QShortcut("Ctrl+F", self, activated = lambda: (self.search.show() , self.search.setFocus()))
                self.hideSearch = QtGui.QShortcut("Esc", self, activated = lambda: (self.search.hide(), self.wb.setFocus()))

                self.quit = QtGui.QShortcut("Ctrl+Q", self, activated = self.close)
                self.zoomIn = QtGui.QShortcut("Ctrl++", self, activated = lambda: self.wb.setZoomFactor(self.wb.zoomFactor()+.2))
                self.zoomOut = QtGui.QShortcut("Ctrl+-", self, activated = lambda: self.wb.setZoomFactor(self.wb.zoomFactor()-.2))
                self.zoomOne = QtGui.QShortcut("Ctrl+=", self, activated = lambda: self.wb.setZoomFactor(1))
                self.wb.settings().setAttribute(QtWebKit.QWebSettings.PluginsEnabled, True)

                self.sb.addPermanentWidget(self.search)
                self.sb.addPermanentWidget(self.pbar)

                self.load_settings()

                self.wb.load(url)
                self.watcher = Watcher(self)

                self.changed.connect(self.wb.reload)

                self.watcher.start()

        def load_settings(self):
                settings.beginGroup('mainwindow')
                pos = settings.value('pos')
                size = settings.value('size')
                if isinstance(pos, QtCore.QPoint):
                        self.move(pos)
                if isinstance(size, QtCore.QSize):
                        self.resize(size)
                settings.endGroup()

        def save_settings(self):
                settings.beginGroup('mainwindow')
                settings.setValue('pos', self.pos())
                settings.setValue('size', self.size())
                settings.endGroup()

        def closeEvent(self, event):
                self.save_settings()
                self.watcher.stop()

if __name__ == "__main__":
        if not os.path.isdir('_build'):
                # very simplistic sanity check. Works for me, as I generally use
                # sphinx-quickstart defaults
                print('You must run this application from a Sphinx directory containing _build')
                rc = 1
        else:
                app=QtGui.QApplication(sys.argv)
                path = os.path.join('_build', 'html', 'index.html')
                url = 'file:///' + pathname2url(os.path.abspath(path))
                url = QtCore.QUrl(url)
                wb=MainWindow(url)
                wb.show()
                rc = app.exec_()
        sys.exit(rc)

Ironpython

Update: Another advantage of using the subprocess / command line approach to notification is that it’s easy to slot in a solution for a platform which doesn’t support inotify.

Alternatives are available for both Windows and Mac OS X. For example, on Windows, if you have IronPython installed, the following script could be used to provide the equivalent functionality to inotifywait (for this specific application):

import clr
import os

from System.IO import FileSystemWatcher, NotifyFilters

stop = False

def on_change(source, e):
        global stop
        if not e.Name.endswith('.html'):
                stop = True
        print('%s: %s, stop = %s' % (e.FullPath, e.ChangeType, stop))

watcher = FileSystemWatcher(os.getcwd())
watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
watcher.EnableRaisingEvents = True
watcher.IncludeSubdirectories = True
watcher.Changed += on_change
watcher.Created += on_change

while not stop:
        pass

Mac OS X

Whereas for Mac OS X, if you install the MacFSEvents package, the following script could be used to provide the equivalent functionality to inotifywait (again, for this specific application):

#!/usr/bin/env python

import os

from fsevents import Observer, Stream

stop = False

def on_change(e):
        global stop
        path = e.name
        if os.path.isfile(path):
                if not path.endswith('.html'):
                        stop = True
        print('%s: %s, stop = %s' % (e.name, e.mask, stop))

observer = Observer()
observer.start()
stream = Stream(on_change, os.getcwd(), file_events=True)
observer.schedule(stream)
try:
        while not stop:
                pass
finally:
        observer.unschedule(stream)
        observer.stop()
        observer.join()