A Python Tkinter GUI App That Won't Make You Puke Blood (Part 2)

Buliding a Pure Python / Tkinter application for downloading from FormSG (Part 2)

Sun, Jun 28, 2020 7-minute read
Source: Sara Kurfeß (https://unsplash.com/photos/-v7KyemBo4g)

This is the second part of my adventure in building a pure Python GUI application with Python's Tkinter library.

If you are looking to build simple GUI application in Python Tkinter and distribute it to your friends, but are having problems navigating the greek documentation, look no further, read on for how I did it.

TL;DR

GUI applications are a good way to share tools that you have built with non-technical friends / colleagues. And in Python, the standard library contains Tkinter ("Tk interface") for building GUI applications.

To keep a GUI application responsive, any task that doesn't return immediately should be hived off to a background thread.

  • This may be achieve by first defining a function for the actual task:

      def _my_slow_task():
          # processing...

    and letting the event handler start the function in a thread like so:

      def my_slow_task():
          threading.Thread(target=_my_slow_task(), daemon=True).start()
    
      # elsewhere
      my_widget.bind('<Button-1>', my_slow_task, '+')

Background

(This is the same as in the first post, please skip ahead as necessary.)

As part of Covid-19 response, I was involved in collecting information via a government website called FormSG. I was administering over 20 forms, with multiple versions of each form, and needed to do daily reporting.

  • In short, there was a lot of manual work involved and naturally, I automated the task using Selenium.

However, while I am comfortable running the automation script from commandline, I needed a way to share the tool with my colleague. And that's when I started taking a closer look at Tkinter. (Note: I initially shared the scripts used batch files, and taught my colleague how to the batch files.)

Previously…

In the first post, I talked about the mini-DSL I created using namedtuple and a simple loop to allow us to declare the UI layout with a simplified code.

In this post, we will continue on the good work that we have done, and the following:

  • explore defining actions,

  • binding the actions to our widgets, and

  • also adding some asynchronous goodness.

Creating Actions

An action (my personal terminology) is a piece of code that reacts to user interaction.

  • This may be known as callbacks or event handlers in other frameworks. But by using the word action, we can think of it from the user's perspective: when I (the user) clicks a button, I expects this action to be performed.

To define an action, simply define a method.

  • Note that the method must have access to the necessary state of the application.

  • As such, the method is conventionally defined as an instance method on the root application (the App class in our case).

In the example code fragment below, import_forms() is the action, and it prompts user for the path to the credential file, and proceeds to load the information using self._add_form(form) if the file path is valid.

class App:

    def __init__(self):

        # Variables for storing the state are declared here.
        # Other initialization (e.g., instantiating the widgets).
        ...

    # An example of an action    
    def import_forms(self):

        cred_file_path = filedialog.askopenfilename(multiple=False,
            filetype=[('FormSG Credentials File', '*.csv')])

        if cred_file_path:
            with open(cred_file_path, 'rt', encoding='utf-8') as cred_file:
                content = cred_file.readlines()

            for details in content:
                form = Form(*details.strip().split(','))
                self._add_form(form)

Binding Actions

To bind the action, I again used a mini-DSL similar to the one I used for declaring UI state (A+ for consistency for me!)

  • First, we use namedtuple from the standard library to define what an Action comprise, namely: (a) widget_name, being the name of the widget to bind to, (b) event, being the user interaciton event to listen for, and (c) callback, being the actual callback to invoke:

      Action = namedtuple('Action', 'widget_name event callback')
  • Secondly, we define the a list of Actions (two are shown in the example below):

      class App:
    
          def bind_actions(self):
    
              # list of actions defined here
              ACTIONS = [
                  Action('button_load-forms', '<Button-1>',
                      lambda _: self.import_forms()),
                  Action('button_download-submissions', '<Button-1>',
                      lambda _: self.download_all_forms()),
              ]
    
          # other methods...
  • Finally, we loop through each Action and actually bind them to the widgets:

      class App:
      
          def bind_actions():
    
              ACTIONS = [...] # list of actions
    
              # loop to actually bind the actions
              for widget_name, event, callback in ACTIONS:
                  self.widgets[widget_name].bind(event, callback, '+')

    (Note: The '+' passed as the last argument in the bind() method call means that each new action is added to the widget instead of replacing the previous. If there is a need for this to be configurable in the DSL, it could be evolved accordingly.)

Putting everything together, we have this:

# Step 1
Action = namedtuple('Action', 'widget_name event callback')

def App:

    def __init__(self):

        ...
        self.bind_actions()
        ...

    def bind_actions(self):

       # Step 2
       ACTIONS = [
           Action('button_load-forms', '<Button-1>',
               lambda _: self.import_forms()),
           ... (more actions)
       ]

       # Step 3
       for widget_name, event, callback in ACTIONS:
           self.widgets[widget_name].bind(event, callback, '+')

The main benefit (at least to me) of using this mini-DSL is two-fold:

  • First, the Action object gives a single name to several objects that logically forms an action: the widget, the event, and the callback. This abstraction reduces the mental burden by allow me to treat all three items as one single entity.

  • Second, it is the the centralization of all actions into a single spot. It forces me to think of all the actions together, and also allow me to see what actions are declared and bound to which widgets.

Hello Asynchronous Programming My Old Friend

The next challenge that arise is to keep the UI responsive (and interactable) even when the application is doing some computation / IO in the background.

The solution I chose is to have the main Action method spawn a background thread to do the processing, and the background thread will disable and enable the appropriate UI elements.

  • First, I wrote the method to perform the slow computation, which in our case is a method that uses Selenium to interact with a headless browser to download certain files:

      def _download_all_forms(self):
    
          self.disable_all_widgets()
    
          # Initialize selenium_gui
          selenium_gui._set_forms_details(self.forms)
          selenium_gui._init(
              self.download_path.get(),
              self.chrome_driver_path.get(), force=True)
    
          # Log into form.gov.sg
          self.login_to_formsg()
    
          # Download data for each form
          for form in self.forms:
              try:
                  selenium_gui.download_csv(form.name)
              except selenium.common.exceptions.WebDriverException as e:
                  print(f'[!] Error downloading data from form: {form}.')
                  print(e)
          print('[*] Download finished!')
    
          self.enable_all_widgets()

    One thing to note about the above code fragment is how _download_all_forms() calls self.disable_all_widgets() at the start to disable the widgets, and self.enable_all_widgets() at the end to re-enable the widgets.

    Because our application does only one thing, we can get away with disabling all widgets. In a bigger applications, only the relevant widgets should be disabled.

    We could have provided a "Cancel" button that would remain enabled which when clicked will terminate the current process. But this shall be left as an exercise for the interested reader.

  • Secondly, we create the Action that will be bound to a widget. This Action will start the method we defined above in a separate thread in order to keep the UI responsive:

    class App:
    
      # the actual method that will be bound to a widget
      def download_all_forms(self):
    
          threading.Thread(target=self._download_all_forms, daemon=True).start()
    
      def bind_actions(self):
    
          ACTIONS = [
              Action('button_download-all-forms', '<Button-1>',
                  lambda _: self.download_all_forms()),
              # more Actions...
          ]
    
          for widget_name, event, callback in ACTIONS:
              self.widgets[widget_name].bind(event, callback, '+')

Putting everything together:

class App:

    ...

    def bind_actions(self):

        ACTIONS = [
            Action('button_download-all-forms', '<Button-1>',
                lambda _: self.download_all_forms()),
            # more Actions...
        ]

        # loop to actually bind the actions
        for widget_name, event, callback in ACTIONS:
            self.widgets[widget_name].bind(event, callback, '+')

    def download_all_forms(self):

        threading.Thread(target=self._download_all_forms, daemon=True).start()

    def _download_all_forms(self):
        
        self.disable_all_widgets()

        # Initialize selenium_gui
        selenium_gui._set_forms_details(self.forms)
        selenium_gui._init(
            self.download_path.get(),
            self.chrome_driver_path.get(), force=True)

        # Log into form.gov.sg
        self.login_to_formsg()

        # Download data for each form
        for form in self.forms:
            try:
                selenium_gui.download_csv(form.name)
            except selenium.common.exceptions.WebDriverException as e:
                print(f'[!] Error downloading data from form: {form}.')
                print(e)
        print('[*] Download finished!')

        self.enable_all_widgets()

Sidenote: If you are interested in see how a background thread can synchronously pass control back to the user (perhaps for a confirmation), refer to the method login_to_formsg().

Lessons Learnt

It is always helpful to be able to build some sort of frontend for your application for ease of distributing any sort of functionality your have built.

In Python, the built-in Tkinter provides a quick-and-relatively-easy way to get a simple UI up-and-running.