* This post was written in jupyter and then pasted into wordpress *
In this post I will explain a method I came up with at work to add a graphical user interface (GUI) to a sequential python script – using generators.
Most tutorials on python generators focus on the advantage for writing iterators. For example the first line in https://wiki.python.org/moin/Generators is:
"Generators functions allow you to declare a function that behaves like an iterator, i.e. it can be used in a for loop."
That’s true and great. But in this post I will not use generators for iteration (at least not explicitly), but rather for their special ability to halt a function and later resume it.
At work we do a lot of exepriments with sound and temperature, and we write sequential scripts for them that we run in a terminal (or idlespork, my favorite IDLE fork). When we started working with volunteers, we wanted them to see some graphs on the screen, but also we didn’t want them to see an ugly terminal. We needed to add a GUI. The point I want to make is this:
Experiments scripts are sequential in their nature:
1. Enter details 2. Turn on oven and press enter 3. Turn off oven and press enter 4. Enter some data 5. Goodbye!
Usually, on the other hand, GUI is not sequential. It is object-oriented and event-driven. And that’s great of course. I love writing GUI in the usual way (I’m kidding, no one likes to write GUI). So how do we take working sequetial python scripts that need user input and add a GUI to it, with as little work as possible?
Step 0 – Have some code
The running example of this post will be a small script that lets you compute the number of beats per minute (BPM) of a song by manually pressing enter on each beat for, say, 30 seconds. Here it is:
import time def bpm(): print("[ 0] press enter on every beat ", end='') input() s = time.time() beats = 1 print("[ 0] press enter on every beat ", end='') while input() == '': print("[%3d] press enter on every beat " % (beats*60/(time.time() - s)), end='') beats += 1
The code is simple and sequential:
1. wait for first enter press, 2. wait for more enter presses, 3. on every enter press calculate the new BPM approximation.
I put on Benny Goodman and Charlie Christian’s A Smo-O-Oth One and ran it:
[ 0] press enter on every beat [ 0] press enter on every beat  press enter on every beat  press enter on every beat  press enter on every beat  press enter on every beat  press enter on every beat  press enter on every beat  press enter on every beat exit
Writing this post was the first time I used
input() in jupyter, and I was happily surprised to find it works.
Step 1 – Refactor screen printing
Displaying stuff is different when using a terminal than when using a GUI. By refactoring out the printing parts of the function, we get ready to replace them with a GUI statement.
def display_terminal(value): print("[%3d] press enter on every key " % value, end='') def bpm(displayfunc=display_terminal): displayfunc(0) input() s = time.time() beats = 1 displayfunc(0) while input() == '': displayfunc(beats * 60 / (time.time() - s)) beats += 1
Step 2 – Yieldify / Refactor out user input
Here we start to use generators. Instead of telling the script to wait for user input inside our function, we will have something external to get input, send it to our function, and then our function will yield back control to the external function.
This is how this looks, with comments indicating the two changed lines:
def bpm_gen(displayfunc=display_terminal): displayfunc(0) # input() yield s = time.time() beats = 1 displayfunc(0) # while input() == '': while (yield) == '': displayfunc(beats * 60 / (time.time() - s)) beats += 1 def bpm_loop(): gen = bpm_gen() last_input = None while True: try: gen.send(last_input) last_input = input() except StopIteration: break
bpm_loop creates a bpm generator and then relies on the
send mechanism to feed the generator with user input. This is where we’re using a generator in a slightly different way than normal – instead of iterating, we keep sending values.
The major point here is this:
We have converted waiting for user input in our function to waiting for our function in a loop that gets user input
This is possible due to the ability of generators to halt and later resume.
Step 3 – Add a GUI
We are ready to add actual GUI components. All we need to do is replace the
bpm_loop function above with something else that calls
This is how this looks with Tkinter:
import tkinter def bpm_gui(): # Create a window top = tkinter.Tk() top.minsize(width=400, height=50) top.maxsize(width=400, height=50) # Create a button beat = tkinter.Button(top, text="[ 0] click on every beat", font="courier") beat.place(x=0, y=0) # New display function that sets the button label def display_label(value): beat.config(text="[%3d] click on every beat" % value, font="courier") # Create a bpm generator gen = bpm_gen(displayfunc=display_label) # Start the generator, i.e. get to first yield next(gen) # Configure the button to call gen.send on each click beat.config(command=lambda: gen.send('')) top.mainloop()
bpm_gui creates a Tkinter window, adds a button, creates a bpm generator that displays BPM approxiamtions in the button’s label, and finally configures the button to call the generator’s
That’s it! We have added a GUI to a sequential script with as little change to the original function as possible. You can imagine how these three steps will work for basically any sequential terminal script. For textual user input (and not just pressing enter), we factor out the
input statement to a function that reads GUI text components, and then it can hide them and reorganize the main window. Even if we do this, we still keep our core logic sequential – we don’t have to encapsulate the core in a class, initialize members, and change the way we think of the code.
Step 4 – Run in a seperate thread
For the running example there’s no problem of speed, but at work we were playing music through headphones for a few seconds after each user input. This caused the GUI main loop to be stuck and it would show signs of dying (on Ubuntu the application window goes dark, on Windows you get the alarming “Not responding” in the title).
To fix this we must make sure the core code, which is now in a generator, be run in a seperate thread. I implemented this by sharing an
Event object between the two threads: the logic thread would wait on the event, and the GUI thread would set it when needed. There are more ways to do this and it really depends on the type of the user input you have.
In any case the code to communicate events can sit in the external functions – the core logic doesn’t need to be changed again. It remains sequential and pretty close to the original code you had.
Step 5 – Adding more stuff / Going overboard
Now that the original code works with a GUI, I can actually add something that I’ve wanted for a while: to see the distributon of BPM approximations with every beat. I.e. draw a matplotlib histogram figure on a canvas.
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2TkAgg from matplotlib.figure import Figure import matplotlib.pyplot as plt def bpm_hist_gui(): top = tkinter.Tk() # Create button beat = tkinter.Button(top, text="[ 0] click on every beat", font="courier") beat.grid(row=1, column=1, sticky=(tkinter.W, tkinter.E)) # Create a canvas fig = Figure(figsize=(5,4), dpi=100) canvas = FigureCanvasTkAgg(fig, master=top) canvas.get_tk_widget().grid(row=2, column=1, sticky=(tkinter.W, tkinter.E)) # Variable to keep bpms bpms =  # New display function that sets the button label def display_label(value): beat.config(text="[%3d] click on every beat" % value, font="courier") if value > 0: # Add value to bpms bpms.append(value) if len(bpms) > 10: # Draw new histogram fig.clear() p = fig.gca() # I actually want to see the BPM as calculated by every two consecutive beats/clicks. diffs = [1/((i + 1) / b2 - i / b1) for i, (b1, b2) in enumerate(zip(bpms[:-1], bpms[1:]))] p.hist(diffs, 20) p.set_xlabel('BPMs', fontsize = 15) p.set_ylabel('Frequency', fontsize = 15) canvas.show() # Create the generator gen = bpm_gen(displayfunc=display_label) # Start the generator, i.e. get to first yield next(gen) # Configure the button to call gen.send on each click beat.config(command=lambda: gen.send('')) top.mainloop()