LearnPyQt — One year in, and much more to come.
A quick retrospective on 2019

It's been a very good year.

Back in May I was looking through my collection of PyQt tutorials and videos and trying to decide what to do with them. They were pretty popular, but being hosted on multiple sites meant they lacked structure between them and were less useful than they could be. I needed somewhere to put them.

Having looked the options available for hosting tutorials and courses I couldn't find something that fit my requirements. So I committed the #1 programmer mistake of building my own. pythonguis.com was born, and it turned out pretty great.

Built on the Django-based Wagtail CMS it has been extended with some custom apps into a fully-fledged learning management system_._ But it's far from complete. Plans include adding progress tracking, certificates and some lightweight gamification. The goal here is to provide little hooks and challenges, to keep you inspired and experimenting with PyQt (and Python). The site uses a freemium model — detailed tutorials, with an upgrade to buy video courses and books for those that want them.

The availability of free tutorials is key — not everyone wants videos or books and not wanting those things shouldn't stop you learning. Even so, the upgrade is a one-off payment to keep it affordable for as many people as possible, no subscriptions here!

New Tutorials

Once the existing tutorials and videos were up and running I set about creating more. These new tutorials were modelled on the popular multithreading tutorial, taking frequently asked PyQt5 questions and pain points and tackling them in detail together with working examples. This led first to the (often dreaded) ModelView architecture which really isn't that bad and then later to bitmap graphics which unlocks the power of QPainter giving you the ability to create your own custom widgets.

Custom widgets

As the list of obvious targets dries up I'll be adding a topic-voting system on site to allow students to request and vote for their particular topics of interest, to keep me on topic with what people actually want.

New Videos

The video tutorials were where it all started, however in the past year these have fallen a little behind. This will be rectified in the coming months, with new video tutorials recorded for the advanced tutorials and updates to the existing videos following shortly after. The issue has been balancing between writing new content and recording new content, but that problem is solved now we have...

New Writers

With the long list of things to tackle I was very happy to be joined this year by a new writer — John Lim. John is a Python developer from Malaysia, who's been developing with PyQt5 for over 2 years and still remembers all the pain points getting started. His first tutorials covered embedding custom widgets from Qt Designer and basic plotting with PyQtGraph both of which were a huge success.

If you're interested in becoming a writer, you can! You get paid, and — assuming you enjoy writing about PyQt — it's a lot of fun.

New Types of Content

In addition to all the new tutorials and videos, we've been experimenting with new types of content on the site. First of all we have been working on a set of example apps and widgets which you can use for inspiration — or just plain use the code from — for your own projects. Everything on the site is open source and free to use.

Goodforbitcoin desktop image

We've also been experimenting with alternatives short-form tutorials & docs for core Qt widgets and features. The first of these by John covers adding scrollable regions with QScrollArea to your app. We'll have more of these, together with more complete documentation re-written for Python coming soon.

New Year

That's all for this year.

Almost. We're currently running a 50% discount on all courses and books with the code NEWYEAR20. Every purchase gets unlimited access to all future updates and upgrades, so this is a great way to get in ahead of all the good stuff coming down the pipeline.

The same code will give 20% off after New Year. Feel free to share it with the people you love, or wait a few days and share it with people you love slightly less.

Thanks for all your support in 2019, and here's to another great year of building GUI apps with Python!

Create GUI Applications with Python & Qt6
The easy way to create desktop applications

My complete guide, updated for 2021 & PySide6. Everything you need build real apps.

Downloadable ebook (PDF, ePub) & Complete Source code

To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount with the code [[ couponCode ]] — Enjoy!

