You are on page 1of 170

Packaging Python Applications with

PyInstaller
The hands-on guide to distributable Python apps
Martin Fitzpatrick

Version 1.0, 2022-06-04


Table of Contents
Getting Started with PyInstaller . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1. What is PyInstaller?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
2. Installing PyInstaller . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
3. My first app . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
Tweaking your Build . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4. Naming your app . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5. Application icons . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
6. Relative paths. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
7. Data files and Resources . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
8. Bundling data folders . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68
9. Hidden Imports, Excludes & Binaries . . . . . . . . . . . . . . . . . . . . . . . . . . 82
Building Installers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
10. Creating a Windows installer with InstallForge . . . . . . . . . . . . . . . . . 87
11. Creating a macOS Disk Image Installer . . . . . . . . . . . . . . . . . . . . . . . . 99
12. Creating a Linux Package with fpm . . . . . . . . . . . . . . . . . . . . . . . . . . 103
Signing Executables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
13. Signing Windows Applications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115
14. Signing macOS Application Bundles. . . . . . . . . . . . . . . . . . . . . . . . . . 118
Advanced Packaging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 120
15. Better Relative Paths . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
16. Detecting the current platform . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
17. Working with command-line arguments . . . . . . . . . . . . . . . . . . . . . . 127
18. Optimizing Packages . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
19. PyInstaller Hooks. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
Troubleshooting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
20. Build doesn’t complete . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
21. Built application doesn’t run . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
Appendix A: What next? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
Appendix B: Other Books by Me. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
©2022 Martin Fitzpatrick. All rights reserved. Source code examples
in this book are licensed under the BSD 3-clause license, and may be
freely used in your own projects.

1
Getting Started with PyInstaller
There is not much fun in creating your own application if you can’t share it
with other people — whether that means publishing it commercially,
sharing it online or just giving it to someone you know. Sharing your apps
allows other people to benefit from your hard work!

Packaging Python applications for distribution has typically been a little


tricky, particularly when targeting multiple platforms (Windows, macOS
and Linux). This is because of the need to bundle the source, data files, the
Python runtime and all associated libraries in a way that will work reliably
on the target system. Thankfully there are tools like PyInstaller available to
take care of this for you! In this chapter we’ll take the first steps in using
PyInstaller to package your Python applications.

In this chapter we’ll walk through the process of packaging a simple


application with PyInstaller.

2
1. What is PyInstaller?
PyInstaller is a cross-platform packaging system which supports building
desktop applications for Windows, macOS and Linux. It automatically
handles packaging of your Python applications, along with any associated
libraries and data files, either into a standalone one-file executable or a
distributable folder you can then use to create an installer.

The source downloads for this book include complete build


 examples for Windows, macOS and Ubuntu Linux.

3
2. Installing PyInstaller
PyInstaller works out of the box with all of the common Python GUI libraries
current versions of PyInstaller are compatible with Python 3.6+. Whatever
project you’re working on, you should be able to package your apps. This
tutorial assumes you have a working installation of Python with pip package
management working.

Virtual Environments
Python Virtual Environments allow you to create a self-contained Python
installation where you can install packages without affecting your other
installations. This is a good idea, because your projects may end up
depending on different specific versions of a library, and no longer work if
these change. By creating a virtual environment you can isolate your
different projects and upgrade things in a controlled way.

Problems commonly happen as the result of conflicting

 dependency versions being pulled in by other packages,


something referred to as dependency hell.

Virtual environments have a further benefit when packaging Python


applications, in that they allow you create a clean environment containing
only the packages that your application needs. This ensures that only actual
dependencies will be bundled into the package, keeping it as small as
possible.

Creating Virtual Environments

To create a virtual environment, open a terminal and change to your


project’s folder using cd. Next create a new virtual environment by running
venv as follows.

python3 -m venv env

4
The second argument env is the location to create the environment — here
we’re creating an environment in a folder called env in our projects folder.

You’ll want to exclude this virtual environment folder from

 your version control. If you’re using Git add env/ to the


.gitignore file.

Activating the Virtual Environment

To start working with your new environment, you’ll need to activate it.
When you activate a virtual environment the environment’s Python and
executables are put at the front of your shell’s PATH variable — meaning they
will be loaded in preference to any other Python installation.

On Linux or macOS:

source env/bin/activate

Or on Windows:

.\env\Scripts\activate

With the virtual environment activated you can use pip as normally to
install packages into the environment as normal. These packages can be
imported and used by any applications run using this Python environment.
If you run your GUI applications with the environment activated, they will
use this python executable. Packages created with PyInstaller will bundle
packages from the environment.

Leaving the Virtual Environment

You can exit an active virtual environment — for example, you switch
projects — by running deactivate:

5
deactivate

You can activate the virtual environment again using `activate ` — you don’t
need to recreate the environment again.

Installing PyInstaller with pip


You can install PyInstaller using pip, just like any other package. If you have
activated a virtual environment the package will be installed there.

pip3 install PyInstaller

If you experience problems packaging your apps, your first step should
always be to update your PyInstaller and hooks packages to the latest versions
using:

pip3 install --upgrade PyInstaller pyinstaller-hooks-contrib

The hooks module contains specific packaging instructions and


workarounds for common Python packages and is updated more regularly
than PyInstaller itself.

6
3. My first app
Before we can start building an application package, we need an
application! To begin with we’re going to start with the simplest, most bare-
bones application imaginable — a single window, with no icons or other
data files. By testing this basic example first you can be sure that the
underlying systems are all working. We can then move onto more
interesting things!

If you already have your own application you want to


package I would still recommend working through these
 initial examples first. Otherwise, when something goes
wrong you won’t know where the problem is.

There are a huge number of different Python GUI frameworks which you
may be using to develop your apps. I use PyQt almost exclusively, but you
may not — and it’s easier to follow code if you’re already familiar with the
libraries being used. For that reason, I’ve included example code for 5 of
the most popular Python GUI libraries below — PyQt6, Tkinter, wxPython,
PySimpleGUI and Kivy — throughout the book when introducing new
examples. The source code downloads with the book also include examples
for PyQt5, PySide6 and PySide2 — giving you 8 options in total.

If you’re using a GUI library not included in the source code


 downloads, let me know and I’ll look into adding it.

7
Your first application
It’s a good idea to start packaging your application from the very beginning so
you can confirm that packaging is still working as you develop it. This is
particularly important if you add additional dependencies. If you only think
about packaging at the end, it can be difficult to debug exactly where the
problems are.

For this example we’re going to start with a simple skeleton app, which
doesn’t do anything interesting. Once we’ve got the basic packaging process
working, we’ll start extending things, confirming the build is still working at
each step.

To start with add the following Python code in a file named app.py in your
project’s folder (where we created the virtual environment).

All the code in this book is available to download from

 http://www.pythonguis.com/d/python-packaging-source.zip
— download it now & save yourself some typing!

8
PyQt6

Listing 1. pyqt6/basic/app.py

from PyQt6.QtWidgets import QMainWindow, QApplication, QPushButton

import sys

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("Hello World")

button = QPushButton("My simple app.")


button.pressed.connect(self.close)

self.setCentralWidget(button)
self.show()

app = QApplication(sys.argv)
w = MainWindow()
app.exec()

To install PyQt6 run pip install pyqt6 from the command


 line.

9
Tkinter

Listing 2. tkinter/basic/app.py

import tkinter as tk

window = tk.Tk()
window.title("Hello World")

def handle_button_press(event):
window.destroy()

button = tk.Button(text="My simple app.")


button.bind("<Button-1>", handle_button_press)
button.pack()

# Start the event loop.


window.mainloop()

 Tkinter is installed by default with Python.

10
wxPython

Listing 3. wxpython/basic/app.py

import wx

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

self.button = wx.Button(self, label="My simple app.")


self.Bind(
wx.EVT_BUTTON, self.handle_button_click, self.button
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.button)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Hello World")
app.MainLoop()

To install wxPython run pip install wxpython from the


 command line.

11
PySimpleGUI

Listing 4. pysimplegui/basic/app.py

import PySimpleGUI as sg

layout = [[sg.Button("My simple app.")]]

window = sg.Window("Hello World", layout)

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED or event == "My simple app.":
break

window.close()

To install PySimpleGUI run pip install pysimplegui from


 the command line.

12
Kivy

Listing 5. kivy/basic/app.py

from kivy.app import App


from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.core.window import Window

Window.size = (300, 200)

class MainWindow(BoxLayout):
def __init__(self):
super().__init__()
button = Button(text="My simple app.")
button.bind(on_press=self.handle_button_clicked)

self.add_widget(button)

def handle_button_clicked(self, event):


App.get_running_app().stop()

class MyApp(App):
def build(self):
self.title = "Hello World"
return MainWindow()

app = MyApp()
app.run()

To install Kivy run pip install kivy from the command


 line.

13
This is a basic bare-bones application which creates a single window with a
pushbutton in it. Pressing the button will close the window. You can run
this app as follows:

python app.py

This should produce one of the following windows (depending on your GUI
library).

Figure 1. Simple app running in PyQt6, Tkinter, wxPythn, Kivy & PySimpleGUI

Building the basic app


Now that we have confirmed our simple application is working, we can
create our first test build. Open your terminal (shell) and navigate to the
folder containing your project. Run the following command to create a
PyInstaller build.

You always need to compile your app on the target system.

 So, if you want to build a Windows executable you’ll need to


do this on a Windows system.

14
pyinstaller app.py

You’ll see a number of messages output, giving debug information about


what PyInstaller is doing. These are useful for debugging issues in your
build, but can otherwise be ignored.

Listing 6. Output running pyinstaller on Windows

> pyinstaller app.py


388 INFO: PyInstaller: 4.7
388 INFO: Python: 3.7.6
389 INFO: Platform: Windows-10-10.0.22000-SP0
392 INFO: wrote app.spec
394 INFO: UPX is not available.
405 INFO: Extending PYTHONPATH with paths
....etc.

After the build is complete, look in your folder and you’ll notice you now
have two new folders dist and build.

 Build failed? Check the Troubleshooting chapter.

Figure 2. build & dist folders created by PyInstaller.

Below is a truncated listing of the folder structure showing the build and
dist folders. The actual files will differ depending on which platform you’re
building on, but the general structure is always the same.

15
.
├── app.py
├── app.spec
├── build
│ └── app
│ ├── localpycos
│ ├── Analysis-00.toc
│ ├── COLLECT-00.toc
│ ├── EXE-00.toc
│ ├── PKG-00.pkg
│ ├── PKG-00.toc
│ ├── PYZ-00.pyz
│ ├── PYZ-00.toc
│ ├── app
│ ├── app.pkg
│ ├── base_library.zip
│ ├── warn-app.txt
│ └── xref-app.html
└── dist
└── app
├── lib-dynload
...

The build folder is used by PyInstaller to collect and prepare the files for
bundling. It contains the results of analysis and some additional logs. For
the most part, you can ignore the contents of this folder, unless you’re
trying to debug issues.

The dist (for "distribution") folder contains the files to be distributed. This
includes your application, bundled as an executable file, together with any
associated libraries (for example the GUI library).

Everything necessary to run your application will be in this


folder, meaning you can take this folder and distribute it to
 someone else to run your app. They won’t need anything
else — they won’t even need to have Python installed!

You can try running your built app yourself now, by running the executable

16
file named app from the dist folder. After a short delay you’ll see the
familiar window of your application pop up as shown below.

Figure 3. Simple app, running after being packaged.

Build succeeded but the app failed to run? Check the


 Troubleshooting chapter.

If you’ve confirmed that the build works, try again but add the --windowed
flag. The --windowed command line option is required to build a .app
application bundle on macOS and to hide the terminal output window on
Windows. On Linux it has no effect.

pyinstaller --windowed app.py

17
Troubleshooting builds.

If you ever have problems running your built executable on


Windows, your first step should be to reenable the console
to get at debug output. You can then consult the
 Troubleshooting chapter. On macOS and Linux the debug
output is always available when running the executable
from a terminal, see the chapter for details. Just remember
to re-enable windowed/no-console mode for the final build!

In the same folder as your Python file, alongside the build and dist folders
PyInstaller will have also created a .spec file.

The .spec file


The .spec file contains the build configuration and instructions that
PyInstaller uses to package up your application. Every PyInstaller project has
a .spec file, which is generated based on the command line options you
pass when running pyinstaller.

When we ran pyinstaller with our script, we didn’t pass in anything other
than the name of our Python application file. This means our spec file
currently contains only the default configuration. If you open it, you’ll see
something similar to what we have below.

Listing 7. common/basic/app.spec

# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(['app.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],

18
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)

exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='app',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='app')

The first thing to notice is that this is a Python file, meaning you can edit it
and use Python code to calculate values for the settings. This is mostly
useful for complex builds, for example when you are targeting different
platforms and want to conditionally define additional libraries or
dependencies to bundle.

If you’re building on macOS and passed the --windowed flag you’ll also have

19
an additional BUNDLE block, which is used to build the .app bundle. That
section will look something like this:

app = BUNDLE(coll,
name='app.app',
icon=None,
bundle_identifier=None)

