5

I am trying to implement QgsTasks into a PlugIn to keep its GUI responsive while running. I have studied the docs at https://docs.qgis.org/3.16/en/docs/pyqgis_developer_cookbook/tasks.html, which unfortunately does not cover such a case in its examples and my Python skills are obviously too low to adapt these examples to my usecase. Also I have studied similar questions like Rewriting QgsTask abstract class? and How to use Threads in PyQgis, mainly to keep UI active? and Usage of QgsTask and QgsTaskManager and How do I maintain a resposive GUI using QThread with PyQGIS.

However, from these docs I dont understand how to implement it properly to run a function of a PlugIn as a task with all its benefits.

Currently, I am focusing my QgsTask experiments on QgsTask.fromFunction(). I have narrowed down my code to:

# imports ...

MESSAGE_CATEGORY = 'My Plugin'

class MyPlugin():
    def __init__(self, iface):
        # standard content by plugin builder
        
    def add_action()#standard
        # standard content by plugin builder

    def unload(self):
        # standard content by plugin builder
        
    def do_some_work(self):        
        QgsMessageLog.logMessage("starting worker",MESSAGE_CATEGORY,Qgis.Info)
        QgsMessageLog.logMessage("before var",MESSAGE_CATEGORY,Qgis.Warning) 
        inputlayer_numberoffields = self.selectedlayer.fields().count() # Function started from class_task crashes here, can not read variable
        QgsMessageLog.logMessage("after var",MESSAGE_CATEGORY,Qgis.Warning) 
        # do heavy work

    def maplayerselection(self):
        self.selectedlayer = self.dlg.MyQgsMapLayerComboBox.currentLayer()
        
    def run(self):
        if self.first_start == True:
            self.first_start = False
            self.dlg = MyPluginDialog()
            self.maplayerselection() 
            self.dlg.StartButton.clicked.connect(self.start_work_as_task) # See below
            #self.dlg.StartButton.clicked.connect(self.do_some_work) # Everything works fine. Can be started multiple times. Gui not responsive while running
        self.dlg.MyQgsMapLayerComboBox.currentIndexChanged.connect(self.maplayerselection)
        self.dlg.show()
        result = self.dlg.exec_()
    
    #class_task = QgsTask.fromFunction('Do something', do_some_work)
    def start_work_as_task(self):
        QgsMessageLog.logMessage("start_work_as_task before starting worker",MESSAGE_CATEGORY,Qgis.Warning)
        function_task = QgsTask.fromFunction('Do something', self.do_some_work()) 
        QgsApplication.taskManager().addTask(function_task) # Starts worker function and runs correctly, but does not bring any benefits (a responsive Gui), only disadvantages like crash on second start
        #QgsApplication.taskManager().addTask(self.class_task) # Starts worker function but does not run correctly. Worker function can not read variables from other functions
        QgsMessageLog.logMessage("start_work_as_task running after starting worker",MESSAGE_CATEGORY,Qgis.Warning)

My "working" implementation (function_task) unfortunately does not bring the expected advantage of a responsive GUI. What am I doing wrong here?

1 Answer 1

5

After a lot (and even more) of reading, trying and testing I still could not find a way to use QgsTasks in a QGIS Plugin. But with the help of mainly these two documents:

I was finally able to build a simple and basic QGIS Plugin using PyQt's QThreads and keeping the GUI active while running.

Imagine you create a QGIS Plugin called "TaskTest" using the PluginBuilder-Plugin and create a simple GUI:

enter image description here

The task_test.py file (explanations in the code as comments):

# -*- coding: utf-8 -*-
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, QObject, QThread, pyqtSignal
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction

from qgis.core import *
# Initialize Qt resources from file resources.py
from .resources import *
# Import the code for the dialog
from .task_test_dialog import TaskTestDialog
from time import sleep
from datetime import datetime
import os.path
import random

MESSAGE_CATEGORY = 'QgsTaskTestPlugin'

