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?

MrXsquared
  • 34,292
  • 21
  • 67
  • 117

1 Answers1

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.

MrXsquared
  • 34,292
  • 21
  • 67
  • 117