If you’re starting your build on another platform, but want to target macOS
later you can add this to the end of your .spec file manually. You can also
toggle the Windows console output by editing console=True to
console=False in the .spec file — this is the equivalent of the --windowed
flag.

Once a .spec file has been generated, you can pass this to pyinstaller
instead of your script to repeat the previous build process. Run this now to
rebuild your executable.

pyinstaller app.spec

The resulting build will be identical to the build used to generate the .spec
file (assuming you have made no changes to your project). For many
PyInstaller configuration changes you have the option of passing command-
line arguments, or modifying your existing .spec file. Which you choose is
up to you, although I would recommend editing the .spec file for more
complex builds.

As already mentioned, you can only build for a given


platform on that platform — i.e. if you want to build a
Windows executable, you’ll need to do it on Windows.

 However, ideally you want to be able to do this using the


same .spec file, to simplify maintenance. If you want to
target multiple platforms try your .spec file now on other
systems to ensure the build is set up correctly.

20
Tweaking your Build
So far we’ve created the simplest application possible & successfully
packaged it with PyInstaller. The packaged version of the application runs as
expected.

 Not working for you? Check the Troubleshooting chapter.

In this chapter we’ll take this simple example and start expanding it, step by
step. We’ll customize our PyInstaller build, adding additional dependencies
on data files, and looking at how to handle problems with 3rd party
libraries. By the end of this chapter you’ll be able to package real
applications.

21
4. Naming your app
One of the simplest changes you can make is to provide a proper "name" for
your application. By default the app takes the name of your source file
(minus the extension), for example main or app. This isn’t usually what you
want to name the executable.

You can provide a nicer name for PyInstaller to use for your executable file
(and dist folder) by editing the .spec file and changing the name= under the
EXE and COLLECT blocks (and BUNDLE on macOS).

Listing 8. common/custom/hello-world.spec

exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='hello-world',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='hello-world')

The name under EXE is the name of the executable file while the name under
COLLECT is the name of the output folder.

22
I’d recommend you to use a name with no spaces for the
 executable — use hyphens or CamelCase instead.

The name specified in the BUNDLE block is used for the macOS app bundle,
which is the user-visible name of the application shown in Launchpad and
on the dock. In our example we’ve called our application executable "hello-
world", but for the .app bundle you can use the more friendly "Hello
World.app".

Listing 9. common/custom/hello-world.spec

app = BUNDLE(coll,
name='Hello World.app',
icon=None,
bundle_identifier=None)

Alternatively, you can re-run the pyinstaller command and pass the -n or
--name configuration flag along with your app.py script.

pyinstaller --windowed -n "hello-world" app.py


# or
pyinstaller --windowed --name "hello-world" app.py

The resulting executable file will be given the name hello-world and the
unpacked build placed in the folder dist\hello-world\. The name of the
.spec file is taken from the name passed in on the command line, so this
will also create a new spec file for you, called hello-world.spec in your root
folder.

If you’ve created a new .spec delete the old one to avoid


 getting confused!

23
Figure 4. Application with custom name "hello-world".

24
5. Application icons
One of the first things you will want to do is add an icon to your application.
This is a bit more complicated than it sounds, particularly if you’re targeting
multiple platforms. Despite the similarities that exist elsewhere, for some
reason the one thing OS designers have been unable to agree on is how to
specify window icons. Windows uses ICO files, macOS ICNS, Linux uses
multiple PNG or SVG files. Even worse, some GUI libraries have different
requirements depending on what you’re using the icons for.

It’s a mess. But we’ll step through the important bits here. The good news is
that once you’ve got your project working, you won’t need to touch it again.

25
A note about icons.

The icon files you can use will vary depending on the GUI
library you’re using and the platform(s) you’re working
with.

PyQt and PySide support any formats across all platforms,


including Scalable Vector Graphics (SVG) for scalable icons. It
automatically converts provided icons to the appropriate
format for the platform your application is running on.
WxPython doesn’t support SVG icons, but can load any

 standard bitmap format file to use as the icon. Kivy requires


PNG files. Other libraries load the icon directly and will
need a platform-specific icon type — for example ICO on
Windows, PNG on Linux.

On macOS the multiple icon sizes can be provided by an


ICNS file included the .app bundle and you can omit the
method-call in your code.

Yes, this is confusing! If you need to you can detect the


current platform and use this to load specific icons. See
Detecting the current platform for details.

Window Icons
We can set the application icon used for the window/dock directly from the
source code. Where the icons appear will depend on your current platform
— Windows will show the icons on the window and (sometimes!) the
taskbar, macOS will show the icons on the Dock and on Linux the icon will
be used in the dock/taskbar depending on your window manager.

Below are examples for setting the window icon for each of the different
GUI frameworks.

26
PyQt6

Listing 10. pyqt6/custom/app.py

from PyQt6.QtWidgets import QMainWindow, QApplication, QPushButton


from PyQt6.QtGui import QIcon

import sys

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("Hello World")

button = QPushButton("My simple app.")


button.pressed.connect(self.close)

self.setCentralWidget(button)
self.show()

app = QApplication(sys.argv)
app.setWindowIcon(QIcon("icon.svg"))
w = MainWindow()
app.exec()

27
Tkinter

Listing 11. tkinter/custom/app.py

import tkinter as tk

window = tk.Tk()
window.title("Hello World")

def handle_button_press(event):
window.destroy()

button = tk.Button(text="My simple app.")


button.bind("<Button-1>", handle_button_press)
button.pack()

# Start the event loop.


window.iconbitmap("icon.ico")
window.mainloop()

28
wxPython

Listing 12. wxpython/custom/app.py

import wx

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

self.button = wx.Button(self, label="My simple app.")


self.Bind(
wx.EVT_BUTTON, self.handle_button_click, self.button
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.button)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Hello World")
w.SetIcon(wx.Icon("icon.ico"))
app.MainLoop()

29
PySimpleGUI

Listing 13. pysimplegui/custom/app.py

import PySimpleGUI as sg

layout = [[sg.Button("My simple app.")]]

window = sg.Window("Hello World", layout, icon="icon.ico")

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED or event == "My simple app.":
break

window.close()

30
Kivy

Listing 14. kivy/custom/app.py

from kivy.app import App


from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.core.window import Window

Window.size = (300, 200)

class MainWindow(BoxLayout):
def __init__(self):
super().__init__()
button = Button(text="My simple app.")
button.bind(on_press=self.handle_button_clicked)

self.add_widget(button)

def handle_button_clicked(self, event):


App.get_running_app().stop()

class MyApp(App):
def build(self):
self.title = "Hello World"
self.icon = "icon.png"
return MainWindow()

app = MyApp()
app.run()

31
If you run the above application you should now see the icon appears on the
window on Windows and on the dock in macOS or Ubuntu Linux.

If you don’t see the icon, make sure you are running the
script from the same folder. Open a shell and cd to the
 folder with your script, then run it with python app.py.
We’ll address this issue in the next chapter!

Figure 5. Windows showing the custom icon.

Taskbar Icons (Windows Only)


On Windows setting your application icon will correctly set the icon on
your windows. However, due to how Windows keeps track of windows and
groups them on the taskbar, sometimes the icon will not show up on the
taskbar.

If it does for you, great! But it may not work when you
 distribute your application, so follow the next steps anyway!

When you run your application, Windows looks at the executable and tries
to guess what "application group" it belongs to. By default, any Python
scripts executed by python.exe (which includes your application) are
grouped under the same "Python" group, and so will show the Python icon.
To stop this happening, we need to provide Windows with a different
application identifier for our app.

The code below does this, by calling the ctypes.windll method


SetCurrentProcessExplicitAppUserModelID() with a custom application id.

32
PyQt6

Listing 15. pyqt6/custom/app_windows_taskbar.py

import os
import sys

from PyQt6.QtGui import QIcon


from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton

basedir = os.path.dirname(__file__)

try: ①
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version" ②
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Hello World")
button = QPushButton("My simple app.")
button.setIcon(QIcon(os.path.join(basedir, "icon.svg")))
button.pressed.connect(self.close)
self.setCentralWidget(button)
self.show()

app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "icon.svg")))
w = MainWindow()
app.exec()

① The code is wrapped in a try/except block since the windll module is not
available on macOS & Linux.
② Customize the app identifier string for your own applications.

33
Tkinter

Listing 16. tkinter/custom/app_windows_taskbar.py

import os
import tkinter as tk

basedir = os.path.dirname(__file__)

try: ①
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version" ②
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

window = tk.Tk()
window.title("Hello World")

def handle_button_press(event):
window.destroy()

button_icon = tk.PhotoImage(file=os.path.join(basedir, "icon.png"))


button = tk.Button(text="My simple app.", image=button_icon)

button.bind("<Button-1>", handle_button_press)
button.pack()

# Set window icon.


window.iconbitmap(os.path.join(basedir, "icon.ico"))

window.mainloop()

① The code is wrapped in a try/except block since the windll module is not
available on macOS & Linux.
② Customize the app identifier string for your own applications.

34
wxPython

35
Listing 17. wxpython/custom/app_windows_taskbar.py

import os

import wx

basedir = os.path.dirname(__file__)

try: ①
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version" ②
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

image = wx.Bitmap(os.path.join(basedir, "icon.ico"))


self.button = wx.Button(self, label="My simple app.")
self.button.SetBitmap(image)
self.Bind(
wx.EVT_BUTTON, self.handle_button_click, self.button
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.button)
self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Hello World")
w.SetIcon(wx.Icon(os.path.join(basedir, "icon.ico")))
app.MainLoop()

36
① The code is wrapped in a try/except block since the windll module is not
available on macOS & Linux.
② Customize the app identifier string for your own applications.

37
PySimpleGUI

Listing 18. pysimplegui/custom/app_windows_taskbar.py

import base64
import os

import PySimpleGUI as sg

basedir = os.path.dirname(__file__)

try: ①
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version" ②
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

def icon(filename): ③
path = os.path.join(basedir, filename)
with open(path, "rb") as f:
return base64.b64encode(f.read())

layout = [
[
sg.Button(
"My simple app.",
image_data=icon("icon.png"),
)
],
]

window = sg.Window(
"Hello World", layout, icon=os.path.join(basedir, "icon.ico")
)

while True:
event, values = window.read()
print(event, values)

38
if event == sg.WIN_CLOSED or event == "My simple app.":
break

window.close()

① The code is wrapped in a try/except block since the windll module is not
available on macOS & Linux.
② Customize the app identifier string for your own applications.

③ PySimpleGUI doesn’t have a native method for loading images. The docs
suggest base64 encoding them into the source, which is a bit
inconvenient — you’ll need to redo this on any changes. Instead you can
use this function like this to handle the loading and encoding for you at
runtime.

39
Kivy

Listing 19. kivy/custom/app_windows_taskbar.py

import os

from kivy.app import App


from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import ButtonBehavior
from kivy.uix.image import Image

basedir = os.path.dirname(__file__)
Window.size = (300, 200)

try: ①
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version" ②
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class ImageButton(ButtonBehavior, Image):


pass

class MainWindow(BoxLayout):
def __init__(self):
super().__init__()
button = ImageButton(
source=os.path.join(basedir, "icon.png"),
size=(32, 32),
pos=(16, 100 - 16),
)

button.bind(on_press=self.handle_button_clicked)
self.add_widget(button)

def handle_button_clicked(self, event):


App.get_running_app().stop()

40
class MyApp(App):
def build(self):
self.title = "Hello World"
self.icon = os.path.join(basedir, "icon.png")
return MainWindow()

app = MyApp()
app.run()

① The code is wrapped in a try/except block since the windll module is not
available on macOS & Linux.
② Customize the app identifier string for your own applications.

41
The listing above shows the generic string
mycompany.myproduct.subproduct.version but you should change this to
reflect your actual application. It doesn’t really matter what you put for this
purpose, but the convention is to use a dotted hierarchical notation.

With this added to your script, the icon should now be visible on your
taskbar.

Figure 6. Custom icon showing on the taskbar.

Executable icons (Windows only)


We now have the icon showing correctly while the application is
running. But you may have noticed that your application executable still has
a different icon. On Windows application executables can have icons
embedded in them to make them more easily identifiable. The default icon
is one provided by PyInstaller, but you can replace it with your own.

To add an icon to the Windows executable you need to provide an .ico


format file to the EXE block.

42
Listing 20. common/custom/hello-world-icons.spec

exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='hello-world',
icon='icon.ico',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None )

To create an .ico file, I recommend you use Greenfish Icon Editor Pro, a
free and open-source tool which can also build icons for Windows. An
example .ico file is included in the downloads with this book.

If you run the pyinstaller build with the modified .spec file, you’ll see the
executable now has the custom icon.

Figure 7. Windows executable showing the default and custom icons.

You can also provide the icon by passing --icon icon.ico to

 PyInstaller on the initial build. You can provide multiple


icons this way to support macOS and Windows.

43
macOS .app bundle icon (macOS only)
On macOS applications are distributed in .app bundles, which can have
their own icons. The bundle icon is used to identify the application in the
Launchpad and on the dock when the application is launched. PyInstaller
can take care of adding the icon to the app bundle for you, you just need to
pass an ICNS format file to the BUNDLE block in the .spec file. This icon will
then show up on the resulting bundle, and be shown when the app is
started.

