Packaging tools into self contained executable with PyInstaller

Sometimes your project has useful internal tools, but building them can bit cumbersome for other teams which can lead to time being wasted trying to instruct how to get build environment setup and the tool compiled. It would be nice to distribute these internal tools as self contained executables for other teams. PyInstaller is a Python package that can create Linux and Windows binaries that extract the package contents and run a script/entrypoint. Here is a short tutorial how to package internal tools with PyInstaller.

If the tool you are packaging is a simple script that does not have external dependencies (for example non Python executables), packaging can be as easy as:

pyinstaller --onefile tool.py --name tool

But many times the tool uses some binaries that have been compiled and need to be added to the package. We can add extra files into to the package with --add-data:

# pyinstaller --onefile tool.py --name tool --add-data '<source>:<destination in package>'
pyinstaller --onefile tool.py --name tool --add-data 'file.txt:.'

Fixing paths

Above solution works quite well, but your tool will not automatically have access to the binary/file you included, as it most likely can not find the file.

To fix this, we need to add a wrapper script that modifies PATH environment variable and adds the extracted package path (or even sub directories) to the PATH.

When pyinstaller extracts the package content, it uses the tmp system directory and creates a random sub directory. Unfortunately __file__ global variable will not point to the correct location, but we need to check the correct location of the wrapper script with a small function:

import os
import sys

def get_script_location():
    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
        # Running from PyInstaller
        # Return location of the extracted package
        location = os.path.realpath(sys._MEIPASS)
    else:
        # Normal python run
        # When running PyInstaller package this would incorrectly return
        # current working directory
        location = os.path.dirname(os.path.realpath(__file__))
    return location

We can now add the script path to the PATH:

def add_to_path(p):
    os.environ['PATH'] = p + os.pathsep + os.environ['PATH']

add_to_path(get_script_location())

and then run the original script:

import tool
tool.main()

Check out https://github.com/buq2/pyinstaller_example for full example and how to cross compile the packages.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.