I. Objectifs de cet article

L'objectif de cet article est simple, il doit vous permettre, à la fin, de savoir comment créer des modèles personnalisés pour tout types de vue en utilisant les classes de base de PySide/PyQt.

Ainsi vous saurez quelle classe de base choisir et dans quel cas l'utiliser, ou comment spécialiser de manière exhaustive la classe mère de tous les modèles QAbstractItemModel.

Note sur PySide vs PyQt : ces deux bindings Python sont compatibles en termes d'API, ainsi le code écrit en utilisant l'un devrait fonctionner sans encombre avec l'autre. Néanmoins PySide étant une implémentation plus libre (LGPL) que PyQt et possédant le soutien en terme de développement de Nokia (cf. About PySide), j'ai décidé de me baser sur cette implémentation et d'utiliser la documentation officielle de PySide pour documenter cet article.

C'est un choix personnel et le code présenté en exemple est utilisable pour les deux bibliothèques moyennant les imports adéquats.

II. Hiérarchie et vue d'ensemble

L'implémentation du patron de conception MVD (Modèle Vue Délégué) est pensé de la façon suivante pour Qt :

  • le modèle permet de stocker les données, de les modifier et de les distribuer ;
  • la vue permet d'afficher les données (en s'appuyant sur un modèle) ;
  • enfin le(s) délégué(s) servent à faire la transition entre l'édition des données et leur rendu dans la vue.

Cette définition quelque peu académique peut s'illustrer comme ceci. Imaginez un tableau permettant d'éditer les données d'une base de données :

  • la variable de type matrice stockant les données du tableau représente le modèle ;
  • le tableau tel que le voit l'utilisateur est la vue ;
  • enfin si l'on choisit, par exemple, des composants précis pour éditer certaines cases de ce tableau (ex. un menu déroulant), alors c'est un délégué qui se charge de gérer le cycle de vie de ce composant.

Dans l'esprit de ce patron, Qt a implémenté quelques vues et quelques modèles de base. Pour mieux se représenter le système, voici une petite représentation hiérarchique des classes modèles du framework :

Image non disponible
Hiérarchie des modèles

Ainsi que les délégués :

Image non disponible
Hiérarchie des délégués

Cet article se pose donc à la fois comme une introduction et comme un approfondissement car il existe déjà beaucoup de modèles de base répondant aux problématiques les plus courantes. Vous pouvez, par ailleurs, voir la partie ci-après Autres modèles de base pour autres objectifs pour la liste exhaustive des modèles existants et de leur utilisation.

Maintenant voyons comment étendre le système pour, par exemple, créer un nouveau modèle de type liste.

III. Création d'un nouveau modèle de type liste

La création d'un nouveau modèle de type liste passe par la création d'une classe ayant pour parent la classe QAbstractListModel.

Un bon code valant mieux que beaucoup d'explications, voici un exemple simple de création d'un nouveau modèle de type liste :

 
Sélectionnez

class SimpleListModel(QAbstractListModel):
    # Constructeur
    def __init__(self, mlist):
        QAbstractListModel.__init__(self)
	    self._items = mlist

    # re-définition du calcul du nombre de lignes
    def rowCount(self, parent = QModelIndex()):
        return len(self._items)

    # méthode principale gérant l'accès aux données via un index
    # avec une notion de role
    def data(self, index, role = Qt.DisplayRole):
        # Role simple de récupération des données
        if role == Qt.DisplayRole:
            return self._items[index.row()]
        # Role d'habillage des données
        elif role == Qt.BackgroundRole:
            if index.row() % 2 == 0:
                return QColor(Qt.gray)
            else:
                return QColor(Qt.lightGray)
        else:
            return None
			

Cet exemple est volontairement peu loquace en commentaires, la version complète écrite par Robin Burchell est disponible dans le fichier des sources téléchargeables de l'article.

Pour expliciter un peu, dans cet exemple, on étend la classe PySide/PyQt de base QAbstractListModel pour ajouter des fonctionnalités tout en conservant les implémentations des fonctions communes à tous types de listes, comme le tri (méthode sort()).