Listing 21. common/custom/hello-world-icons.spec

app = BUNDLE(coll,
name='Hello World.app',
icon='icon.icns',
bundle_identifier=None)

ICNS is the file format for icon files on macOS. You can create icon files on
macOS using Icon Composer. You can also create macOS icons on Windows
using Greenfish Icon Editor Pro.

Figure 8. macOS .app bundle showing the default and custom icons.

You can also provide the icon by passing --icon icon.icns

 to PyInstaller on the initial build. You can provide multiple


icons this way to support macOS and Windows.

In our example the icon set on the bundle will be replaced by the set icon
method calls in the code. However, on macOS you can skip setting the icon

44
from your application entirely and just set the icon through the .app bundle
if you wish.

Linux icons
On Linux there aren’t these additional hoops to jump though. Things will
mostly just work by setting the icon in your application — dependent on
which GUI library you’re using.

The one place where the icon won’t appear is in the

 applications menu, but this is handled during packaging —


see Creating a Linux Package with fpm for details.

45
6. Relative paths
So far we’ve created a simple application and modified it to add a
window icon — nice and straightforward, right? But there is a gotcha here,
which might not be immediately apparent.

Open a shell and change to the folder where your script is saved. Run it as
normal:

python3 app.py

If the icons are in the correct location, you should see them. Now change to
the parent folder, and try and run your script again (change <folder> to the
name of the folder your script is in).

cd ..
python3 <folder>/app.py

Figure 9. Window with icon missing.

The icons don’t appear. What’s happening?

We’re using relative paths to refer to our data files. These paths are relative
to the current working directory — not the folder your script is in, but the
folder you ran it from. If you run the script from elsewhere it won’t be able
to find the files.

One common reason for icons not showing up, is running

 examples in an IDE which uses the project root as the


current working directory.

46
This is a minor issue before the app is packaged, but once it’s installed you
don’t know what the current working directory will be when it is run — if it’s
wrong your app won’t be able to find it’s data files. We need to fix this
before we go any further, which we can do by making our paths relative to
our application folder.

What are paths?


Paths describe the location of files in your filesystem. There are two types of
path — absolute and relative. An absolute path describes the path entirely
from the root (bottom) of the filesystem, while a relative path describes the
path from (or relative to) the current location in the filesystem.

It is not immediately obvious, but when you provide just a filename for a
file, e.g. hello.jpg, that is a relative path. When the file is loaded, it is
loaded relative to the current active folder. Confusingly, the current active
folder is not necessarily the same folder your script is in.

Why not just use absolute paths? Because they will only work
on your own filesystem, or a filesystem with exactly the
same structure. If I develop an application in my own home

 folder and use absolute paths to refer to files, e.g.


/home/martin/myapp/images/somefile.png, it will only work
for other people who also have a home folder named martin
and put the folder there. That would be a bit strange.

Dealing with relative paths.


To work around this, we need to tell our code that our file paths are always
relative to the base folder of our project — the folder which contains our
application script. This is fairly simple to do using Python, we just need two
things.

1. a way to find the path to the folder our currently running script is in

47
2. to take this path and use it to construct paths of the images we’re loading

The path of our current script is always available in Python with the global
variable __file__. We can get the folder of the file using os.path.dirname
— for directory name — which will give us the relative path to our project
folder from the current working directory.

In the updated code below, we define a new variable basedir, using


os.path.dirname to get the containing folder of __file__. We then use this
to build the relative paths for data files using os.path.join().

Take a look at [relative-paths] for a more robust way of

 working with relative paths in your apps, which is advised


for larger projects.

Since our app.py file is in the root of our folder, all other paths are relative
to that.

48
PyQt6

Listing 22. pyqt6/custom/app_relative_paths.py

import os
import sys

from PyQt6.QtGui import QIcon


from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton

basedir = os.path.dirname(__file__)

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("Hello World")

button = QPushButton("My simple app.")


button.setIcon(QIcon(os.path.join(basedir, "icon.svg")))
button.pressed.connect(self.close)

self.setCentralWidget(button)
self.show()

app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "icon.svg")))
w = MainWindow()
app.exec()

49
Tkinter

Listing 23. tkinter/custom/app_relative_paths.py

import os
import tkinter as tk

basedir = os.path.dirname(__file__)

window = tk.Tk()
window.title("Hello World")

def handle_button_press(event):
window.destroy()

button_icon = tk.PhotoImage(file=os.path.join(basedir, "icon.png"))


button = tk.Button(text="My simple app.", image=button_icon)
button.bind("<Button-1>", handle_button_press)
button.pack()

# Set window icon.


window.iconbitmap(os.path.join(basedir, "icon.ico"))

window.mainloop()

50
wxPython

Listing 24. wxpython/custom/app_relative_paths.py

import os
import wx

basedir = os.path.dirname(__file__)

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

image = wx.Bitmap(os.path.join(basedir, "icon.ico"))


self.button = wx.Button(self, label="My simple app.")
self.button.SetBitmap(image)
self.Bind(
wx.EVT_BUTTON, self.handle_button_click, self.button
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.button)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Hello World")
w.SetIcon(wx.Icon(os.path.join(basedir, "icon.ico")))
app.MainLoop()

51
PySimpleGUI

Listing 25. pysimplegui/custom/app_relative_paths.py

import base64
import os

import PySimpleGUI as sg

basedir = os.path.dirname(__file__)

def icon(filename):
path = os.path.join(basedir, filename)
with open(path, "rb") as f:
return base64.b64encode(f.read())

layout = [
[
sg.Button(
"My simple app.",
image_data=icon("icon.png"),
)
],
]

window = sg.Window(
"Hello World", layout, icon=os.path.join(basedir, "icon.ico")
)

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED or event == "My simple app.":
break

window.close()

52
Kivy

53
Listing 26. kivy/custom/app_relative_paths.py

import os

from kivy.app import App


from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import ButtonBehavior
from kivy.uix.image import Image

basedir = os.path.dirname(__file__)
Window.size = (300, 200)

class ImageButton(ButtonBehavior, Image):


pass

class MainWindow(BoxLayout):
def __init__(self):
super().__init__()
button = ImageButton(
source=os.path.join(basedir, "icon.png"),
size=(32, 32),
pos=(16, 100 - 16),
)
button.bind(on_press=self.handle_button_clicked)
self.add_widget(button)

def handle_button_clicked(self, event):


App.get_running_app().stop()

class MyApp(App):
def build(self):
self.title = "Hello World"
self.icon = os.path.join(basedir, "icon.png")
return MainWindow()

app = MyApp()
app.run()

54
Try and run your app again — both from the same folder and the parent
folder — you’ll find that the icon now appears as expected, no matter where
you launch the app from.

55
7. Data files and Resources
So we now have a application working, with a custom name, custom
application icon and a couple of tweaks to ensure that the icon is displayed
on all platforms and wherever the application is launched from. With this
in place, the final step is to ensure that this icon is correctly packaged with
your application and continues to be shown when run from the dist folder.

 Try it, it wont.

The issue is that our application now has a dependency on a external data
file (the icon file) that’s not part of our source. For our application to work,
we now need to distribute this data file along with it. PyInstaller can do this
for us, but we need to tell it what we want to include, and where to put it in
the output.

In the next section we’ll look at the options available to you for managing
data files associated with your app. This approach is not just for icon files, it
can be used for any other data files that need to be packaged with your
application.

Bundling data files with PyInstaller


Our application now has a dependency on a single icon file.

56
PyQt6

Listing 27. pyqt6/data-file/app.py

from PyQt6.QtWidgets import (


QMainWindow,
QApplication,
QPushButton,
QVBoxLayout,
QLabel,
QWidget,
)
from PyQt6.QtGui import QIcon
import sys, os

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("Hello World")
layout = QVBoxLayout()
label = QLabel("My simple app.")
label.setMargin(10)
layout.addWidget(label)

button = QPushButton("Push")
button.pressed.connect(self.close)
layout.addWidget(button)

container = QWidget()
container.setLayout(layout)

57
self.setCentralWidget(container)

self.show()

app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "icon.svg")))
w = MainWindow()
app.exec()

58
Tkinter

Listing 28. tkinter/data-file/app.py

import os
import tkinter as tk

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

window = tk.Tk()
window.title("Hello World")

label = tk.Label(text="My simple app.")


label.pack()

def handle_button_press(event):
window.destroy()

button_icon = tk.PhotoImage(file=os.path.join(basedir, "icon.png"))


button = tk.Button(text="My simple app.", image=button_icon)
button.bind("<Button-1>", handle_button_press)
button.pack()

# Set window icon.


window.iconbitmap(os.path.join(basedir, "icon.ico"))

# Start the event loop.


window.mainloop()

59
wxPython

60
Listing 29. wxpython/data-file/app.py

import os
import wx

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

self.label = wx.StaticText(self, label="My simple app.")


self.button = wx.Button(self, label="Push")
self.Bind(
wx.EVT_BUTTON, self.handle_button_click, self.button
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.label)
self.sizer.Add(self.button)
self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Hello World")
w.SetIcon(wx.Icon(os.path.join(basedir, "icon.ico")))
app.MainLoop()

61
PySimpleGUI

Listing 30. pysimplegui/data-file/app.py

import PySimpleGUI as sg
import os
import base64

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

def icon(filename):
path = os.path.join(basedir, "icons", filename)
with open(path, "rb") as f:
return base64.b64encode(f.read())

layout = [
[sg.Text("My simple app.")],
[
sg.Button(
"Push",
image_data=icon("icon.png"),
)
],
]

window = sg.Window(
"Hello World", layout, icon=os.path.join(basedir, "icon.ico")
)

while True:
event, values = window.read()
print(event, values)

62
if event == sg.WIN_CLOSED or event == "Push":
break

window.close()

63
Kivy

Listing 31. kivy/data-file/app.py

import os

from kivy.app import App


from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import ButtonBehavior
from kivy.uix.image import Image
from kivy.uix.label import Label

basedir = os.path.dirname(__file__)
Window.size = (300, 200)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class ImageButton(ButtonBehavior, Image):


pass

class MainWindow(BoxLayout):
def __init__(self):
super().__init__(orientation="vertical")
label = Label(text="My simple app.")

button = ImageButton(
source=os.path.join(basedir, "icon.png"),
size=(32, 32),
pos=(16, 50 - 16),
)

button.bind(on_press=self.handle_button_clicked)

64
self.add_widget(label)
self.add_widget(button)

def handle_button_clicked(self, event):


App.get_running_app().stop()

class MyApp(App):
def build(self):
self.title = "Hello World"
self.icon = os.path.join(basedir, "icon.png")
return MainWindow()

app = MyApp()
app.run()

65
The simplest way to get this data file into the dist folder is to just tell
PyInstaller to copy them over. PyInstaller accepts a list of individual file
paths to copy over, together with a folder path relative to the dist/<app
name> folder where it should to copy them to.

As with other options, this can be specified by command line arguments,


--add-data which you can provide multiple times.

pyinstaller --add-data "icon.svg:." --name "hello-world" app.py

The path separator is platform-specific, on Linux or Mac


 use : while on Windows use ;

Or via the datas list in the Analysis section of the spec file, as a 2-tuple of
source and destination locations.

a = Analysis(['app.py'],
pathex=[],
binaries=[],
datas=[('icon.svg', '.')],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)

And then execute the .spec file with:

pyinstaller hello-world.spec

In both cases we are telling PyInstaller to copy the specified file icon.svg to
the location . which means the output folder dist. We could specify other

66
locations here if we wanted. If you run the build, you should see your .svg
file now in the output folder dist ready to be distributed with your
application.

Figure 10. The icon file copied to the dist folder.

If you run your app from dist you should now see the icon as expected.

Figure 11. The icon showing on the window (Windows) and dock (macOS and Ubuntu)

The file must be loaded using a relative path, and be in the

 same relative location to the EXE as it was to the .py file


for this to work.

If you start your build on a Windows machine, your .spec


file may end up containing paths using double back-slashes

 \\. This will not work on other platforms, so you should


replace these with single forward-slashes /, which work on
all platforms.

67
8. Bundling data folders
Usually you will have more than one data file you want to include with
your packaged file. The latest PyInstaller versions let you bundle folders
just like you would files, keeping the sub-folder structure. To demonstrate
bundling folders of data files, lets add a few more buttons to our app and
add icons to them. We can place these icons under a folder named icons.

68
PyQt6

Listing 32. pyqt6/data-folder/app.py

from PyQt6.QtWidgets import (


QMainWindow,
QApplication,
QLabel,
QVBoxLayout,
QPushButton,
QWidget,
)
from PyQt6.QtGui import QIcon
import sys, os

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.setWindowTitle("Hello World")
layout = QVBoxLayout()
label = QLabel("My simple app.")
label.setMargin(10)
layout.addWidget(label)

button_close = QPushButton("Close")
button_close.setIcon(
QIcon(os.path.join(basedir, "icons", "lightning.svg"))
)
button_close.pressed.connect(self.close)
layout.addWidget(button_close)

69
button_maximize = QPushButton("Maximize")
button_maximize.setIcon(
QIcon(os.path.join(basedir, "icons", "uparrow.svg"))
)
button_maximize.pressed.connect(self.showMaximized)
layout.addWidget(button_maximize)