class Worker(QThread):
    finished = pyqtSignal() # create a pyqtSignal for when task is finished
    progress = pyqtSignal(int) # create a pyqtSignal to report the progress to progressbar
    
    def __init__(self):
        super(QThread, self).__init__()
        #print('workerinit')
        self.stopworker = False # initialize the stop variable
        
    def run(self):
        self.progress.emit(0) # reset progressbar
        self.time = 22 # just a variable to work with
        self.total = 10 # just a variable to work with
        wait_time = self.time / 100.0 # calculate the waste time
        # print('run') # debug: we just entered the run method of Worker-Class
        for i in range(self.total): # just do something
            sleep(wait_time) # just waste some time
            self.progress.emit(int((i+1)/self.total*100)) # report the current progress via pyqt signal to reportProgress method of TaskTest-Class
            if self.stopworker == True: # if cancel button has been pressed the stop method is called and stopworker set to True. If so, break the loop so the thread can be stopped
                print('break req')
                break
        self.finished.emit() # report via pyqt signal that run method of Worker-Class has been finished
        
    def stop(self):
        self.stopworker = True
        
class TaskTest:
    """QGIS Plugin Implementation."""
    
    def __init__(self, iface): # just the default generated by PluginBuilder
        # Save reference to the QGIS interface
        self.iface = iface
        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)
        # initialize locale
        locale = QSettings().value('locale/userLocale')[0:2]
        locale_path = os.path.join(
            self.plugin_dir,
            'i18n',
            'TaskTest_{}.qm'.format(locale))

        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        # Declare instance attributes
        self.actions = []
        self.menu = self.tr(u'&TaskTest')

        # Check if plugin was started the first time in current QGIS session
        # Must be set in initGui() to survive plugin reloads
        self.first_start = None

    # noinspection PyMethodMayBeStatic
    
    def tr(self, message): # just the default generated by PluginBuilder
        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
        return QCoreApplication.translate('TaskTest', message)

    def add_action(
        self,
        icon_path,
        text,
        callback,
        enabled_flag=True,
        add_to_menu=True,
        add_to_toolbar=True,
        status_tip=None,
        whats_this=None,
        parent=None): # just the default generated by PluginBuilder

        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if status_tip is not None:
            action.setStatusTip(status_tip)

        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            # Adds plugin icon to Plugins toolbar
            self.iface.addToolBarIcon(action)

        if add_to_menu:
            self.iface.addPluginToMenu(
                self.menu,
                action)

        self.actions.append(action)

        return action

    def initGui(self): # just the default generated by PluginBuilder
        """Create the menu entries and toolbar icons inside the QGIS GUI."""
        icon_path = ':/plugins/task_test/icon.png'
        self.add_action(
            icon_path,
            text=self.tr(u'TaskTestPlugin'),
            callback=self.run,
            parent=self.iface.mainWindow())

        # will be set False in run()
        self.first_start = True

    def unload(self): # just the default generated by PluginBuilder
        """Removes the plugin menu item and icon from QGIS GUI."""
        for action in self.actions:
            self.iface.removePluginMenu(
                self.tr(u'&TaskTest'),
                action)
            self.iface.removeToolBarIcon(action)

    def startWorker(self): # method to start the worker thread
        self.thread = QThread()
        self.worker = Worker()
        # see https://realpython.com/python-pyqt-qthread/#using-qthread-to-prevent-freezing-guis
        # and https://doc.qt.io/qtforpython/PySide6/QtCore/QThread.html
        self.worker.moveToThread(self.thread) # move Worker-Class to a thread
        # Connect signals and slots:
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)
        self.worker.progress.connect(self.reportProgress)
        self.thread.start() # finally start the thread
        self.dlg.Test_StartTest.setEnabled(False) # disable the start-thread button while thread is running
        self.thread.finished.connect(lambda: self.dlg.Test_StartTest.setEnabled(True)) # enable the start-thread button when thread has been finished

    def killWorker(self): # method to kill/cancel the worker thread
        self.worker.stop() # call the stop method in worker class
        # print('pushed cancel') # debugging
        # see https://doc.qt.io/qtforpython/PySide6/QtCore/QThread.html
        try: # to prevent a Python error when the cancel button has been clicked but no thread is running use try/except
            if self.thread.isRunning(): # check if a thread is running
                # print('pushed cancel, thread is running, trying to cancel') # debugging
                #self.thread.requestInterruption() # not sure how to actually use it as there are no examples to find anywhere, one somehow would need to listen to isInterruptionRequested()
                self.thread.exit() # Tells the thread’s event loop to exit with a return code.
                self.thread.quit() # Tells the thread’s event loop to exit with return code 0 (success). Equivalent to calling exit (0).
                self.thread.wait() # Blocks the thread until https://doc.qt.io/qtforpython/PySide6/QtCore/QThread.html#PySide6.QtCore.PySide6.QtCore.QThread.wait
        except:
            # print('kill worker - task does no longer exist') # debugging 
            pass
       
    def reportProgress(self, n): # method to report the progress to gui
        # print(f"Long-Running Step: {n}") # debugging purposes
        self.dlg.Test_ProgressBar.setValue(n) # set the current progress in progress bar
        
    def run(self):
        # Create the dialog with elements (after translation) and keep reference
        # Only create GUI ONCE in callback, so that it will only load when the plugin is started
        if self.first_start == True:
            self.first_start = False
            self.dlg = TaskTestDialog()
            self.dlg.Test_StartTest.clicked.connect(lambda: self.startWorker()) # call the startWorker method when button has been clicked
            self.dlg.Test_Cancel.clicked.connect(lambda: self.killWorker()) # call the killWorker method when button has been clicked
            
        # show the dialog
        self.dlg.show()
        # Run the dialog event loop
        result = self.dlg.exec_()
        # See if OK was pressed
        if result:
            # Do something useful here - delete the line containing pass and
            # substitute with your code.
            pass