La documentation officielle est parfaite en elle-même, aussi je vous invite à la consulter dès qu'un point vous demande confirmation (ex. pour le QAbstractListModel). Elle nous précise qu'une sous-classe bien élevée ré-implémente la méthode headerData() précisant l'en-tête de la colonne.

De plus, si la vue n'est pas en lecture seule alors il est nécessaire d'implémenter les méthodes suivantes :

  • setData(index, value[, role=Qt::EditRole]) : permet de définir les données à la position index dans la table pour le rôle (role) donné ;
  • flags() : prenant en entrée l'index d'un élément et retournant l'indicateur Qt.ItemIsEditable ;
  • si la liste est de taille variable, les méthodes insertRows() et removeRows() doivent aussi être implémentées en appelant, juste avant et après l'insertion ou la suppression, les méthodes : beginInsertRows() et endInsertRows() ou beginRemoveRows() et endRemoveRows().

Pour avoir plus d'informations sur les différentes méthodes pouvant être ré-implémentées, il suffit de se reporter à la partie création d'un modèle depuis la base qui contient la liste exhaustive des méthodes disponibles.

Avec ce petit exemple on a réussi à implémenter un modèle pouvant gérer de manière personnalisée une simple liste d'objets. La partie suivante va nous permettre de créer un modèle personnalisé de type tableau.

IV. Création d'un nouveau modèle de type tableau

La création d'un nouveau modèle de type tableau passe par la création d'une classe ayant pour parent la classe QAbstractTableModel

Pour approfondir un peu plus, je vais présenter deux exemples, un basique et un porté sur le formatage. Ces deux exemples ont été créés par Eliot auteur du blog SaltyCrane.com et ont été adaptés pour cet article avec son autorisation. Voici donc le premier exemple permettant de créer un modèle simple de tableau :

 
Sélectionnez

from PySide.QtCore import *
from PySide.QtGui import *
import sys

# données à représenter
my_array = [['00','01','02'],
            ['10','11','12'],
            ['20','21','22']]

def main():
    app = QApplication(sys.argv)
    w = MyWindow()
    w.show()
    sys.exit(app.exec_())

# création de la vue et du conteneur
class MyWindow(QWidget):
    def __init__(self, *args):
        QWidget.__init__(self, *args)

        tablemodel = MyTableModel(my_array, self)
        tableview = QTableView()
        tableview.setModel(tablemodel)

        layout = QVBoxLayout(self)
        layout.addWidget(tableview)
        self.setLayout(layout)

# création du modèle
class MyTableModel(QAbstractTableModel):
    def __init__(self, datain, parent=None, *args):
        QAbstractTableModel.__init__(self, parent, *args)
        self.arraydata = datain

    def rowCount(self, parent):
        return len(self.arraydata)

    def columnCount(self, parent):
        return len(self.arraydata[0])

    def data(self, index, role):
        if not index.isValid():
            return QVariant()
        elif role != Qt.DisplayRole:
            return QVariant()
        return QVariant(self.arraydata[index.row()][index.column()])

if __name__ == "__main__":
    main()
				

Cet exemple n'est pas si différent du modèle précédent, on a juste adapté la structure des données pour gérer un tableau bidimensionnel plutôt qu'une liste simple. Mais cet exemple nous permet de profiter des facilités de la syntaxe de Python notamment pour la méthode columnCount() où l'on récupère la taille d'une tranche du tableau.

L'exemple suivant accorde une attention particulière au formatage des données et présente un petit programme permettant avec PySide/PyQt d'afficher le résultat de la commande "dir c:/" sous MS Windows :

 
Sélectionnez

import re
import os
import sys 
from PySide.QtCore import * 
from PySide.QtGui import * 
 
def main(): 
    app = QApplication(sys.argv) 
    w = MyWindow() 
    w.show() 
    sys.exit(app.exec_()) 
 