container = QWidget()
container.setLayout(layout)

self.setCentralWidget(container)

self.show()

app = QApplication(sys.argv)
app.setWindowIcon(QIcon(os.path.join(basedir, "icons", "icon.svg")))
w = MainWindow()
app.exec()

70
Tkinter

Listing 33. tkinter/data-folder/app.py

import os
import tkinter as tk

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

window = tk.Tk()
window.title("Hello World")

label = tk.Label(text="My simple app.")


label.pack()

def handle_button_press(event):
window.destroy()

button_close_icon = tk.PhotoImage(
file=os.path.join(basedir, "icons", "lightning.png")
)
button_close = tk.Button(
text="Close",
image=button_close_icon,
)
button_close.bind("<Button-1>", handle_button_press)
button_close.pack()

button_maximimize_icon = tk.PhotoImage(
file=os.path.join(basedir, "icons", "uparrow.png")
)
button_maximize = tk.Button(

71
text="Maximize",
image=button_maximimize_icon,
)
button_maximize.bind("<Button-1>", handle_button_press)
button_maximize.pack()

# Set window icon.


window.iconbitmap(os.path.join(basedir, "icons", "icon.ico"))

# Start the event loop.


window.mainloop()

72
wxPython

Listing 34. wxpython/data-folder/app.py

import os
import wx

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

self.label = wx.StaticText(self, label="My simple app.")

image = wx.Bitmap(
os.path.join(basedir, "icons", "lightning.png")
)
self.button_close = wx.Button(self, label="Close")
self.button_close.SetBitmap(image)
self.Bind(
wx.EVT_BUTTON, self.handle_close_button, self.button_close
)

image = wx.Bitmap(
os.path.join(basedir, "icons", "uparrow.png")
)
self.button_maximize = wx.Button(self, label="Maximize")
self.button_maximize.SetBitmap(image)
self.Bind(
wx.EVT_BUTTON,
self.handle_maximize_button,
self.button_maximize,

73
)

self.sizer = wx.BoxSizer(wx.VERTICAL)
self.sizer.Add(self.label)
self.sizer.Add(self.button_close)
self.sizer.Add(self.button_maximize)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_close_button(self, event):


self.Close()

def handle_maximize_button(self, event):


self.Maximize()

app = wx.App(False)
w = MainWindow(None, "Hello World")
w.SetIcon(wx.Icon(os.path.join(basedir, "icons", "icon.ico")))
app.MainLoop()

74
PySimpleGUI

Listing 35. pysimplegui/data-folder/app.py

import PySimpleGUI as sg
import os
import base64

basedir = os.path.dirname(__file__)

try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

def icon(filename):
path = os.path.join(basedir, "icons", filename)
with open(path, "rb") as f:
return base64.b64encode(f.read())

layout = [
[sg.Text("My simple app.")],
[
sg.Button(
"Close",
image_data=icon("lightning.png"),
)
],
[
sg.Button(
"Maximize",
image_data=icon("uparrow.png"),
)
],
]

window = sg.Window(

75
"Hello World",
layout,
icon=os.path.join(basedir, "icons", "icon.ico"),
)

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED or event == "Close":
break
if event == "Maximize":
window.maximize()

window.close()

76
Kivy

Listing 36. kivy/data-folder/app.py

import os

from kivy.app import App


from kivy.core.window import Window
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import ButtonBehavior
from kivy.uix.image import Image
from kivy.uix.label import Label

basedir = os.path.dirname(__file__)
Window.size = (300, 200)
try:
from ctypes import windll # Only exists on Windows.

myappid = "mycompany.myproduct.subproduct.version"
windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
except ImportError:
pass

class ImageButton(ButtonBehavior, Image):


pass

class MainWindow(BoxLayout):
def __init__(self):
super().__init__(orientation="vertical")
label = Label(text="My simple app.")

button_close = ImageButton(
source=os.path.join(basedir, "icons", "lightning.png"),
)

button_close.bind(on_press=self.handle_close_button_clicked)

button_maximize = ImageButton(
source=os.path.join(basedir, "icons", "uparrow.png"),
)

77
button_maximize.bind(
on_press=self.handle_maximize_button_clicked
)

self.add_widget(label)
self.add_widget(button_close)
self.add_widget(button_maximize)

def handle_close_button_clicked(self, event):


App.get_running_app().stop()

def handle_maximize_button_clicked(self, event):


Window.size = (600, 400)

class MyApp(App):
def build(self):
self.title = "Hello World"
self.icon = os.path.join(basedir, "icons", "icon.png")
return MainWindow()

app = MyApp()
app.run()

78
The Windows taskbar icon fix is included in this code, you

 can skip it if you are not building an application for


Windows.

The icons (both SVG files) are stored under a subfolder named 'icons'.

.
├── app.py
└── icons
└── lightning.svg
└── uparrow.svg
└── icon.svg

If you run this you’ll see the following window, with icons on the buttons
and an icon in the window or dock.

Figure 12. Windows with multiple icons running in PyQt6, Tkinter, wxPythn, Kivy &
PySimpleGUI.

79
We’ve updated the taskbar & dock icon paths, and they continue to work
across all platforms.

Figure 13. Icons & taskbar icons on Windows, macOS & Linux

To copy the icons folder across to our build application, we just need to add
the folder to our .spec file Analysis block. As for the single file, we add it as
a tuple with the source path (from our project folder) and the destination
folder under the resulting dist folder.

Listing 37. common/data-folder/hello-world.spec

# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(['app.py'],
pathex=[],
binaries=[],
datas=[('icons', 'icons')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)

exe = EXE(pyz,

80
a.scripts,
[],
exclude_binaries=True,
name='hello-world',
icon='icons/icon.ico',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='hello-world')
app = BUNDLE(coll,
name='Hello World.app',
icon='icons/icon.icns',
bundle_identifier=None)

If you run the build using this spec file you’ll now see the icons folder
copied across to the dist folder. If you run the application from the folder
— or anywhere else — the icons will display as expected, as the relative
paths remain correct in the new location.

81
9. Hidden Imports, Excludes & Binaries
When you package something with PyInstaller it looks through your source
code to find all the imports it uses. It then looks through the source code of
those packages and looks for all the imports they use. This continues until
it’s traversed the entire package tree and found everything that should be
needed to run your application.

This is great in theory, unfortunately a lot of packages do weird things to


import packages at runtime, which PyInstaller cannot follow. Maybe your
code does something weird too? Let’s not judge too harshly.

There are also cases where the reverse happens — PyInstaller finds and
imports something which really shouldn’t be included in your package. This is
usually less of a problem — unless it breaks the build, then your application
bundle will just end up a bit bigger. But you should still try and solve this if
you can.

To deal with both these situations PyInstaller provides a way to manually tell
it about imports it should include, or should not include in the package
you’re building. Like all PyInstaller configuration, these can be provided
either as command-line arguments, or by editing the .spec file.

Hidden imports
The hidden imports directive allows you to specify packages/modules to
include in your package build. These modules will be added to the list of
modules to include in the package. You can use this to manually add
modules which PyInstaller has failed to find while scanning your project —
for example conditional imports, or packages imported in a non-standard
way.

You can specify hidden imports from the command line:

pyinstaller --windowed --hidden-import my_module app.py

82
Or, editing the spec file.

a = Analysis(
['app.py'],
...
hiddenimports=['my_module'],
...
)

You can add as many hidden imports as you need for your project, and you
can also use wildcard imports to import submodules. For example, the
following is valid:

a = Analysis(
['app.py'],
...
hiddenimports=['my_module.*'],
...
)

Whatever entries you put in hiddenimports is the equivalent of adding an


import statement with that value, i.e.

import my_module.*

Excludes
The excludes directive allows you to specify packages/modules to exclude
from your package build. These modules will be stripped from the list of
modules to include in the package after PyInstaller has finished collecting
them. One place where excludes can help is when packaging a 3rd party
module which is trying to import something your application doesn’t need.

For example, on my system it is currently impossible to build applications


depending on numpy (which includes anything which uses Pandas or

83
Matplotlib) by default. The build will fail with Failed to import module
__PyInstaller_hooks_0_pytest. It’s trying to packaging a testing
framework! I don’t need that in a distributed app.

There is already a hook for numpy in PyInstaller so the build should work out
of the box. While this will probably be fixed in a later version of the
PyInstaller hooks, that’s no good for me right now.

To stop the build failing I can add a simple exclude for PyTest with

pyinstaller --windowed --exclude-module pytest app.py

Or, editing the spec file.

a = Analysis(
['app.py'],
...
excludes=['pytest'],
...
)

Note if you need to exclude multiple modules you can provide the
--exclude-module option multiple times, or add multiple items to the
excludes=[].

It’s always worth taking a look in the dist folder to see

 what’s getting packaged. If you see anything in there you


don’t think your application needs, try excluding it.

Binaries
Some Python code depends on non-Python binary libraries — usually .dll
or .so files. These cannot be found by PyInstaller since they aren’t imported.
In that case you will need to manually add them to your build using the
binaries directive.

84
Binary files to include are specified with the complete path, together with a
destination location in the build package — just like data files.

pyinstaller --windowed --add-binary /usr/lib/libiodbc.2.dylib:. app.py

Or, editing the spec file.

a = Analysis(
['app.py'],
...
binaries=[( '/usr/lib/libiodbc.2.dylib', '.' )],
...
)

Your application (or the packaged library) will need to take care of loading
the library from the new location in the packaged application.

Most popular Python libraries already have hooks files

 which define these binaries to include, so you won’t need to


do this often.

85
Building Installers
So far we’ve used PyInstaller to create a packaged executable version of our
applications, together with their libraries and data files. While you can
share the resulting folder with other users — for example as a .zip file —
and they will be able to run it on their computer, it’s not a very friendly way
to share your software.

Each platform has its own standards for distributing software and to give
the best user experience you should try and follow these. On Windows this
is usually executable setup installers, on macOS .app bundles in a Disk
Image and on Linux a distribution-specific package, such as a .deb.

Despite it’s name PyInstaller doesn’t create installers. So for the next step
we’re going to need some other tools. In this chapter we’ll walk step by step
through building platform-appropriate installers for Windows, macOS &
Linux.

86
10. Creating a Windows installer with
InstallForge
So far we’ve used PyInstaller to bundle applications for distribution. The
output of this bundling process is a folder, named dist which contains all
the files our application needs to run. While you could share this folder with
your users as a ZIP file it’s not the best user experience.

Windows desktop applications are normally distributed with installers


which handle the process of putting the executable (and any other files) in
the correct place and adding Start Menu shortcuts. Next we’ll look at how we
can take our dist folder and use it to create a functioning Windows
installer.

To create our installer we’ll be using a tool called InstallForge.


InstallForge is free and can be downloaded from this page. The working
InstallForge configuration is available in the downloads for this book, as
Hello World.ifp however bear in mind that the source paths will need to be
updated for your system.

If you’re impatient, you can download the Example


 Windows Installer first.

We’ll now walk through the basic steps of creating an installer with
InstallForge.

General
When you first run InstallForge you’ll be presented with this General tab.
Here you can enter the basic information about your application, including
the name, program version, company and website.

87
Figure 14. InstallForge initial view, showing General settings.

You can also select the target platforms for the installer, from various
versions of Windows that are currently available. This ensures people can
only install your application on versions of Windows which are compatible
with it.

There is no magic here, selecting additional platforms in the


installer won’t make your application work on them! You
 need to check your application runs on the target versions
of Windows before enabling them in the installer.

Setup
Click on the left sidebar to open the "Files" page under "Setup". Here you
can specify the files to be bundled in the installer.

88
Use "Add Files…" on the toolbar and select all the files in the dist/hello-
world folder produced by PyInstaller. The file browser that pops up allows
multiple file selections, so you can add them all in a single go, however you
need to add folders separately. Click "Add Folder…" and add any folders
under dist/hello-world such as your icons folder and other libraries.

Figure 15. InstallForge Files view, add all files & folders to be packaged.

Contents of selected folders will be included recursively,


 you do not need to select subfolders.

Once you’re finished scroll through the list to the bottom and ensure that
the folders are listed to be included. You want all files and folders under
dist/hello-world to be present. But the folder dist/hello-world itself
should not be listed.

89
The default install path can be left as-is. The values between angled
brackets, e.g. <company> , are variables and will be filled automatically from
the configuration.

Next, it’s nice to allow your users to uninstall your application. Even though
it’s undoubtedly awesome, they may want to remove it at some time in the
future. You can do this under the "Uninstall" tab, simply by ticking the box.
This will also make the application appear in Windows "Add or Remove
Programs".

Figure 16. InstallForge add Uninstaller for your app.

Dialogs
The "Dialogs" section can be used to show custom messages, splash screens
or license information to the user. The "Finish" tab lets you control what

90
happens once the installer is complete, and it’s helpful here to give the user
the option to run your program once it’s installed.

To do this you need to tick the box next to "Run program" and add your own
application EXE into the box. Since <installpath>\ is already specified, we
can just add hello-world.exe. Arguments can be used to pass any arguments
to the program on the first launch.

