Packaging PyQt5 apps with fbs

Distribute cross-platform GUI applications with the fman Build System

Create Simple GUI Applications has been updated! New chapters on multithreading and packaging your apps.

fbs is a cross-platform PyQt5 packaging system which supports building desktop applications for Windows, Mac and Linux (Ubuntu, Fedora and Arch). Built on top of PyInstaller it wraps some of the rough edges and defines a standard project structure which allows the build process to be entirely automated. The included resource API is particularly useful, simplifying the handling of external data files, images or third-party libraries — a common pain point when bundling apps.

Below is a walkthrough for creating PyQt5 applications using fbs from scratch, and for converting existing projects over to the system. If you’re targeting multiple platforms with your app, it's definitely worth a look.

fbs is licensed under the GPL. This means you can use the fbs system for free in packages distributed with the GPL. For commercial (or other non-GPL) packages you must buy a commercial license. See the fbs licensing page for up-to-date information.

If you're impatient, you can grab the Moonsweeper installers directly for Windows, MacOS or Linux (Ubuntu).

Requirements

fbs works out of the box with both PyQt PyQt5 and Qt for Python PySide2. The only other requirement is PyInstaller which handles the packaging itself. You can install these in a virtual environment (or your applications virtual environment) to keep your environment clean.

fbs only supports Python versions 3.5 and 3.6

python3 -m venv fbsenv

Once created, activate the virtual environment by running from the command line —

# On Mac/Linux:
source fbsenv/bin/activate

# On Windows:
call fbsenv\scripts\activate.bat

Finally, install the required libraries. For PyQt5 you would use —

pip3 install fbs PyQt5 PyInstaller==3.4

Or for Qt for Python (PySide2) —

pip3 install fbs PySide2 PyInstaller==3.4

fbs installs a command line tool fbs into your path which provides access to all fbs  management commands. To see the complete list of commands available run fbs.

martin@Martins-Laptop testapp $ fbs
usage: fbs [-h]
           {startproject,run,freeze,installer,sign_installer,repo,upload,release,test,clean,buildvm,runvm,gengpgkey,register,login,init_licensing}
           ...

fbs

positional arguments:
  {startproject,run,freeze,installer,sign_installer,repo,upload,release,test,clean,buildvm,runvm,gengpgkey,register,login,init_licensing}
    startproject        Start a new project in the current directory
    run                 Run your app from source
    freeze              Compile your code to a standalone executable
    installer           Create an installer for your app
    sign_installer      Sign installer, so the user's OS trusts it
    repo                Generate files for automatic updates
    upload              Upload installer and repository to fbs.sh
    release             Bump version and run clean,freeze,...,upload
    test                Execute your automated tests
    clean               Remove previous build outputs
    buildvm             Build a Linux VM. Eg.: buildvm ubuntu
    runvm               Run a Linux VM. Eg.: runvm ubuntu
    gengpgkey           Generate a GPG key for Linux code signing
    register            Create an account for uploading your files
    login               Save your account details to secret.json
    init_licensing      Generate public/private keys for licensing

optional arguments:
  -h, --help            show this help message and exit

Starting an app

If you’re starting a PyQt5 application from scratch, you can use the fbs startproject management command to create a complete, working and packageable application stub in the current folder. This has the benefit of allowing you to test (and continue to test) the packageability of your application as you develop it, rather than leaving it to the end.

fbs startproject

The command walks you through a few questions, allowing you to fill in details of your application. These values will be written into your app source and configuration. The bare-bones app will be created under the src/ folder in the current directory.

martin@Martins-Laptop ~ $ fbs startproject
App name [MyApp] : HelloWorld
Author [Martin] : Martin Fitzpatrick
Mac bundle identifier (eg. com.martin.helloworld, optional):

If you already have your own working PyQt5 app you will need to either a) use the generated app as a guideline for converting yours to the same structure, or b) create a new app using startproject and migrate the code over.

Running your new project

You can run this new application using the following fbs command in the same folder you ran startproject from.

fbs run

If everything is working this should show you a small empty window with your apps' title — exciting eh?

HelloWorld on WindowsHelloWorld on MacHelloWorld on Ubuntu

The application structure

The startproject command generates the required folder structure for a fbs PyQt5 application. This includes a src/build which contains the build settings for your package, main/icons which contains the application icons, and src/python for the source.

