Packaging and Shipping Python Apps for the Desktop

Part 1: Know thy tools!

If you’ve been following my blogs lately, you might have noticed that I’ve been writing a lot on edge machine learning, for both mobile and desktop.

While building models and writing code that runs inference on them is one thing, it’s equally important to also package your solution in a way that lets your end-users actually use them.

This is extremely easy to do as a mobile developer since tools like Android Studio and Xcode take this burden away from you as a developer and handle the packaging of the code itself.

But as someone who’s writing desktop apps with Python (using frameworks like PySide2 or PyQt5); this process isn’t very straightforward. There isn’t any IDE that does this packaging for you, nor is there any definitive guide that you can follow to package your apps without running into significant issues.

Having gone through this hassle of packaging apps written in Python with PySide2, I figured that this might be a good topic to write a detailed blog post on, covering not only the steps to do it but also outlining some common issues and pitfalls that you might encounter along the way.

In the first part of this multi-part series, we’ll be looking at the tools that allow us to package a basic Python app to run it on a Mac (since PySide2 allows for cross-platform development, the process should be similar for Windows as well).

So now that the premise of the post is clear, let’s get started!

Step 1: Introduction to the tools

There are several options available when it comes to packaging your desktop app written in Python. Let’s look at the popular ones available, along with the pros and cons of each.

PyInstaller

PyInstaller is the most famous and commonly used in the Python community.
Since it’s being actively developed with new commits added almost daily, you likely won’t go wrong with it.

It’s also very well-documented with extensive community support available on both the GitHub repo and StackOverflow alike. PyInstaller can also package your app for both Windows and macOS, which is something that not a lot of its alternatives can do.

The only issue with PyInstaller might be its learning curve. While it’s by no means hard to get started with, newbie developers might face some issues when it comes to understanding the way spec files created by PyInstaller work.

PyInstaller comes with a GPL license with an exception that states that you have no restrictions in using PyInstaller for commercial closed-source applications as-is. However, any kind of modifications to it will have to comply with the GPL license.

What this means is, if you have an app that’s to be used commercially and you don’t want to share its source code, you can use PyInstaller as long as you don’t make any changes to its source code. The moment you make any changes to PyInstaller’s source code, your app will have to make either its source code public or purchase a license for PyInstaller.

Fbs

Fbs is another packaging tool that’s built on top of PyInstaller and offers an easier onboarding process as compared to the former.

Contrary to PyInstaller, fbs is extremely easy to get started with, and along with the packaged executable, it can also create Windows and OSX installers for the app, which is something that PyInstaller doesn’t do!

Fbs is also actively maintained on GitHub, with the last commit 3 weeks ago, at the time of writing this tutorial.

The only problem with fbs is the licensing associated with the project. Fbs enforces a GPL license on the projects that use it, and unlike PyInstaller, there aren’t any exclusions.

What this means is, if you have a closed-source commercial app packaged with fbs, you either need to disclose its source code or purchase the commercial license for it.

For most beginners, using fbs is a good idea, but if you’re someone who’s concerned about not revealing their source code and are low on funds, this might not be a viable option for you.

For the purpose of this blog post, I’ll be covering packaging with PyInstaller, since it’s not only more flexible in terms of what all you can do with it, but is also more relaxed with its licensing requirements.

Step 2: Installing the required dependencies

Before we go ahead and write any code, it’s important that we first have all the required dependencies installed on our development machine.

For the current example, these are the dependencies we’ll need:

We can use pip to install these dependencies with the following command:

Step 3: Building a UNIX executable with PyInstaller

Once the dependencies are in place, the next step is to create and package a simple “Hello World” app.

For the purposes of this post, we’ll be taking a look at a simple app written in Python that uses OpenCV to show images contained in a folder. We’ll be covering more complex apps in the next part of this post; but for the purpose of keeping things simple, we’ll start with something easy.

Here’s the Python script that we’ll be packaging:

import cv2
import pathlib 

folder_path = "folder_path"

for file in pathlib.Path(folder_path).iterdir():
  try:
    opencv_image = cv2.imread(r"{}".format(file.resolve()))
    cv2.imshow("img", opencv_image)
  except:
    continue
  key = cv2.waitKey(0)

This code snippet uses OpenCV as an external dependency. To package this script with PyInstaller, we’ll be running the following command:

Executing the command will trigger a build and will also simultaneously create a main.spec file in the same directory as your main.py file.

