PyQGIS – Solving QgsTask Finished-Function Execution Issue in QGIS

multithreadingpyqgisqgis-3qgis-plugins

I am writing a plugin for QGIS. I created a task by subclassing QgsTask, it's basically copied from the PyQGIS Cookbook and looks like this:

import json
import urllib.request

from qgis.PyQt.QtCore import NULL
from qgis.core import QgsProject, QgsFeature, QgsVectorLayer, QgsTask

from .utils import point2wgs84, message_bar, log_message, LayerNotFoundError


class GeocodeTask(QgsTask):
    def __init__(self, only_missing: bool, config, finished_callback):
        if only_missing:
            super().__init__('Fehlende Adressen bestimmen')
        else:
            super().__init__('Alle Adressen bestimmen')

        self.finished_callback = finished_callback
        self.exception = None
        self.only_missing = only_missing
        self.config = config

        # Counters for progress display
        self.total_features = 0
        self.finished_features = 0
        self.failed_features = 0

        # DEBUG
        self.debug = True

    def finished(self, result):
        log_message('Aufgabe abgeschlossen', 'info')
        if result:
            log_message('Die Aufgabe "{}" wurde erfolgreich beendet. Es wurden {} Adressen nicht gefunden'.format(
                self.description, str(self.failed_features)), 'success')
            message_bar('Die Aufgabe "{}" wurde erfolgreich beendet. Es wurden {} Adressen nicht gefunden'.format(
                self.description, str(self.failed_features)), 'success')
        else:
            if self.exception is None:
                log_message(
                    'Die Aufgabe "{}" wurde nicht erfolgreich, aber ohne Fehlermeldung beendet.\n\
                    (Wahrscheinlich vom Benutzer abgebrochen)'.format(
                        self.description), 'warning')
                message_bar('Die Aufgabe "{}" wurde wahrscheinlich vom Benutzer abgebrochen.'.format(self.description),
                            'warning')

            else:
                log_message(
                    'Die Aufgabe "{}" wurde mit einem Fehler beendet: {}'.format(self.description, self.exception),
                    'critical')
                message_bar(
                    'Die Aufgabe "{}" wurde mit einem Fehler beendet: {}'.format(self.description, self.exception),
                    'critical')
                raise self.exception
        self.finished_callback()

    def geocode_feature(self, layer: QgsVectorLayer, feature: QgsFeature):
        if self.only_missing:
            # If the address is already set, continue with the next one
            if feature['address'] != NULL:
                return True

        # Get the coordinates of the point
        coord = feature.geometry().asPoint()
        coord_converted = point2wgs84(coord)
        lat = coord_converted[1]
        lon = coord_converted[0]

        # Send the coordinates to the osm api
        url = 'https://nominatim.openstreetmap.org/reverse?format=json&lat={lat}&lon={lon}'.format(lon=lon, lat=lat)
        con = urllib.request.urlopen(url)
        res = con.read().decode('utf-8')
        con.close()
        results = json.loads(res)

        try:
            # If road, house number, postcode and town is set
            result = results['address']['road'] + ' ' + str(results['address']['house_number']) + ', ' + \
                     results['address']['postcode'] + ' ' + results['address']['town']
        except KeyError:
            try:
                # If road, postcode and town is set, but the house number not
                result = results['address']['road'] + ', ' + results['address']['postcode'] + ' ' + results['address'][
                    'town']
            except KeyError:
                # If no address is found
                result = NULL

        # Save the address to the feature
        feature['address'] = result
        layer.updateFeature(feature)
        if result is NULL:
            return False
        else:
            return True

    def geocode_layer(self, layer: QgsVectorLayer):
        # DEBUG
        max_features = self.finished_features + 20

        layer.startEditing()
        for feature in layer.getFeatures():
            # Look if the task was cancelled
            if self.isCanceled():
                break

            # If geocode_feature() is true, an address was set or already found
            if not self.geocode_feature(layer, feature):
                self.failed_features += 1
            self.finished_features += 1

            # Display progress
            self.setProgress(self.finished_features / self.total_features * 100)

            # DEBUG
            if self.debug and self.finished_features > max_features:
                break
        layer.commitChanges()

    def run(self):
        try:
            try:
                layer_hydranten: QgsVectorLayer = QgsProject.instance().mapLayersByName(self.config.layers.hydranten)[0]
                layer_brunnen: QgsVectorLayer = QgsProject.instance().mapLayersByName(self.config.layers.brunnen)[0]
            except IndexError:
                message_bar('Ein Layer konnte nicht gefunden werden. Bitte überprüfen Sie die Einstellungen.',
                            level='critical')
                self.exception = LayerNotFoundError()
                return False

            if self.debug:
                self.total_features = 40
            else:
                self.total_features += layer_brunnen.featureCount()
                self.total_features += layer_hydranten.featureCount()

            self.geocode_layer(layer_brunnen)
            # Look if the task was cancelled
            if self.isCanceled():
                return False

            self.geocode_layer(layer_hydranten)
            # Look if the task was cancelled
            if self.isCanceled():
                return False
            return True
        except Exception as ex:
            self.exception = ex
            return False