class MyWindow(QWidget): 
    def __init__(self, *args): 
        QWidget.__init__(self, *args) 

        # création du tableau
        self.get_table_data()
        table = self.createTable() 
         
        # mise en page
        layout = QVBoxLayout()
        layout.addWidget(table) 
        self.setLayout(layout) 

    def get_table_data(self):
        stdouterr = os.popen4("dir c:\\")[1].read()
        lines = stdouterr.splitlines()
        lines = lines[5:]
        lines = lines[:-2]
        self.tabledata = [re.split(r"\s+", line, 4)
                     for line in lines]

    def createTable(self):
        # création de la vue
        tv = QTableView()

        # création du modèle
        header = ['date', 'time', '', 'size', 'filename']
        tm = MyTableModel(self.tabledata, header, self) 
        tv.setModel(tm)

        # rajout d'une taille minimale
        self.setMinimumSize(400, 300)

        # et suppression du quadrillage
        tv.setShowGrid(False)

        # mise en place de la police
        font = QFont("Courier New", 8)
        tv.setFont(font)

        # suppression de l'en-tête verticale
        vh = tv.verticalHeader()
        vh.setVisible(False)

        # mise en place de l en-tête horizontale
        hh = tv.horizontalHeader()
        hh.setStretchLastSection(True)

        # redimensionnement de la largeur des colonnes
        tv.resizeColumnsToContents()

        # définition de la largeur des cellules
        nrows = len(self.tabledata)
        for row in xrange(nrows):
            tv.setRowHeight(row, 18)

        return tv
 
class MyTableModel(QAbstractTableModel): 
    def __init__(self, datain, headerdata, parent=None, *args): 
        QAbstractTableModel.__init__(self, parent, *args) 
        self.arraydata = datain
        self.headerdata = headerdata
 
    def rowCount(self, parent): 
        return len(self.arraydata) 
 
    def columnCount(self, parent): 
        return len(self.arraydata[0]) 
 
    def data(self, index, role): 
        if not index.isValid(): 
            return QVariant() 
        elif role != Qt.DisplayRole: 
            return QVariant() 
        return QVariant(self.arraydata[index.row()][index.column()]) 

    def headerData(self, col, orientation, role):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
            return QVariant(self.headerdata[col])
        return QVariant()

if __name__ == "__main__": 
    main()
				

Ce code (conçu pour Python 2.4) permet de bien illustrer la mise en place des en-têtes de colonnes et d'un formatage précis. Pour ceux souhaitant l'exécuter avec Python 3, une version modifiée et mise au goût du jour est disponible dans le zip des sources de l'article.

On sait maintenant comment gérer des modèles de types liste et tableau, mais de nombreux autres modèles existent déjà nativement dans la bibliothèque Qt et la partie suivante va les présenter avec leurs particularités.

V. Autres modèles de base pour d'autres objectifs

Nous allons évoquer dans cette partie les différents modèles existants de base dans PySide/PyQt

  • le QStringListModel est utilisé pour stocker des objets QString dans une simple liste ;
  • le QDirModel fournit des informations sur les fichiers et répertoires du système de fichiers local ;
  • le QStandardItemModel permet de gérer des structures complexes d'objets (arbres, listes, tableaux...) en se servant du composant de base QStandardItem. Pour plus d'informations sur la manière de l'utiliser, consultez la documentation officielle qui présente de très bons exemples ;
  • enfin les modèles QSqlQueryModel (Lecture seule), QSqlTableModel et QSqlRelationalTableModel permettent de gérer plus facilement les liens avec une base de données en utilisant une approche modèle/vue. Vous pouvez trouver un exemple d'utilisation de ce type de modèles dans mon précédent tutoriel : Introduction et prise en main de PyQt. Chacun de ces modèles se distingue par sa gestion des particularités des bases de données, ainsi le modèle le plus précis le QSqlRelationalTableModel permet d'obtenir un tableau éditable où les clés étrangères sont directement gérées par des menus déroulants.
Image non disponible
Modèle QSqlRelationalTable avec gestion d'une clé étrangère

