Qt program¶
See also
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()