Building a deployable Python-Electron App

Building a normal, deployable application for Mac or Windows, using Python 3 is hard. There is nothing like Visual Studio or Delphi (remember that?) for Python where you can drag and drop to design a GUI, press a button and get an .exe or .app to give people. Sad.

The closest you can get is to follow a long recipe of steps — which I propose to outline here. But first you have to choose a GUI toolkit for Python. Many programmers — including me — consider Python GUIs and associated deployment/packaging technology to be lacking. I’ve used wxPython in the past (see my Pynsource UML tool — reverse engineer Python into UML class diagrams), and thought I’d try the Chrome browser based Electron as my front end for my latest project.

What’s that you hear — Electron? Yep.

Electron is a framework for creating cross-platform native applications with web technologies like JavaScript, HTML, and CSS.

One of the great things about using Electron as a GUI for Python is that you get to use cutting edge web technologies and you don’t have to learn some old, barely maintained GUI toolkit — in fact you can re-use your existing JavaScript, HTML, and CSS knowledge that you already use everyday for building websites.

Let me ask you this: How much momentum, money $$$$, time and how many developer minds are focused on advancing web technologies? Answer: it’s staggeringly huge. Compare this with the number of people maintaining old toolkits from the 90’s e.g. wxPython? Answer: perhaps one or two people in their spare time. Which would you rather use? You get the idea.

In addition to having a GUI that potentially displays everything you have ever seen rendered in a web page, using Electron as a GUI toolkit gets you

  • native menus, notifications
  • installers, automatic updates to your app
  • debugging and profiling that you are used to, using the Chrome debugger
  • ES6 syntax (a cleaner Javascript with classes, module imports, no need for semicolons etc.). Squint, look sideways, and it kinda looks like Python… ;-)
  • the full power of nodejs and its huge npm package repository
  • the large community and ecosystem of Electron
  • the ability to use tens of thousands of Javascript plugins and JavaScript UI widgets and controls e.g. jQuery UI, Webix widgets, Kendo, Chartjs for Charting and various visualisation plugins — the possibilities are endless and exciting
  • In a sense, Google and tens of thousands of developers working on improving your GUI toolkit every day, every hour

Electron Python is a template of code where you use Electron (nodejs + chromium) as a GUI talking to Python 3 as a backend via zerorpc. Similar to Eel but much more capable e.g. you get proper native operating system menus — and users don’t need to have Chrome already installed. It’s been around for a while and recently I decided to give it a try. Don’t be put off by the need to use a specific version of nodejs — I was able to use the latest nodejs and Electron.

I built and released both a Windows and Mac version of my app. It was a bit of a laborious road and this is an attempt at documenting my journey, so that you too can create a real, cross-platform, downloadable product with a nice GUI, in Python 3.

Proof it works

The Electron-Python app that I developed and deployed is called Print42 — check it out, incl. videos and screenshots at http://bit.ly/print42atug.

Print42 is a developer tool — a GUI app for tailing log files onto an onscreen printer tape. The key here is that the onscreen tape is writable, for dumping screenshots and text fragment notes into that same log file tape — without affecting the original log file on disk. Screenshots and text annotations are inserted into the “tape” and interspersed with the live log file entries. You can then scroll back and see your screenshots and notes and log entries. In essence you are “annotating” your log file with rich,helpful information — without affecting it, giving context and meaning to points in time in the log file, for better subsequent analysis.

Print42 built with Electron-Python

Using the Python library python-escpos allows Print42 to support thermal printers (the kind you see in shops and supermarkets). A cheap, super fast thermal printer can thus be used as a “ticker tape” output for your log files. You might want to turn on Print42 expression filtering if your log files are verbose — or then again — thermal paper is cheap and requires no ink! Seriously, I believe that having a (instant on, silent when idle) thermal printer on each programmer’s desk is a productivity booster — not just for log files but for quickly printing all sorts of screen-grabs that can be written on and studied e.g. long expressions, complex code fragments etc. Sometimes you just need a pen and a few colours to think through a problem. I’m also a fan of fountain pens and inks — so that’s an organic vintage hobby I blend with my tech work.

Many Print42 features rely on web technologies. The infinite main scrolling display was implemented as <div> blocks. Font and image size options were naturally implemented in CSS, fancy search was implemented via the javascript plugin electron-in-page-search.

