Compiling Python programs with Pyinstaller

Pyinstaller is a CLI tool that compiles Python scripts into executable binaries, installable through PIP. Let us go through a couple of examples of using this tool.

SMTP enumeration script from previous post can be trivially compiled with this tool by running the following commnd:

$ pyinstaller smtp_enum.py

This creates two directories - build/ for intermediate files and dist/ for the results of compilation. However we find that dist/ contains multiple binary files, whereas it is generally more convenient to compile it into single file that statically links all the dependencies. Adding --onefile argument to our command tells the Pyinstaller to do so:

$ pyinstaller --onefile smtp_enum.py

On Windows this create an .exe file; on Linux and macOS it will create a platform specific binary. Cross-compilation is not supported.

However things will not always be so simple. We may want to compile something like the following code that imports third party modules and has a GUI with image to be loaded from the file. This code is largely based on someone elses educational example that I took the liberty to update and clean up a little. It will scrape Bitcoin price from Coindesk and show it in a GUI window.

#!/usr/bin/python3

from tkinter import *
from datetime import datetime

import requests
from lxml import html

previous = None

# Grab the bitcoin price
def Update():
    global previous

    resp = requests.get("https://www.coindesk.com/price/bitcoin")
    print(resp.url)
        
    tree = html.fromstring(resp.text)
    price_large = tree.xpath('//span[contains(@class, "briNjb")]')[0].text
    price_large = price_large.replace(",", "")

    print(price_large)

    # Update our bitcoin label
    bit_label.config(text=price_large)
    # Set timer to 30 seconds
    # 1 second = 1000
    root.after(30000, Update)

    # Get Current Time
    now = datetime.now()
    current_time = now.strftime("%I:%M:%S %p")

    # Update the status bar
    status_bar.config(text=f"Last Updated: {current_time}   ")

    # Determine Price Change
    # grab current Price
    current = price_large

    # remove the comma
    current = current.replace(",", "")

    if previous is not None:
        if float(previous) > float(current):
            latest_price.config(
                text=f"Price Down {round(float(previous)-float(current), 2)}", fg="red"
            )

        elif float(previous) == float(current):
            latest_price.config(text="Price Unchanged", fg="grey")

        else:
            latest_price.config(
                text=f"Price Up {round(float(current)-float(previous), 2)}", fg="green"
            )
    else:
        previous = current
        latest_price.config(text="Price Unchanged", fg="grey")


def main():
    global root
    global bit_label
    global status_bar
    global previous
    global latest_price

    root = Tk()
    root.title("Bitcoin Price Grabber")
    root.geometry("550x210")
    root.config(bg="black")

    now = datetime.now()
    current_time = now.strftime("%I:%M:%S %p")

    my_frame = Frame(root, bg="black")
    my_frame.pack(pady=20)

    logo = PhotoImage(file="images/bitcoin.png")
    logo_label = Label(my_frame, image=logo, bd=0)
    logo_label.grid(row=0, column=0, rowspan=2)

    bit_label = Label(
        my_frame, text="TEST", font=("Helvetica", 45), bg="black", fg="green", bd=0
    )
    bit_label.grid(row=0, column=1, padx=20, sticky="s")

    latest_price = Label(
        my_frame, text="move test", font=("Helvetica", 8), bg="black", fg="grey"
    )
    latest_price.grid(row=1, column=1, sticky="n")

    # Create status bar
    status_bar = Label(
        root,
        text=f"Last Updated {current_time}   ",
        bd=0,
        anchor=E,
        bg="black",
        fg="grey",
    )

    status_bar.pack(fill=X, side=BOTTOM, ipady=2)

    # On program start, run update function
    Update()

    root.mainloop()


if __name__ == "__main__":
    main()

As our first attempt, we run the following command:

$ pyinstaller --onefile bitcoin.py

Compilation succeeds, but the program crashes due to not being able to find an image file:

$ cd dist/
$ ./bitcoin 
Traceback (most recent call last):
  File "bitcoin.py", line 113, in <module>
  File "bitcoin.py", line 80, in main
  File "tkinter/__init__.py", line 4064, in __init__
  File "tkinter/__init__.py", line 4009, in __init__
_tkinter.TclError: couldn't open "images/bitcoin.png": no such file or directory
[26587] Failed to execute script 'bitcoin' due to unhandled exception!

This is because we have a hardcoded relative path to the file that the program is not generally able to resolve. It would be best if the image file was compiled into executable as well. Turn out, according to a certain helpful StackOverflow answer there are two simple fixes we need to do (altough the question was about compiling PyGame-based game that includes graphical and sound assets, the answer can be applied for the scripts like the one we are dealing with).

The first fix is to introduce a following function to resolve a relative path to an asset file, then making sure that all code uses it instead of trying to access hardcoded paths directly:

import sys
import os

def resource_path(relative_path):
    try:
    # PyInstaller creates a temp folder and stores path in _MEIPASS
        base_path = sys._MEIPASS
    except Exception:
        base_path = os.path.abspath(".")

    return os.path.join(base_path, relative_path)

For the script in question, we only need to update the PhotoImage() constructor call to use resource_path() function:

    logo = PhotoImage(file=resource_path("images/bitcoin.png"))

Now we rerun the command from before:

$ pyinstaller --onefile bitcoin.py

However, the binary executable file still crashes when we try to run it. The second fix we have to do is to update bitcoin.spec file that originally looks like this:

# -*- mode: python ; coding: utf-8 -*-


block_cipher = None


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

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

Think of this file as a build configuration file. Like we can see, it specifies a script name and various build options. We need to update datas line with a list of tuples for each asset (1 entry in our case). First element in the tuple is source path of the asset. Second element is destination directory path. We want these to be consistent, as launching the Python script from binary version and from the source should load exactly the same file. Thus we update this line as follows:

    datas=[("images/bitcoin.png", "images/")],

Now we can compile the script based on the spec file:

$ pyinstaller bitcoin.spec

This generates a functional executable we can run. On macOS, it even creates an .app package we can copy into /Applications directory.

Screenshot

Trickster Dev

Code level discussion of web scraping, gray hat automation, growth hacking and bounty hunting


By rl1987, 2022-04-19