Dugan Chen's Homepage

Various Things

A PyQt implementation of JQuery’s Promises and Deferreds

I tried to implement jQuery’s Promises and Deferreds in PyQt. I was moderately successful.

The use case here is chaining QNetworkAccessManager calls. When you have the name of an artist, it takes two calls to fetch the artist’s photo from last.fm. The first call fetches a reply that has the image URL. The second call fetches the image.

I want to see how feasible it would be to do it the way you would in jQuery. Here is an actual line that you’re about to see:

service.getArtistInfo(artist).then(getUrl)\
    .then(fetchArt)

In the above, getUrl and fetchArt are both callbacks.

The following example builds on my last entry, only with the artist’s bio replaced with the artist’s photo.

As mentioned, I was only moderately successful. When you write PyQt, you’re writing C++ in Python, and I had to fight the two languages the entire way. To deal with the fact that C++ is strongly-typed, I made the signals and callbacks always pass and take unicode strings. I also had to pay attention to Qt’s memory management model, where objects are deleted when their parents are.

The resulting example, with all its callbacks, is arguably even uglier and less readable than normal PyQt code would be. And no, I’m not going to try to unit test it. Yes, I realize that “I’m not going to try to unit test it” actually means “this isn’t actually usable.”

Nevertheless, here it is:

#!/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,
                         QLabel, QPixmap,
                         QMainWindow,
                         QVBoxLayout, QWidget)
from PyQt4.QtNetwork import (QNetworkAccessManager,
                             QNetworkRequest)
import base64
import json
import urlparse
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._photoLabel = QLabel()
        self._photoPixmap = QPixmap()
        self._photoLabel.setPixmap(
            self._photoPixmap)
        layout.addWidget(self._photoLabel)

        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 _onArtistIndexChanged(self, index):
        self.artistIndexChanged.emit(index)

    def setPixmapData(self, data):
        self._photoPixmap.loadFromData(data)
        self._photoLabel.setPixmap(
            self._photoPixmap)


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

        def getUrl(promise, data):
            deserialized = json.loads(data)
            artist = deserialized['artist']
            url = artist['image'][-1]['#text']
            promise._resolved.emit(url)
            promise.deleteLater()

        def fetchArt(promise, url):

            # Prevent the Promise from being
            # garbage-collected.
            promise.setParent(self._view)

            def artCallback(data):
                self._view.setPixmapData(data)
                promise.deleteLater()
            service.getArtistPhotoReply(
                url, artCallback)

        # A chain of network calls. The line that
        # justifies this example.
        service.getArtistInfo(artist)\
            .then(getUrl).then(fetchArt)

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


class Promise(QObject):

    _resolved = pyqtSignal(unicode)

    def __init__(self, callback, parent=None):
        super(Promise, self).__init__(parent)
        self._callback = callback

    def then(self, callback):
        promise = Promise(callback, self)
        self._resolved.connect(promise.resolve)
        return promise

    def resolve(self, data):
        self._callback(self, data)


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)
        reply = self._network.get(request)

        def callback(myPromise, replyText):
            myPromise._resolved.emit(replyText)
            myPromise.deleteLater()

        promise = Promise(callback, self)

        def handleReply():
            newReply = self.sender()
            newReply.deleteLater()
            data = reply.readAll().data()
            promise.resolve(data)

        reply.finished.connect(handleReply)

        return promise

    def getArtistPhotoReply(self, url, callback):
        qurl = QUrl.fromEncoded(url)
        request = QNetworkRequest(qurl)
        reply = self._network.get(request)

        def handleReply():
            newReply = self.sender()
            newReply.deleteLater()
            data = newReply.readAll()
            callback(data)
        reply.finished.connect(handleReply)

if __name__ == '__main__':
    main()