For [[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount with the code [[ couponCode ]] — Enjoy!

Continue reading

Simple threading in PyQt/PySide apps with .start() of QThreadPool  PySide

In PyQt version 5.15.0 and PySide 6.2.0, the .start() method of QThreadPool was extended to take a Python function, a Python method, or a PyQt/PySide slot, besides taking only a QRunnable object. This simplifies running Python code in the background, avoiding the hassle of creating a QRunnable object for each task. For more information about creating a QRunnable object for multithreading, see the multithreading tutorial. The .start() method schedules the execution of a function/method/slot on a separate thread using QThreadPool, so it avoids blocking the main GUI thread of your app. Therefore, if you have one or more long-running tasks that need to be completed or be running in the background, pass them to .start() and be done. We'll build a simple demo app that simulates a long-running task to show how .start() can move a user-defined Python function/method or a PyQt/PySide slot onto a separate thread. But first, let’s begin with a flawed approach. Blocking the GUI Our demo app is a sheep counter that counts upwards from 1. While this is happening, you can press a button to pick a sheep. And since picking a sheep is hard, it takes some time to complete. This is how our demo app looks like. Make sure you're using PyQt 5.15.0+ or PySide 6.2.0+; otherwise, the demo app won’t work for you. PySide6 PyQt6 PyQt5 python import time from PySide6.QtCore import Slot, QTimer from PySide6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @Slot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @Slot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt6.QtCore import pyqtSlot, QTimer from PyQt6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt5.QtCore import pyqtSlot, QTimer from PyQt5.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function represents a long-running task! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() When you run the demo app and press the Pick a sheep! button, you’ll notice that for 5 seconds, the GUI is completely unresponsive. That's not good. The delay in GUI responsiveness comes from the line time.sleep(5) which pauses execution of Python code for 5 seconds. This was added to simulate a long-running task. We can, however, improve that by threading, as you’ll see later on. Feel free to experiment by increasing the length of the delay – pass a number greater than 5 to .sleep() – and you may notice that your operating system starts complaining about the demo app not responding. Run a task on a separate thread So, how can we improve the responsiveness of our demo app? This is where the extended .start() method of QThreadPool comes in! First, we need to import QThreadPool, so let’s do that. PySide6 PyQt6 PyQt5 python from PySide6.QtCore import QThreadPool python from PyQt6.QtCore import QThreadPool python from PyQt5.QtCore import QThreadPool Next, we need to create a QThreadPool instance. Let’s add python self.thread_manager = QThreadPool() to the __init__ block of the MainWindow class. Now, let’s create a pick_sheep_safely() slot. It will use the .start() method to call the long-running pick_sheep() slot and move it from the main GUI thread onto a separate thread. PySide PyQt python @Slot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # This is where the magic happens! python @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # This is where the magic happens! Also, make sure that you connect the pick_sheep_safely() slot with the pressed signal of self.pick_sheep_button. So, in the __init__ block of the MainWindow class, you should have python self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) And if you followed along, the code of our improved demo app should now be: PySide6 PyQt6 PyQt5 python import time from PySide6.QtCore import Slot, QThreadPool, QTimer from PySide6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @Slot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @Slot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @Slot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt6.QtCore import pyqtSlot, QThreadPool, QTimer from PyQt6.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() python import time from PyQt5.QtCore import pyqtSlot, QThreadPool, QTimer from PyQt5.QtWidgets import ( QLabel, QWidget, QMainWindow, QPushButton, QVBoxLayout, QApplication, ) class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.setFixedSize(250, 100) self.setWindowTitle("Sheep Picker") self.sheep_number = 1 self.timer = QTimer() self.picked_sheep_label = QLabel() self.counted_sheep_label = QLabel() self.layout = QVBoxLayout() self.main_widget = QWidget() self.thread_manager = QThreadPool() self.pick_sheep_button = QPushButton("Pick a sheep!") self.layout.addWidget(self.counted_sheep_label) self.layout.addWidget(self.pick_sheep_button) self.layout.addWidget(self.picked_sheep_label) self.main_widget.setLayout(self.layout) self.setCentralWidget(self.main_widget) self.timer.timeout.connect(self.count_sheep) self.pick_sheep_button.pressed.connect(self.pick_sheep_safely) self.timer.start() @pyqtSlot() def count_sheep(self): self.sheep_number += 1 self.counted_sheep_label.setText(f"Counted {self.sheep_number} sheep.") @pyqtSlot() def pick_sheep(self): self.picked_sheep_label.setText(f"Sheep {self.sheep_number} picked!") time.sleep(5) # This function doesn't affect GUI responsiveness anymore... @pyqtSlot() def pick_sheep_safely(self): self.thread_manager.start(self.pick_sheep) # ...since .start() is used! if __name__ == "__main__": app = QApplication([]) main_window = MainWindow() main_window.show() app.exec() When you press the Pick a sheep! button now, the pick_sheep() slot is executed on a separate thread and no longer blocks the main GUI thread. The sheep counting goes on, and the GUI remains responsive – even though our demo app still has to complete a long-running task in the background. Try increasing the length of the delay now – for example, time.sleep(10) – and notice that it doesn’t affect the GUI anymore. Conclusion And that’s it! I hope you’ll find the extended .start() method of QThreadPool helpful in any of your PyQt/PySide GUI apps that have long-running tasks to be executed in the background. More