.
└── src
    ├── build
    │   └── settings
    │       ├── base.json
    │       ├── linux.json
    │       └── mac.json
    └── main
        ├── icons
        │   ├── Icon.ico
        │   ├── README.md
        │   ├── base
        │   │   ├── 16.png
        │   │   ├── 24.png
        │   │   ├── 32.png
        │   │   ├── 48.png
        │   │   └── 64.png
        │   ├── linux
        │   │   ├── 1024.png
        │   │   ├── 128.png
        │   │   ├── 256.png
        │   │   └── 512.png
        │   └── mac
        │       ├── 1024.png
        │       ├── 128.png
        │       ├── 256.png
        │       └── 512.png
        └── python
            └── main.py

Your bare-bones PyQt5 application is generated in src/main/python/main.py and is a complete working example you can use to base your own code on.

from fbs_runtime.application_context import ApplicationContext
from PyQt5.QtWidgets import QMainWindow

import sys

class AppContext(ApplicationContext):           # 1. Subclass ApplicationContext
    def run(self):                              # 2. Implement run()
        window = QMainWindow()
        version = self.build_settings['version']
        window.setWindowTitle("HelloWorld v" + version)
        window.resize(250, 150)
        window.show()
        return self.app.exec_()                 # 3. End run() with this line

if __name__ == '__main__':
    appctxt = AppContext()                      # 4. Instantiate the subclass
    exit_code = appctxt.run()                   # 5. Invoke run()
    sys.exit(exit_code)

If you’ve built PyQt5 applications before you’ll notice that building an application with fbs introduces a new concept — the ApplicationContext.

The ApplicationContext

When building PyQt5 applications there are typically a number of components or resources that are used throughout your app. These are commonly stored in the QMainWindow or as global vars which can get a bit messy as your application grows. The ApplicationContext provides a central location for initialising and storing these components, as well as providing access to some core fbs features.

The ApplicationContext object also creates and holds a reference to a global QApplication object — available under ApplicationContext.app. Every Qt application must have one (and only one) QApplication to hold the event loop and core settings. Without fbs you would usually define this at the base of your script, and call .exec() to start the event loop.

Without fbs this would look something like this — 

if __name__ == '__main__':
    app = QApplication()
    w = MyCustomWindow()
    app.exec_()

The equivalent with fbs would be —

if __name__ == '__main__':
    ctx = ApplicationContext()
    w = MyCustomWindow()
    ctx.app.exec_()

If you want to create your own custom QApplication initialisation you can overwrite the .app property on your ApplicationContext subclass using cached_property (see below).

This basic example is clear to follow. However, once you start adding custom styles and translations to your application the initialisation can grow quite a bit. To keep things nicely structured fbs recommends creating a .run method on your ApplicationContext.

This method should handle the setup of your application, such as creating and showing a window, finally starting up the event loop on the .app object. This final step is performed by calling self.app.exec_() at the end of the method.

class AppContext(ApplicationContext):
    def run(self):
        ...
        return self.app.exec_()

As your initialisation gets more complicated you can break out subsections into separate methods for clarity, for example —

class AppContext(ApplicationContext):
    def run(self):
        self.setup_fonts()
        self.setup_styles()
        self.setup_translations()
        return self.app.exec_()

    def setup_fonts(self):
        # ...do something ...

    def setup_styles(self):
        # ...do something ...

    def setup_translations(self):
        # ...do something ...

On execution the .run() method will be called and your event loop started. Execution continues in this event loop until the application is exited, at which point your .run() method will return (with the appropriate exit code).

Learning PyQt?

Create Simple GUI Applications with Python & Qt is my guide to building professional desktop apps with Python. Updated for 2019.

Building a real application

The bare-bones application doesn’t do very much, so below we’ll look at something more complete — the Moonsweeper application from my 15 minute apps. The updated source code is available to download here.

Only the changes required to convert Moonsweeper over to fbs are covered here. If you want to see how Moonsweeper itself works, see the original article. The custom application icons were created using icon art by Freepik.

The project follows the same basic structure as for the stub application we created above.

.
├── README.md
├── requirements.txt
├── screenshot-minesweeper1.jpg
├── screenshot-minesweeper2.jpg
└── src
    ├── build
    │   └── settings
    │       ├── base.json
    │       ├── linux.json
    │       └── mac.json
    └── main
        ├── Installer.nsi
        ├── icons
        │   ├── Icon.ico
        │   ├── README.md
        │   ├── base
        │   │   ├── 16.png
        │   │   ├── 24.png
        │   │   ├── 32.png
        │   │   ├── 48.png
        │   │   └── 64.png
        │   ├── linux
        │   │   ├── 1024.png
        │   │   ├── 128.png
        │   │   ├── 256.png
        │   │   └── 512.png
        │   └── mac
        │       ├── 1024.png
        │       ├── 128.png
        │       ├── 256.png
        │       └── 512.png
        ├── python
        │   ├── __init__.py
        │   └── main.py
        └── resources
            ├── base
            │   └── images
            │       ├── bomb.png
            │       ├── bug.png
            │       ├── clock-select.png
            │       ├── cross.png
            │       ├── flag.png
            │       ├── plus.png
            │       ├── rocket.png
            │       ├── smiley-lol.png
            │       └── smiley.png
            └── mac
                └── Contents
                    └── Info.plist