Print42 supports printing selections of the tape to normal desktop printers too— though you won’t get the thermal paper “line by line and tear it off” vibe, because all desktop printers must of course print a page at a time.

Electron Pros and Cons

The ability to leverage my existing JavaScript, HTML, and CSS skills to build a UI has been priceless. I’m excited that I can build installable Python 3 ‘desktop apps’ for mac, windows and linux — that look like proper, professional apps including proper system menus and installers. There may be a few downsides, however.

I found that communicating between the Electron GUI and Python 3 using remote procedure calls is not ideal, however it’s pretty easy and on the bright side, arguably encourages clean partitioning of your app into UI vs business logic. Here is an example of Javascript (Electron) sending a UI preferences dictionary to Python for saving to a preferences file

// Connect to Python subsystem
const zerorpc = require("zerorpc");
let client = new zerorpc.Client();
client.connect("tcp://127.0.0.1:PORTNUMBER");
function py_save_prefs() {
let data = {
magic: settings.PREFS_MAGIC,
tape_file: settings.get_VIRTUAL_TAPE_FILE(),
tape_file_gif: settings.get_GIF_TAPE_FILE(),
auto_feed_delay: $('#auto_feed_delay').val(),
auto_feed_enabled: $('#cb_auto_feed').prop('checked'),
print_to_epson_enabled: $('#cb_print_to_epson').prop('checked'),
font: $('#input-font').val(),
file_history: settings.get_file_history()
}
client.invoke("save_prefs", data, (error, result) => {
if (error)
show_error(error)
else {
// log.debug('preferences saved ok')
}
})
}

Architecture Tip: I put all my Javascript functions that called out to Python e.g. like py_save_prefs() above, into a single Javascript file. I also clearly defined my Python ‘API’ in a single Python module. This way the communication between the two layers/sides was organised and neat.

There are those who don’t like the idea of using Electron at all. If you don’t have the web skills, then clearly the heavy use of HTML, CSS and Javascript required for using Electron, is not for you. Another reason against Electron may be that the deployed app is big because it bundles inside of itself a complete copy of the powerful cutting edge Chrome browser. A deployed app might be around 70Meg which expands to 200M on disk when installed. Note that the users of your app don’t need to have Chrome installed — Electron’s Chromium is self contained and secretly hiding inside your deployed app.

Let’s get started

The basic instructions and template project are available on the electron-python github page

The basic idea is that whilst any Electron app can run happily on its own and do lots of things inside Chromium — you add a simple bit of Javascript code to spawn Python (which acts as a server on a port) and Javascript talks to Python over that same port. The Python ‘server’ side of things is implemented something like:

# Python code
import zerorpc
import gevent, signal
port = 1234
addr = 'tcp://127.0.0.1:' + port
s = zerorpc.Server(PythermalApi())
s.bind(addr)
gevent.signal(signal.SIGTERM, s.stop)
gevent.signal(signal.SIGINT, s.stop) # ^C

s.run()

The call to s.run() blocks and voila we have a server that processes incoming API calls.

The class PythermalApi() above, specifies the API that I am exposing — a Python class whose methods end up being the API endpoints. Simple! The Python API is thus my ‘business logic’:

# Python code
class PythermalApi(object):
    def echo(self, text):
"""echo any text"""
return text
    .... (more endpoints) ...

For example, the Python API function echo is now a function that Electron/Javascript can call with the following Javascript code:

// Javascript code
client.invoke("echo", "hello", (error, result) => {
if (error)
show_error(error)
else {
console.log('Got this from Python: ' + result);
}
})

In this example Python API endpoint echo is simply returning the text that Javascript sends as a parameter, back to Javascript. You can pass numbers, dictionaries, lists etc. as parameters and as return values.

Installation

The Python side of things involves installing zerorpc,

An easy to use, intuitive, and cross-language RPC
zerorpc is a light-weight, reliable and language-agnostic library for distributed communication between server-side processes. It builds on top of ZeroMQ and MessagePack. Support for streamed responses — similar to python generators — makes zerorpc more than a typical RPC engine. Built-in heartbeats and timeouts detect and recover from failed requests. Introspective capabilities, first-class exceptions and the command-line utility make debugging easy.

which is the mechanism by which nodejs calls functions in Python. Here is my final Print42 Pipfile