Figure 17. InstallForge configure optional run program on finish install.

System
Under "System" select "Shortcuts" to open the shortcut editor. Here you can
specify shortcuts for both the Start Menu and Desktop if you like.

91
Figure 18. InstallForge configure Shortcuts, for Start Menu and Desktop.

Click "Add…" to add new shortcuts for your application. Choose between
Start menu and Desktop shortcuts, and fill in the name and target file. This
is the path your application EXE will end up at once installed. Since
<installpath>\ is already specified, you simply need to add your
application’s EXE name onto the end, here hello-world.exe

92
Figure 19. InstallForge, adding a Shortcut.

Build
With the basic settings in place, you can now build your installer.

At this point you can save your InstallForge project so you


 can re-build the installer from the same settings in future.

Click on the "Build" section at the bottom to open the build panel.

93
Figure 20. InstallForge, ready to build.

Click on the Build icon on the toolbar to start the build process. If you
haven’t already specified a setup file location you will be prompted for one.
This is the location where you want the completed installer to be saved.
The build process will began, collecting and compressing the files into the
installer.

94
Figure 21. InstallForge, build complete.

Once complete you will be prompted to run the installer. This is entirely
optional, but a handy way to find out if it works.

Running the installer


The installer itself shouldn’t have any surprises, working as expected.
Depending on the options selected in InstallForge you may have extra panels
or options.

95
Figure 22. InstallForge, running the resulting installer.

Step through the installer until it is complete. You can optionally run the
application from the last page of the installer, or you can find it in your start
menu.

96
Figure 23. Hello World in the Start Menu on Windows 11.

Wrapping up
In a previous chapter we covered how to build your Python applications
into a distributable executable using PyInstaller. In this chapter we’ve taken
this built PyInstaller application and walked through the steps of using
InstallForge to build an installer for the app. Following these steps you
should be able to package up your own applications and make them
available to other people on Windows.

97
Another popular tool for building Windows installers is
NSIS which is a scriptable installer, meaning you configure

 it’s behavior by writing custom scripts. If you’re going to be


building your application frequently and want to automate
the process, it’s definitely worth a look.

98
11. Creating a macOS Disk Image Installer
In a previous chapter we used PyInstaller to build a macOS .app file from
our application. Opening this .app will run your application, and you can
technically distribute it to other people as it is. However, there’s a catch —
macOS .app files are actually just folders with a special extension. This
means they aren’t suited for sharing as they are — end users would need to
download all the individual files inside the folder.

The solution is to distribute the .app inside a Zip .zip or disk image .dmg file.
Most commercial software uses disk images since you can also include a
shortcut to the user’s Applications folder, allowing them to drag the
application over in a single move. This is now so common than many users
would be quite confused to be faced with anything else. Let’s just stick with
the convention.

If you’re impatient, you can download the Example macOS


 Disk Image first.

create-dmg
It’s relatively straightforward to create DMG files yourself, but I’d
recommend starting by using the tool create-dmg which can be installed
from Homebrew. This tool installs as a simple command-line tool, which
you can call passing in a few parameters to generate your DMG installer.

You can install the create-dmg package with Homebrew.

brew install create-dmg

Once installed you have access to the create-dmg bash script. Below is a
subset of the options, which can be displayed by running create-dmg
--help

99
--volname <name>: set volume name (displayed in the Finder sidebar and
window title)
--volicon <icon.icns>: set volume icon
--background <pic.png>: set folder background image (provide png, gif,
jpg)
--window-pos <x> <y>: set position the folder window
--window-size <width> <height>: set size of the folder window
--text-size <text_size>: set window text size (10-16)
--icon-size <icon_size>: set window icons size (up to 128)
--icon <file_name> <x> <y>: set position of the file's icon
--hide-extension <file_name>: hide the extension of file
--app-drop-link <x> <y>: make a drop link to Applications, at location
x, y
--eula <eula_file>: attach a license file to the dmg
--no-internet-enable: disable automatic mount&copy
--format: specify the final image format (default is UDZO)
--add-file <target_name> <file|folder> <x> <y>: add additional file or
folder (can be used multiple times)
--disk-image-size <x>: set the disk image size manually to x MB
--version: show tool version number
-h, --help: display the help

Volume is a technical name for a disk, so Volume name is the


 name you want to give to the disk image (DMG) itself.

Together with the options given above, you need to specify the output name
for your DMG file and an input folder — the folder containing your .app
generated by PyInstaller.

Below we’ll use create-dmg to create an installer DMG for our Hello World
application. We’re only using some of the available options here — setting
the name & icon of the disk volume, positioning and sizing the window,
setting the icon for our app and adding the /Applications drop destination
link. This is the bare minimum you will likely want to set for your own
applications, and you can customize it further yourself if you prefer.

Since create-dmg copies all files in the specified folder into the DMG you’ll
need to ensure that your .app file is in a folder by itself. I recommend

100
creating a folder dmg and copying the built .app bundle into it into it. Below
I’ve created a small script to perform the packaging, including a test to
check for and remove any previously-built DMG files.

Listing 38. common/installer/mac/makedmg.sh

#!/bin/sh
test -f "Hello World.dmg" && rm "Hello World.dmg"
test -d "dist/dmg" && rm -rf "dist/dmg"
# Make the dmg folder & copy our .app bundle in.
mkdir -p "dist/dmg"
cp -r "dist/Hello World.app" "dist/dmg"
# Create the dmg.
create-dmg \
--volname "Hello World" \
--volicon "icons/icon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "Hello World.app" 200 190 \
--hide-extension "Hello World.app" \
--app-drop-link 600 185 \
"Hello World.dmg" \
"dist/dmg/"

Save this into the root of your project named build-dmg.sh and then make it
executable with.

$ chmod +x build-dmg.sh

Then execute the script to build the package.

$ ./build-dmg.sh

The create-dmg process will run and a DMG file will be created in the
current folder, matching the name you’ve given for the output file (the
second to last argument, with the .dmg extension). You can now distribute

101
the resulting DMG file to other macOS users!

Figure 24. The resulting Disk Image showing our .app bundle and the Applications
shortcut. Drag the app across to install.

For more information on create-dmg see the documentation


 on Github.

102
12. Creating a Linux Package with fpm
In an previous chapter we used PyInstaller to bundle the application
into a Linux executable, along with the associated data files. The output of
this bundling process is a folder which can be shared with other users.
However, in order to make it easy for them to install it on their system, we
need to create a Linux package.

Packages are distributable files which allow users to install software on


their Linux system. They automatically handle putting files in the correct
places, as well as setting up application entries in the dock/menu to make it
easier to launch the app.

On Ubuntu (and Debian) packages are named .deb files, on Redhat


.rpm and on Arch Linux .pacman. These files are all different formats, but
thankfully the process for building them is the same using a tool named
fpm. fpm is a packaging system by Jordan Issel, which takes a folder (or list
of files) and assembles them into a Linux package.

In this chapter we’ll work through the steps for creating a


Linux package, using an Ubuntu .deb file as an example.
 However, thanks to the magic of fpm, you will be able to use
the same approach for other Linux systems.

Figure 25. Ubuntu Package, for our "Hello World" application

103
If you’re impatient, you can download the Example Ubuntu
 Package first.

Installing fpm
The fpm tool is written in ruby and requires ruby to be installed to use it.
Install ruby using your systems package manager, for example.

$ sudo apt-get install ruby

Once ruby is installed, you can install fpm using the gem tool.

$ gem install fpm --user-install

