Writing a GUI Application with Python and Py2App

Matt Harzewski
Jun 12 · 9 min read

Have you ever wanted to whip up a quick and dirty graphical interface for a utility you made, and give your Python script a first-class presence in your Dock? Apple doesn’t make it easy to give programs the graphical treatment without breaking out Swift/Objective-C and learning the whole Cocoa ecosystem. Fortunately, there is an easier way…

I’m the conflicted sort of computer user. One half of me loves the text-based world of terminal software, from piping data around in Bash to editing text in vim when it suits me. I’ve never touched a GUI when using Git, and I dabble in tiling window managers when I use Linux. The other half appreciates quality GUIs…and I’m certainly picky about them. I’ve been a fan of the Macintosh ecosystem since it was a minor fringe thing, and firmly believe that applications should strictly follow Apple’s Human Interface Guidelines. Applications should look and act like Mac apps first and be their own thing second.

Obviously I loathe Electron and its ilk. If I wanted my apps to feel like something other than Cocoa apps, I wouldn’t be using a Mac, would I? Over-designed web pages in a wrapper aren’t my cup of tea at all. That digression could be an article in itself, so I will spare you the ranting.

So here’s the scenario: we, as programmers, inevitably automate irritating tasks and throw together scripts that make life easier. While we may be comfortable with popping a terminal open and running something with the old python3 somefile.py, most people are not. If I want to help a family member or coworker or local volunteer organization out with a small bespoke bit of software, how can I make it accessible to them?

The two important things that we need are:

  1. A GUI, so the user can click fancy things instead of supplying arguments to an arcane terminal program.
  2. For the app to live in the Dock and be launch-able with a single click, like any other application.

The second point is actually the easiest problem to solve, surprisingly. There are tools out there that can automate the process of packaging programs into application bundles so the OS will treat them as we expect. Py2App is what we will be using here. For Windows users, there is a counterpart called Py2Exe. They work similarly, and our Python GUI app should work with both. So while the focus here is on the Mac OS, this guide is applicable to Windows as well.

GUI Frameworks

Python has many options for making GUIs, with different pros and cons. While it’s known for looking ugly and out of place, tkinter comes built in to Python. Then you have PyQT if you’re fond of the Qt framework, and wxPython. We will be using wxPython here. It’s a Python library implementing bindings for wxWidgets, a cross-platform GUI library that uses native UI widgets for the target platform. Its license is similar to LGPL, so you can mostly use it with impunity, whereas Qt licensing is complicated territory. (Qt has a split GPL and commercial license, so GPL projects can use it freely, but commercial ones will have to pay up if they don’t want their technically-derivative program to be GPL. Personally, I’m a big fan of the GPL and the Free Software movement, but I get that it could be limiting for some use cases…)

GUI programming is almost universally a pain. You’re typically going to write a sizable amount of boilerplate to draw a window and start laying out widgets how you want them. There isn’t really a way around that, no matter what tool you use, if you want to go beyond very simple UIs.

In a minute, we’re going to get dirty and start putting together a very basic GUI application with wxPython, but it’s also worth noting that there is also a fantastic tool called Gooey that can save some time for certain uses. Gooey uses wxPython under the hood, but automatically generates cookie-cutter GUIs based on command line arguments found in your Python script. If you use ArgumentParser for a command line interface, it can create a graphical UI with only a couple of added lines. This may be the path of least resistance for some tools, if you want to get it done quickly and aren’t too fussed about the particulars of the GUI beyond having one.

Making a GUI with wxPython

As an example, we’re going make a simple application that fetches the latest posts from the /r/programming section of Reddit and displays them in a table view. When clicked, the associated URL will open in your browser.

Simple, and looking exactly like the sort of demo one would make with Swift or Objective-C…but in Python.

To get a simple window up, let’s start with the following:

