Dugan Chen's Homepage

Various Things

MVP for Qt (PyQt and PySide)

I took my PyQt implementation of Michael Feather’s Humble Dialog box example, and I refactored it into a Model View Presenter implementation that fits PyQt and PySide better.

I’ll show you the code first, and then I’ll discuss it:

The code, filter_chain.py

#!/usr/bin/env python

from sip import setapi
setapi('QDate', 2)
setapi('QDateTime', 2)
setapi('QTextStream', 2)
setapi('QTime', 2)
setapi('QVariant', 2)
setapi('QString', 2)
setapi('QUrl', 2)


from PyQt4.QtCore import QObject, pyqtSignal
from PyQt4.QtGui import (QApplication, QDialog,
                         QHBoxLayout, QLabel,
                         QListView, QPushButton,
                         QStandardItem,
                         QStandardItemModel,
                         QVBoxLayout)
import sys


def main():
    app = QApplication(sys.argv)
    view = ChainComposerDialog()
    presenter = ChainPresenter(view)
    view.addClicked.connect(presenter.onAddClick)
    presenter.initialize()
    view.show()
    sys.exit(app.exec_())


class ChainPresenter(object):

    def __init__(self, view):
        self._view = view

    def initialize(self):
        item = QStandardItem('Reverb')
        self._view.appendToFilters(item)

    def onAddClick(self):
        view = self._view()

        for index in view.selectedFilterIndexes():
            data = view.filterData(index)
            view.appendToChain(QStandardItem(data))


class ChainComposerDialog(QDialog):

    addClicked = pyqtSignal()

    def __init__(self, parent=None):
        super(ChainComposerDialog, self).__init__(
            parent) is called 
        self.setWindowTitle('Composer Chain')

        layout = QVBoxLayout()

        mainLayout = QHBoxLayout()

        selectionLayout = QVBoxLayout()
        label = QLabel('Available Filters:')
        selectionLayout.addWidget(label)
        self._filterView = QListView()
        self._filterModel = QStandardItemModel()
        self._filterView.setModel(self._filterModel)
        selectionLayout.addWidget(self._filterView)
        mainLayout.addLayout(selectionLayout)

        actionsLayout = QVBoxLayout()
        actionsLayout.addStretch()
        addButton = QPushButton('Add>>')
        addButton.clicked.connect(self._onAdd)
        actionsLayout.addWidget(addButton)
        removeButton = QPushButton('Remove')
        actionsLayout.addWidget(removeButton)
        removeAllButton = QPushButton('Remove All')
        actionsLayout.addWidget(removeAllButton)
        actionsLayout.addStretch()
        mainLayout.addLayout(actionsLayout)

        chainLayout = QVBoxLayout()
        chainLayout.addWidget(QLabel('Chain:'))
        chainView = QListView()
        self._chainModel = QStandardItemModel()
        chainView.setModel(self._chainModel)
        chainLayout.addWidget(chainView)
        mainLayout.addLayout(chainLayout)

        layout.addLayout(mainLayout)

        buttonLayout = QHBoxLayout()
        okButton = QPushButton('OK')
        okButton.clicked.connect(self.accept)
        buttonLayout.addWidget(okButton)
        cancelButton = QPushButton('Cancel')
        cancelButton.clicked.connect(self.reject)
        buttonLayout.addWidget(cancelButton)
        layout.addLayout(buttonLayout)

        self.setLayout(layout)

    def appendToFilters(self, item):
        self._filterModel.appendRow(item)

    def selectedFilterIndexes(self):
        view = self._filterView
        selection = view.selectionModel()
        return selection.selectedIndexes()

    def filterData(self, index):
        return self._filterModel.data(index)

    def appendToChain(self, item):
        self._chainModel.appendRow(item)

    def filterCount(self):
        return self._filterModel.rowCount()

    def chainCount(self):
        return self._chainModel.rowCount()

    def _onAdd(self):
        self.addClicked.emit()


if __name__ == '__main__':
    main()

The code itself is PyQt, but the discussion below also applies to PySide.

First, I replaced the QListWidgets with QStandardItemModel/QListView combinations. The architectural changes are as follows:

How It Works

The View is more Passive than it was before. It has no state except for what’s contained in the widgets, and it and does not have a reference to the Presenter. It has a clean public interface that consists entirely of getters, setters and signals. The getters and setters do just enough so that the Presenter can use the view without violating the Law of Demeter. All View widgets are treated as private.

The View has an “add” button. Clicking this causes the View to emit an “addClicked” signal. The way this works is that the add button’s “clicked” signal is connected to a private slot on the View, and this slot emits the View’s “addClicked” signal.

The View’s “addClicked” signal is connected to an “onAddClick” slot on the Presenter. The View communicates with the Presenter using signals, and the Presenter has a reference to the View. In the “onAddClick” slot, the Presenter uses this reference to manipulate the View.

In effect, events in the View send data to the Presenter, which then does any necessary processing, and then propagates any changes back to the View. This is consistent with how model-view-whatever implementations are usually supposed to work. WPF works like this; the main difference is that WPF calls the Presenter the ViewModel.

No signal from a widget is ever connected directly to a slot on another widget. There will be cases where one widget in the View just causes the hidden method to be called with a value, sending that value to the Presenter, and then then Presenter just sends that value directly back to the View, which puts it in another widget. This may seem crazy: why not just connect the signal on the first widget to the slot on the second widget? Well, that works fine until you actually want to do some processing on the value. What this architecture does is ensure that any growth of complexity will take place in the Presenter.

Notes on Scaling

For programs larger than this example, here are a couple of notes.

First, one main window or dialog box is one “View.” In tabbed layout, you might be tempted to treat each tab as a separate View, and create one Presenter per tab. I recommend not doing that.

Second, the “models” in Qt’s Model/View Programming classes are, in this architecture, part of the View. They hold data for widgets. I recommend using QStandardItemModel whenever possible. Subclass it if you need behavior that you need to subclass it for. If you need to store application-specific data, then have Qt return it when asked for that data in the Qt.UserRole. If you inherit from an abstract class higher up the hierarchy (such as QAbstractItemModel), then you’d be tempted to store data in the widget’s “model” that should be in the application’s Presenter.

How It Tests

How unit testable is this? Actually, very. First, remember that we only need to test the Presenter. The Presenter is a plain old Python object that is completely decoupled from Qt. Its View reference is introduced via dependency injection. The View itself has a very clean, very mockable interface. Here’s the unit test suite for the above code, using nose and mock:

tests.py

from filter_chain import ChainPresenter
from mock import MagicMock
from nose.tools import eq_, ok_


def test_initialize():

    view = MagicMock()
    presenter = ChainPresenter(view)
    presenter.initialize()
    ok_(view.appendToFilters.called)


def test_add_click():

    chain = []

    view = MagicMock()
    view.selectedFilterIndexes = MagicMock(
        return_value=[''])

    def appendToChain(value):
        chain.append(value)
    view.appendToChain = appendToChain
    presenter = ChainPresenter(view)
    presenter.onAddClick()
    eq_(1, len(chain))