[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
zerorpc = "*"
python-escpos = "==3.0a4"
attrs = "*"
pyusb = "*"
click = "*"
gevent = "*"
appdirs = "*"
pillow = "<5.1.0"
cffi = "*"
greenlet = "*"
[dev-packages]
PyInstaller = "*"
pywin32 = {version = "*", sys_platform = "== 'win32'"}
[requires]
python_version = "3.6"
[pipenv]
allow_prereleases = true

Pipfiles are an alternative to requirements.txt and virtualenv environments. You can install needed dependencies with the command pipenv install, then activate the virtual environment with the command pipenv shell. Read more about the fabulous pipenv here. I’m a big fan.

The nodejs/Electron side of things involves git cloning the sample project, running npm install (assuming you have installed nodejs) and then typing npm start to launch the resulting app.

Alternatively, (my preference), you can build an Electron project from scratch using something like modern and current like electron-forge and simply paste in the fragments of code (from the fyears template project) needed to perform the communication with Python.

The equivalent to Python’s Pipfile/requirements.txt in Electron/nodejs is the file package.json. Here is my final Print42 package.json

{
"name": "print42",
"productName": "Print42",
"version": "1.0.2",
"description": "Ticker tape and scrapbook for tailing logfiles, thermal printer support",
"main": "src/index.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint src --color"
},
"keywords": [],
"author": "Andy",
"license": "MIT",
"config": {
"forge": {
"make_targets": {
"win32": [
"squirrel"
],
"darwin": [
"zip",
"dmg"
],
"linux": [
"deb",
"rpm"
]
},
"electronPackagerConfig": {
"packageManager": "npm",
"prune": true,
"icon": "src/icons/icon",
"ignore": [
"/src/pycalc$",
"\\.git(ignore|config)",
"\\.vscode",
"\\.idea",
"/package-lock\\.json",
"Pipfile($|\\.lock)",
"/(api_pythermal|api|prtail)\\.spec",
"/bin$",
"/build$",
"/out2$",
"/Output$",
"/README\\.md",
"/print42\\.iss",
"/UsbDk_1.0.19_x64\\.msi",
"/(api_pythermal|t|t2)\\.py$",
"/pythermal$"
]
},
"electronInstallerDMG": {
"title": "Print42 Install",
"icon": "src/icons/icon.icns"
},
"electronWinstallerConfig": {
"name": "print42"
},
"electronInstallerDebian": {},
"electronInstallerRedhat": {},
"github_repository": {
"owner": "",
"name": ""
},
"windowsStoreConfig": {
"packageName": "",
"name": "print42"
}
}
},
"dependencies": {
"always-tail": "^0.2.0",
"electron-compile": "^6.4.2",
"electron-in-page-search": "^1.3.2",
"electron-log": "^2.2.14",
"electron-squirrel-startup": "^1.0.0",
"file-url": "^2.0.2",
"fix-path": "^2.1.0",
"jimp": "^0.2.28",
"shelljs": "^0.8.2",
"tmp": "0.0.33",
"zerorpc": "git+https://github.com/fyears/zerorpc-node.git"
},
"devDependencies": {
"babel-plugin-transform-async-to-generator": "^6.24.1",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"electron-forge": "^5.1.1",
"electron-prebuilt-compile": "1.8.4",
"electron-rebuild": "^1.7.3",
"eslint": "^3.19.0",
"eslint-config-airbnb": "^15.1.0",
"eslint-plugin-import": "^2.9.0",
"eslint-plugin-jsx-a11y": "^5.1.1",
"eslint-plugin-react": "^7.7.0",
"semver": "^5.5.0"
}
}

Note that you would typically let e.g. the electron-forge command build thepackage.json file for you, and then you would add what you need later e.g. install additional nodejs packages with npm install PACKAGENAME --saveas well as adding additional lines manually using an editor (ummm… PyCharm first, Vim second :)


Issues I had

My first problem was wanting to install a later version of nodejs than the one being referred to on the electron-python github page. Secondly the advice to constantly clear node and electron caches during installation sounded a bit hacky.

So I decided to build the nodejs project from first principles, using electron-forge. This gave me the latest and greatest nodejs and electron. To make the version of nodejs you have compatible with the version of electron you have (which weirdly comes with its own version of node internally) you must run:

#every time you run 'npm install',you have to run this:
./node_modules/.bin/electron-rebuild

otherwise you will get ‘module mismatch’ errors when running npm install. You can always remove your ./node_modulesdirectory entirely and rebuild it with npm install if you ever get into bother.

