pythonpyqt5yieldpyside2qeventloop

Is it safe to assume that using a QEventLoop is a correct way of creating qt5-compatible coroutines in python?


I'm using a custom QEventLoop instance in my code to simulate the QDialog.exec_() function. That is, the ability to pause the python script at some point without freezing the GUI, and then, at some other point of time after the user manually interacts with the GUI, the program resumes its execution right after the QEventLoop.exec_() call, by calling QEventLoop.quit(). This is the exact behaviour of what a coroutine should look like.

To illustrate the example, here's a MRE of what I'm doing:

from PySide2.QtWidgets import QApplication, QWidget, QVBoxLayout, QRadioButton, QButtonGroup, QDialogButtonBox
from PySide2.QtCore import Qt, QTimer, QEventLoop

recursion = 5

def laterOn():
    # Request settings from user:
    dialog = SettingsForm()

    # Simulate a coroutine.
    # - Python interpreter is paused on this line.
    # - Other widgets code will still execute as they are connected from Qt5 side.
    dialog.exec_()

    # After the eventloop quits, the python interpreter will execute from where
    # it was paused:

    # Using the dialog results:
    if (dialog.result):
        # We can use the user's response somehow. In this simple example, I'm just
        # printing text on the console.
        print('SELECTED OPTION WAS: ', dialog.group.checkedButton().text())

class SettingsForm(QWidget):
    def __init__(self):
        super().__init__()
        vbox = QVBoxLayout()
        self.setLayout(vbox)
        self.eventLoop = QEventLoop()
        self.result = False

        a = QRadioButton('A option')
        b = QRadioButton('B option')
        c = QRadioButton('C option')

        self.group = QButtonGroup()
        self.group.addButton(a)
        self.group.addButton(b)
        self.group.addButton(c)

        bbox = QDialogButtonBox()
        bbox.addButton('Save', QDialogButtonBox.AcceptRole)
        bbox.addButton('Cancel', QDialogButtonBox.RejectRole)
        bbox.accepted.connect(self.accept)
        bbox.rejected.connect(self.reject)

        vbox.addWidget(a)
        vbox.addWidget(b)
        vbox.addWidget(c)
        vbox.addWidget(bbox)

        global recursion
        recursion -= 1
        if (recursion > 0):
            QTimer.singleShot(0, laterOn)

    def accept(self):
        self.close()
        self.eventLoop.quit()
        self.result = True

    def reject(self):
        self.close()
        self.eventLoop.quit()
        self.result = False

    def exec_(self):
        self.setWindowModality(Qt.ApplicationModal)
        self.show()
        self.eventLoop.exec_()

###
app = QApplication()

# Initialize widgets, main interface, etc...
mwin = QWidget()
mwin.show()

QTimer.singleShot(0, laterOn)

app.exec_()

In this code, the recursion variable control how many times different instances of QEventLoop are crated, and how many times its .exec_() method is called, halting the python interpreter without freezing the other widgets.


It can be seen that the QEventLoop.exec_() acts just like a yield keyword from a python generator function. Is it correct to assume that yield is used every time QEventLoop.exec() is called? Or it's not something related to a coroutine at all, and another thing is happening at the background? (I don't know if there's a way to see the PySide2 source code, so that's why I'm asking.)


Solution

  • I believe you don't completely understand how event driven programming works.

    Fundamentally speaking, there is an infinite while loop that just waits for anything to happen.

    There is normally an event queue which is normally empty until something happens, and then it processes each event until the queue is empty again. Each event will eventually trigger something, normally by doing a function call.

    That loop will "block" the execution of anything that exists after the loop within the same function block, but that doesn't prevent it to call functions on itself.

    Consider this simple example:

    queue = []
    
    def myLoop(index):
        running = True
        while running:
            queue.extend(getSystemEvents())
            while queue:
                event = queue.pop(0)
                if event == 1:
                    doSomething(index)
                elif event == 10:
                    myLoop(index + 1)
                elif event < 0:
                    running = False
                    break
    
        print('exiting loop', index)
    
    def doSomething(index):
        print('Hello there!', index)
    
    def getSystemEvents():
        # some library that get system events and returns them
    
    myLoop()
    print('program ended')
    

    Now, what happens is that the Qt application interacts with the underlying OS and receives events from it, which is fundamentally what the getSystemEvents() pseudo function above does. Not only you can process events within the loop and call functions from it, but you can even spawn a further event loop that would be able to do the same.

    Strictly speaking, the first myLoop() call will be "blocked" until it exits, and you'll never get the last print until it's finished, but those functions can still be called, as the loop itself will call them.

    There is no change in the execution scope: the functions are called from the event loop, so their scope is actually nested within the loop.

    There is also no yield involved (at least in strict python sense), since an event loop is not a generator: while conceptually speaking they are similar in the fact that they are both routines that control the behavior of a loop, generators are actually quite different, as they are considered "semicoroutines"; while an event loop is always active (unless blocked by something else or interrupted) and it does not yield (in general programming sense), a generator becomes active only when actively called (for instance, when next() is called) and its execution is then blocked right after yielding, and will not progress until a further item is requested.

    A QEventLoop (which is actually used by the QCoreApplication exec() itself) fundamentally works like the example above (nested loops included), but with some intricacies related to threading, object trees and event dispatching/handling, because there can be multiple levels of event loops and handlers.

    This is also a reason for which sometimes the documentation discourages the usage of exec for some widgets that support event loops (specifically, QDialog), as there are certain situations for which the relations between objects can become extremely "controversial" and can cause unexpected behavior when any object in the tree "between" nested loops gets destroyed.