Packaging PyQt5 apps with fbs
Distribute cross-platform GUI applications with the fman Build SystemPackaging and distribution course
This tutorial is also available for PySide2
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.
This tutorial will take you through the steps of 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.
If you’re impatient, you can grab the Moonsweeper installers directly for Windows, MacOS or Linux (Ubuntu).
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.
fbs is built on top of PyInstaller. You can also use PyInstaller directly to package applications, see our Packaging PyQt5 & PySide2 applications for Windows, with PyInstaller tutorial.
Install requirements
fbs works out of the box with both PyQt
PyQt5
and Qt for PythonPySide2
. The only other requirement isPyInstaller
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
bashpython3 -m venv fbsenv
Once created, activate the virtual environment by running from the command line —
bash<span class="hljs-comment"># On Mac/Linux:</span> <span class="hljs-built_in">source</span> fbsenv/bin/activate <span class="hljs-comment"># On Windows:</span> call fbsenv\scripts\activate.bat
Finally, install the required libraries. For PyQt5 you would use —
pythonpip3 install fbs PyQt5 PyInstaller==<span class="hljs-number">3.4</span>
Or for Qt for Python (PySide2) —
pythonpip3 install fbs PySide2 PyInstaller==<span class="hljs-number">3.4</span>
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 runfbs
.bashmartin@Martins-Laptop testapp $ fbs usage: fbs [-h] {startproject,run,freeze,installer,sign_installer,repo, upload,release,<span class="hljs-built_in">test</span>,clean,buildvm,runvm,gengpgkey,register,login,init_licensing} ... fbs positional arguments: {startproject,run,freeze,installer,sign_installer,repo, upload,release,<span class="hljs-built_in">test</span>,clean,buildvm,runvm,gengpgkey,register,login,init_licensing} startproject Start a new project <span class="hljs-keyword">in</span> the current directory run Run your app from <span class="hljs-built_in">source</span> freeze Compile your code to a standalone executable installer Create an installer <span class="hljs-keyword">for</span> your app sign_installer Sign installer, so the user<span class="hljs-string">'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 </span>
Now you’re ready to start packaging applications with fbs.
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.bashfbs 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.bashmartin@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.bashfbs run
If everything is working this should show you a small empty window with your apps’ title — exciting eh?
Hello World App on Windows
Hello World App on Mac
Hello World App on Linux
The application structure
The
startproject
command generates the required folder structure for a fbs PyQt5 application. This includes asrc/build
which contains the build settings for your package,main/icons
which contains the application icons, andsrc/python
for the source.bash. └── 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.python<span class="hljs-keyword">from</span> fbs_runtime.application_context.PyQt5 <span class="hljs-keyword">import</span> ApplicationContext <span class="hljs-keyword">from</span> PyQt5.QtWidgets <span class="hljs-keyword">import</span> QMainWindow <span class="hljs-keyword">import</span> sys <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-comment"># 1. Subclass ApplicationContext</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-comment"># 2. Implement run()</span> window = QMainWindow() version = self.build_settings[<span class="hljs-string">'version'</span>] window.setWindowTitle(<span class="hljs-string">"HelloWorld v"</span> + version) window.resize(<span class="hljs-number">250</span>, <span class="hljs-number">150</span>) window.show() <span class="hljs-keyword">return</span> self.app.exec_() <span class="hljs-comment"># 3. End run() with this line</span> <span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>: appctxt = AppContext() <span class="hljs-comment"># 4. Instantiate the subclass</span> exit_code = appctxt.run() <span class="hljs-comment"># 5. Invoke run()</span> 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. TheApplicationContext
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 globalQApplication
object — available underApplicationContext.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 —
python<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>: app = QApplication() w = MyCustomWindow() app.exec_()
The equivalent with fbs would be —
python<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>: 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 yourApplicationContext
.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 callingself.app.exec_()
at the end of the method.python<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self</span>):</span> ... <span class="hljs-keyword">return</span> self.app.exec_()
As your initialisation gets more complicated you can break out subsections into separate methods for clarity, for example —
python<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self</span>):</span> self.setup_fonts() self.setup_styles() self.setup_translations() <span class="hljs-keyword">return</span> self.app.exec_() <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">setup_fonts</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-comment"># ...do something ...</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">setup_styles</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-comment"># ...do something ...</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">setup_translations</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-comment"># ...do something ...</span>
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).
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 below.
Moonsweeper Source (fbs) PyQt5
Moonsweeper Source (fbs) PySide2
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 App 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.
python. ├── 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 │ │ ├── <span class="hljs-number">16.</span>png │ │ ├── <span class="hljs-number">24.</span>png │ │ ├── <span class="hljs-number">32.</span>png │ │ ├── <span class="hljs-number">48.</span>png │ │ └── <span class="hljs-number">64.</span>png │ ├── linux │ │ ├── <span class="hljs-number">1024.</span>png │ │ ├── <span class="hljs-number">128.</span>png │ │ ├── <span class="hljs-number">256.</span>png │ │ └── <span class="hljs-number">512.</span>png │ └── mac │ ├── <span class="hljs-number">1024.</span>png │ ├── <span class="hljs-number">128.</span>png │ ├── <span class="hljs-number">256.</span>png │ └── <span class="hljs-number">512.</span>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 withfbs run
or once packaged.json{ <span class="hljs-attr">"app_name"</span>: <span class="hljs-string">"Moonsweeper"</span>, <span class="hljs-attr">"author"</span>: <span class="hljs-string">"Martin Fitzpatrick"</span>, <span class="hljs-attr">"main_module"</span>: <span class="hljs-string">"src/main/python/main.py"</span>, <span class="hljs-attr">"version"</span>: <span class="hljs-string">"0.0.0"</span> }
The script entry point is at the base of
src/main/python/main.py
. This creates theAppContext
object and calls the.run()
method to start up the app.python<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>: 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.python<span class="hljs-keyword">from</span> fbs_runtime.application_context.PyQt5 <span class="hljs-keyword">import</span> ApplicationContext, \ cached_property <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self</span>):</span> self.main_window.show() <span class="hljs-keyword">return</span> self.app.exec_() <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">main_window</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> MainWindow(self) <span class="hljs-comment"># Pass context to the window.</span> <span class="hljs-comment"># ... snip ...</span>
The
cached_property
decoratorThe
.run()
method accessesself.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 yourApplicationContext.__init__
block as shown below.python<span class="hljs-keyword">from</span> fbs_runtime.application_context.PyQt5 <span class="hljs-keyword">import</span> ApplicationContext <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">self, *args, **kwargs</span>):</span> <span class="hljs-built_in">super</span>(AppContext, self).__init__(*args, **kwargs) self.window = Window() <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self</span>):</span> self.window.show() <span class="hljs-keyword">return</span> 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 folderbase
plus any combination of the other folders shown below. Thebase
folder contains files common to all platforms, while the platform-specific folders can be used for any files specific to a given OS.bashbase/ <span class="hljs-comment"># for files required on all OSs</span> windows/ <span class="hljs-comment"># for files only required on Windows</span> mac/ <span class="hljs-comment"># " " " " " Mac</span> linux/ <span class="hljs-comment"># " " " " " Linux</span> arch/ <span class="hljs-comment"># " " " " " Arch Linux</span> fedora/ <span class="hljs-comment"># " " " " " Debian Linux</span> ubuntu/ <span class="hljs-comment"># " " " " " Ubuntu Linux</span>
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 theApplicationContext.get_resource()
method. This method takes the name of a file which can be found somewhere in theresources/
folder and returns the absolute path to that file. You can use this returned absolute path to open the file as normal.python<span class="hljs-keyword">from</span> fbs_runtime.application_context.PyQt5 <span class="hljs-keyword">import</span> ApplicationContext, cached_property <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppContext</span>(<span class="hljs-params">ApplicationContext</span>):</span> <span class="hljs-comment"># ... snip ...</span> <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">img_bomb</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> QImage(self.get_resource(<span class="hljs-string">'images/bug.png'</span>)) <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">img_flag</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> QImage(self.get_resource(<span class="hljs-string">'images/flag.png'</span>)) <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">img_start</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> QImage(self.get_resource(<span class="hljs-string">'images/rocket.png'</span>)) <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">img_clock</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> QImage(self.get_resource(<span class="hljs-string">'images/clock-select.png'</span>)) <span class="hljs-meta"> @cached_property</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">status_icons</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-keyword">return</span> { STATUS_READY: QIcon(self.get_resource(<span class="hljs-string">"images/plus.png"</span>)), STATUS_PLAYING: QIcon(self.get_resource(<span class="hljs-string">"images/smiley.png"</span>)), STATUS_FAILED: QIcon(self.get_resource(<span class="hljs-string">"images/cross.png"</span>)), STATUS_SUCCESS: QIcon(self.get_resource(<span class="hljs-string">"images/smiley-lol.png"</span>)) } <span class="hljs-comment"># ... snip ...</span>
In our Moonsweeper application above, we have a bomb image file available at
src/main/resources/base/images/bug.jpg
. By callingctx.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 undersrc/main/resources/mac/images/bug.jpg
and we calledctx.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 yourresources/
load correctly locally you can be confident they will load correctly in your distributed applications.Using the
ApplicationContext
from appAs shown above, our
ApplicationContext
object has cached properties to load and return the resources. To allow us to access these from ourQMainWindow
we can pass the context in and store a reference to it in our window__init__
.python<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MainWindow</span>(<span class="hljs-params">QMainWindow</span>):</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">self, ctx</span>):</span> <span class="hljs-built_in">super</span>(MainWindow, self).__init__() self.ctx = ctx <span class="hljs-comment"># Store a reference to the context for resources, etc.</span> <span class="hljs-comment"># ... snip ...</span>
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.pythonl = QLabel() l.setPixmap(QPixmap.fromImage(self.ctx.img_bomb)) l.setAlignment(Qt.AlignRight | Qt.AlignVCenter) hb.addWidget(l) <span class="hljs-comment"># ... snip ...</span> 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, theQImage
created and returned. On subsequent calls, we’ll get the image from the cache.python<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">init_map</span>(<span class="hljs-params">self</span>):</span> <span class="hljs-comment"># Add positions to the map</span> <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> <span class="hljs-built_in">range</span>(<span class="hljs-number">0</span>, self.b_size): <span class="hljs-keyword">for</span> y <span class="hljs-keyword">in</span> <span class="hljs-built_in">range</span>(<span class="hljs-number">0</span>, 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) <span class="hljs-comment"># Connect signal to handle expansion.</span> w.clicked.connect(self.trigger_start) w.expandable.connect(self.expand_reveal) w.ohno.connect(self.game_over) <span class="hljs-comment"># ... snip ...</span> self.button.setIcon(self.ctx.status_icons[STATUS_PLAYING]) <span class="hljs-comment"># ... snip ...</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">update_status</span>(<span class="hljs-params">self, status</span>):</span> 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.
bashfbs 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:
pythonfbs 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 foldertarget/<AppName>
- MacOS X will create an
.app
application bundle intarget/<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.
Create GUI Applications with Python & Qt6
The easy way to create desktop applicationsMy complete guide, updated for 2021 & PyQt6. Everything you need build real apps.
To support developers in Italy I give a 50% discount with the code WE5GY0 — Enjoy!
Creating the 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 —bashfbs installer
The Windows installer will be created at
target/<AppName>Setup.exe
.Moonsweeper Windows NSIS installer
Mac installer
There are no additional steps to create a MacOS installer. Just run the fbs command —
bashfbs 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.Download the Moonsweeper .dmg bundle here
Moonsweeper Mac Disk Image
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.
bashfbs 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. 2/2
Packaging PyQt5 apps with fbs — Distribute cross-platform GUI applications with the fman Build System
Pages: 1 2