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)
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 anAction
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
ofActions
(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 thebind()
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()
callsself.disable_all_widgets()
at the start to disable the widgets, andself.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. ThisAction
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.