import wx
import requests, json
import webbrowser
class AppWindow(wx.Frame):
def __init__(self):
super().__init__(parent=None, title='/r/programming', size=(650, 400))
self.panel = wx.Panel(self)
self.Show()
if __name__ == '__main__':
app = wx.App(False)
window = AppWindow()
app.MainLoop()

We define a class of AppWindow that inherits the wx.Frame class. In wx parlance, a Frame is essentially a window. The init method takes care of setting basic window properties, such as the title and starting dimensions. Then we define a panel to put our UI elements into and call the inherited Show() method to display our Window.

In the __main__ block, we create the application context and instantiate the window for kicking off wx’s event loop. If we were to add window2 = AppWindow(), we would create two identical windows.

Creating new windows is a simple manner of instantiating more objects.

Now to expand things a bit by adding the table view. We need a self.items list to hold all of the data we’re going to query from Reddit, and a ListCtrl (table) element.

The build_table() and row_clicked() methods will be fleshed out momentarily. The former will construct the table columns and fill the table with data, while the second function is the event handler for when a table row is double clicked.

class AppWindow(wx.Frame):    
def __init__(self):
super().__init__(parent=None, title='/r/programming', size=(650, 400))
self.items = []
self.panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
self.table = wx.ListCtrl(
self, size=(-1, 400),
style=wx.LC_REPORT | wx.BORDER_SUNKEN
)
self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.row_clicked, self.table)
self.build_table()
sizer.Add(self.table, 0, wx.EXPAND)
self.SetSizer(sizer)
self.Show()
def build_table(self):
self.table.InsertColumn(0, 'Title', width=400)
self.table.InsertColumn(1, 'User', width=100)
self.table.InsertColumn(2, 'Comments', width=100)
# data will be loaded here
def row_clicked(self, event):
return

It looks a bit messy, but one important takeaway is that Sizers are like the AutoLayout system Apple uses in iOS/Mac apps…or sort of like CSS flexbox. If you define it right, it will ensure that elements will reflow to fit the window size. You could also define a fixed window size and manually set everything off of fixed sizes and coordinates, but resizing is a fairly basic feature. In this example, I’m defining a vertical “stack” Sizer and allowing the ListCtrl to expand horizontally. This way I could add more elements to the Sizer, and they would appear below the table.

As an aside, I could add a row of buttons below the table by making a horizontal Sizer full of button objects and adding it to the main sizer.

hsizer = wx.BoxSizer(wx.HORIZONTAL)
b1 = wx.Button(self, wx.ID_ANY, 'Button 1')
b2 = wx.Button(self, wx.ID_ANY, 'Button 2')
hsizer.Add(b1, 0, wx.EXPAND)
hsizer.Add(b2, 0, wx.EXPAND)
sizer.Add(hsizer, 0, wx.EXPAND)

We won’t actually leave these buttons here in the final application, but it serves to illustrate the basic idea behind the Sizer system. (Again, if you know CSS flex boxes or have played with Cocoa or JavaFx or any modern UI framework, you’ve seen something similar.)

Now let’s load up some data and fill the table.

def build_table(self):
self.table.InsertColumn(0, 'Title', width=400)
self.table.InsertColumn(1, 'User', width=100)
self.table.InsertColumn(2, 'Comments', width=100)
response = requests.get(
'https://www.reddit.com/r/programming.json',
headers = {'User-agent': 'Fancy Reddit GUI 1.0'}
).text
data = json.loads(response)
i = 0
for item in data['data']['children']:
self.items.append(item['data'])
self.table.InsertItem(i, item['data']['title'])
self.table.SetItem(i, 1, item['data']['author'])
self.table.SetItem(i, 2, str(item['data']['num_comments']))
i += 1

First off: this is actually bad. Since it’s a quick example, I’m just firing off a quick HTTP request to Reddit and grabbing some JSON output. However, it is bad practice to do network requests or other slow tasks on the same thread as the GUI. If something is going to take more than 100–200 milliseconds, kick it off onto another thread and update the main one later. Because otherwise the UI will hang while it waits for the request to complete, which really isn’t pleasant as far as user experience goes. For this example, it’s sufficient, but a real application should take care to avoid freezing up the UI.