Here’s what the contents of that main.spec file might look like:

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

block_cipher = None


a = Analysis(['main.py'],
             pathex=['/Users/harshitdwivedi/Desktop/test'],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             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='main',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=True )
          
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               upx_exclude=[],
               name='main')

Along with this, PyInstaller also creates 2 more folders called build and dist.

If you navigate to the dist folder and open the folder called main inside it, you should see a UNIX executable called main. This executable is what you can use to execute the code we wrote above on a Mac or Linux machine.

To see it in action, simply open a terminal in that folder and type in the following command:

You should see a window pop up that shows the images contained inside the folder that we’ve specified in the Python code above.

For any subsequent builds of your app, instead of typing pyinstaller main.py, we’ll be using pyinstaller main.spec. The spec file acts as a config file for all our builds, and any changes made to it will be reflected in the build that we trigger.

Now, let’s look at the contents of this spec file and see what each of them means.

Step 4: Understanding the spec file

Quoting from the official docs of PyInstaller:

As a developer, your work will mostly focus on the first class, i.e. the Analysis class; so let’s have a look at the important and crucial aspects of this file:

...
a = Analysis(['main.py'],
             pathex=['/Users/harshitdwivedi/Desktop/test'],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
...

The first line points to your Python script that’s supposed to be the entry point to your application—here it’s main.py.

The pathex attribute essentially tells PyInstaller the path from where it can pick up external dependencies. Normally, you might want to add the path to your virtual environment’s site-packages dir here. For instance, here’s my pathex for a production app that I’m working on:

Up next, we have binaries and datas, which contain a list of non-Pythonic binaries and the list of non-binary files needed by your app, respectively.

For example, if I have a specific .so file that I need to be packaged into the app, I’ll list that in the binaries attribute, whereas if I need a .png file to be packaged into the app, I’ll list that inside the datas attribute.

For my app, I don’t have any additional binary dependency; however, there are some non-binary files needed by my app, and here’s what my datas attribute looks like:

Up next, we have the hiddenimports attribute, which instructs PyInstaller to package any additional module that your app might depend on.

While PyInstaller is intelligent enough to identify all the dependencies in your script automatically, it might sometimes fail to identify a few dependencies, and that’s where you can use this handy attribute to instruct it to package any additional dependency into your app.

Lastly, we also have the excludes attribute, which is the opposite of the hiddenimports attribute we saw above. In case PyInstaller tries to pack more stuff than necessary, you can always instruct it to ignore certain packages by adding them to the excludes attribute.

For instance, here’s what hiddenimports and excludes looks like for me:

The rest of the options aren’t often used while packaging apps, so we’ll be skipping them to keep this post short and concise.

You can always go through the official PyInstaller spec manual to see what each of them mean:

Step 5: Customizing the packaged app

As you might have noticed in Step 3, PyInstaller gives us an executable that we can run from the terminal. While that sounds good, it’s actually not ideal, as you certainly won’t be asking your users to run the packaged app from their terminal, right?

Worry not, because PyInstaller also allows us to build macOS .app and Windows .exe files as well, which allows your users to start your app directly, by double-clicking on the app.

To do so, open your spec file and add a new section at the very end that looks like this:

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

Once done, rebuild the app with the command pyinstaller main.spec

Doing so will result in a main.app file being created in the dist folder, and you should now be able to run your app by double-clicking on it!

If you look closely, you’ll see that this app doesn’t have an icon associated with it. In order to add a custom icon to the app, you can add a new attribute to the Bundle class in the spec file as follows:

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

This will look for an icon.icns file in the directory containing your main.py file and bind it to the app file built above.

In order to create an icons file from a .png file, you can follow the tutorial here:

Once done, this is what the app will look like:

To rename the app, you can simply rename the name attribute from Bundle, Collect, and Exe classes, which should rename your packaged app.

And that’s it! Hopefully, the basics of packaging and running a Python app on a desktop environment are clear to you now. In the next post, we’ll continue building upon the learnings from this blog to package more complicated apps, including ones involving TensorFlow.

We’ll also be looking at some common pitfalls that you might face while packaging apps, as well as ways of making your apps leaner and faster!

Thanks for reading! If you enjoyed this story, please click the 👏 button and share it to help others find it! Feel free to leave a comment 💬 below.

Have feedback? Let’s connect on Twitter.

Fritz

Author

Comments 0 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *