How do you run a blocking method asynchronously with Gio.Task in a Python+GTK app?

I’ve been struggling with this too. It seems like python bindings for Gio.Task do not work as expected. Anyway, I’ve managed to run background tasks using Gio.Task now. This is what I understand so far.

  1. Both make_lunch_async and make_lunch_finish should be part of the same class that defines make_lunch i.e. Cook class.

  2. A class that defines async functions and uses Gio.Task (in this case Cook class) should derive from GObject.Object.

  3. Probably the name make_lunch_async_callback should not be used for a function because there are two different functions called by make_lunch_async that do very different things

    1. One is called to prepare and run make_lunch in a thread. I’ve named this function _make_lunch_thread_callback.

      • This function should be private i.e. its name should start with an underscore.
      • This should be part of the same class which defines make_lunch_async (in this case Cook class).
      • This function should not do any UI changes.
    2. The other is called when previously mentioned function (_make_lunch_thread_callback) returns. I’ve named this function on_make_lunch_finished.

      • This is primarily used to update the UI according to the outcome.
      • This should be part of the same class in which we call make_lunch_async (in this case AppWindow class).
  4. Function make_lunch_finish is responsible for retrieving the return value from our task.

  5. The word result should not be used for outcome/return_value of make_lunch. Using it can be confusing since it is used by the documentation for function arguments of types that implement Gio.AsyncResult interface. Gio.Task also implements it.

    So, I use result as argument name only for our Gio.Task instance and only when it is being used as a Gio.AsyncResult.

    And I use only the word outcome to represent the outcome of make_lunch.

See code comments for more information.

#!/bin/env python3

import random
import time
import sys
import gi

gi.require_version("Gtk", "3.0")

from gi.repository import GObject, Gio, Gtk


# VIEWS

class App(Gtk.Application):
    def __init__(self):
        Gtk.Application.__init__(
            self,
            application_id="org.example.app",
            flags=Gio.ApplicationFlags.FLAGS_NONE
        )

    def do_activate(self):
        # Show default application window.
        window = AppWindow(self)
        self.add_window(window)
        window.show_all()


@Gtk.Template(filename="app-window.ui")
class AppWindow(Gtk.ApplicationWindow):
    __gtype_name__ = "AppWindow"

    start_button = Gtk.Template.Child("start-button")
    operation_box = Gtk.Template.Child("operation-box")
    progress_box = Gtk.Template.Child("progress-box")
    spinner = Gtk.Template.Child("spinner")
    success_box = Gtk.Template.Child("success-box")
    success_label = Gtk.Template.Child("success-label")
    failure_box = Gtk.Template.Child("failure-box")
    failure_label = Gtk.Template.Child("failure-label")
    back_button = Gtk.Template.Child("back-button")

    def __init__(self, app):
        super().__init__()
        self.set_application(app)

    @Gtk.Template.Callback()
    def on_start_button_clicked(self, button):
        """Handle the BUTTON's clicked signal."""
        self.operation_box.set_visible(False)
        self.progress_box.set_visible(True)
        self.spinner.start()

        cook = Cook()
        cook.make_lunch_async(self.on_make_lunch_finished, ("rice", "lentils", "carrots"))

    @Gtk.Template.Callback()
    def on_back_button_clicked(self, button):
        """Handle the BUTTON's clicked signal."""
        self.operation_box.set_visible(True)
        self.success_box.set_visible(False)
        self.failure_box.set_visible(False)
        button.set_visible(False)

    def on_make_lunch_finished(self, source_object, result, user_data):
        '''Get outcome from make_lunch_async and call show_outcome'''
        cook = source_object

        outcome = cook.make_lunch_finish(result)

        if outcome != -1:
            self.show_outcome(outcome)

        self.spinner.stop()

    def show_outcome(self, outcome):
        """Update application according to the outcome."""
        self.progress_box.set_visible(False)
        self.back_button.set_visible(True)

        if isinstance(outcome, Plate):
            message = "Lunch is ready: {}".format(outcome)
            self.success_label.set_text(message)
            self.success_box.set_visible(True)
        else:
            message = outcome.get("Error")
            self.failure_label.set_text(message)
            self.failure_box.set_visible(True)


# MODELS

class Plate():
    def __init__(self, ingredients):
        self.ingredients = ingredients

    def __str__(self):
        return ", ".join(self.ingredients)


class Cook(GObject.Object):
    def __init__ (self):
        super().__init__()

        # Required to hold actual task data since we cannot
        # set task data directly.
        self.pool = {}

    def make_lunch(self, *ingredients):
        time.sleep(5)

        outcomes = [
            Plate(ingredients),
            {"Error": "Lunch is burned!!"}
        ]

        return random.choice(outcomes)

    def make_lunch_async (self, callback, ingredients):
        '''Make lunch asynchronously (in the background)'''

        task = Gio.Task.new(self, None, callback, None)
        task.set_return_on_cancel(False)

        # Since we cannot set task data to anything accept None or an integer, we
        # set task data to id of our actual data (ingredients) and store the actual
        # data in an instance-wide dictionary (self.pool) with key being the id of our data.
        # Also, we need to remove our data from the dictionary when we're done with it.
        ingredients_id = id(ingredients)
        task.set_task_data(ingredients_id, lambda x: self.pool.pop(ingredients_id))
        self.pool[ingredients_id] = ingredients

        task.run_in_thread(self._make_lunch_thread_callback)

    # Below function is only for internal use. So, it should be private
    # i.e. it's name should start with an underscore
    def _make_lunch_thread_callback (self, task, source_object, task_data, cancellable):
        '''Called by make_lunch_async to make lunch in a thread'''

        if task.return_error_if_cancelled():
            return
        
        # Value of argument 'task_data' is always None for some reason.
        # So, we need to get task_data using task.get_task_data() method.
        task_data = task.get_task_data()

        # Task data is just an id of the actual data. So, we need to get
        # the actual data from our instance-wide dictionary
        ingredients = self.pool[task_data]

        outcome = self.make_lunch(*ingredients)

        task.return_value(outcome)

    def make_lunch_finish (self, result):
        '''Get outcome of making_lunch'''
        
        # Note that Gio.Task.is_valid is a class method. So, no matter if
        # you call it as Gio.Task.is_valid or as result.is_valid, you will
        # have to give it `result` as the first argument
        if not Gio.Task.is_valid(result, self):
            return -1

        return result.propagate_value().value


# RUN APP
if __name__ == "__main__":
    app = App()
    exit_status = app.run(sys.argv)
    sys.exit(exit_status)
1 Like