VI. Créer son modèle depuis la base

Créer son propre modèle est un sujet assez avancé qui ne doit pas être abordé à la légère sous peine de perdre de longues heures en débogage. Commençons donc par nous fixer un objectif : nous allons créer à partir de la classe mère QAbstractItemModel un modèle nous permettant d'afficher des données dans une vue arborescente la QTreeView.

Image non disponible
Notre objectif : la QTreeView

Nous allons, pour aborder cette tâche, nous baser sur une approche existante, celle de Hardcoded Software (cf. section liens), en la simplifiant un peu et en se concentrant sur le code et son explication, mais d'abord un petit point sur les concepts abordés dans ce code :

Tout d'abord parlons des QModelIndex, leur rôle est de fournir une indexation des éléments de notre modèle conditionnée par la ligne, la colonne, et (potentiellement) le QModelIndex de son parent (d'où la création d'une hiérarchie et donc d'un arbre). Il est important de lire la description détaillée du QModelIndex et de la QAbstractItemModel pour bien savoir où mettre les pieds et ce que l'un attend de l'autre.

Le code ci-dessous va créer deux classes abstraites qui vont implémenter deux méthodes, très difficile à gérer index() et parent(), tout en restant suffisamment génériques pour pouvoir être utilisées dans n'importe quelle situation où l'on veut utiliser une vue en arbre. Pour expliquer les méthodes du code ci-dessous je parlerai donc d'indice en faisant référence au QModelIndex unique de chaque objet du modèle.

  • La méthode index() : permet de récupérer l'indice servant d'accesseur à un objet du modèle. Chaque objet du modèle a un indice et cette méthode permet de le récupérer, moyennant le triplet unique (ligne, colonne, parent) ;
  • La méthode parent() : permet de récupérer le parent de l'indice donné en paramètre ou un indice invalide si celui-ci n'a pas de parent.

Maintenant place au code :

 
Sélectionnez

# Tout d'abord créons un objet qui va jouer le rôle de noeud dans notre vue arborescente
# il est volontairement simple pour être adaptable à toutes situations
# avec :
#   - un parent ;
#   - un fils ;
#   - et un enregistrement associé (la valeur du noeud).
class TreeNode(object):
    def __init__(self, parent, row):
        self.parent = parent
        self.row = row
        self.subnodes = self._getChildren()

    def _getChildren(self):
        raise NotImplementedError()		

# Nous allons créer une classe mère de nos futurs modèles
# pour limiter la complexité et gérer ce que l'on peut gérer en amont :
class TreeModel(QAbstractItemModel):
    def __init__(self):
        QAbstractItemModel.__init__(self)
        self.rootNodes = self._getRootNodes()

    # à implémenter par la future classe fille
    def _getRootNodes(self):
        raise NotImplementedError()
    
    # cette méthode héritée de QAbstractItemModel doit retourner
    # l'indice de l'enregistrement en entrée moyennant le parent (un QModelIndex)
    # c.f. paragraph suivant pour plus d'explications.
    def index(self, row, column, parent):
    	# si l'indice du parent est invalide
        if not parent.isValid():
            return self.createIndex(row, column, self.rootNodes[row])
        parentNode = parent.internalPointer()
        return self.createIndex(row, column, parentNode.subnodes[row])

	# cette méthode héritée de QAbstractItemModel doit retourner
	# l'indice du parent de l'indice donné en paramètre
	# ou un indice invalide (QModelIndex()) si le noeud n'a pas de parent
	# ou si la requête est incorrecte
	# c.f. paragraph suivant pour plus d'explications.
    def parent(self, index):
        if not index.isValid():
            return QModelIndex()
        # on récupère l'objet sous-jacent avec la méthode internalPointer de l'indice
        node = index.internalPointer()
        if node.parent is None:
            return QModelIndex()
        else:
        	# si tout est valide alors on crée l'indice associé pointant vers le parent
            return self.createIndex(node.parent.row, 0, node.parent)

    def reset(self):
        self.rootNodes = self._getRootNodes()
        QAbstractItemModel.reset(self)

    def rowCount(self, parent):
        if not parent.isValid():
            return len(self.rootNodes)
        node = parent.internalPointer()
        return len(node.subnodes)

			