If you see a warning that you don’t have

 ~/.local/share/gem/ruby/2.7.0/bin` in your PATH you will


need to add that to your path in your .bashrc file.

Once the installation is complete, you’re ready to use fpm. You can check it
is installed and working by running:

$ fpm --version
1.14.2

Checking your build


In a terminal, change to the folder containing your application source files
& run a PyInstaller build to generate the dist folder. Test that the generated
build runs as expected (it works, and icons appear) by opening the dist
folder in the file manager, and double-clicking on the application
executable.

If everything works, you’re ready to package the application — if not, go

104
back and double check everything.

It’s always a good idea to test your built application before

 packaging it. That way, if anything goes wrong, you know


where the problem is!

Now let’s package our folder using fpm.

Structuring your package


Linux packages are used to install all sorts of applications, including system
tools. Because of this they are set up to allow you to place files anywhere in
the Linux filesystem — and there are specific correct places to put different
files. For a GUI application like ours, we can put our executable and
associated data files all under the same folder (in /opt). However, to have
our application show up in the menus/search we’ll also need to install a
.desktop file under /usr/share/applications.

The simplest way to ensure things end up in the correct location is to


recreate the target file structure in a folder & then tell fpm to package using
that folder as the root. This process is also easily automatable using a script
(see later).

In your projects root folder, create a new folder called package and
subfolders which map to the target filesystem — /opt will hold our
application folder hello-world, and /usr/share/applications will hold our
.desktop file, while /usr/share/icons… will hold our application icon.

$ mkdir -p package/opt
$ mkdir -p package/usr/share/applications
$ mkdir -p package/usr/share/icons/hicolor/scalable/apps

Next copy (recursively, with -r to include subfolders) the contents of


dist/app to package/opt/hello-world — the /opt/hello-world path is the
destination of our application folder after installation.

105
$ cp -r dist/hello-world package/opt/hello-world

We’re copying the dist/hello-world folder. The name of

 this folder will depend on the name configured in


PyInstaller.

The icons
We’ve already set an icon for our application while it’s running, using the
penguin.svg file. However, we want our application to show it’s icon in the
dock/menus. To do this correctly, we need to copy our application icons
into a specific location, under /usr/share/icons.

This folder contains all the icon themes installed on the system, but default
icons for applications are always placed in the fallback hicolor theme, at
/usr/share/icons/hicolor. Inside this folder, there are various folders for
different sizes of icons.

$ ls /usr/share/icons/hicolor/
128x128/ 256x256/ 64x64/ scalable/
16x16/ 32x32/ 72x72/ symbolic/
192x192/ 36x36/ 96x96/
22x22/ 48x48/ icon-theme.cache
24x24/ 512x512/ index.theme

We’re using a Scalable Vector Graphics (SVG) file so our icon belongs under
the scalable folder. If you’re using a specifically sized PNG file, place it in
the correct location — and feel free to add multiple different sizes, to ensure
your application icon looks good when scaled. Application icons go in the
subfolder apps.

$ cp icons/penguin.svg
package/usr/share/icons/hicolor/scalable/apps/hello-world.svg

106
Name the destination filename of the icon after your

 application to avoid it clashing with any others! Here we’re


calling it hello-world.svg.

The .desktop file


The .desktop file is a text configuration file which tells the Linux desktop
about a desktop application — for example, where to fine the executable,
the name and which icon to display. You should include a .desktop file for
your apps to make them easy to use. An example .desktop file is shown
below — add this to the root folder of your project — with the name hello-
world.desktop, and make any changes you like.

Listing 39. common/installer/linux/hello-world.desktop

[Desktop Entry]

# The type of the thing this desktop file refers to (e.g. can be Link)
Type=Application

# The application name.


Name=Hello World

# Tooltip comment to show in menus.


Comment=A simple Hello World application.

# The path (folder) in which the executable is run


Path=/opt/hello-world

# The executable (can include arguments)


Exec=/opt/hello-world/hello-world

# The icon for the entry, use the target filesystem path.
Icon=hello-world

Now the hello-world.desktop file is ready, we can copy it into our install
package with.

107
$ cp hello-world.desktop package/usr/share/applications

Permissions
Packages retain the permissions of installed files from when they were
packaged, but will be installed by root. In order for ordinary users to be
able to run the application, you need to change the permissions of the files
created.

We can recursively apply the correct permissions 755 - owner can


read/write/execute, group/others can read/execute. to our executable and
folders, and 644, owner can read/write, group/others can read to all our other
library and icons/desktop files.

$ find package/opt/hello-world -type f -exec chmod 644 -- {} +


$ find package/opt/hello-world -type d -exec chmod 755 -- {} +
$ find package/usr/share -type f -exec chmod 644 -- {} +
$ chmod +x package/opt/hello-world/hello-world

Building your package


Now everything is where it should be in our package "filesystem", we’re
ready to start building the package itself.

Enter the following into your shell.

fpm -C package -s dir -t deb -n "hello-world" -v 0.1.0 -p hello-


world.deb

The arguments in order are:

• -C the folder to change to before searching for files: our package folder

• -s the type of source(s) to package: in our case dir, a folder

• -t the type of package to build: a deb Debian/Ubuntu package

108
• -n the name of the application: "hello-world"

• -v the version of the application: 0.1.0

• -p the package name to output: hello-world-deb

You can create other package types (for other Linux

 distributions) by changing the -t argument. For more


command line arguments, see the fpm documentation.

After a few seconds, you should see a message to indicate that the package
has been created.

$ fpm -C package -s dir -t deb -n "hello-world" -v 0.1.0 -p hello-


world.deb
Created package {:path=>"hello-world.deb"}

Installation
The package is ready! Let’s install it.

$ sudo dpkg -i hello-world.deb

You’ll see some output as the install completes.

Selecting previously unselected package hello-world.


(Reading database ... 172208 files and directories currently
installed.)
Preparing to unpack hello-world.deb ...
Unpacking hello-world (0.1.0) ...
Setting up hello-world (0.1.0) ...

Once installation has completed, you can check the files are where you
expect, under /opt/hello-world

109
$ ls /opt/hello-world
app libpcre2-8.so.0
base_library.zip libpcre.so.3
icons libpixman-1.so.0
libatk-1.0.so.0 libpng16.so.16
libatk-bridge-2.0.so.0 libpython3.9.so.1.0
etc.

Next try and run the application from the menu/dock — you can search for
"Hello World" and the application will be found (thanks to the .desktop
file).

Figure 26. Application shows up in the Ubuntu search panel, and will also appear in
menus on other environments.

If you run the application, the icons will show up as expected.

110
Figure 27. Application runs and all icons show up as expected.

Scripting the build


We’ve walked through the steps required to build an installable Ubuntu
.deb package from a Python application. While it’s relatively
straightforward once you know what you’re doing, if you need to do it
regularly it can get quite tedious and prone to mistakes.

To avoid problems, I recommend scripting this with a simple bash script &
fpm’s own automation tool.

package.sh
Save in your project root and chmod +x to make it executable.

111
Listing 40. common/installer/linux/package.sh

#!/bin/sh
# Create folders.
[ -e package ] && rm -r package
mkdir -p package/opt
mkdir -p package/usr/share/applications
mkdir -p package/usr/share/icons/hicolor/scalable/apps

# Copy files (change icon names, add lines for non-scaled icons)
cp -r dist/hello-world package/opt/hello-world
cp icons/penguin.svg
package/usr/share/icons/hicolor/scalable/apps/hello-world.svg
cp hello-world.desktop package/usr/share/applications

# Change permissions
find package/opt/hello-world -type f -exec chmod 644 -- {} +
find package/opt/hello-world -type d -exec chmod 755 -- {} +
find package/usr/share -type f -exec chmod 644 -- {} +
chmod +x package/opt/hello-world/hello-world

.fpm file

fpm allows you to store the configuration for the packaging in a


configuration file. The file name must be .fpm and it must be in the folder
you run the fpm tool. Our configuration is as follows.

Listing 41. common/installer/linux/.fpm

-C package
-s dir
-t deb
-n "hello-world"
-v 0.1.0
-p hello-world.deb

You can override any of the options you like when executing
 fpm by passing command line arguments as normal.

112
Executing the build

With these scripts in place our application can be packaged reproducibly


with the commands:

pyinstaller hello-world.spec
./package.sh
fpm

Feel free to customize these build scripts further yourself to suit your own
project!

In this chapter we’ve stepped through the process of taking a working build
from PyInstaller and using fpm to bundle this up into a distributable Linux
package for Ubuntu. Following these steps you should be able to package
up your own applications and make them available to other people.

113
Signing Executables
Modern operating systems make a concerted effort to stop users from
running software which can harm the system. Software downloaded from
unknown sources can pose a risk.

To mitigate this risk Windows and macOS will show quite deliberately
frightening messages when you try and launch unverified software. In both
cases verified means specifically that the software has been signed by a
known entity. If you don’t sign your software, your users may be scared off
from running it.

In this chapter we’ll look at how to go about verifying your ownership of


your software.

114
13. Signing Windows Applications
Unsigned applications on Windows (including installers) will show an
"Unknown Publisher" warning when run. While installation isn’t
necessarily blocked outright, it’s not a reassuring experience for your users.

Figure 28. Windows "Unknown Publisher" warning.

To get rid of these warnings you need to sign your executables.

Window uses a signed-executable model for validating software


installations, but allows that validation to be carried out by third parties. In
practice this means that you need to buy a code signing certificate from a
certificate provider and use that to sign your software.

There are a huge number of providers available, who all all offer basically
the same service at wildly different prices. Your best bet is to just try and
find the cheapest authentic provider you can.

Standard vs. Extended Validation


The main decision you have is between Standard Validation and Extended
Validation. Both are sufficient to sign executables, with EV required only for
drivers. That said, EV is the only way to guarantee that no warnings are
shown — with a normal validation cert you need to accrue "reputation" by
having the software installed on a sufficient number of (internet connected)

115
machines (that report their installs). If that sounds like too high a bar for
your software, you may want to consider EV.

There is a list of certificate providers on on the Microsoft site. Below are


links to the two cheapest options I’ve found through that site (as of June
2022).

• Comodo Code Signing

• Comodo Enhanced Verification (EV) Code Signing

These links redirect to the 3rd Party sellers I’ve found with
the cheapest code signing certificates. They will include

 affiliate links where those are available, but that doesn’t


make it more expensive for you or alter what I link to. If you
find a cheaper alternative, let me know.

Once you’ve purchased your certificate you can download it and use it —
you need the .pfx file for actually signing your executable.

Signing Executables
To sign binary files you can use the signtool command. The signtool can
be downloaded as part of the Windows 10 SDK.

Once installed, you can use the signtool tool as follows, passing in the
certificate you downloaded, your password and the binary file to be signed.
The /d parameter gives a description of what is being signed — for example,
your application name. The /t parameter is the URL to time server for time-
stamping the signed application. Finally /v enables verbose output.

signtool sign /f "certificate.pfx" /p password /d "Application Name"


/t http://time.certum.pl /v dist/hello-world/hello-world.exe

 For all available options see the signtool documentation.

116
Repeat this for every binary which needs to be signed in your application —
usually the main executable is enough. Python and other libraries have
already been signed by their respective creators, you don’t need to sign
them again.

Since you are going to need to do this any time the


application is updated, I would recommend creating a

 sign.bat batch file with the signing instructions in it. You


can then run this after the packaging step, before moving
on to create the installer.

Once the file(s) are signed, you can proceed to build the installer as normal.

Remember to also sign the resulting installer file output by


 InstallForge!

Test the signed installer on your own computer to confirm that the
warnings have disappeared — if the signing process has completed
properly, there won’t be.

You’re now free to distribute your signed application to other users!

117
14. Signing macOS Application Bundles
Signing macOS application bundles is a pretty long-winded process, and
comes with a $99/year bill from Apple. But once you’ve done it your Python
applications will run without warnings on your user’s systems.

Entitlements .plist
First we need to create an entitlements.plist file which describes the
additional access or security exceptions that your application needs to run.
Because of the way that PyInstaller works there are a few standard entries
required. Below is an example working file.

<?xml version="1.0" encoding="UTF-8"?>


<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for all binaries built by PyInstaller -->
<key>com.apple.security.cs.allow-unsigned-executable-
memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

Save this somewhere in your project, you’ll need to refer to in a later step.

Identity
Signing macOS software is done using an Apple ID. To sign software you
need to have an active developer account which currently costs $99/year.

Download and install X-Code from the Apple App Store. Open and then
navigate to XCode › Preferences in the menu and select the Accounts tab.
Click the [ + ] in the lower-left corner, choose Apple ID and enter your apple

118
ID and password. You can download and install existing keys from
https://developer.apple.com

Once your developer ID is installed, you can use it to sign the application. In
the terminal, enter the following command to get the hash ID of your
appropriate developer ID.

security find-identity -p basic -v

This will output a list of identities on your system. Take the 6-character
hash on the left as your identifier, and pass that to the codesign command
below. Replace MyApp.app with the name of your app bundle and use the
path where you saved the entitlements.plist in a previous step.

codesign --deep -s $DEVELOPER_ID_APPLICATION -entitlements


path/to/entitlements.plist -o runtime MyApp.app

Once your .app bundle is signed to you can package it into a Disk Image as
normal.

Notarizing
Once you’ve completed the signing process above, the application will run,
but your users will still receive a warning the first time they run it.

Figure 29. Warning on first run.

To stop this happening you will need to submit your application to Apple
for notarization. See Apple’s notarization guide for the latest information.

119
Advanced Packaging
For most projects, the tips so far will be enough to get your application
packaged and successfully running on other computers. In this chapter
we’ll look at some additional tricks you can use to improve the portability
and usability of your application, as well as optimizing the build process to
get the best possible version of your packaged app.

120
15. Better Relative Paths
When we load external data files into our applications we typically do this
using paths. While straightforward in principle, there are a couple of ways
this can trip you up. As your applications grow in size, maintaining the
paths can get a bit unwieldly and it’s worth taking a step back to implement
a more reliable system.

In the Relative paths chatper we introduced a simple way to deal with this
problem when loading some image icons. There, we used the __file__
built-in to get the path of the currently running script (our application) and
then used os functions to first get the directory of our script and then use
that to build the full path.

import os
basedir = os.path.dirname(__file__)

# then ...
icon_path = os.path.join(basedir, "icon.svg")

This works well for simple applications where you have a single main script
and load relatively few files. But having to duplicate the basedir calculation
in every file you load from and use os.path.join to construct the paths
everywhere quickly turns into a maintenance headache. If you ever need to
restructure the files in your project, it’s not going to be fun. Thankfully
there is a simpler way!

Using a Paths class


The data files your application needs to load are usually fairly structured —
there are common types of file to load, or you are loading them for common
purposes. Typically you will store related files in related folders to make
managing them easier. We can make use of this existing structure to build a
regular way to construct paths for our files.

121
To do this we can create a custom Paths class which uses a combination of
attributes and methods to build folder and file paths respectively. The core
of this is the same os.path.dirname(__file__) and os.path.join()
approach used above, with the added benefit of being self-contained and
easily modifiable.

Take the following code and add it to the root of your project, in a file
named paths.py.

Listing 42. paths/paths.py

import os

class Paths:

base = os.path.dirname(__file__)
images = os.path.join(base, "images")
icons = os.path.join(images, "icons")
data = os.path.join(base, "images")

# File loaders.
@classmethod
def icon(cls, filename):
return os.path.join(cls.icons, filename)

@classmethod
def image(cls, filename):
return os.path.join(cls.images, filename)

@classmethod
def data(cls, filename):
return os.path.join(cls.data, filename)

To experiment with the paths module you can start up a


 Python interpreter in your project root and use from paths
import Paths

Now, anywhere in your application you can import the Paths class an use it

122
directly. The attributes base, icons, images, and data all return the paths to
their respective folders under the base folder. Notice how the icons folder
is constructed from the images path — nesting this folder under that one.

Feel free to customize the names and structure of the paths,


 etc. to match the folder structure in your own project.

>>> from paths import Paths


>>> Paths.data
'U:\\home\\martin\\books\\create-simple-gui-applications\\code
\\further\\data'
>>> Paths.icons
'U:\\home\\martin\\books\\create-simple-gui-applications\\code
\\further\\images\\icons'

We don’t create an object instance from this class — we don’t


call Paths() — because we don’t need one. The paths are
static and unchanging, so there is no internal state to
 manage by creating an object. The methods must be
decorated as @classmethod to be accessible on the class
itself.

The methods icon, image and data are used to generate paths including
filenames. In each case you call the method passing in the filename to add
to the end of the path. These methods all depend on the folder attributes
described above. For example, if you want to load a specific icon you can
call the Paths.icon() method, passing in the name, to get the full path
back.

>>> Paths.icon('bug.png')
'U:\\home\\martin\\books\\create-simple-gui-applications\\code
\\further\\images\\icons\\bug.png'

In your application code you could use this as follows to construct the path

123
and load the icon.

QIcon(Paths.icon('bug.png'))

This keeps your code much tidier, helps ensure the paths are correct and
makes it much easier if you ever want to restructure how your files are
stored. For example, say you’ve moved the icons folder out of the images
folder and want to update the paths in your application: now you only need
to change the paths.py definition and all icons will work as before.

icons = os.path.join(images, 'icons')


# to move to top level, make icons derive from base instead
icons = os.path.join(base, 'icons')

124
16. Detecting the current platform
Python GUI libraries do a good job of papering over the differences between
different platforms, allowing you to write code and be reasonably confident
it will work anywhere. However, there are times when it can be useful to
know the platform yourself — whether to get something working, or
improve the way something looks or behaves.

One simple example is changing the icon files you use on different
platforms. While you may be able to get things working using the same
files, using platform-specific types can offer benefits for icon scaling —
ensuring your app looks as good as possible.

Thankfully it’s easy to detect the current platform with Python using either
sys.platform

import sys
sys.platform
'win32'

Or platform.name

import platform
platform.name
'Windows'

The names used for the common platforms are shown below.

Platform platform.name sys.platform

Windows Windows win32

macOS Darwin darwin

Linux Linux linux

You can use these values to optionally run different code paths using if /

125
else blocks. If you want to load different data files (such as icons) on
different platforms you can combine this with the earlier [relative paths]
example, using the platform name to build alternate paths.

Listing 43. common/paths/paths-platform.py

import os
import sys

class Paths:

base = os.path.dirname(__file__)
platform = os.path.join(
base, sys.platform
) # platform specific folders
images = os.path.join(base, "images")
icons = os.path.join(platform, "icons")
data = os.path.join(base, "images")

# File loaders.
@classmethod
def icon(cls, filename):
return os.path.join(cls.icons, filename)

@classmethod
def image(cls, filename):
return os.path.join(cls.images, filename)

@classmethod
def data(cls, filename):
return os.path.join(cls.data, filename)

In the above example, the application will load the icons from
<base>/win32/icons or <base>/darwin/icons depending on which platform
it is run on.

You can use this approach to customize other things, like


 loading different help files for different systems.

126
17. Working with command-line
arguments
If you have created an application which works with specific file
types — for example a video editor that opens videos, a document editor
that opens document files — it can be useful to have your application open
these files automatically. On all platforms, when you tell the OS to open a
file with a specific application, the filename to open is passed to that
application as a command-line argument.

When your application is run, the arguments passed to the application are
always available in sys.argv. To open files automatically, you can check
the value of sys.argv at startup and, if you find a filename in there, open it.

The following app when run will open a window with all the command line
arguments received displayed.

127
PyQt6

Listing 44. pyqt6/arguments/arguments.py

from PyQt6.QtWidgets import (


QApplication,
QWidget,
QLabel,
QVBoxLayout,
)

import sys

class Window(QWidget):
def __init__(self):
super().__init__()

layout = QVBoxLayout()

for arg in sys.argv: ①


l = QLabel(arg)
layout.addWidget(l)

self.setLayout(layout)
self.setWindowTitle("Arguments")

app = QApplication(sys.argv)
w = Window()
w.show()

app.exec()

① sys.argv is a list of strings. All arguments are strings.

128
Tkinter

Listing 45. tkinter/arguments/arguments.py

import tkinter as tk
import sys

window = tk.Tk()
window.title("Arguments")

for arg in sys.argv: ①


label = tk.Label(text=arg)
label.pack()

# Start the event loop.


window.mainloop()

① sys.argv is a list of strings. All arguments are strings.

129
wxPython

Listing 46. wxpython/arguments/arguments.py

import wx
import sys

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(200, -1))

self.sizer = wx.BoxSizer(wx.VERTICAL)

for arg in sys.argv: ①


label = wx.StaticText(self, label=arg)
self.sizer.Add(label)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

def handle_button_click(self, event):


self.Close()

app = wx.App(False)
w = MainWindow(None, "Arguments")
app.MainLoop()

① sys.argv is a list of strings. All arguments are strings.

130
PySimpleGUI

Listing 47. pysimplegui/arguments/arguments.py

import PySimpleGUI as sg
import sys

layout = []
for arg in sys.argv: ①
layout.append(
[sg.Text(arg)],
)

window = sg.Window("Hello World", layout)

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED:
break

window.close()

① sys.argv is a list of strings. All arguments are strings.

131
Kivy

Listing 48. kivy/arguments/arguments.py

from PyQt6.QtWidgets import (


QApplication,
QWidget,
QLabel,
QVBoxLayout,
)

import sys

class Window(QWidget):
def __init__(self):
super().__init__()

layout = QVBoxLayout()

for arg in sys.argv: ①


l = QLabel(arg)
layout.addWidget(l)

self.setLayout(layout)
self.setWindowTitle("Arguments")

app = QApplication(sys.argv)
w = Window()
w.show()

app.exec()

① sys.argv is a list of strings. All arguments are strings.

132
Run this app from the command line, passing in a filename (you can make
anything up, we don’t load it). You can pass as many, or as few, arguments
as you like.

Arguments are passed to your application as a list of str. All arguments


are strings, even numeric ones. You can access any argument you like using
normal list indexing — for example `sys.argv[1] would return the 2nd
argument.