My second problem was a hesitancy to use the special version of nodejs package zerorpc which the sample fyears github project specifies. After much research and gnashing of teeth, I gave in. It turns out that fyear has merely made a couple of lines of changes to the official zerorpc nodejs package, in order for it to be compatible with being able to package your app into a single .exe or .app. The forked zerorpc repo is branched off the official repo so any future changes should ripple through, so all good.

extract from package.json...
"dependencies": {
"zerorpc": "^0.9.7"  <— use the official repo if don’t care about packaging
"zerorpc": "git+https://github.com/fyears/zerorpc-node.git"  <— use this custom repo if you want to package
}

Note: also that, on a mac,brew install zeromq is not needed, because installing zerorpc via python’s pip install zerorpc or pipenv install zerorpc is enough, and gives you the relevant libraries and the zerorpc CLI.

I develop under Mac OS, but I managed to get my project running fine under Windows 10. I used the windows equivalent to brew which is the program chocolaty to install a bash shell containing git e.g. choco install git — or you can simply use the linux subsystem for windows to get your bash shell under Windows and your git tooling.

Packaging

Packaging is a three step process.

  • Step 1: First you run pyinstaller to bundle up all the Python files and create a standalone Python executable in the dist directory. When this exe is run by Electron, the Python app runs as a zerorpc server on a particular port. You will need to create a .spec file to keep your pyinstaller configuration in (see example below).
  • Step 2: Then you run nodejs electron-packager which does the same bundling for electron plus grabs the python dist directory (created by the previous step, plus any other directories that you don’t exclude) and creates a distributable .app for Mac and .exe for Windows. It optionally creates .dmg files for Mac. If you created your electron project with electron-forge then the appropriate command is electron-forge package.
  • Step 3 (optional): For Windows you will probably also want to make a proper installer/uninstaller. I use the free program Inno Setup for this last step, under Windows.

In step 2 (whole app creation), I recommend that you tellelectron-packager to exclude bits of your app that don’t need to be shipped, by adding entries in package.json under electronPackagerConfig / ignore. Be especially careful to exclude the pyinstaller build/ directory as it is huge and not needed in the deployed app. And of course you don’t want to ship your .git repository :-) You’ll notice the many entries for ignored/excluded files and directories in my package.json — here is the relevant section:

"ignore": [
"/src/pycalc$",
"\\.git(ignore|config)",
"\\.vscode",
"\\.idea",
"/package-lock\\.json",
"Pipfile($|\\.lock)",
"/(api_pythermal|api|prtail)\\.spec",
"/bin$",
"/build$",
"/out2$",
"/Output$",
"/README\\.md",
"/print42\\.iss",
"/UsbDk_1.0.19_x64\\.msi",
"/(api_pythermal|t|t2)\\.py$",
"/pythermal$"
]

PyInstaller tips and tricks

Step 1 (python standalone creation via pyinstaller) can be tricky when needed libraries are not automatically detected, which is almost always the case. You need to figure out which library was missed and add it to the hiddenimports section of your .spec file. Conversely you will need to exclude big fat Python libraries that you don’t need to ship by adding entries to the excludes section.

And then even when packages are correctly included, some packages have auxiliary files that they depend on that are missed e.g. the file capabilities.json which contains a database of thermal printer protocols had to be explicitly included by some custom Python code I had to add (the .spec file actually just Python code). Here is my final Print42 .spec file

# -*- mode: python -*-

block_cipher = None

import sys
sys.modules['FixTk'] = None

options = [ ('v', None, 'OPTION'), ('W ignore', None, 'OPTION') ]

import os
import escpos

# figure out path to file we need from a site package module
capabilities_json = os.path.join(os.path.dirname(escpos.__file__), 'capabilities.json')

# Conditional trick
binaries_to_ship = [('libusb-1.0.dll', '.')] if sys.platform == 'win32' else []

a = Analysis(['api_pythermal.py'],
pathex=[
'/Users/Andy/Devel/print42',
'c:\\Users\\Andy\\Desktop\\print42-win10'
],
binaries=binaries_to_ship,
datas= [
(capabilities_json, 'escpos' ),
],
hiddenimports=[
'escpos.constants',
'escpos.escpos',
'escpos.exceptions',
'escpos.printer',
'bottle_websocket',
'gevent.__ident',
'gevent._greenlet',
'gevent.libuv',
'gevent.libuv.loop',
'gevent.__semaphore',
'gevent.__hub_local',
'gevent.__greenlet_primitives',
'gevent.__waiter',
'gevent.__hub_primitives',
'gevent._event',
'gevent._queue',
'gevent.__imap',
'gevent._local',
],
hookspath=[],
runtime_hooks=[],
excludes=['tcl', 'tk', 'FixTk', '_tkinter', 'tkinter', 'Tkinter', 'numpy','cryptography', 'django', 'PyQt5'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
exclude_binaries=True,
name='api_pythermal',
debug=False,
strip=False,
upx=True,
console=True )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='api_pythermal')

