Dugan Chen's Homepage

Various Things

MVP with PyQt. With a Model layer.

Some of the peer feedback I received on my MVP for Qt (PyQt and PySide) made good points. Pushing all the complexity to a single monolithic Presenter class simply moves the problem; you still have to subdivide that complexity, or the Presenter will be huge. Furthermore, if you have a tabbed layout, there are benefits to having one behavioral class (Presenter, controller, etc) per tab. That way, you can decouple one tab’s behavior from that of the other tabs or from that of the main window. Decoupling is good architecture! I think, actually, that I can resolve this.

The solution is to introduce the “M” in MVP. The View layer and the Presenter layer remain, and they keep the same interfaces. Most of the Presenter’s implementation, however, will be refactored into a separate Model layer. The View uses signals to call the Presenter. The Presenter has references to both the View and the Model classes. It calls the Model classes to get things done, and then calls the View to update it as necessary.

It is a Model layer, and it can contain as many classes as you want. Including one per tab.

Furthermore, the Presenter now dispatches requests between the View and the Model layer, and does little else. All of the logic is now in the Model layer. That means that the unit test coverage will now be for the Model layer, and not for the Presenter or View.

Here’s a new example. Its main window has a text box where you enter your name. Below that are two tabs, both of which use the information in the text box. The “Greeting” tab has a “Greet” button and a “Clear” button. Clicking “Greet” causes the read-only text box on the Greeting tab to say “Hi, “, followed by the name that you entered. The “Snubbing” tab works similarly: it says “You suck” instead of “Hi”.

The View

First, let’s start with the View. For readability, I’ve defined each tab as a separate class. However, what I proposed yesterday still applies. The View is the entire main window, and it has getters and setters for properties on both tabs.

view.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 pyqtSignal
from PyQt4.QtGui import (QFormLayout, QLineEdit,
                         QMainWindow, QPushButton,
                         QTabWidget, QVBoxLayout,
                         QWidget)


class MainWindow(QMainWindow):

    clearGreetingClicked = pyqtSignal()
    greetClicked = pyqtSignal()

    clearSnubbingClicked = pyqtSignal()
    snubClicked = pyqtSignal()

    def __init__(self, parent=None):

        super(MainWindow, self).__init__(parent)

        layout = QVBoxLayout()
        self._nameEdit = QLineEdit()
        nameLayout = QFormLayout()
        nameLayout.addRow(self.tr('&Name'),
                          self._nameEdit)
        layout.addLayout(nameLayout)

        tabWidget = QTabWidget()

        self._greetTab = GreetTab()

        self._greetTab.writeClicked.connect(
            self._onGreetClick)
        self._greetTab.clearClicked.connect(
            self._onClearGreetingClick)
        tabWidget.addTab(self._greetTab,
                         self.tr('&Greet'))

        self._snubTab = SnubTab()
        self._snubTab.writeClicked.connect(
            self._onSnubClick)
        self._snubTab.clearClicked.connect(
            self._onClearSnubbingClick)

        tabWidget.addTab(self._snubTab,
                         self.tr('&Snub'))

        layout.addWidget(tabWidget)

        widget = QWidget()
        widget.setLayout(layout)
        self.setCentralWidget(widget)

    def name(self):
        return self._nameEdit.text()

    def setGreeting(self, value):
        self._greetTab.setMessage(value)

    def setSnubbing(self, value):
        self._snubTab.setMessage(value)

    def _onGreetClick(self):
        self.greetClicked.emit()

    def _onClearGreetingClick(self):
        self.clearGreetingClicked.emit()

    def _onSnubClick(self):
        self.snubClicked.emit()

    def _onClearSnubbingClick(self):
        self.clearSnubbingClicked.emit()