Try running the script above with the following —

python arguments.py filename.mp4

This will produce the window below. Notice that when run with python the
first argument is actually the Python file which is being executed.

Figure 30. The window open showing the command line arguments.

If you package your application for distribution, this may no longer be the
case — the first argument may now be the file you are opening, as there is
no Python file passed as an argument. This can cause problems, but a
simple way around this is to use the last argument passed to your
application as the filename, e.g.

if len(sys.argv) > 0:
filename_to_open = sys.argv[-1]

Alternatively, you can remove the currently executing script name if it is in


the list. The currently executing Python script name is always available in
__file__.

133
if __file__ in sys.argv:
sys.argv.remove(__file__)

It will always be in the list, unless you have packaged your


 app.

Below is a further example, where we accept a filename on the command


line, and then open that text file for display in the window.

134
PyQt6

Listing 49. pyqt6/arguments/arguments_open.py

from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit

import sys

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.editor = QTextEdit()

if __file__ in sys.argv: ①
sys.argv.remove(__file__)

if sys.argv: ②
filename = sys.argv[0] ③
self.open_file(filename)

self.setCentralWidget(self.editor)
self.setWindowTitle("Text viewer")

def open_file(self, fn):

with open(fn, "r") as f:


text = f.read()

self.editor.setPlainText(text)

app = QApplication(sys.argv)
w = MainWindow()
w.show()

app.exec()

① If the script name is in sys.argv remove it.

② If there is still something in sys.argv (not empty).

135
③ Take the first argument as the filename to open.

136
Tkinter

Listing 50. tkinter/arguments/arguments_open.py

import tkinter as tk
import sys

window = tk.Tk()
window.title("Text viewer")

if __file__ in sys.argv: ①
sys.argv.remove(__file__)

def open_file(filename):
with open(filename, "r") as f:
return f.read()

text = ""
if sys.argv: ②
filename = sys.argv[0] ③
text = open_file(filename)

textbox = tk.Text()
textbox.insert(tk.END, text)
textbox.pack()

# Start the event loop.


window.mainloop()

① If the script name is in sys.argv remove it.

② If there is still something in sys.argv (not empty).

③ Take the first argument as the filename to open.

137
wxPython

Listing 51. wxpython/arguments/arguments_open.py

import wx
import sys

class MainWindow(wx.Frame):
def __init__(self, parent, title):
wx.Frame.__init__(self, parent, title=title, size=(400, 300))
self.sizer = wx.BoxSizer(wx.VERTICAL)
self.text = wx.TextCtrl(self, style=wx.TE_MULTILINE)
self.sizer.Add(self.text, wx.EXPAND, wx.EXPAND)

if __file__ in sys.argv: ①
sys.argv.remove(__file__)

if sys.argv: ②
filename = sys.argv[0] ③
self.text.LoadFile(filename)

self.SetSizer(self.sizer)
self.SetAutoLayout(True)
self.Show()

app = wx.App(False)
w = MainWindow(None, "Text viewer")
app.MainLoop()

① If the script name is in sys.argv remove it.

② If there is still something in sys.argv (not empty).

③ Take the first argument as the filename to open.

138
PySimpleGUI

Listing 52. pysimplegui/arguments/arguments_open.py

import PySimpleGUI as sg
import sys

if __file__ in sys.argv: ①
sys.argv.remove(__file__)

text = ""
if sys.argv: ②
filename = sys.argv[0] ③
with open(filename, "r") as f:
text = f.read()

layout = [
[sg.Text(text)],
]

window = sg.Window("Hello World", layout)

while True:
event, values = window.read()
print(event, values)
if event == sg.WIN_CLOSED:
break

window.close()

① If the script name is in sys.argv remove it.

② If there is still something in sys.argv (not empty).

③ Take the first argument as the filename to open.

139
Kivy

Listing 53. kivy/arguments/arguments_open.py

import sys

from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit

class MainWindow(QMainWindow):
def __init__(self):
super().__init__()

self.editor = QTextEdit()

if __file__ in sys.argv: ①
sys.argv.remove(__file__)

if sys.argv: ②
filename = sys.argv[0] ③
self.open_file(filename)

self.setCentralWidget(self.editor)
self.setWindowTitle("Text viewer")

def open_file(self, fn):


with open(fn, "r") as f:
text = f.read()

self.editor.setPlainText(text)

app = QApplication(sys.argv)
w = MainWindow()
w.show()

app.exec()

① If the script name is in sys.argv remove it.

② If there is still something in sys.argv (not empty).

140
③ Take the first argument as the filename to open.

141
You can run this as follows, to view the passed in text file.

python arguments_open.py notes.txt

To support opening files in macOS bundled apps you will

 need to enable argv emulation or Open event handling. See


the PyInstaller argv documentation for details.

142
18. Optimizing Packages
If you’re using 3rd party modules your excitement at successfully packaging
your application may be short lived. Once you try and share it with
someone else you realize it’s absolutely enormous.

While access to fast internet makes this less of a problem than it used to be,
you should always try and keep your application installer as small as
possible to make it easy & quick to download or share.

Distributable applications need to package the Python executable and


standard library — this is unavoidable. Where you can make a difference is
in pruning your 3rd party packages to remove any unnecessary code.

In this chapter we’ll go through some of the common causes of large


packages and how to prune them in your PyInstaller config.

Optional imports
Some packages have optional support allowing them to work with other
packages. Typically they import these other packages within a try/except
block, meaning that if the other package doesn’t exist, everything will
continue to work.

If your application doesn’t need these 3rd party optional packages, you don’t
want them included in your package.

You can see what’s being pulled into your package by


 looking into the dist folder.

If you package your application in a Python environment which doesn’t


have these other modules installed, then everything will work as expected.
If you package your application in a Python environment which does have
these other modules installed, then they will be bundled into your package
by PyInstaller.

143
The best solution to this is to use virtual environments to ensure that you
have a clean Python environment containing only the packages that your
application needs to run. If you’re unable to use virtual environments, or
the package your using has optional packages in it’s requirements (so
they’re getting installed automatically) you can instead use PyInstaller
excludes directives to remove them.

Tkinter

One common issue with PyInstaller builds is Tkinter automatically getting


bundled, even when you’re not actually using it.

Tkinter is included with Python itself and so always available in your


package lists — even in a virtual environment. Some 3rd party modules such
as Matplotlib have (optional) dependencies on Tkinter, meaning that any
GUI application you build which uses Matplotlib will automatically pull in
Tkinter too — even if the UI is built with PyQt or Kivy for example.

To avoid this happening you will need to add a specific exclude for Tkinter:

pyinstaller --windowed --exclude-module tkinter app.py

Or, editing the spec file.

a = Analysis(
['app.py'],
...
excludes=['tkinter'],
...
)

 Of course, don’t exclude Tkinter if you’re using it!

Matplotlib

If you’re building a PyQt5 application which depends on Matplotlib.

144
Matplotlib has optional imports for a number of different UI frameworks. If
you have these installed, they’ll be pulled into the package automatically.

If your system also has PyQt6, PySide6 and PySide2 installed, you could
exclude them with.

excludes=['PyQt6','PySide6','PySide2'],

Pandas
Pandas has quite a long list of optional dependencies which you can
add/remove depending on what you’re doing.

For example, if your application is not using the plotting capabilities of


pandas — perhaps you just want to use it for loading CSV or other data files
— then you can safely exclude the Matplotlib dependency in PyInstaller.

excludes=['matplotlib'],

Or, if you are not loading/saving Excel files, you could also choose to
exclude:

excludes=['xlrd', 'xlwt', 'xlsxwriter', 'openpyxl', 'pyxlsb'],

Or, if you’re not using pandas for linear algebra, you could exclude:

excludes=['scipy'],

Remember to take a look in the dist folder to see what’s

 actually been packaged before you start frantically


excluding things, and use virtual environments if you can!

145
Tests
Some packages bundle their own tests, which are never required in your
packaged applications. Look for any folders named tests in the packaged
dist folder and exclude them from PyInstaller using excludes in the .spec
file. For example —

excludes=['tests'],

Removing non-Package files


The previous examples describe how to remove unwanted Python packages
from the PyInstaller build. But sometimes you want to remove specific files
— for example, you may want to remove .dll files which are not required
on the target platform, or remove 3rd party package data files which your
application doesn’t need.

In that case excluding the packages using excludes won’t work, and you
instead need to modify PyInstaller file lists directly.

When packaging an application PyInstaller builds lists of files, of different


types, which need to be copied across to the target dist folder. By
modifying these file lists you can have complete control over what is
copied.

The analysis block is where the files are collected.

a = Analysis(['app.py'],
binaries=[])

These file lists are then passed to COLLECT to be passed into the output
folder. Notice that COLLECT is passed a.binaries and a.datas, etc. — these
are the results of the analysis.

146
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='app'
)

The a.binaries (and other) file lists are of type TOC, which is a custom set
type. This means you can modify the files in there by creating your own TOC
and subtracting it from the list. For example, you could use the following to
remove Tkinter binaries from a build.

The code would be added after the ANALYSIS block and before the COLLECT
block.

a.binaries = a.binaries - TOC([


('tcl85.dll', None, None),
('tk85.dll', None, None),
('_tkinter', None, None)
])

If you run a build with this modification, the listed files will not be copied
into the dist folder during the packaging process.

Try and exclude entire packages where possible first before


 moving to exclude individual binaries.

147
19. PyInstaller Hooks
So far we’ve been packaging our applications using the built-in PyInstaller
hooks. As we’ve already learnt, hooks are the mechanism that PyInstaller
uses to understand how to package 3rd party packages. Hooks include
instructions telling PyInstaller what to include and exclude from the
resulting package, or additional data files that the package depends on,
which must also be copied.

If you can you should always try and use the built-in hooks first, if only
because figuring out the problems and writing the hooks isn’t much fun.
However, sometimes it’s unavoidable. For example, perhaps you’ve put
some of your own code into a 3rd party package for internal development
reasons and PyInstaller isn’t packaging it correctly.

