23

I've read quite a lot about this subject in the past and watched some interesting talks like this one from Uncle Bob's. Still, I always find pretty difficult to architect properly my desktop applications and distinguish which should be the responsibilities on the UI side and which ones on the logic side.

Very brief summary of good practices is something like this. You should design your logic decoupled from UI, so that way you could use (theoretically) your library no matter which kind of backend/UI framework. What this means is basically the UI should be as dummy as possible and the heavy processing should be done on the logic side. Said otherwise, I could literally use my nice library with a console application, a web application or a desktop one.

Also, uncle Bob suggests differing discussions of which technology to use will give you a lot of benefits (good interfaces), this concept of deferring allows you to have highly decoupled well-tested entities, that sounds great but still is tricky.

So, I know this question is quite a broad question which has been discussed many many times over the whole internet and also in tons of good books. So to get something good out of it I'll post a very little dummy example trying to use MCV on pyqt:

import sys
import os
import random

from PyQt5 import QtWidgets
from PyQt5 import QtGui
from PyQt5 import QtCore

random.seed(1)


class Model(QtCore.QObject):

    item_added = QtCore.pyqtSignal(int)
    item_removed = QtCore.pyqtSignal(int)

    def __init__(self):
        super().__init__()
        self.items = {}

    def add_item(self):
        guid = random.randint(0, 10000)
        new_item = {
            "pos": [random.randint(50, 100), random.randint(50, 100)]
        }
        self.items[guid] = new_item
        self.item_added.emit(guid)

    def remove_item(self):
        list_keys = list(self.items.keys())

        if len(list_keys) == 0:
            self.item_removed.emit(-1)
            return

        guid = random.choice(list_keys)
        self.item_removed.emit(guid)
        del self.items[guid]


class View1():

    def __init__(self, main_window):
        self.main_window = main_window

        view = QtWidgets.QGraphicsView()
        self.scene = QtWidgets.QGraphicsScene(None)
        self.scene.addText("Hello, world!")

        view.setScene(self.scene)
        view.setStyleSheet("background-color: red;")

        main_window.setCentralWidget(view)


class View2():

    add_item = QtCore.pyqtSignal(int)
    remove_item = QtCore.pyqtSignal(int)

    def __init__(self, main_window):
        self.main_window = main_window

        button_add = QtWidgets.QPushButton("Add")
        button_remove = QtWidgets.QPushButton("Remove")
        vbl = QtWidgets.QVBoxLayout()
        vbl.addWidget(button_add)
        vbl.addWidget(button_remove)
        view = QtWidgets.QWidget()
        view.setLayout(vbl)

        view_dock = QtWidgets.QDockWidget('View2', main_window)
        view_dock.setWidget(view)

        main_window.addDockWidget(QtCore.Qt.RightDockWidgetArea, view_dock)

        model = main_window.model
        button_add.clicked.connect(model.add_item)
        button_remove.clicked.connect(model.remove_item)


class Controller():

    def __init__(self, main_window):
        self.main_window = main_window

    def on_item_added(self, guid):
        view1 = self.main_window.view1
        model = self.main_window.model

        print("item guid={0} added".format(guid))
        item = model.items[guid]
        x, y = item["pos"]
        graphics_item = QtWidgets.QGraphicsEllipseItem(x, y, 60, 40)
        item["graphics_item"] = graphics_item
        view1.scene.addItem(graphics_item)

    def on_item_removed(self, guid):
        if guid < 0:
            print("global cache of items is empty")
        else:
            view1 = self.main_window.view1
            model = self.main_window.model

            item = model.items[guid]
            x, y = item["pos"]
            graphics_item = item["graphics_item"]
            view1.scene.removeItem(graphics_item)
            print("item guid={0} removed".format(guid))


class MainWindow(QtWidgets.QMainWindow):

    def __init__(self):
        super().__init__()

        # (M)odel ===> Model/Library containing should be UI agnostic, right now it's not
        self.model = Model()

        # (V)iew      ===> Coupled to UI
        self.view1 = View1(self)
        self.view2 = View2(self)

        # (C)ontroller ==> Coupled to UI
        self.controller = Controller(self)

        self.attach_views_to_model()

    def attach_views_to_model(self):
        self.model.item_added.connect(self.controller.on_item_added)
        self.model.item_removed.connect(self.controller.on_item_removed)


if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)

    form = MainWindow()
    form.setMinimumSize(800, 600)
    form.show()
    sys.exit(app.exec_())

The above snippet contains a lot of flaws, the more obvious being the model being coupled to the UI framework (QObject, pyqt signals). I know the example is really dummy and you could code it on few lines using a single QMainWindow but my purpose is to understand how to architect properly a bigger pyqt application.

QUESTION

How would you architect properly a big PyQt application using MVC following good general practices?

REFERENCES

I've made a similar question to this here

BPL
  • 455
  • 1
  • 4
  • 11

3 Answers3

1

I'm coming from a (primarily) WPF / ASP.NET background and attempting to make an MVC-ish PyQT app right now and this very question is haunting me. I'll share what I'm doing and I'd be curious to get any constructive comments or criticism.

Here's a little ASCII diagram:

View                          Controller             Model
---------------
| QMainWindow |   ---------> controller.py <----   Dictionary containing:
---------------   Add, remove from View                |
       |                                               |
    QWidget       Restore elements from Model       UIElementId + data
       |                                               |
    QWidget                                         UIElementId + data
       |                                               |
    QWidget                                         UIElementId + data
      ...