The src/build/settings/base.json stores the basic details about the application, including the entry point to run the app with fbs run or once packaged.

{
    "app_name": "Moonsweeper",
    "author": "Martin Fitzpatrick",
    "main_module": "src/main/python/main.py",
    "version": "0.0.0"
}

The script entry point is at the base of src/main/python/main.py. This creates the AppContext object and calls the .run() method to start up the app.

if __name__ == '__main__':
    appctxt = AppContext()
    exit_code = appctxt.run()
    sys.exit(exit_code)

The ApplicationContext defines a .run() method to handle initialisation. In this case that consists of creating and showing the main window, then starting up the event loop.

from fbs_runtime.application_context import ApplicationContext, \
    cached_property


class AppContext(ApplicationContext):
    def run(self):
        self.main_window.show()
        return self.app.exec_()

    @cached_property
    def main_window(self):
        return MainWindow(self)  # Pass context to the window.

    # ... snip ...

The cached_property decorator

The .run() method accesses self.main_window. You’ll notice that this method is wrapped in an fbs @cached_property decorator. This decorator turns the method into a property (like the Python @property decorator) and caches the return value.

The first time the property is accessed the method is executed and the return value cached. On subsequent calls, the cached value is returned directly without executing anything. This also has the side-effect of postponing creation of these objects until they are needed.

You can use @cached_property to define each application component (a window, a toolbar, a database connection or other resources). However, you don’t have to use the @cached_property — you could alternatively declare all properties in your ApplicationContext.__init__ block as shown below.

from fbs_runtime.application_context import ApplicationContext

class AppContext(ApplicationContext):

    def __init__(self, *args, **kwargs):
        super(AppContent, self).__init__(*args, **kwargs)

        self.window = Window()

    def run(self):
        self.window.show()
        return self.app.exec_()

Accessing resources with .get_resource

Applications usually require additional data files beyond the source code — for example files icons, images, styles (Qt’s .qss files) or documentation. You may also want to bundle platform-specific libraries or binaries. To simplify this fbs defines a folder structure and access method which work seamlessly across development and distributed versions.

The top level folder resources/ should contain a folder base plus any combination of the other folders shown below. The base folder contains files common to all platforms, while the platform-specific folders can be used for any files specific to a given OS.

base/           # for files required on all OSs
windows/        # for files only required on Windows
mac/            # "  "      "    "        "  Mac
linux/          # "  "      "    "        "  Linux
arch/           # "  "      "    "        "  Arch Linux
fedora/         # "  "      "    "        "  Debian Linux
ubuntu/         # "  "      "    "        "  Ubuntu Linux

Getting files into the right place to load from a distributed app across all platforms is usually one of the faffiest bits of distributing PyQt applications. It’s really handy that fbs handles this for you.

To simplify the loading of resources from your resources/ folder in your applications fbs provides the ApplicationContext.get_resource() method. This method takes the name of a file which can be found somewhere in the resources/ folder and returns the absolute path to that file. You can use this returned absolute path to open the file as normal.

from fbs_runtime.application_context import ApplicationContext,     cached_property


class AppContext(ApplicationContext):

    # ... snip ...

    @cached_property
    def img_bomb(self):
        return QImage(self.get_resource('images/bug.png'))

    @cached_property
    def img_flag(self):
        return QImage(self.get_resource('images/flag.png'))

    @cached_property
    def img_start(self):
        return QImage(self.get_resource('images/rocket.png'))

    @cached_property
    def img_clock(self):
        return QImage(self.get_resource('images/clock-select.png'))

    @cached_property
    def status_icons(self):
        return {
            STATUS_READY: QIcon(self.get_resource("images/plus.png")),
            STATUS_PLAYING: QIcon(self.get_resource("images/smiley.png")),
            STATUS_FAILED: QIcon(self.get_resource("images/cross.png")),
            STATUS_SUCCESS: QIcon(self.get_resource("images/smiley-lol.png"))
        }

    # ... snip ...

In our Moonsweeper application above, we have a bomb image file available at src/main/resources/base/images/bug.jpg. By calling ctx.get_resource('images/bug.png') we get the absolute path to that image file on the filesystem, allowing us to open the file within our app.