You can fix these issues by manually adding files to the .spec file, but it can
turn into a bit of a mess. If you want to control packaging properly on a per-
module basis, you should write your own hook file.

Hooks work exactly the same as the .spec file, but allow you to structure
your additional imports, excludes and other files on a per-module basis.

If you’ve written a hook for 3rd party module, you should

 consider submitting it to the pyinstaller-hooks-contrib


project on Github.

How to write hooks


PyInstaller hooks files are very simple. In the file you specify one or more
global variables from hiddenimports, excludedimports, datas or binaries,
containing the modifications to PyInstallers default build of the package.
These variables have the same formats as in the .spec file and work in
exactly the same way.

Hooks files must follow a regular name pattern, hook-full.import.name.py

148
— that is, the name starts with hook- followed by the complete dotted
Python import hierarchy. Often this is just the module name.

The majority of hooks just include additional hiddenimports — this is where


PyInstaller has most of the difficulty with 3rd party packages. For example
PyInstallers built-in hook for the dnspython package looks like this:

hiddenimports = [
"dns.rdtypes.*",
"dns.rdtypes.ANY.*"
]

You can use excludes to exclude imports from being bundled with your
application. Note however, that if this import is explictly imported in
another included module or source file, it won’t be removed.

excludes = ['tkinter']

The above will exclude Tkinter from being packaged unless it is explicitly
imported by another package.

It might help to think of this as ignoring the import in the


 current package only.

You can also use hooks to specify data and binary files to include, just as you
would in the .spec file.

datas = [('icons', 'icons')]


binaries=[( '/usr/lib/libiodbc.2.dylib', '.' )]

Using hooks
When you have written a custom hook, you need to tell PyInstaller about it.
The simplest way to do this is to save the hook in a folder in your project

149
folder — I suggest hooks — and then point PyInstaller at that folder.

pyinstaller --windowed --additional-hooks-dir hooks app.py

Or, editing the spec file.

a = Analysis(
['app.py'],
...
hookspath=['hooks'],
...
)

If you are the owner of a Python package, you can also

 bundle hooks with your own package to help other


developers use it.

If you want to learn more about writing PyInstaller hooks


 take a look at see the PyInstaller documentation.

150
Troubleshooting
Of course it would be nice if every time you packaged your applications
it worked first time. With the recent improvement in PyInstaller hooks, the
chance of this being the case is much higher than it used to be. But still
things may go wrong.

Below we’ll cover some of the common issues you’ll see when trying to
package applications along with simple fixes to get you past that point.

There are two ways that things go wrong —

1. Build doesn’t complete

2. Built application doesn’t run

If you encounter a problem not covered here, let me know!


If we can work it out, I’ll add the instructions to a future
 update (or submit a hooks file to PyInstaller) so nobody else
needs to suffer.

151
20. Build doesn’t complete
Tried to package your application and got a strange error? You’re in the
right place.

The first step is to look at the error message — in some cases PyInstaller will
throw exceptions to halt the build for good reasons, such as your platform
not being supported or a later version of Python being required. If you see
one of those errors either upgrade your Python version, or upgrade
PyInstaller.

pip3 install --upgrade PyInstaller pyinstaller-hooks-contrib

Updating PyInstaller and the hooks should always be your


 first step when things go wrong.

However, sometimes the errors don’t make much sense at all. In that case,
read on — I’ve added two common errors and their solutions below.

 If you encounter a problem not covered here, let me know!

152
ModuleNotFound Errors
During the build process you will often see ModuleNotFound errors. These
occur when PyInstaller tries to import optional dependencies of modules &
those modules are not available in your Python environment. Usually these
can be ignored — if they’re not in your environment, it’s probably because
your application doesn’t need them.

However, in some cases these errors can cause the build to fail. In that case
something is wrong. Look at the module which is throwing errors — if it’s
something your application doesn’t need you can try to add the module to
the excludes, so PyInstaller will ignore it (see PyTest below).

If it’s something your application does need, then your first step should
always be to try and upgrade the package itself and/or the Pyinstaller hooks.

PyTest Errors: numpy, matplotlib & Pandas

PyTest is a testing framework used for testing Python applications and


modules. While many packages use it, there is usually no reason to include
testing frameworks in your application packages — unless your application
is a testing tool! However, sometimes PyInstaller will throw errors while
trying to package it.

Most often you’ll encounter this while trying to package

 NumPy or any other library that depends on it, such as


Matplotlib or Pandas.

153
importlib_load_source
return mod_loader.load_module()
File "<frozen importlib._bootstrap_external>", line 407, in
_check_name_wrapper
File "<frozen importlib._bootstrap_external>", line 907, in
load_module
File "<frozen importlib._bootstrap_external>", line 732, in
load_module
File "<frozen importlib._bootstrap>", line 265, in _load_module_shim
File "<frozen importlib._bootstrap>", line 696, in _load
File "<frozen importlib._bootstrap>", line 677, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 728, in
exec_module
File "<frozen importlib._bootstrap>", line 219, in
_call_with_frames_removed
File "python37\lib\site-
packages\_pyinstaller_hooks_contrib\hooks\stdhooks\hook-pytest.py",
line 18, in <module>
import pytest
File "python37\lib\site-packages\pytest\__init__.py", line 6, in
<module>
from _pytest import __version__
ModuleNotFoundError: No module named '_pytest'
...
PyInstaller.exceptions.ImportErrorWhenRunningHook: Failed to import
module __PyInstaller_hooks_0_pytest

If your application doesn’t need PyTest to be installed (it probably doesn’t)


you can just add the pytest package to your excluded modules.

--excludes=pytest

Or, in the .spec file.

excludes=['pytest'],

154
This same approach can be used to exclude any other

 packages which are throwing errors, but which you are


reasonably sure your application doesn’t need to run.

IOError("Python library not found!")


Sometimes PyInstaller can’t find your Python library — it needs to bundle the
Python into your package so it will run. Of course, this is a bit strange since
it’s actually running using Python when it tells you this error, but PyInstaller
specifically needs a dynamically linked version of Python which some
systems do not have.

There are different options depending on your platform, but as a first step I
recommend using virtual environments to ensure the Python executable is in
a predictable location (and in the PATH).

If this doesn’t help, or you don’t want to do that you can also tell PyInstaller
the location of the Python library by using the LD_LIBRARY_PATH (Linux) or
DYLD_LIBRARY_PATH (macOS) environment variables.

If that still doesn’t work, the issue may be with your Python installation
itself. Try installing a new version of Python, creating a new virtual
environment and attempting to package the app with this.

You can have multiple Python versions on a your computer,

 and using virtual environments keeps everything isolated


nicely.

155
21. Built application doesn’t run
Sometimes your PyInstaller build will complete, but the resulting
application won’t start. Usually these failures are silent, with the
application either just not starting or flashing a window for a fraction of a
second. Not helpful!

 If you encounter a problem not covered here, let me know!

Getting the debug information


Your first steps are to get the debug output from your application. The way
to do this is dependent on which platform you’re building and running your
app on.

Windows

Your first step on Windows should be to re-enable the console in your .spec
file. This will allow you to see the debug output from the running
application — it’s much easier to debug if you have something to go on!

exe = EXE(pyz,
a.scripts,
[],
...
console=True, # Enable the console, so you can see debug
output.
...
)

macOS

On macOS you can run the application from the command line as-is,
without modifying the .spec file — the debug output will be shown there for
you. Open a terminal and navigate to your .app bundle, then cd inside.

156
cd MyApp.app

 macOS bundles are just folders!

Continue navigating inside to <myapp.app>/Contents/MacOS/ and look for


your executable — it will be named using the name from your .spec file.
You can then run it from the terminal, e.g.

./myapp

The ./ is necessary to run an executable from the current


 folder — otherwise it will look in the path.

You will see any debug output on the command line.

Linux

On Linux you can run the executable from the terminal. There are no app
bundles, etc. in Linux, so just navigate to the folder containing the
executable and execute it as for any other executable or script.

./myapp

Common Errors
Once you have the error/logging output from running your app, you can
proceed to debugging it. See the next sections for some tips for debugging
common problems with PyInstaller builds.

ModuleNotFoundError

The most common error you’ll see when running your built PyInstaller
applications is Module not found. This error means that the application is
trying to import a package/module but not able to find it. In packaged

157
applications this is most likely caused by the module not being copied over
to the build folder.

Usually, this happens because PyInstaller has not seen the module imported
while analyzing your code (or one of the modules it uses). You can fix this
by explicitly adding the module to the hiddenimports directive.

If you have added anything to excludes you should check


 first that this isn’t what is causing the missing packages.

--hidden-import=my_module

Or, in the .spec file.

hiddenimports=['my_module'],

Replace my_module with the name of the module that PyInstaller is


complaining about. Remember that you can add as many hidden imports as
you need for your project, and you can also use wildcard imports to import
submodules. For example, the following is valid:

a = Analysis(
['app.py'],
...
hiddenimports=['my_module.*'],
...
)

Whatever entries you put in hiddenimports is the equivalent of adding an


import statement with that value, i.e.

import my_module.*

After adding the hidden imports retry the build and see if it works now. If

158
not, you’ll likely see another error about something else which is missing —
add this too! Carry on repeating this process until you’ve identified all the
missing imports.

159
Graphviz

Graphviz is a graph visualization library, which takes structured text files


as input and generates graphics outputs. The tools are a collection of
command line applications — the Python Graphviz library just wraps these
tools.

Unfortunately when it starts up it tries to find the executables in the PATH


and then fails — even if you’re bundled them in your project, it won’t look
there.

Traceback (most recent call last):


File "graphviz/backend/execute.py", line 85, in run_check
File "subprocess.py", line 489, in run
File "subprocess.py", line 854, in __init__
File "subprocess.py", line 1702, in _execute_child
FileNotFoundError: [Errno 2] No such file or directory: PosixPath
('dot')

This behavior has been patched in the latest PyInstaller hooks update. To
fix, simply update your hooks.

pip3 install --upgrade pyinstaller-hooks-contrib

160
PyQtGraph

This error may be seen if using a mismatched version of PyQtGraph and the
PyInstaller hooks files.

no module pyqtgraph.graphicsItems.ViewBox.axisCtrlTemplate_pyqt5

The simplest solution is to both update PyQtGraph to latest version and


update Pyinstaller & hooks files with.

pip3 install --upgrade PyInstaller pyinstaller-hooks-contrib pyqtgraph

If the problem persists, see the PyInstaller hooks Issues for latest updates.

161
yt_dlp

The yt_dlp library is a fork/updated version of the youtube-downloader


package, which can be used to download YouTube videos in your apps.

The library uses hidden imports which are not detected by PyInstaller.
While the module will package successfully the resulting executable will
not run.

Traceback (most recent call last):


File "main.py", line 10, in <module>
import yt_dlp
File "PyInstaller\loader\pyimod03_importers.py", line 495, in
exec_module
File "yt_dlp\__init__.py", line 11, in <module>
File "PyInstaller\loader\pyimod03_importers.py", line 495, in
exec_module
File "yt_dlp\compat\__init__.py", line 14, in <module>
File "yt_dlp\compat\compat_utils.py", line 36, in passthrough_module
File "importlib\__init__.py", line 127, in import_module
ModuleNotFoundError: No module named 'yt_dlp.compat._legacy'

You can fix this by informing PyInstaller about the hidden imports.

--hidden-import=yt_dlp.compat._legacy

Or, in the .spec file.

hiddenimports=['yt_dlp.compat._legacy'],

162
Appendix A: What next?
This book covers the key things you need to know to start Packaging GUI
applications with Python. If you’ve made it here you should be well on your
way to create your own apps!

But there is still a lot to discover while you build your applications. To help
with this I post regular tips, tutorials and code snippets on the Python GUIs
website. Like this book all samples are MIT licensed and free to mix into
your own apps. You may also be interested in joining my Python GUI
Academy where I have video tutorials covering the topics in this book &
beyond!

Thanks for reading, and if you have any feedback or suggestions please let
me know!

Get access to updates


If you bought this book directly from me, you will receive automatic
updates to this book. If you bought this book elsewhere, you can email your
receipt to me to get access future updates.

163
Appendix B: Other Books by Me
If you’re interested in Python GUI programming you may be interested in
reading my other books, shown below. You can see the full list of my
Python GUI books on the Python GUIs website, or my other books on my
own website.

Figure 31. Create GUI Applications with Python & Qt6, 5th Edition PyQt6

164
Figure 32. Create GUI Applications with Python & Qt5, 5th Edition PyQt5

165
Figure 33. Create GUI Applications with Python & Qt6, 5th Edition PySide6

166
Index
A pytest, 153

app bundle, 44
R
arguments, 127
relative paths, 46
C
S
command-line arguments, 127
spec file, 18
D sys.argv, 127

data files, 56
T
data folders, 68
troubleshooting, 151
Debian, 103
U
E
Ubuntu, 103
executable icons, 42
V
F
virtual environment, 4
fpm, 103
Y
G
youtube, 162
Graphviz, 160

I
icons, 32
installer, 95
InstallForge, 87

L
Linux package, 103

P
paths, 46
PyInstaller, 103, 3, 87
pyqtgraph, 161

167

You might also like