My application has a lot (LOT) of UI elements and widgets that need to be easily modified by a number of programmers. The "view" code consists of a QMainWindow with a QTreeWidget containing items that get displayed by a QStackedWidget on the right (think Master-Detail view).

Because items can be added and removed dynamically from the QTreeWidget, and I'd like to support undo-redo functionality, I opted to create a model that keeps track of current / prior states. The UI commands pass information through to the model (adding or removing a widget, updating the information in a widget) by the controller. The only time the controller passes information up to the UI is on validation, event handling, and loading a file / undo & redo.

The model itself is composed of a dictionary of the UI element ID with the value it last held (and a few additional pieces of information). I keep a list of prior dictionaries and can revert to a previous one if someone hits undo. Eventually the model gets dumped to disk as a certain file format.

I'll be honest - I found this pretty hard to design. PyQT does not feel like it lends itself well to being divorced from the model, and I couldn't really find any open source programs trying to do something quite similar to this. Curious how other people have approached this.

PS: I realize QML is an option for doing MVC, and it seemed attractive until I realized how much Javascript was involved -- and the fact it's still fairly immature in terms of being ported to PyQT (or just period). The complicating factors of no great debugging tools (hard enough with just PyQT) and the need for other programmers to modify this code easily who don't know JS nixed it.

0

I wanted to build an application. I started writing individual functions that did tiny tasks (look for something in the db, compute something, look for a user with autocomplete). Displayed on the terminal. Then put these methods in a file, main.py..

Then I wanted to add a UI. I looked around different tools and settled for Qt. I used Creator to build the UI, then pyuic4 to generate UI.py.

In main.py, I imported UI. Then added the methods that are triggered by UI events on top of the core functionality (literally on top: "core" code is at the bottom of the file and has nothing to do with UI, you could use it from the shell if you want to).

Here's an example of method display_suppliers that displays a list of suppliers (fields: name, account) on a Table. (I cut this from the rest of the code just to illustrate the structure).

As the user types in the text field HSGsupplierNameEdit, the text changes and each time it does, this method is called so the Table changes as the user types.

It gets the suppliers from a method called get_suppliers(opchoice) which is independent from the UI and works from the console, too.

from PyQt4 import QtCore, QtGui
import UI

class Treasury(QtGui.QMainWindow):

    def __init__(self, parent=None):
        self.ui = UI.Ui_MainWindow()
        self.ui.setupUi(self)
        self.ui.HSGsuppliersTable.resizeColumnsToContents()
        self.ui.HSGsupplierNameEdit.textChanged.connect(self.display_suppliers)

    @QtCore.pyqtSlot()
    def display_suppliers(self):

        """
            Display list of HSG suppliers in a Table.
        """
        # TODO: Refactor this code and make it generic
        #       to display a list on chosen Table.


        self.suppliers_virement = self.get_suppliers(self.OP_VIREMENT)
        name = unicode(self.ui.HSGsupplierNameEdit.text(), 'utf_8')
        # Small hack for auto-modifying list.
        filtered = [sup for sup in self.suppliers_virement if name.upper() in sup[0]]

        row_count = len(filtered)
        self.ui.HSGsuppliersTable.setRowCount(row_count)

        # supplier[0] is the supplier's name.
        # supplier[1] is the supplier's account number.

        for index, supplier in enumerate(filtered):
            self.ui.HSGsuppliersTable.setItem(
                index,
                0,
                QtGui.QTableWidgetItem(supplier[0])
            )

            self.ui.HSGsuppliersTable.setItem(
                index,
                1,
                QtGui.QTableWidgetItem(self.get_supplier_bank(supplier[1]))
            )

            self.ui.HSGsuppliersTable.setItem(
                index,
                2,
                QtGui.QTableWidgetItem(supplier[1])
            )

            self.ui.HSGsuppliersTable.resizeColumnsToContents()
            self.ui.HSGsuppliersTable.horizontalHeader().setStretchLastSection(True)


    def get_suppliers(self, opchoice):
        '''
            Return a list of suppliers who are 
            relevant to the chosen operation. 

        '''
        db, cur = self.init_db(SUPPLIERS_DB)
        cur.execute('SELECT * FROM suppliers WHERE operation = ?', (opchoice,))
        data = cur.fetchall()
        db.close()
        return data

I don't know much about best practices and things like that, but this is what made sense to me and incidentally made it easier for me to get back to the app after a hiatus and wanting to make a web application out of it using web2py or webapp2. The fact the code that actually does the stuff is independent and at the bottom makes it easy to just grab it, and then just change how the results are displayed (html elements vs desktop elements).

0

... a lot of flaws, the more obvious being the model being coupled to the UI framework (QObject, pyqt signals).

So don't do this!

class Model(object):
    def __init__(self):
        self.items = {}
        self.add_callbacks = []
        self.del_callbacks = []

    # just use regular callbacks, caller can provide a lambda or whatever
    # to make the desired Qt call
    def emit_add(self, guid):
        for cb in self.add_callbacks:
            cb(guid)

That was a trivial change, that completely decoupled your model from Qt. You can even move it into a different module now.

Useless
  • 12,380
  • 2
  • 34
  • 46