Dugan Chen's Homepage

Various Things

PyQt Implementation of MSDN’s MVP Example

I had a look at Microsoft’s Model View Presenter example, and I couldn’t resist blogging a PyQt version.

For the PyQt version, we’ll have a dropdown list of music artists. When one artist is selected, its bio is retrieved from Last.FM and loaded into the UI.

I only loosely skimmed the article and its source code. What I have is consistent with Microsoft’s class diagram. As Microsoft does, I also pass instances of both the View and the Service into the Presenter.

The Service layer deserves mention. It smoothes out one of Qt’s more annoying and inconsistent parts, which is handling QNetworkReplys. See: Why QNetworkAccessManager should not have the finished(QNetworkReply *) signal. The ArtistInfoService’s “getArtistInfo” method returns an ArtistInfoReply. The ArtistInfoReply class has a “dataReceived” signal. When data is read from the network, the dataReceived signal is emitted with the deserialized reply, then both the ArtistInfoReply and the QNetworkReply that it encapsulates are automatically cleaned up. There is no need for clients to call self.sender(), or to call deleteLater() on anything.

I did what I could to not have my Last.FM API key in plain text. Yes, you can still easily steal it. This is my way of asking you not to.

As in previous examples, although the Presenter takes a View, neither the View nor the Presenter are responsible for connecting the View’s signals to the Presenter’s slots. This is to decouple them for testing.

Here is the complete application.

artist_bios.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, Qt, QUrl,
                          pyqtSignal)
from PyQt4.QtGui import (QApplication, QComboBox,
                         QMainWindow, QTextEdit,
                         QVBoxLayout, QWidget)
from PyQt4.QtNetwork import (QNetworkAccessManager,
                             QNetworkRequest)
import base64
import urlparse
import json
import sys


def main():

    app = QApplication(sys.argv)
    view = BigFourView()
    service = ArtistInfoService()
    presenter = BigFourPresenter(view, service)
    view.artistIndexChanged.connect(
        presenter.loadArtistAtIndex)
    presenter.loadArtistAtIndex(0)
    view.show()
    sys.exit(app.exec_())


class BigFourView(QMainWindow):

    artistIndexChanged = pyqtSignal(int)

    def __init__(self, parent=None):
        super(BigFourView, self).__init__(parent)

        layout = QVBoxLayout()

        artistBox = QComboBox()
        artistBox.currentIndexChanged.connect(
            self._onArtistIndexChanged)
        artistBox.addItem('Anthrax')
        artistBox.addItem('Megadeth')
        artistBox.addItem('Metallica')
        artistBox.addItem('Slayer')
        layout.addWidget(artistBox)
        self._artistBox = artistBox

        self._bioEdit = QTextEdit()
        self._bioEdit.setReadOnly(True)
        layout.addWidget(self._bioEdit)

        widget = QWidget()
        widget.setLayout(layout)
        self.setCentralWidget(widget)
        text = self.tr('Artist Bios')
        self.setWindowTitle(text)

    def artistData(self, index):
        return self._artistBox.itemData(
            index, Qt.DisplayRole)

    def artistIndex(self):
        return self._artistBox.currentIndex()

    def setBioHtml(self, html):
        self._bioEdit.setHtml(html)

    def _onArtistIndexChanged(self, index):
        self.artistIndexChanged.emit(index)


class BigFourPresenter(object):

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

    def loadArtistAtIndex(self, index):
        artist = self._view.artistData(index)

        service = self._artistInfoService
        reply = service.getArtistInfo(artist)
        reply.dataReceived.connect(
            self._onArtistDataReceive)

    def _onArtistDataReceive(self, data):
        bio = data['artist']['bio']['content']
        self._view.setBioHtml(bio)


class ArtistInfoService(QObject):

    def __init__(self, parent=None):
        super(ArtistInfoService, self).__init__(
            parent)
        self._apiKey = base64.b64decode(
            'Mjk1YTAxY2ZhNjVmOWU1MjFiZGQyY2Mz'
            'YzM2ZDdjODk=')

        self._network = QNetworkAccessManager(self)

    def getArtistInfo(self, artist):

        qurl = QUrl()
        url = urlparse.urlunsplit([
            'http', 'ws.audioscrobbler.com',
            '2.0/', '', ''])
        qurl.setEncodedUrl(url)
        qurl.addEncodedQueryItem('method',
                                 'artist.getinfo')
        qurl.addEncodedQueryItem('artist', artist)
        qurl.addEncodedQueryItem('api_key',
                                 self._apiKey)
        qurl.addEncodedQueryItem('format', 'json')
        request = QNetworkRequest(qurl)
        networkReply = self._network.get(request)
        return ArtistInfoReply(networkReply, self)


class ArtistInfoReply(QObject):

    dataReceived = pyqtSignal(dict)

    def __init__(self, reply, parent=None):
        super(ArtistInfoReply, self).__init__(
            parent)
        reply.finished.connect(self._onFinish)

    def _onFinish(self):
        self.deleteLater()
        reply = self.sender()
        reply.deleteLater()
        data = reply.readAll().data()
        deserialized = json.loads(data)
        self.dataReceived.emit(deserialized)


if __name__ == '__main__':
    main()

For tests, we’re only unit testing the Presenter. The View doesn’t need to be tested, and the Service needs not unit tests but integration tests.

Testing is slightly complicated by the implementation of the Presenter. It calls the Service to get an ArtistReply, and then connects the signal on the ArtistReply to a slot on the Presenter. When this signal is emitted, the Presenter updates the View.

This implementation is testable. We mock the View, the ArtistService and the ArtistReply. To the mock ArtistReply, we add a method to force the emission of the dataReceived signal. In two steps, the test suite first calls loadArtistAtIndex, which just hooks up the signal and exits, and then forces the signal to be emitted. Finally, it asserts that the value that was emitted has been propagated to the View.

Here is the test suite.

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 artist_bios import BigFourPresenter
from nose.tools import eq_


def test_presenter():

    view = MockView()
    view.setBioHtml('')
    reply = MockReply()
    service = MockService(reply)
    presenter = BigFourPresenter(view, service)
    presenter.loadArtistAtIndex(0)
    reply.emitDataReceived('this is the bio')
    eq_('this is the bio', view.bioHtml())


class MockView(object):

    def bioHtml(self):
        return self._bioHtml

    def artistData(self, index):
        return

    def setBioHtml(self, html):
        self._bioHtml = html


class MockService(object):

    def __init__(self, reply):
        self._reply = reply

    def getArtistInfo(self, artist):
        return self._reply


class MockReply(QObject):

    dataReceived = pyqtSignal(dict)

    def __init__(self, parent=None):
        super(MockReply, self).__init__(parent)

    def emitDataReceived(self, bio):

        data = {'artist': {'bio': {'content': bio}}
                }
        self.dataReceived.emit(data)