0

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.)

Carl HR
  • 776
  • 5
  • 12
  • Well, it depends on the meaning you give to the terms you're using. For instance, I don't think that it's correct to say that "Python interpreter is paused on this line": in fact, the *execution* of the specific flow of that function is blocked because it's waiting for the function to return. And the concept of coroutine is ambiguous in this context (while conceptually correct in a broader sense, IMHO). I don't really understand your comparison with yield: an event loop is not a generator, it decides what to do with an event, or eventually exit (and that's the only case it returns something). – musicamante Jun 26 '23 at 14:39
  • I understand it's really hard to grasp. But here we go. What happens if you create an infinite while-loop inside python's script? It's the same thing as a heavy processing function: it freezes the GUI. The reason is because internally, qt5 is running a main event loop that emits events at every iteration. For each connected signals and slots to these events, functions are executed. – Carl HR Jun 26 '23 at 16:36
  • This happens inside a single thread (aka the main thread), so that means that if a function gets stuck and never returns, the next listener will never be called by the qt5 main event loop. So, it freezes the GUI, because no events are being processed anymore. The code is stuck on a while-loop. – Carl HR Jun 26 '23 at 16:36
  • The main reason I think that QEventLoop.exec_() behaves like a yield from a generator is because, if inside the QEventLoop.exec_ there was a infinite while-loop that keeps waiting for QEventLoop.quit, that function would freeze the GUI. But it does not. It returns immediately, and gives scope to the qt5 main event loop in the background, allowing it to execute any pending events from now on, until QEventLoop.quit is finally called. – Carl HR Jun 26 '23 at 16:37
  • @musicamante But how does QEventLoop.exec_() changes the execution scope? How does it makes it possible for, when QEventLoop.quit() is called, to comeback at that exact spot where QEventLoop.exec_ was called, and keep executing from that point onwards? It should call `yield` correct? That's my question. Is calling QEventLoop.exec() the equivalent form of calling `yield` to the qt5 main event loop, preventing it from freezing the GUI? And is calling QEventLoop.quit() the equivalent of continuing a generator function? Sounds dumb, but that's my doubt. – Carl HR Jun 26 '23 at 16:37
  • And also, I would finally like to know if it really behaves like a yield keyword from python, in all cases such as: recursion, thread-safety, and so on. – Carl HR Jun 26 '23 at 16:41
  • 1
    In `QEventLoop.exec` there **is** an infinite while loop. QApplication (basically, QCoreApplication) actually uses QEventLoop when calling its `exec()`. And it does *not* return immediately: it blocks the execution of the function(s) that called it, but that doesn't prevent the execution of *other* functions, because it's the event loop itself that calls them. And the yield assumption is actually wrong because generators work in the opposite way: they are "passive" functions that "unlock" when another item is requested, while an event loop is always active. – musicamante Jun 26 '23 at 17:14
  • The event loop does not "change" the execution scope, it actually calls other functions based on the event it processes. It looks like it is not blocking because any event that would require change is actually processed: mouse movements, keyboard interaction, system events, timers, etc. Theoretically, you can even avoid a QEventLoop (even `QApplication.exec()`) just by doing `while runningFlag: QApplication.processEvents()`. – musicamante Jun 26 '23 at 17:19
  • Ah ok, that makes sense... Thanks a lot. I tried to research about it, but found nothing: because the assumption was false since the start. In my head things geared way different. – Carl HR Jun 26 '23 at 17:32

1 Answers1

1

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.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Yes I get it now. What I was thinking was that there was only a single main event loop running in the background, and whenever a function called exec_(), it didn't create another loop on top of the first one, but simply yielded and returned the scope to the first loop. My bad for not reading the [documentation](https://doc.qt.io/qtforpython-5/PySide2/QtCore/QEventLoop.html#PySide2.QtCore.PySide2.QtCore.QEventLoop) about it... it really says "call exec() on it to start a local event loop. From within the event loop, calling exit() will force exec() to return". – Carl HR Jun 26 '23 at 18:27