class GreetTab(QWidget):

    clearClicked = pyqtSignal()
    writeClicked = pyqtSignal()

    def __init__(self, parent=None):
        super(GreetTab, self).__init__(parent)
        layout = QVBoxLayout()
        greetLabel = self.tr('Gr&eet')
        greetButton = QPushButton(greetLabel)
        greetButton.clicked.connect(
            self._onWriteClick)
        layout.addWidget(greetButton)
        self._greeting = QLineEdit()
        self._greeting.setReadOnly(True)
        layout.addWidget(self._greeting)
        clearLabel = self.tr('&Clear Greeting')
        clearButton = QPushButton(clearLabel)
        clearButton.clicked.connect(
            self._onClearClick)
        layout.addWidget(clearButton)
        self.setLayout(layout)

    def setMessage(self, value):
        self._greeting.setText(value)

    def _onClearClick(self):
        self.clearClicked.emit()

    def _onWriteClick(self):
        self.writeClicked.emit()


class SnubTab(QWidget):

    clearClicked = pyqtSignal()
    writeClicked = pyqtSignal()

    def __init__(self, parent=None):
        super(SnubTab, self).__init__(parent)
        layout = QVBoxLayout()
        snubButton = QPushButton(self.tr('Sn&ub'))
        snubButton.clicked.connect(
            self._onWriteClick)
        layout.addWidget(snubButton)
        self._snubbing = QLineEdit()
        self._snubbing.setReadOnly(True)
        layout.addWidget(self._snubbing)
        clearLabel = self.tr('C&lear Snubing')
        clearButton = QPushButton(clearLabel)
        clearButton.clicked.connect(
            self._onClearClick)
        layout.addWidget(clearButton)
        self.setLayout(layout)

    def setMessage(self, value):
        self._snubbing.setText(value)

    def _onClearClick(self):
        self.clearClicked.emit()

    def _onWriteClick(self):
        self.writeClicked.emit()

The Presenter

The Presenter takes three constructor parameters. These parameters are the View and two Models: one for each tab. All it does is dispatch requests from the View to the Models and then update the View with what’s returned.

It has a handler for each message that the main window can send, and it knows about the entire main window. This makes it very easy to pass data from any part of the main window to any other part.

presenter.py

class Presenter(object):

    def __init__(self, view, greetingModel,
                 snubbingModel):
        self._view = view
        self._greetingModel = greetingModel
        self._snubbingModel = snubbingModel

    def onGreetClick(self):
        name = self._view.name()
        greeting = self._greetingModel.getText(name)
        self._view.setGreeting(greeting)

    def onClearGreetingClick(self):
        self._view.setGreeting('')

    def onSnubClick(self):
        name = self._view.name()
        snubbing = self._snubbingModel.getText(name)
        self._view.setSnubbing(snubbing)

    def onClearSnubbingClick(self):
        self._view.setSnubbing('')

The Models

Each of the two Model classes implements one tab’s behavior. This is a small example, but not too small to show this:

models.py

class AbstractModel(object):

    def __init__(self, intro):

        # Where intro is "You suck" or "Hi"
        self._intro = intro

    def getText(self, name):

        if len(name):
            return self._intro + ', ' + name

        return ''


class GreetingModel(AbstractModel):

    def __init__(self):
        super(GreetingModel, self).__init__('Hi')


class SnubbingModel(AbstractModel):

    def __init__(self):
        super(SnubbingModel, self).__init__(
            'You suck')
 

The Unit Tests

The unit test coverage is, of course, now for the Model classes.

tests.py

from nose.tools import eq_, ok_
from models import (AbstractModel, GreetingModel,
                    SnubbingModel)


def test_get_text():

    model = AbstractModel('Yo')
    eq_('Yo, Dugan', model.getText('Dugan'))


def test_get_text_empty():

    model = AbstractModel('')
    eq_('', model.getText(''))


def test_instantiations():
    ok_(GreetingModel())
    ok_(SnubbingModel())

The Application

The application starts the Qt event loop, instantiates the classes, connects the signals, etc.

greet_or_snub.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 import QtGui
from views import MainWindow
from models import GreetingModel, SnubbingModel
from presenters import Presenter
import sys


def main():
    app = QtGui.QApplication(sys.argv)
    view = MainWindow()
    presenter = Presenter(view, GreetingModel(),
                          SnubbingModel())
    view.greetClicked.connect(
        presenter.onGreetClick)
    view.clearGreetingClicked.connect(
        presenter.onClearGreetingClick)
    view.snubClicked.connect(presenter.onSnubClick)
    view.clearSnubbingClicked.connect(
        presenter.onClearSnubbingClick)
    view.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()