The task runs great, but when it is finished, only the built-in QGIS message comes up that the task has been completed. What I wrote in the finished()-function is not executed at all. I start the task by pressing a menu entry that executes this piece of code:

self.task_running = True
task = GeocodeTask(False, self.config, self.set_task_running_false)
QgsApplication.taskManager().addTask(task)
message_bar('Task gestartet: Alle Adressen bestimmen')

And this is my QGIS version:

QGIS-Version                        3.20.0-Odense
QGIS-Codeversion                    decaadbb31
Qt-Version                          5.15.2
Python-Version                      3.9.5
GDAL-Version                        3.3.1
PROJ-Version                        8.1.0
EPSG-Registraturdatenbankversion    v10.027 (2021-06-17)
GEOS-Version                        3.9.1-CAPI-1.14.2
SQLite-Version                      3.35.2
PDAL-Versio                         2.3.0
PostgreSQL-Client-Version           13.0
SpatiaLite-Version                  5.0.1
QWT-Version                         6.1.3
QScintilla2-Version                 2.11.5
OS-Version                          Windows 10 Version 2009

Active Python-Plugins:              firstaid, GeoCoding, loeschwasser, pluginbuilder3, plugin_reloader, db_manager, processing

Best Answer

i found the same question on the right sidebar. (QGIS QgsTaks finished and completed never called)

It says that QGIS tasks in plugins are broken, because the finished() and completed() functions are never executed, but there is a way to achieve the same with Qt Signals.

I tried this and it worked:
I added a pyqtSignal to my task class:

class GeocodeTask(QgsTask):
    finished_signal = pyqtSignal(bool, str, int, object)

    def __init__(self, only_missing: bool, config):
        if only_missing:
            super().__init__('Fehlende Adressen bestimmen', QgsTask.CanCancel)
        else:
            super().__init__('Alle Adressen bestimmen', QgsTask.CanCancel)

        self.exception = None
        self.only_missing = only_missing
        self.config = config

        # Counters for progress display
        self.total_features = 0
        self.finished_features = 0
        self.failed_features = 0

Then i added every time, my run function could exit, this lines:

self.finished_signal.emit(False, self.description(), self.failed_features, self.exception)
return False

The first parameter shows if the task completed successfully, the second and third parameters are needed for a message and the last parameter contains an exception (if aviable) to trow it in the main thread.

As last, I moved the finished function to the main plugin class and registered it to the signal in the task:

    def geocode_finished(self, state: bool, description: str, failed_features: int, exception: object):
        if state:
            message_bar('Die Aufgabe "{}" wurde erfolgreich beendet. Es wurden {} Adressen nicht gefunden'.format(
                description, str(failed_features)), level='success')
        else:
            if exception is None:
                log_message(
                    'Die Aufgabe "{}" wurde nicht erfolgreich, aber ohne Fehlermeldung beendet.\n\
                    (Wahrscheinlich vom Benutzer abgebrochen)'.format(
                        description), level='warning')
                message_bar(
                    'Die Aufgabe "{}" wurde wahrscheinlich vom Benutzer abgebrochen.'.format(description),
                    level='warning')
            else:
                message_bar(
                    'Die Aufgabe "{}" wurde mit einem Fehler beendet: {}'.format(description, exception),
                    level='critical')
                raise exception
        self.task_running = False
# ...
    def geocode_all(self):
        self.task_running = True
        task = GeocodeTask(False, self.config)
        task.finished_signal.connect(self.geocode_finished)
        QgsApplication.taskManager().addTask(task)
        message_bar('Task gestartet: Alle Adressen bestimmen', level='info')
Related Question