Building a deployable Python-Electron App

  • 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

Proof it works

Print42 built with Electron-Python

Electron Pros and Cons

// 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')
}
})
}

Let’s get started

# Python codeimport 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()
# Python codeclass PythermalApi(object):    def echo(self, text):
"""echo any text"""
return text
.... (more endpoints) ...
// Javascript codeclient.invoke("echo", "hello", (error, result) => {
if (error)
show_error(error)
else {
console.log('Got this from Python: ' + result);
}
})

Installation

[[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
{
"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"
}
}
{
"name": "print42",
"productName": "Print42",
"version": "1.0.4",
"description": "Log and Print to modern Thermal printers",
"main": "src/index.js",
"scripts": {
"start": "electron-forge start",
"reportandy": "electron src/indexreportandy.js",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint src --color"
},
"keywords": [],
"author": "Andy",
"license": "MIT",
"config": {
"forge": {
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {
"name": "print42"
}
},
{
"name": "@electron-forge/maker-zip",
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-dmg",
"config": {
"background": "src/images/print42-dmg-bgnd.png"
},
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-deb",
"config": {
"icon": "src/images/png/128x128.png"
},
"platforms": [
"linux"
]
}
],
"packagerConfig": {
"executableName": "print42",
"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",
"/junk*",
"/print42\\.iss",
"/UsbDk_1.0.19_x64\\.msi",
"/(api_pythermal|t|t2)\\.py$",
"/pythermal$"
]
}
}
},
"dependencies": {
"always-tail": "^0.2.0",
"electron-compile": "^6.4.4",
"electron-in-page-search": "^1.3.2",
"electron-log": "^4.0.0",
"electron-squirrel-startup": "^1.0.0",
"file-url": "^3.0.0",
"fix-path": "^2.1.0",
"jimp": "^0.9.3",
"shelljs": "^0.8.3",
"tmp": "^0.1.0",
"zerorpc": "^0.9.8"
},
"devDependencies": {
"@electron-forge/cli": "^6.0.0-beta.46",
"@electron-forge/maker-deb": "^6.0.0-beta.46",
"@electron-forge/maker-dmg": "^6.0.0-beta.46",
"@electron-forge/maker-rpm": "^6.0.0-beta.46",
"@electron-forge/maker-squirrel": "^6.0.0-beta.46",
"@electron-forge/maker-zip": "^6.0.0-beta.46",
"babel-plugin-transform-async-to-generator": "^6.24.1",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"electron": "3",
"electron-rebuild": "^1.8.8",
"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"
}
}

Issues I had

#every time you run 'npm install',you have to run this:./node_modules/.bin/electron-rebuild# or simply$(npm bin)/electron-rebuild
̶e̶x̶t̶r̶a̶c̶t̶ ̶f̶r̶o̶m̶ ̶p̶a̶c̶k̶a̶g̶e̶.̶j̶s̶o̶n̶.̶.̶.̶
̶"̶d̶e̶p̶e̶n̶d̶e̶n̶c̶i̶e̶s̶"̶:̶ ̶{̶
̶"̶z̶e̶r̶o̶r̶p̶c̶"̶:̶ ̶"̶^̶0̶.̶9̶.̶7̶"̶ ̶ ̶<̶-̶ ̶u̶s̶e̶ ̶t̶h̶e̶ ̶o̶f̶f̶i̶c̶i̶a̶l̶ ̶r̶e̶p̶o̶ ̶i̶f̶ ̶d̶o̶n̶'̶t̶ ̶c̶a̶r̶e̶ ̶a̶b̶o̶u̶t̶ ̶p̶a̶c̶k̶a̶g̶i̶n̶g̶
̶"̶z̶e̶r̶o̶r̶p̶c̶"̶:̶ ̶"̶g̶i̶t̶+̶h̶t̶t̶p̶s̶:̶/̶/̶g̶i̶t̶h̶u̶b̶.̶c̶o̶m̶/̶f̶y̶e̶a̶r̶s̶/̶z̶e̶r̶o̶r̶p̶c̶-̶n̶o̶d̶e̶.̶g̶i̶t̶"̶ ̶ ̶<̶-̶ ̶u̶s̶e̶ ̶t̶h̶i̶s̶ ̶c̶u̶s̶t̶o̶m̶ ̶r̶e̶p̶o̶ ̶i̶f̶ ̶y̶o̶u̶ ̶w̶a̶n̶t̶ ̶t̶o̶ ̶p̶a̶c̶k̶a̶g̶e̶
̶}̶

Packaging

  • 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 commands are electron-forge packageand electron-forge make.
    Dec 2019 update: Note thatelectron-forge won’t be available as a CLI command unless it is installed globally with npm’s -g parameter so you will have to run node_modules/.bin/electron-forge to invoke it. Since the recommended bootstrap for an electron-forge project is npx create-electron-app my-app you won’t get such a global install but you will get a package.json which includes scripts (which are like aliases to run certain command via npm run scriptname and which you can edit, change and add your own) so that you can simply invokenpm run package and npm run make which are even easier ways to invoke these commands. The package step builds a self contained dir, the make step builds a deployable (app, dmg, deb etc.)
  • 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.
"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

# -*- 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

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
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
zerorpc tcp://localhost:4242 print "this is a test"
zerorpc tcp://localhost:4242 api_call22
zerorpc tcp://localhost:4242 add 1 1

Using Virtual Machines for building

Conclusion

My Other Cool Software

  • GitUML — generate UML diagrams instantly from any GitHub repository containing Python code (web app)
GitUML — generate UML diagrams instantly from any GitHub repository containing Python code (web app). A revolution in documentation — diagrams automatically update when you push code using git

About Andy Bulka

Resources for Learning Electron

--

--

--

Software developer in Australia.

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Andy Bulka

Andy Bulka

Software developer in Australia.

More from Medium

Receive and Parse a CSV file with NestJs — Part 1

Getting Started with NestJS

Using LDAP SASL binding in nodejs with ldapts

Near Realtime Data Monitoring: A Simple Javascript