Avec l'implémentation de ces deux méthodes complexes (index() et parent()), on simplifie grandement notre modèle qui est maintenant utilisable :

 
Sélectionnez

			
class NamedElement(object): # notre structure interne pour gérer les objets
    def __init__(self, name, subelements):
        self.name = name
        self.subelements = subelements

# notre noeud concret implémentant getChildren
class NamedNode(TreeNode):
    def __init__(self, ref, parent, row):
        self.ref = ref
        TreeNode.__init__(self, parent, row)

	# renvoie la liste des noeuds fils en utilisant la liste subelements de 
	# notre objet (interne) NamedElement
    def _getChildren(self):
        return [NamedNode(elem, self, index)
            for index, elem in enumerate(self.ref.subelements)]
        
# et enfin notre modèle avec 
class NamesModel(TreeModel):
    def __init__(self, rootElements):
        self.rootElements = rootElements
        TreeModel.__init__(self)

    def _getRootNodes(self):
        return [NamedNode(elem, None, index)
            for index, elem in enumerate(self.rootElements)]

    def columnCount(self, parent):
        return 1

	# permet de récupérer les données liées à un indice et un rôle.
	# ces données peuvent ainsi varier selon le rôle.
    def data(self, index, role):
        if not index.isValid():
            return None
        node = index.internalPointer()
        if role == Qt.DisplayRole and index.column() == 0:
            return node.ref.name
        return None

    def headerData(self, section, orientation, role):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole \
            and section == 0:
            return 'Name'
        return None
			

Ce n'est pas du code prêt à l'emploi pour aller en production, mais il est suffisamment basique pour permettre de bien comprendre les concepts en jeu. Pour sa part Hardcoded Software a distribué, en plus de ce code, une version libre (sous license BSD) du code qu'ils utilisent avec des améliorations de conception et de performance disponible ici.

J'espère que cet exemple vous aura permis de bien appréhender la création d'un modèle à partir de la base. Bien sûr, ce code se base sur une représentation orientée arbre, mais je trouvais important d'aborder la question de la QTreeView. En effet, il est plus probable que vous partiez du QAbstractItemModel pour créer une vue arborescente, les modèles QAbstractListModel et QAbstractTableModel étant plus adaptés pour d'autres types de vues.

Cet exemple permet aussi de bien saisir les limitations du patron MVD car, si on est parti du principe que modèles et vues étaient combinables indépendemment, quand il s'agit d'un modèle d'arbre, l'implémentation devient rapidement spécifique et complexe.

La complexité autour de la création de son propre modèle a d'ailleurs poussé un des développeurs de Qt à construire un petit outil de vérification des modèles : ModelTest. Cet outil en C++ n'est pas disponible pour PySide/PyQt mais n'hésitez pas à me contacter si ce défi vous tente, je serais ravi d'aider.

VII. Conclusion

En conclusion, on a pu voir de nombreux modèles existant au sein de PySide/PyQt qui permettent de réaliser beaucoup sans avoir à recoder son propre modèle, ainsi que comment étendre certains modèles, voire créer son propre modèle en partant de la base.

Compte tenu des possibilités du système il reste assez rare de créer son propre modèle, mais j'espère que cet article vous permettra de vous lancer sans appréhension dans cette tâche, n'hésitez pas d'ailleurs à contribuer en retour, en communiquant ces modèles à la communauté pour permettre à chacun d'en profiter et d'apprendre.

VIII. Liens pour aller plus loin

IX. Remerciements...

Je tiens à remercier Thibaut Cuvelier responsable Qt pour son aide dans la rédaction de cet article, Robin Burchell et Eliot - SaltyCrane pour leur aide et leur expertise autour PySide/PyQt et enfin Jacques Thery pour sa relecture attentive.