Beyond that, the code should be fairly self explanatory. It’s just iterating the items in the JSON response, storing the relevant data in that self.items list we made in the first step, and populating the table. When adding items to the table, you first insert a new row with InsertItem and specify the index to insert into (hence the i counter). That first function call populates the first column in the row. Then you need to call SetItem with the row index, column index and data to set for each of the remaining columns.

Now let’s implement the event handler for when a row is double-clicked. The event object passed on has a couple of useful functions. If we accessed event.GetText(), we’d have the text of the first column (the article title) that was clicked. This may be good for some use cases, but what we really need is the URL of the Reddit submission. Fortunately, the event object also has a GetIndex() method that supplies the position of the row that was selected. Now we can pair that up with that item list we made before…

def row_clicked(self, event):
row = event.GetIndex()
item = self.items[row]
url = item['url']
webbrowser.open_new_tab(url)

Not bad. Now that we have the URL, we can simply pass it to Python’s handy webbrowser package to open it in the default browser.

Before we finish up, let’s add one last touch to the app: by default, the app doesn’t respond to Command+Q or Command+W, and the top application menu doesn’t do anything when you click it. Just add these two lines before self.Show():

self.menu_bar = wx.MenuBar()
self.SetMenuBar(self.menu_bar)

This creates a new, default menu bar object and sets it on the application, which restores the expected functionality. It’ll say “Python” instead of the name of your application for now, but once you package it up as a proper Mac application, the correct name will be pulled from the plist file in the bundle.

Bundling the App for Distribution

Now that you have a working GUI application, it’s time to turn it into a self-contained app bundle. As it stands, we still need to run the Python script from the command line, which isn’t convenient for most users. Using Py2App and setuptools, we can automatically package everything—including a Python runtime—into a .app bundle that can be run with a click, just like any native application.

It’s worth noting that everything we’ve done thus far should be fully cross-platform, so this app could run on MacOS, Windows or Linux. Py2App will generate a Mac bundle from the code, which will only work on a Mac, but you can use Py2Exe to make a Windows version just as easily. In fact, since they both use the Python setuptools utility, you could even write a setup script that can bundle both in one go. I will be focusing on the Mac side, but it shouldn’t be difficult to adapt it for other platforms.

If you haven’t already installed Py2App with pip, do so now. Then create a setup.py file in your application’s directory and add the following:

from setuptools import setupAPP = ['subreddit.py']
DATA_FILES = []
OPTIONS = {
'argv_emulation': True,
'site_packages': True,
#'iconfile': 'appicon.icns',
'packages': ['wx', 'requests'],
'plist': {
'CFBundleName': 'Subreddit',
}
}
setup(
app=APP,
data_files=DATA_FILES,
options={'py2app': OPTIONS},
setup_requires=['py2app'],
)

This is mostly generated boilerplate. You specify your application’s main filename in the APP portion, the user-facing application name at CFBundleName, and mention any third-party libraries you may have used in the packages list. (Anything you installed through pip and used in the app. Otherwise they tend to not get copied, which breaks things.) You can also specify an icon file. If you leave that line commented out, you’ll have a generic icon. Otherwise, you’ll have to convert a PNG or similar into an ICNS file.

Now run python3 setup.py py2app. The console will churn through a bunch of stuff, and you should end up with two new folders in your working directory: build and dist. The dist one should contain the finished app bundle, which you can double-click and run. It’ll be a bit chunky compared to a some native binaries (about 80-100MB) since it’ll have a Python interpreter and all of the libraries you used tagging along, but that seems to be par for the course these days.


There you have it: a simple, quick Python GUI application that can be distributed and run like any other Mac (or Windows) application.

You can find the complete code in this GitHub Gist.

The Startup

Medium's largest active publication, followed by +705K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store