If the file does not exist FileNotFoundError will be raised instead.

The handy thing about this method is that it transparently handles the platform folders under src/main/resources giving OS-specific files precedence. For example, if the same file was also present under src/main/resources/mac/images/bug.jpg and we called ctx.get_resource('images/bug.jpg') we would get the Mac version of the file.

Additionally get_resource works both when running from source and when running a frozen or installed version of your application. If your resources/ load correctly locally you can be confident they will load correctly in your distributed applications.

Using the ApplicationContext from app

As shown above, our ApplicationContext object has cached properties to load and return the resources. To allow us to access these from our QMainWindow we can pass the context in and store a reference to it in our window __init__.

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

        self.ctx = ctx  # Store a reference to the context for resources, etc.

# ... snip ...

Now that we have access to the context via self.ctx we can use it this in any place we want to reference these external resources.

        l = QLabel()
        l.setPixmap(QPixmap.fromImage(self.ctx.img_bomb))
        l.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
        hb.addWidget(l)

# ... snip ...

        l = QLabel()
        l.setPixmap(QPixmap.fromImage(self.ctx.img_clock))
        l.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
        hb.addWidget(l)

The first time we access self.ctx.img_bomb the file will be loaded, the QImage created and returned. On subsequent calls, we’ll get the image from the cache.

    def init_map(self):
        # Add positions to the map
        for x in range(0, self.b_size):
            for y in range(0, self.b_size):
                w = Pos(x, y, self.ctx.img_flag, self.ctx.img_start, self.ctx.img_bomb)
                self.grid.addWidget(w, y, x)
                # Connect signal to handle expansion.
                w.clicked.connect(self.trigger_start)
                w.expandable.connect(self.expand_reveal)
                w.ohno.connect(self.game_over)

# ... snip ...

        self.button.setIcon(self.ctx.status_icons[STATUS_PLAYING])

# ... snip ...

    def update_status(self, status):
        self.status = status
        self.button.setIcon(self.ctx.status_icons[self.status])

Those are all the changes needed to get the Moonsweeper app packageable with fbs. If you open up the source folder you should be able to start it up as before.

fbs run

If that’s working, you’re ready to move onto freezing and building in the installer.

Freezing the app

Freezing is the process of turning a Python application into a standalone executable that can run on another user’s computer. Use the following command to turn the app's source code into a standalone executable:

fbs freeze

The resulting executable depends on the platform you freeze on — the executable will only work on the OS you built it on (e.g. an executable built on Windows will run on another Windows computer, but not on a Mac).

  • Windows will create an .exe executable in the folder target/<AppName>
  • MacOS X will create an .app application bundle in target/<AppName>.app
  • Linux will create an executable in the folder target/<AppName>

On Windows you may need to install the Windows 10 SDK, although fbs will prompt you if this is the case.

Creating an installer

While you can share the executable files with users, desktop applications are normally distributed with installers which handle the process of putting the executable (and any other files) in the correct place. See the following sections for platform-specific notes before creating

You must freeze your app first then create the installer.

Windows installer

The Windows installer allows your users to pick the installation directory for the executable and adds your app to the user’s Start Menu. The app is also added to installed programs, allowing it to be uninstalled by your users.

Before you create installers on Windows you will need to install NSIS and ensure its installation directory is in your PATH. You can then build an installer using —

fbs installer

The Windows installer will be created at target/<AppName>Setup.exe.

The Windows NSIS installer

Download the MoonsweeperSetup .exe here

Mac installer

There are no additional steps to create a MacOS installer. Just run the fbs command —

fbs installer

On Mac the command will generate a disk image at target/<AppName>.dmg. This disk image will contain the app bundle and a shortcut to the Applications folder. When your users open it they can drag the app to the Applications folder to install it.

The .dmg installer on Mac

Download the Moonsweeper .dmg bundle here

Linux installer

To build installers on Linux you need to install the Ruby tool Effing package management! — use the installation guide to get it set up. Once that is in place you can use the standard command to create the Linux package file.

fbs installer

The resulting package will be created under the target/ folder. Depending on your platform the package file will be named <AppName>.deb, <AppName>.pkg.tar.xz or <AppName>.rpm. Your users can install this file with their package manager.

Download the Moonsweeper .deb file here

Find out more

More information about how the fbs packaging system works can be found in the manual which also introduces more advanced features should as distributing releases of Linux apps, reporting errors to the Sentry error logging platform and adding license keys to your software.

Thanks to Michael Herrmann the creator of fbs for doing the initial work to convert Moonsweeper over to fbs