The .ui file:

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>TaskTestDialogBase</class>
 <widget class="QDialog" name="TaskTestDialogBase">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>427</width>
    <height>300</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>TaskTest</string>
  </property>
  <widget class="QDialogButtonBox" name="button_box">
   <property name="geometry">
    <rect>
     <x>30</x>
     <y>240</y>
     <width>341</width>
     <height>32</height>
    </rect>
   </property>
   <property name="orientation">
    <enum>Qt::Horizontal</enum>
   </property>
   <property name="standardButtons">
    <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
   </property>
  </widget>
  <widget class="QProgressBar" name="Test_ProgressBar">
   <property name="geometry">
    <rect>
     <x>40</x>
     <y>150</y>
     <width>161</width>
     <height>31</height>
    </rect>
   </property>
   <property name="toolTip">
    <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Current Progress of Task-Test&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
   </property>
   <property name="locale">
    <locale language="English" country="UnitedStates"/>
   </property>
   <property name="value">
    <number>0</number>
   </property>
   <property name="textVisible">
    <bool>true</bool>
   </property>
   <property name="orientation">
    <enum>Qt::Horizontal</enum>
   </property>
   <property name="invertedAppearance">
    <bool>false</bool>
   </property>
  </widget>
  <widget class="QPushButton" name="Test_Cancel">
   <property name="geometry">
    <rect>
     <x>220</x>
     <y>150</y>
     <width>121</width>
     <height>31</height>
    </rect>
   </property>
   <property name="toolTip">
    <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Cancel the Task-Test&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
   </property>
   <property name="text">
    <string>Cancel Test</string>
   </property>
  </widget>
  <widget class="QPushButton" name="Test_StartTest">
   <property name="geometry">
    <rect>
     <x>40</x>
     <y>100</y>
     <width>251</width>
     <height>41</height>
    </rect>
   </property>
   <property name="toolTip">
    <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Start the Task-Test&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
   </property>
   <property name="text">
    <string>Start Test</string>
   </property>
  </widget>
 </widget>
 <resources/>
 <connections>
  <connection>
   <sender>button_box</sender>
   <signal>accepted()</signal>
   <receiver>TaskTestDialogBase</receiver>
   <slot>accept()</slot>
   <hints>
    <hint type="sourcelabel">
     <x>20</x>
     <y>20</y>
    </hint>
    <hint type="destinationlabel">
     <x>20</x>
     <y>20</y>
    </hint>
   </hints>
  </connection>
  <connection>
   <sender>button_box</sender>
   <signal>rejected()</signal>
   <receiver>TaskTestDialogBase</receiver>
   <slot>reject()</slot>
   <hints>
    <hint type="sourcelabel">
     <x>20</x>
     <y>20</y>
    </hint>
    <hint type="destinationlabel">
     <x>20</x>
     <y>20</y>
    </hint>
   </hints>
  </connection>
 </connections>
</ui>

Unfortunately my knowledge in this kind of stuff is still very limited, so I can not explain in detail, but as there are so few examples to find, I hope this basic example helps someone having the same questions and issues as I had/have.

Please see yourself still beeing encuraged to add your own answer so I can eventually move my accept checkmark. Or feel free to edit my answer if you can improve it.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.