Packaged/Deployment Directory Structures

The trickiest bit is understanding what deployed files and directories are being created and how they all relate to each other. You may need to learn to explore inside .app files (on a Mac, r. click, show package contents) to do this.

The main trick to Electron finding and launching Python at runtime is a piece of Javascript that you must include in your electron app. The script caters for both a development situation and a situation when you are deployed on a user’s machine — the way of launching Python is slightly different in each case. This script is available at the fyears github Electron-Python site. 
Post build development workflow tip: The Javascript code which spawns Python relies on detecting the dist/ directory, so whilst developing it is good practice to delete the dist/ directory so that your app is run correctly in development mode.

Under Mac my build script was

rm -rf build/
rm -rf dist/

# Step 1. Python build - just includes .pyc not source code ok
pyinstaller api_pythermal.spec

# Step 2.
electron-forge package

# Run - test
./out/print42-darwin-x64/print42.app/Contents/MacOS/print42

# Make the .dmg
npm run make

On a Mac, note that electron-forge package (step 2) builds the .app — if you want a .dmg as well, you need to also run npm run make.

Under windows 10 my build script was

if exist build rmdir /s build
if exist dist rmdir /s dist

REM Python build - just includes .pyc not source code ok
pyinstaller api_pythermal.spec

REM quick test of python side of things
dist\api_pythermal\api_pythermal.exe

npm run package
REM Run - test final application
echo The exe is built and lives in the 'out' directory
out\Print42-win32-x64\Print42.exe

echo Next run inno script, result typically is Output/setup.exe

Notice that after the pyinstaller step, I actually run dist\api_pythermal\api_pythermal.exe which runs the python app that, as explained earlier, is really a server waiting for incoming remote procedure calls from Electron/nodejs/Javascript. I run the Python executable to check that the pyinstaller step worked ok. As a bonus, I can also open a terminal and run client commands like:

zerorpc tcp://localhost:4242 print "this is a test"
zerorpc tcp://localhost:4242 api_call22
zerorpc tcp://localhost:4242 add 1 1

where print, api_call22 and add are all Python entry points. This is a great CLI based way to test the Python side of things, without the Electron GUI. Once satisfied, I press ^C and say yes to continue the build script running.

Just to be clear, the resulting file dist\api_pythermal\api_pythermal.exe is merely the Python server — its the resulting file out\Print42-win32-x64\Print42.exe that we really want — this is the final distributable, standalone application. When the latter exe runs, Electron runs, and your Javascript code running in Electron/nodejs in turn spawns/runs the python server and talks to it as necessary. When Electron dies, ensure you kill the Python server running secretly on your user’s machine.

Using Virtual Machines for building

I found that I had to create a virtual machine running the oldest Mac OS that I could find, Mavericks. Why? Because mac apps built in this manner on a particular version of Mac OS will only run on that version of Mac OS or later. So for the broadest compatibility, I had to build on old Mavericks in a virtual machine. Then the resulting app can run in Mavericks and higher: Yosemite, El Capitan, Sierra, High Sierra and Mojave (ooh I like Mojave dark theme and desktop stacks!)

I also used a virtual machine for my Windows 10 builds — though that is because I develop on a Mac, though I suspect the same issue haunts Windows 10 too. I nevertheless built with the latest Windows 10 — hopefully the forced updates to Windows 10 that get pushed down to users will keep your Electron-Python apps compatible with a wide user base.

Conclusion

The journey was quite long, but I feel quite chuffed that I managed to create a real, cross-platform, downloadable product with a nice GUI, in Python 3. And I trust this blog will help you to do this too.

Please give Print42 a try — it has been created as a tool to help developers. And someone please wrap Electron-Python into an IDE so that in the future all we have to do is click a ‘build’ button — like we could 20 years ago. :-)

About Andy Bulka

Resources for Learning Electron