How to Create PyInstaller Packages#

PyInstaller is a widely used Python tool for bundling a Python package into a single sharable, distributable package. This page shows you how to create a PyInstaller package for a py5 Sketch.

PyInstaller will attempt to analyze Python code and figure out what Python libraries need to be included in the package. For complex applications, the default analysis might miss some necessary Python libraries, binary files, or data files. Since py5 is using Java, there will be some packaging customizations that need to be addressed with the PyInstaller utilities.

PyInstaller Spec File Explanation#

See this gist if you want to start with a working PyInstaller example. For a line by line explanation, read the rest of this page. The below example Spec File will create an application called simple from a py5 Sketch implemented in the Python file simple.py. Typically you would put this in a Spec File named simple.spec and run it at the command line with:

pyinstaller simple.spec

Now, on to the contents of the Spec File.

Imports#

First, import a few utility functions from PyInstaller. We will use these to gather information on the Python libraries, binary files, or data files that PyInstaller would otherwise miss.

from PyInstaller.utils.hooks import collect_submodules, collect_dynamic_libs, collect_data_files
datas, binaries, hiddenimports = [], [], []

Java Runtime Environment#

The py5 library requires Java to be installed on all users’ machines. If you want to include the Java Runtime Environment in the PyInstaller Package, you will need to locate Java on your machine and add the path to datas, similar to the following:

# modify the following line to reflect the location of Java 17 on your machine
datas += [('/usr/lib/jvm/java-17-openjdk', 'JAVA_HOME')]

Note that the 'JAVA_HOME' you see here has nothing to do with the JAVA_HOME environment variable. Instead, PyInstaller will copy the contents of /usr/lib/jvm/java-17-openjdk into a new directory named JAVA_HOME in the constructed package. At runtime, py5 will detect that it has been packaged with PyInstaller and will check for this special JAVA_HOME directory. If it is found, py5 will use that Java installation to run the Sketch.

If you wish, you can skip this step and reduce the final package size by ~25%. If this JAVA_HOME directory is not included in the package, py5 will search for Java in the usual install locations. The Sketch will run normally if it is found. Only skip this step if you are confident all of the users you will distribute your package to already have the proper version of Java installed. Or, perhaps your Python code is doing something clever with the Python library install-jdk to install Java on the machines of your users that don’t already have it. You can make your own choices for how to approach this.

py5#

The Python library py5 contains several Java jar files. These must be explicitly named to be included in the PyInstaller package.

datas += collect_data_files('py5', includes=['**/*.jar'])

The Python library py5 also contains native libraries for OpenGL. These must also be explicitly named.

binaries += collect_dynamic_libs('py5')

The above code will collect the native libraries py5 contains for all supported platforms. This is a bit excessive because the package PyInstaller creates will not be able to run on all platforms. If you want to limit the collected native libraries to only those that are relevant for the package, you can do so with code similar to the following:

binaries += filter(lambda x: x[1].split('/')[2] in ["linux-amd64"], collect_dynamic_libs('py5'))

The below code will collect the py5 logo images, found in py5_tools. If this is omitted, a warning message will be displayed to the console when the Sketch executes.

datas += collect_data_files('py5_tools')

Sketch Code#

Normally PyInstaller packages Python bytecode files, not the actual source files. However, py5 needs the actual source file if your Sketch calls size() from the setup() function.

Technically, size() should only be called from a settings() function, but py5 lets you simplify your code and put it in setup() instead. This is conditional on py5 being able to parse your code and create a settings() function on your behalf. To accomplish this, py5 needs access to the source code for your setup() function.

You can skip this step if your py5 Sketch already has a settings() function that calls size().

datas += [('simple.py', '.')]

Extra Missing Python Libraries#

The Python libraries xmlrpc and debugpy both have quirks that prevent PyInstaller from packaging them properly. These two lines of code fix that.

hiddenimports += collect_submodules('xmlrpc')
datas += collect_data_files('debugpy')

Exclude Unnecessary Python Libraries#

Sometimes PyInstaller will package Python libraries your application doesn’t actually need. Unless your Sketch explicitly uses these, the following libraries can be safely excluded. This will reduce the final package size by ~30%. There might be other libraries that can also be excluded. If package size is a concern, feel free to experiment.

excludes = ['matplotlib', 'scipy', 'jedi', 'lxml', 'PyQt5']

PyInstaller Package Assembly#

The below code is a slight modification of the default PyInstaller generated Spec File to connect the variables defined above to PyInstaller’s Analysis class. Observe the use of the binaries, datas, hiddenimports, and excludes variables.

block_cipher = None

a = Analysis(['simple.py'],
             pathex=[],
             binaries=binaries,
             datas=datas,
             hiddenimports=hiddenimports,
             hookspath=[],
             hooksconfig={},
             runtime_hooks=[],
             excludes=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)

If you wish to package your Sketch into one directory, finish your Spec File with this:

exe = EXE(pyz,
          a.scripts,
          [],
          exclude_binaries=True,
          name='simple',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=True,
          disable_windowed_traceback=False,
          target_arch=None,
          codesign_identity=None,
          entitlements_file=None)

coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               upx_exclude=[],
               name='simple')

To create a single file executable, conclude your Spec File with this instead:

exe = EXE(pyz,
          a.scripts,
          a.binaries,
          a.zipfiles,
          a.datas,
          [],
          name='simple',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          upx_exclude=[],
          runtime_tmpdir=None,
          console=True,
          disable_windowed_traceback=False,
          target_arch=None,
          codesign_identity=None,
          entitlements_file=None)