PyInstaller Deep Dive: Complete Guide with uv¶
AI Assisted (Claude Sonnet 4)
What is PyInstaller?¶
PyInstaller bundles Python applications into standalone executables that can run on systems without Python installed. It analyzes your code, finds all dependencies, and packages everything into a single file or directory.
How it works:
- Analysis: Scans your code for imports and dependencies
- Collection: Gathers all required modules, libraries, and data files
- Bundling: Creates a bootloader that extracts and runs your application
- Distribution: Produces executable(s) that work on target systems
Installation and Setup with uv¶
Basic Setup¶
# Create project with uv
uv init my-pyinstaller-project
cd my-pyinstaller-project
# Add PyInstaller as development dependency
uv add --dev pyinstaller
# Add your application dependencies
uv add requests click rich pillow
# Verify installation
uv run pyinstaller --version
Project Structure¶
my-pyinstaller-project/
โโโ pyproject.toml # Project configuration
โโโ uv.lock # Locked dependencies
โโโ src/
โ โโโ myapp/
โ โโโ __init__.py
โ โโโ main.py # Entry point
โ โโโ utils.py
โ โโโ data/ # Data files
โ โโโ config.json
โ โโโ logo.png
โโโ assets/ # Build assets
โ โโโ icon.ico # Windows icon
โ โโโ icon.icns # macOS icon
โ โโโ version.txt
โโโ build/ # Build artifacts (auto-created)
โโโ dist/ # Final executables (auto-created)
โโโ myapp.spec # PyInstaller spec file
Basic PyInstaller Usage¶
Simple One-File Executable¶
# Basic one-file executable
uv run pyinstaller --onefile src/myapp/main.py
# One-file with custom name
uv run pyinstaller --onefile --name myapp src/myapp/main.py
# GUI application (no console window)
uv run pyinstaller --onefile --windowed src/myapp/main.py
# Console application (with console window)
uv run pyinstaller --onefile --console src/myapp/main.py
Directory Distribution¶
# Create directory distribution (faster startup)
uv run pyinstaller src/myapp/main.py
# Directory with custom name
uv run pyinstaller --name myapp src/myapp/main.py
Adding Icons and Metadata¶
# Windows executable with icon
uv run pyinstaller --onefile --icon=assets/icon.ico src/myapp/main.py
# macOS executable with icon
uv run pyinstaller --onefile --icon=assets/icon.icns src/myapp/main.py
# Add version information (Windows)
uv run pyinstaller --onefile --version-file=assets/version.txt src/myapp/main.py
PyInstaller Spec Files¶
What is a Spec File?¶
A spec file is a Python script that tells PyInstaller exactly how to build your application. It provides fine-grained control over the build process.
Generating a Spec File¶
# Generate spec file without building
uv run pyi-makespec --onefile src/myapp/main.py
# Generate with options
uv run pyi-makespec --onefile --windowed --icon=assets/icon.ico src/myapp/main.py
# This creates myapp.spec file
Basic Spec File Structure¶
myapp.spec:
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['src/myapp/main.py'], # Entry point scripts
pathex=[], # Additional search paths
binaries=[], # Binary files to include
datas=[], # Data files to include
hiddenimports=[], # Modules not auto-detected
hookspath=[], # Custom hook directories
hooksconfig={}, # Hook configuration
runtime_hooks=[], # Runtime hook scripts
excludes=[], # Modules to exclude
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='myapp', # Executable name
debug=False, # Debug mode
bootloader_ignore_signals=False,
strip=False, # Strip debug symbols
upx=True, # UPX compression
upx_exclude=[], # Files to exclude from UPX
runtime_tmpdir=None, # Temporary directory
console=True, # Console vs GUI mode
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None, # macOS code signing
entitlements_file=None, # macOS entitlements
icon='assets/icon.ico' # Application icon
)
Advanced Spec File Configuration¶
Complete example with all options:
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# Configuration
block_cipher = None
app_name = 'MyApp'
app_version = '1.0.0'
# Collect data files from packages
datas = []
datas += collect_data_files('mypackage') # Collect all data files
datas += [('src/myapp/data', 'data')] # Manual data inclusion
# Collect hidden imports
hiddenimports = []
hiddenimports += collect_submodules('requests') # All requests submodules
hiddenimports += ['pkg_resources.py2_warn'] # Common hidden import
# Binary files (DLLs, shared libraries)
binaries = []
if sys.platform.startswith('win'):
# Windows-specific binaries
binaries += [('C:/path/to/library.dll', '.')]
elif sys.platform.startswith('linux'):
# Linux-specific binaries
binaries += [('/usr/lib/x86_64-linux-gnu/libssl.so.1.1', '.')]
elif sys.platform.startswith('darwin'):
# macOS-specific binaries
binaries += [('/usr/local/lib/libcrypto.dylib', '.')]
a = Analysis(
['src/myapp/main.py'],
pathex=[os.path.abspath('.')],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=['hooks/'], # Custom hooks directory
hooksconfig={
"gi": {
"icons": ["Adwaita"],
"themes": ["Adwaita"],
"languages": ["en_US", "de_DE"],
},
},
runtime_hooks=['hooks/runtime_hook.py'],
excludes=[
'tkinter', # Exclude GUI frameworks if not used
'matplotlib', # Large packages to exclude
'scipy',
'pandas',
'numpy.testing',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
# Remove unwanted files from collection
def remove_from_list(input_list, item_to_remove):
return [(name, path, type_) for name, path, type_ in input_list
if not name.startswith(item_to_remove)]
# Clean up collected files
a.datas = remove_from_list(a.datas, 'share/doc')
a.datas = remove_from_list(a.datas, 'share/man')
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name=app_name,
debug=False, # Set to True for debugging
bootloader_ignore_signals=False,
strip=True, # Strip debug symbols (Linux/macOS)
upx=True, # Enable UPX compression
upx_exclude=[ # Files to exclude from UPX
'vcruntime140.dll',
'python3x.dll',
],
runtime_tmpdir=None,
console=False, # GUI application
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='assets/icon.ico',
version='assets/version_info.py' # Version information file
)
# macOS App Bundle (optional)
if sys.platform.startswith('darwin'):
app = BUNDLE(
exe,
name=f'{app_name}.app',
icon='assets/icon.icns',
bundle_identifier='com.mycompany.myapp',
info_plist={
'NSHighResolutionCapable': 'True',
'CFBundleShortVersionString': app_version,
'CFBundleVersion': app_version,
},
)
Building with Spec File¶
# Build using spec file
uv run pyinstaller myapp.spec
# Build with additional options
uv run pyinstaller --clean myapp.spec
uv run pyinstaller --noconfirm myapp.spec
Data Files and Resources¶
Including Data Files¶
PyInstaller needs explicit instructions for non-Python files:
# In spec file - various ways to include data
datas = [
# (source, destination_in_bundle)
('src/myapp/data/config.json', 'data'),
('src/myapp/data/*.png', 'images'), # Wildcards
('assets/fonts/', 'fonts/'), # Entire directory
]
# Programmatically collect data files
from PyInstaller.utils.hooks import collect_data_files
datas += collect_data_files('mypackage')
datas += collect_data_files('mypackage', subdir='templates')
Accessing Data Files at Runtime¶
In your Python code:
import sys
import os
from pathlib import Path
def get_resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except AttributeError:
# Development mode
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
# Usage examples
config_path = get_resource_path('data/config.json')
image_path = get_resource_path('images/logo.png')
# Alternative using pathlib
def get_resource_path_pathlib(relative_path):
"""Get resource path using pathlib"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller bundle
return Path(sys._MEIPASS) / relative_path
else:
# Development
return Path(__file__).parent / relative_path
# Load configuration
config_file = get_resource_path_pathlib('data/config.json')
with open(config_file, 'r') as f:
config = json.load(f)
Common Data File Patterns¶
# Spec file examples for common scenarios
# Include all files from a directory
datas = [('src/myapp/templates', 'templates')]
# Include specific file types
import glob
datas = [(f, 'images') for f in glob.glob('src/myapp/images/*.png')]
# Include package data files
from PyInstaller.utils.hooks import collect_data_files
datas += collect_data_files('babel', subdir='localedata')
datas += collect_data_files('certifi') # SSL certificates
# Include fonts (common for GUI apps)
if sys.platform.startswith('win'):
datas += [('C:/Windows/Fonts/arial.ttf', 'fonts')]
elif sys.platform.startswith('linux'):
datas += [('/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', 'fonts')]
Hidden Imports and Dependencies¶
Understanding Hidden Imports¶
PyInstaller's static analysis can miss dynamically imported modules:
# These imports might be missed by PyInstaller
module_name = 'requests.adapters'
__import__(module_name)
# Dynamic imports
importlib.import_module('cryptography.hazmat.backends.openssl')
# Plugin systems
pkg_resources.iter_entry_points('myapp.plugins')
Specifying Hidden Imports¶
In spec file:
hiddenimports = [
# Common hidden imports
'pkg_resources.py2_warn',
'pkg_resources.markers',
# Cryptography
'cryptography.hazmat.backends.openssl',
'cryptography.hazmat.bindings._rust',
# Requests
'requests.packages.urllib3',
'requests.packages.urllib3.util.retry',
# SQLAlchemy
'sqlalchemy.dialects.sqlite',
'sqlalchemy.pool',
# Pillow/PIL
'PIL._tkinter_finder',
# PyQt/PySide
'sip',
# Scientific packages
'scipy.sparse.csgraph._validation',
'scipy.special._ufuncs_cxx',
]
# Automatically collect all submodules
from PyInstaller.utils.hooks import collect_submodules
hiddenimports += collect_submodules('requests')
hiddenimports += collect_submodules('urllib3')
Command line:
uv run pyinstaller --hidden-import=pkg_resources.py2_warn \
--hidden-import=cryptography.hazmat.backends.openssl \
src/myapp/main.py
Finding Missing Dependencies¶
# Run with imports debug to see what's being imported
uv run pyinstaller --debug=imports src/myapp/main.py
# Check the build log for missing modules
# Look for "WARNING: Hidden import" messages
# Test the executable and check for ImportError
./dist/myapp/myapp # Run and observe errors
Shared Libraries and Binary Dependencies¶
Understanding Shared Library Issues¶
Common problems:
- Missing system libraries (.dll
, .so
, .dylib
)
- Version mismatches between build and runtime systems
- Architecture mismatches (32-bit vs 64-bit)
- Path issues in bundled libraries
Including Shared Libraries¶
In spec file:
import sys
import os
binaries = []
if sys.platform.startswith('win'):
# Windows DLLs
binaries += [
('C:/path/to/custom.dll', '.'),
('venv/Lib/site-packages/package/lib/*.dll', 'lib'),
]
elif sys.platform.startswith('linux'):
# Linux shared objects
binaries += [
('/usr/lib/x86_64-linux-gnu/libssl.so.1.1', '.'),
('/usr/lib/x86_64-linux-gnu/libcrypto.so.1.1', '.'),
('/usr/local/lib/libcustom.so', 'lib'),
]
elif sys.platform.startswith('darwin'):
# macOS dylibs
binaries += [
('/usr/local/lib/libssl.1.1.dylib', '.'),
('/usr/local/lib/libcrypto.1.1.dylib', '.'),
('/opt/homebrew/lib/libcustom.dylib', 'lib'),
]
# Auto-collect binaries from packages
from PyInstaller.utils.hooks import collect_dynamic_libs
binaries += collect_dynamic_libs('numpy')
binaries += collect_dynamic_libs('scipy')
Debugging Library Issues¶
Finding missing libraries:
# Linux: Check dependencies
ldd dist/myapp/myapp
# macOS: Check dependencies
otool -L dist/myapp/myapp
# Windows: Use Dependency Walker or similar tools
# Or use PowerShell:
Get-Command dist/myapp/myapp.exe | Select-Object -ExpandProperty FileVersionInfo
Runtime library debugging:
# Add to your main script for debugging
import sys
print(f"Executable path: {sys.executable}")
print(f"Python path: {sys.path}")
if hasattr(sys, '_MEIPASS'):
print(f"PyInstaller temp path: {sys._MEIPASS}")
import os
print("Files in temp directory:")
for root, dirs, files in os.walk(sys._MEIPASS):
level = root.replace(sys._MEIPASS, '').count(os.sep)
indent = ' ' * 2 * level
print(f"{indent}{os.path.basename(root)}/")
subindent = ' ' * 2 * (level + 1)
for file in files:
print(f"{subindent}{file}")
Common Library Solutions¶
OpenSSL/Cryptography:
# Often needed for HTTPS requests
hiddenimports = [
'cryptography.hazmat.backends.openssl',
'cryptography.hazmat.bindings._rust',
]
# Include OpenSSL libraries explicitly
if sys.platform.startswith('win'):
binaries += [
('venv/Lib/site-packages/cryptography/hazmat/bindings/*.dll', '.'),
]
NumPy/SciPy:
# Include BLAS/LAPACK libraries
from PyInstaller.utils.hooks import collect_dynamic_libs
binaries += collect_dynamic_libs('numpy')
binaries += collect_dynamic_libs('scipy')
# Exclude large unused parts
excludes = [
'numpy.tests',
'scipy.tests',
'matplotlib.tests',
]
Qt/GUI Libraries:
# PyQt5/6 or PySide2/6
hiddenimports += [
'sip',
'PyQt5.sip',
'PyQt5.QtCore',
'PyQt5.QtGui',
'PyQt5.QtWidgets',
]
# Include Qt plugins
datas += [
('venv/Lib/site-packages/PyQt5/Qt/plugins', 'PyQt5/Qt/plugins'),
]
Platform-Specific Considerations¶
Windows-Specific Issues¶
Console vs GUI Applications:
# Console application (shows command prompt)
uv run pyinstaller --console src/myapp/main.py
# GUI application (no command prompt)
uv run pyinstaller --windowed src/myapp/main.py
Windows version information:
# Create version_info.py
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x4,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable('040904B0', [
StringStruct('CompanyName', 'My Company'),
StringStruct('FileDescription', 'My Application'),
StringStruct('FileVersion', '1.0.0.0'),
StringStruct('InternalName', 'myapp'),
StringStruct('LegalCopyright', 'Copyright ยฉ 2024'),
StringStruct('OriginalFilename', 'myapp.exe'),
StringStruct('ProductName', 'My Application'),
StringStruct('ProductVersion', '1.0.0.0')
])
]),
VarFileInfo([VarStruct('Translation', [1033, 1200])])
]
)
# Use in spec file
exe = EXE(
# ... other parameters ...
version='version_info.py'
)
Antivirus false positives:
# Code signing can reduce false positives
signtool sign /f certificate.pfx /p password /t http://timestamp.server dist/myapp.exe
# Or exclude common problematic features
uv run pyinstaller --exclude-module tkinter \
--exclude-module matplotlib \
src/myapp/main.py
macOS-Specific Issues¶
App Bundle creation:
# In spec file for macOS app bundle
app = BUNDLE(
exe,
name='MyApp.app',
icon='assets/icon.icns',
bundle_identifier='com.mycompany.myapp',
info_plist={
'CFBundleShortVersionString': '1.0.0',
'CFBundleVersion': '1.0.0',
'NSHighResolutionCapable': 'True',
'NSAppTransportSecurity': {
'NSAllowsArbitraryLoads': True
},
'LSMinimumSystemVersion': '10.13.0',
},
)
Code signing and notarization:
# Sign the executable
codesign --force --sign "Developer ID Application: Your Name" dist/MyApp.app
# Create zip for notarization
ditto -c -k --keepParent dist/MyApp.app MyApp.zip
# Submit for notarization
xcrun notarytool submit MyApp.zip \
--keychain-profile "AC_PASSWORD" \
--wait
# Staple the ticket
xcrun stapler staple dist/MyApp.app
Linux-Specific Issues¶
Shared library compatibility:
# Check glibc version compatibility
ldd --version
# Build on older system for compatibility
# Or use containers/chroot environments
AppImage creation:
# Install AppImage tools
uv add --dev appimage-builder
# Create AppImage after PyInstaller build
appimage-builder --recipe appimage.yml
Optimization and Troubleshooting¶
Reducing Bundle Size¶
Exclude unnecessary modules:
excludes = [
# GUI frameworks if not used
'tkinter',
'PyQt5', 'PyQt6',
'PySide2', 'PySide6',
# Scientific libraries if not used
'matplotlib',
'scipy',
'pandas',
'numpy.tests',
# Development tools
'pytest',
'setuptools',
'pip',
# Documentation
'docutils',
'sphinx',
# Unused stdlib modules
'xml.etree',
'urllib.robotparser',
'calendar',
'datetime', # if not used
]
Use UPX compression:
# In spec file
exe = EXE(
# ... other parameters ...
upx=True,
upx_exclude=[
'vcruntime140.dll', # Don't compress critical DLLs
'python3x.dll',
]
)
Optimize imports:
# Use specific imports instead of star imports
from requests import get, post # Instead of: from requests import *
import json # Instead of: import json, xml, yaml, ...
Performance Optimization¶
Faster startup:
# Use directory distribution instead of onefile
# (onefile extracts to temp dir each startup)
# Reduce number of modules
# Use lazy imports where possible
def heavy_function():
import numpy as np # Import only when needed
# ... function code
Memory optimization:
# Exclude test modules
excludes = [
'*.tests',
'*.test',
'tests',
'test',
]
# Use noarchive for faster imports
a = Analysis(
# ... other parameters ...
noarchive=False # Set to True for faster imports, larger size
)
Common Troubleshooting¶
Debug build issues:
# Debug mode for detailed output
uv run pyinstaller --debug=all src/myapp/main.py
# Log imports to find missing modules
uv run pyinstaller --debug=imports src/myapp/main.py
# Clean build
uv run pyinstaller --clean --noconfirm myapp.spec
Runtime debugging:
# Add debug output to your main script
import sys
import os
def debug_environment():
print(f"Python executable: {sys.executable}")
print(f"Python version: {sys.version}")
print(f"Platform: {sys.platform}")
if hasattr(sys, '_MEIPASS'):
print(f"Running from PyInstaller bundle")
print(f"Temp directory: {sys._MEIPASS}")
print(f"Executable: {sys.executable}")
else:
print("Running in development mode")
print(f"Current working directory: {os.getcwd()}")
print(f"Python path: {sys.path}")
# Call at startup for debugging
if __name__ == "__main__":
debug_environment()
main()
Module not found errors:
# Add missing module as hidden import
uv run pyinstaller --hidden-import=missing_module src/myapp/main.py
# Or in spec file
hiddenimports = ['missing_module']
Advanced Build Automation with uv¶
Build Script Integration¶
build.py:
#!/usr/bin/env python3
"""
Advanced PyInstaller build script using uv
"""
import subprocess
import sys
import platform
import shutil
from pathlib import Path
import argparse
def run_command(cmd, description=""):
"""Run command and handle errors"""
print(f"๐จ {description or cmd}")
try:
result = subprocess.run(cmd, shell=True, check=True,
capture_output=True, text=True)
print(f"โ
{description or cmd} completed")
return result
except subprocess.CalledProcessError as e:
print(f"โ {description or cmd} failed: {e}")
print(f"Error output: {e.stderr}")
sys.exit(1)
def clean_build():
"""Clean build directories"""
dirs_to_clean = ['build', 'dist', '__pycache__']
for dir_name in dirs_to_clean:
if Path(dir_name).exists():
shutil.rmtree(dir_name)
print(f"๐งน Cleaned {dir_name}")
def build_executable(mode='onefile', gui=False, optimize=False,
debug=False, spec_file=None):
"""Build executable with PyInstaller"""
# Sync dependencies first
run_command("uv sync", "Syncing dependencies")
if spec_file:
# Use spec file
cmd = f"uv run pyinstaller"
if debug:
cmd += " --debug=all"
cmd += f" {spec_file}"
else:
# Command line build
cmd = f"uv run pyinstaller"
# Mode
if mode == 'onefile':
cmd += " --onefile"
elif mode == 'onedir':
cmd += " --onedir"
# GUI vs Console
if gui:
cmd += " --windowed"
else:
cmd += " --console"
# Optimization
if optimize:
cmd += " --strip"
cmd += " --exclude-module tkinter"
cmd += " --exclude-module matplotlib"
# Debug
if debug:
cmd += " --debug=all"
# Icon
system = platform.system().lower()
icon_ext = 'ico' if system == 'windows' else 'icns'
icon_path = f"assets/icon.{icon_ext}"
if Path(icon_path).exists():
cmd += f" --icon={icon_path}"
# Entry point
cmd += " src/myapp/main.py"
run_command(cmd, "Building executable")
def test_executable():
"""Test the built executable"""
system = platform.system().lower()
exe_name = "myapp"
if system == "windows":
exe_name += ".exe"
# Find executable
exe_path = None
if Path(f"dist/{exe_name}").exists():
exe_path = f"dist/{exe_name}"
elif Path(f"dist/myapp/{exe_name}").exists():
exe_path = f"dist/myapp/{exe_name}"
if exe_path:
print(f"๐งช Testing executable: {exe_path}")
# Test with --help flag
try:
result = subprocess.run([exe_path, "--help"],
capture_output=True, text=True, timeout=30)
if result.returncode == 0:
print("โ
Executable test passed")
return True
else:
print(f"โ Executable test failed: {result.stderr}")
return False
except subprocess.TimeoutExpired:
print("โ Executable test timed out")
return False
except Exception as e:
print(f"โ Executable test error: {e}")
return False
else:
print("โ Executable not found")
return False
def main():
parser = argparse.ArgumentParser(description="PyInstaller build script")
parser.add_argument("--mode", choices=['onefile', 'onedir'],
default='onefile', help="Build mode")
parser.add_argument("--gui", action='store_true',
help="Build GUI application")
parser.add_argument("--optimize", action='store_true',
help="Enable optimizations")
parser.add_argument("--debug", action='store_true',
help="Enable debug mode")
parser.add_argument("--clean", action='store_true',
help="Clean before build")
parser.add_argument("--test", action='store_true',
help="Test executable after build")
parser.add_argument("--spec", help="Use spec file instead of command line")
args = parser.parse_args()
if args.clean:
clean_build()
build_executable(
mode=args.mode,
gui=args.gui,
optimize=args.optimize,
debug=args.debug,
spec_file=args.spec
)
if args.test:
success = test_executable()
if not success:
sys.exit(1)
print("๐ Build completed successfully!")
if __name__ == "__main__":
main()
Usage:
# Basic build
uv run python build.py
# Optimized GUI build
uv run python build.py --gui --optimize --clean
# Debug build with testing
uv run python build.py --debug --test
# Use spec file
uv run python build.py --spec myapp.spec --test
CI/CD Integration¶
GitHub Actions example:
name: Build Executables
on:
push:
tags: ['v*']
pull_request:
branches: [main]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-latest
platform: windows
executable: myapp.exe
- os: macos-latest
platform: macos
executable: myapp
- os: ubuntu-latest
platform: linux
executable: myapp
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
version: "latest"
- name: Sync dependencies
run: uv sync
- name: Build executable
run: uv run python build.py --optimize --clean
- name: Test executable
run: uv run python build.py --test
- name: Upload executable
uses: actions/upload-artifact@v4
with:
name: executable-${{ matrix.platform }}
path: |
dist/${{ matrix.executable }}
dist/myapp/
retention-days: 30
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: dist/${{ matrix.executable }}
draft: false
prerelease: false
Complete Real-World Example¶
Project Structure¶
flask-desktop-app/
โโโ pyproject.toml
โโโ uv.lock
โโโ myapp.spec # PyInstaller spec file
โโโ build.py # Build automation script
โโโ src/
โ โโโ desktop_app/
โ โโโ __init__.py
โ โโโ main.py # Entry point
โ โโโ app.py # Flask application
โ โโโ models.py # Data models
โ โโโ utils.py # Utilities
โ โโโ static/ # Web assets
โ โ โโโ css/
โ โ โโโ js/
โ โ โโโ images/
โ โโโ templates/ # HTML templates
โ โโโ base.html
โ โโโ index.html
โโโ assets/ # Build assets
โ โโโ icon.ico # Windows icon
โ โโโ icon.icns # macOS icon
โ โโโ version_info.py # Windows version info
โโโ hooks/ # Custom PyInstaller hooks
โ โโโ hook-custom_module.py
โโโ dist/ # Built executables
Flask Desktop Application¶
src/desktop_app/main.py:
#!/usr/bin/env python3
"""
Desktop Flask application with PyInstaller
"""
import sys
import os
import threading
import webbrowser
import time
from pathlib import Path
# Add the application directory to Python path
if hasattr(sys, '_MEIPASS'):
# Running as PyInstaller bundle
app_dir = Path(sys._MEIPASS)
else:
# Running in development
app_dir = Path(__file__).parent
# Import Flask app
from .app import create_app
def get_resource_path(relative_path):
"""Get absolute path to resource for PyInstaller"""
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.abspath("."), relative_path)
def find_free_port():
"""Find a free port for the Flask server"""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
s.listen(1)
port = s.getsockname()[1]
return port
def run_flask_app(port):
"""Run Flask application in a separate thread"""
app = create_app()
# Configure static and template paths for PyInstaller
if hasattr(sys, '_MEIPASS'):
app.static_folder = os.path.join(sys._MEIPASS, 'static')
app.template_folder = os.path.join(sys._MEIPASS, 'templates')
app.run(host='127.0.0.1', port=port, debug=False, use_reloader=False)
def open_browser(url):
"""Open browser after a short delay"""
time.sleep(1.5) # Wait for Flask to start
webbrowser.open(url)
def main():
"""Main application entry point"""
print("Starting Desktop Flask Application...")
# Find free port
port = find_free_port()
url = f"http://127.0.0.1:{port}"
print(f"Server will run on: {url}")
# Start Flask in background thread
flask_thread = threading.Thread(target=run_flask_app, args=(port,))
flask_thread.daemon = True
flask_thread.start()
# Open browser
browser_thread = threading.Thread(target=open_browser, args=(url,))
browser_thread.daemon = True
browser_thread.start()
print("Application started! Press Ctrl+C to exit.")
try:
# Keep main thread alive
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
sys.exit(0)
if __name__ == "__main__":
main()
src/desktop_app/app.py:
"""
Flask application factory
"""
from flask import Flask, render_template, jsonify
import os
import sys
def create_app():
"""Create and configure Flask application"""
app = Flask(__name__)
# Configure secret key
app.config['SECRET_KEY'] = 'your-secret-key-here'
@app.route('/')
def index():
"""Main page"""
return render_template('index.html', title='Desktop Flask App')
@app.route('/api/info')
def api_info():
"""API endpoint returning system info"""
return jsonify({
'platform': sys.platform,
'python_version': sys.version,
'executable': sys.executable,
'frozen': hasattr(sys, '_MEIPASS'),
'temp_path': getattr(sys, '_MEIPASS', 'Not bundled')
})
@app.route('/api/files')
def api_files():
"""API endpoint listing bundled files"""
if hasattr(sys, '_MEIPASS'):
files = []
for root, dirs, filenames in os.walk(sys._MEIPASS):
for filename in filenames:
rel_path = os.path.relpath(os.path.join(root, filename), sys._MEIPASS)
files.append(rel_path)
return jsonify({'files': sorted(files)})
else:
return jsonify({'files': ['Running in development mode']})
return app
Advanced PyInstaller Spec File¶
myapp.spec:
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# Application configuration
app_name = 'DesktopFlaskApp'
block_cipher = None
# Collect Flask templates and static files
datas = [
('src/desktop_app/templates', 'templates'),
('src/desktop_app/static', 'static'),
]
# Collect data files from packages
datas += collect_data_files('flask')
datas += collect_data_files('jinja2')
datas += collect_data_files('werkzeug')
# Hidden imports for Flask and related packages
hiddenimports = [
# Flask essentials
'flask',
'flask.json',
'jinja2',
'jinja2.ext',
'werkzeug',
'werkzeug.security',
'werkzeug.serving',
'werkzeug.routing',
# Standard library modules often missed
'pkg_resources.py2_warn',
'pkg_resources.markers',
# Threading support
'threading',
'queue',
# Network support
'socket',
'socketserver',
'http.server',
# JSON support
'json',
'simplejson',
]
# Collect all Flask submodules
hiddenimports += collect_submodules('flask')
hiddenimports += collect_submodules('jinja2')
hiddenimports += collect_submodules('werkzeug')
# Binaries (if needed)
binaries = []
# Modules to exclude (reduce size)
excludes = [
'tkinter',
'matplotlib',
'scipy',
'numpy',
'pandas',
'PIL',
'PyQt5',
'PyQt6',
'PySide2',
'PySide6',
'test',
'tests',
'unittest',
'doctest',
'pdb',
'pydoc',
]
a = Analysis(
['src/desktop_app/main.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=['hooks'],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
# Filter out unnecessary files
def filter_binaries(binaries_list):
"""Remove unnecessary binary files"""
filtered = []
skip_patterns = ['api-ms-win', 'ucrtbase', 'msvcp', 'vcruntime']
for name, path, type_info in binaries_list:
skip = any(pattern in name.lower() for pattern in skip_patterns)
if not skip:
filtered.append((name, path, type_info))
return filtered
a.binaries = filter_binaries(a.binaries)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name=app_name,
debug=False, # Set to True for debugging
bootloader_ignore_signals=False,
strip=True, # Strip debug symbols
upx=True, # Enable UPX compression
upx_exclude=[
'vcruntime140.dll',
'msvcp140.dll',
'api-ms-win-*.dll',
],
runtime_tmpdir=None,
console=True, # Change to False for GUI-only
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='assets/icon.ico' if sys.platform.startswith('win') else 'assets/icon.icns',
version='assets/version_info.py' if sys.platform.startswith('win') else None,
)
# Create macOS app bundle
if sys.platform.startswith('darwin'):
app = BUNDLE(
exe,
name=f'{app_name}.app',
icon='assets/icon.icns',
bundle_identifier='com.mycompany.desktopflaskapp',
info_plist={
'NSHighResolutionCapable': 'True',
'CFBundleShortVersionString': '1.0.0',
'CFBundleVersion': '1.0.0',
'LSMinimumSystemVersion': '10.13.0',
'NSAppTransportSecurity': {
'NSAllowsArbitraryLoads': True
},
},
)
Custom PyInstaller Hook¶
hooks/hook-custom_module.py:
"""
Custom PyInstaller hook for additional modules
"""
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
# Collect data files
datas = collect_data_files('my_custom_package')
# Collect hidden imports
hiddenimports = collect_submodules('my_custom_package')
# Additional hidden imports
hiddenimports += [
'my_custom_package.submodule',
'my_custom_package.plugins',
]
Windows Version Information¶
assets/version_info.py:
"""
Windows version information for PyInstaller
"""
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 0, 0, 0),
prodvers=(1, 0, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x4,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable('040904B0', [
StringStruct('CompanyName', 'My Company'),
StringStruct('FileDescription', 'Desktop Flask Application'),
StringStruct('FileVersion', '1.0.0.0'),
StringStruct('InternalName', 'DesktopFlaskApp'),
StringStruct('LegalCopyright', 'Copyright ยฉ 2024 My Company'),
StringStruct('OriginalFilename', 'DesktopFlaskApp.exe'),
StringStruct('ProductName', 'Desktop Flask Application'),
StringStruct('ProductVersion', '1.0.0.0')
])
]),
VarFileInfo([VarStruct('Translation', [1033, 1200])])
]
)
Troubleshooting Common Issues¶
ImportError: No module named 'module_name'¶
Solution:
# Add to hiddenimports in spec file
hiddenimports = ['missing_module_name']
# Or use command line
uv run pyinstaller --hidden-import=missing_module_name src/app/main.py
FileNotFoundError: No such file or directory¶
Solution:
# Add data files to spec file
datas = [('path/to/file', 'destination')]
# Or use command line
uv run pyinstaller --add-data "src_path;dest_path" src/app/main.py # Windows
uv run pyinstaller --add-data "src_path:dest_path" src/app/main.py # Unix
SSL Certificate Issues¶
Solution:
# Include certificates in spec file
import certifi
datas = [(certifi.where(), 'certifi')]
# Or set environment variable in code
import os
import certifi
os.environ['SSL_CERT_FILE'] = certifi.where()
Large Executable Size¶
Solutions:
# Exclude unnecessary modules
excludes = [
'tkinter', 'matplotlib', 'scipy', 'numpy.tests',
'pandas', 'PIL', 'PyQt5', 'test', 'tests'
]
# Use UPX compression
upx=True
# Use onedir instead of onefile for faster startup
# (though it creates a directory instead of single file)
Slow Startup Time¶
Solutions:
# Use onedir distribution instead of onefile
uv run pyinstaller --onedir src/app/main.py
# Reduce number of imports
# Use lazy imports in your code
# Set noarchive=True in spec file for faster imports
noarchive=True
Best Practices Summary¶
Development Best Practices¶
- Use uv for dependency management - Fast, reproducible environments
- Test early and often - Build executables during development
- Use spec files - Better control and reproducibility
- Version control spec files - Track build configuration changes
- Automate builds - Use scripts and CI/CD pipelines
Build Configuration Best Practices¶
- Start simple - Begin with basic builds, add complexity gradually
- Use appropriate build mode - onefile vs onedir based on needs
- Optimize for target audience - Consider size vs startup time trade-offs
- Include necessary data files - Don't forget templates, configs, etc.
- Handle platform differences - Test on target platforms
Distribution Best Practices¶
- Code sign executables - Reduces antivirus false positives
- Test on clean systems - Virtual machines without Python
- Document system requirements - Minimum OS versions, dependencies
- Provide installation instructions - Help users install and run
- Monitor for issues - Be prepared to update builds for compatibility
This comprehensive guide covers everything you need to know about using PyInstaller with uv for creating robust, distributable Python applications. The combination of uv's speed and PyInstaller's maturity provides an excellent foundation for Python application distribution.
PyInstaller Alternatives and Comparisons¶
While PyInstaller is the most popular choice for creating Python executables, several alternatives exist, each with unique strengths and trade-offs. Here's a comprehensive comparison to help you choose the right tool.
1. Nuitka - The Performance Champion¶
What it is: Nuitka is a Python compiler that translates Python code to C++ and then compiles to native machine code.
Key Features: - True compilation (not bundling like PyInstaller) - Faster execution than interpreted Python - Smaller executables than PyInstaller - Gradual optimization - can compile individual modules
Using Nuitka with uv:
# Install Nuitka
uv add --dev nuitka
# Basic compilation
uv run python -m nuitka --onefile src/myapp/main.py
# Optimized compilation
uv run python -m nuitka --onefile --remove-output --assume-yes-for-downloads src/myapp/main.py
# GUI application
uv run python -m nuitka --onefile --windows-disable-console src/myapp/main.py # Windows
uv run python -m nuitka --onefile --macos-create-app-bundle src/myapp/main.py # macOS
# Advanced optimization
uv run python -m nuitka --onefile --lto=yes --plugin-enable=numpy src/myapp/main.py
Pros vs PyInstaller:
- โ Faster execution (10-50% performance improvement)
- โ Smaller file sizes (often 2-3x smaller)
- โ Better startup time (no extraction needed)
- โ True compilation provides some code obfuscation
- โ Plugin system for optimizing specific packages
Cons vs PyInstaller:
- โ Longer compile times (can be 5-10x slower)
- โ C++ compiler required on build machine
- โ Less mature ecosystem and fewer workarounds
- โ Some Python features not supported (eval, exec limitations)
- โ Debugging is harder when issues occur
Best for: Performance-critical applications, production software where execution speed matters, applications that will be run frequently.
2. cx_Freeze - The Cross-Platform Veteran¶
What it is: Cross-platform freezing tool that bundles Python applications, similar to PyInstaller but with different architecture.
Using cx_Freeze with uv:
# Install cx_Freeze
uv add --dev cx_freeze
# Create setup script
# setup.py
from cx_Freeze import setup, Executable
build_options = {
'packages': ['requests', 'click'],
'excludes': ['tkinter', 'unittest'],
'include_files': [('data/', 'data/')],
}
executables = [
Executable('src/myapp/main.py', target_name='myapp')
]
setup(
name='MyApp',
version='1.0',
description='My Application',
options={'build_exe': build_options},
executables=executables
)
# Build
uv run python setup.py build_exe
Pros vs PyInstaller: - โ Simpler architecture - easier to understand - โ Good cross-platform support - โ Fine-grained control over what gets included - โ Modular design - can bundle specific modules only
Cons vs PyInstaller: - โ Less automatic dependency detection - โ More manual configuration required - โ Smaller community and fewer resources - โ Less sophisticated hiding of imports
Best for: Developers who want more control over the bundling process, projects with well-understood dependencies.
3. auto-py-to-exe - The GUI Wrapper¶
What it is: A graphical user interface wrapper around PyInstaller that makes it easier to configure builds.
Using auto-py-to-exe with uv:
# Install auto-py-to-exe
uv add --dev auto-py-to-exe
# Launch GUI
uv run auto-py-to-exe
# Or use JSON configuration
uv run auto-py-to-exe --config config.json
Pros vs PyInstaller: - โ User-friendly GUI for beginners - โ Visual configuration of build options - โ JSON export for reproducible builds - โ All PyInstaller features available
Cons vs PyInstaller: - โ Additional dependency for simple projects - โ Less suitable for automation and CI/CD - โ GUI overhead for experienced users
Best for: Beginners, one-off builds, developers who prefer GUI tools.
4. py2exe - Windows Specialist¶
What it is: Windows-only tool for creating executable files from Python scripts.
Using py2exe with uv:
# Install py2exe (Windows only)
uv add --dev py2exe
# Create setup script
# setup.py
from distutils.core import setup
import py2exe
setup(
console=['src/myapp/main.py'],
options={
'py2exe': {
'bundle_files': 1,
'compressed': True,
'excludes': ['tkinter'],
}
},
zipfile=None,
)
# Build
uv run python setup.py py2exe
Pros vs PyInstaller: - โ Windows-optimized with good OS integration - โ Smaller executables on Windows - โ Mature and stable for Windows deployment
Cons vs PyInstaller: - โ Windows-only (major limitation) - โ Less active development - โ Limited Python version support
Best for: Windows-only applications where you need optimal Windows integration.
5. py2app - macOS Specialist¶
What it is: macOS-only tool for creating application bundles (.app files).
Using py2app with uv:
# Install py2app (macOS only)
uv add --dev py2app
# Create setup script
# setup.py
from setuptools import setup
APP = ['src/myapp/main.py']
DATA_FILES = []
OPTIONS = {
'argv_emulation': True,
'includes': ['requests', 'click'],
'excludes': ['tkinter'],
}
setup(
app=APP,
data_files=DATA_FILES,
options={'py2app': OPTIONS},
setup_requires=['py2app'],
)
# Build
uv run python setup.py py2app
Pros vs PyInstaller: - โ Native macOS app bundles with proper integration - โ Better macOS-specific features (Info.plist, etc.) - โ Optimized for macOS deployment
Cons vs PyInstaller: - โ macOS-only (major limitation) - โ Less flexible than PyInstaller - โ Steeper learning curve for complex apps
Best for: macOS-only applications that need native app bundle features.
6. PyOxidizer - The Rust-Powered Solution¶
What it is: A modern tool written in Rust that embeds Python interpreters into applications.
Using PyOxidizer with uv:
# Install PyOxidizer
uv add --dev pyoxidizer
# Initialize project
uv run pyoxidizer init-rust-project myapp
# Configure pyoxidizer.bzl
# pyoxidizer.bzl
def make_exe():
config = default_python_config()
config.run_command = "from myapp import main; main()"
exe = PythonExecutable(
name = "myapp",
config = config,
)
for resource in find_resources_in_path("src"):
exe.add_python_resource(resource)
return exe
def make_install(exe):
files = FileManifest()
files.add_python_resource(".", exe)
return files
register_target("exe", make_exe)
register_target("install", make_install, depends=["exe"])
resolve_targets()
# Build
uv run pyoxidizer build
Pros vs PyInstaller: - โ Modern architecture built with Rust - โ Flexible configuration with Python-like syntax - โ Good performance and small sizes - โ Custom Python distributions possible
Cons vs PyInstaller: - โ Complex setup and configuration - โ Smaller community and fewer examples - โ Rust knowledge helpful for advanced usage - โ Less mature than PyInstaller
Best for: Advanced users who want cutting-edge technology and maximum control.
7. Briefcase - The Mobile-Ready Solution¶
What it is: Part of the BeeWare suite, designed for creating native applications across multiple platforms including mobile.
Using Briefcase with uv:
# Install briefcase
uv add --dev briefcase
# Initialize project
uv run briefcase new
# Configure pyproject.toml
[tool.briefcase]
project_name = "My App"
bundle = "com.example"
version = "0.0.1"
description = "My Application"
[tool.briefcase.app.myapp]
formal_name = "My App"
description = "My Application Description"
sources = ["src/myapp"]
requires = ["requests", "click"]
# Build
uv run briefcase create
uv run briefcase build
uv run briefcase package
Pros vs PyInstaller: - โ Multi-platform support including mobile (iOS, Android) - โ Native app packaging for each platform - โ Modern toolchain and active development - โ Web deployment support
Cons vs PyInstaller: - โ More complex setup for simple desktop apps - โ Less mature for traditional desktop deployment - โ Requires platform-specific SDKs for mobile
Best for: Applications targeting multiple platforms including mobile, modern cross-platform development.
Comprehensive Comparison Table¶
Feature | PyInstaller | Nuitka | cx_Freeze | py2exe | py2app | PyOxidizer | Briefcase |
---|---|---|---|---|---|---|---|
Platforms | All | All | All | Windows | macOS | All | All + Mobile |
Ease of Use | โญโญโญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญ | โญโญโญโญ |
Performance | โญโญโญ | โญโญโญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญโญ | โญโญโญ |
Binary Size | โญโญ | โญโญโญโญ | โญโญโญ | โญโญโญโญ | โญโญโญ | โญโญโญโญ | โญโญโญ |
Build Speed | โญโญโญโญ | โญโญ | โญโญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ |
Startup Time | โญโญ | โญโญโญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญโญ | โญโญโญ |
Community | โญโญโญโญโญ | โญโญโญ | โญโญ | โญโญ | โญโญ | โญโญ | โญโญโญ |
Documentation | โญโญโญโญโญ | โญโญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญโญ |
Auto Detection | โญโญโญโญโญ | โญโญโญโญ | โญโญ | โญโญโญ | โญโญโญ | โญโญโญ | โญโญโญโญ |
Use Case Recommendations¶
Choose PyInstaller when:¶
- ๐ฏ General-purpose desktop applications
- ๐ Cross-platform deployment required
- ๐ฅ Large development team (good docs/community)
- โก Fast development cycles needed
- ๐ฆ Complex dependencies (many packages)
- ๐ Mature toolchain required
Choose Nuitka when:¶
- ๐ Performance is critical
- ๐ฆ Smaller executable size needed
- ๐ Code obfuscation desired
- ๐ฐ Commercial applications (worth the compile time)
- ๐ฎ Games or real-time applications
Choose cx_Freeze when:¶
- ๐ Fine control over bundling process
- ๐ Well-understood dependencies
- ๐ง Custom build processes needed
- ๐จโ๐ป Experienced with packaging
Choose Platform-Specific Tools when:¶
- ๐ช py2exe: Windows-only with tight OS integration
- ๐ py2app: macOS-only with native app bundles
- ๐ฑ Briefcase: Need mobile deployment
Choose PyOxidizer when:¶
- ๐ฆ Cutting-edge technology acceptable
- ๐ Maximum control over Python distribution
- ๐ฆ Custom Python builds needed
- ๐ง Advanced packaging requirements
Performance Comparison¶
Real-World Benchmarks¶
Test Application: Flask web app with requests, click, rich dependencies
Tool | Build Time | Binary Size | Startup Time | Runtime Performance |
---|---|---|---|---|
PyInstaller | 45s | 28 MB | 2.1s | Baseline |
Nuitka | 3m 20s | 12 MB | 0.3s | +25% faster |
cx_Freeze | 1m 10s | 25 MB | 1.8s | Baseline |
py2exe | 55s | 18 MB | 1.5s | Baseline |
Memory Usage During Build:
Tool | Peak Memory | Disk Space (temp) |
---|---|---|
PyInstaller | 450 MB | 120 MB |
Nuitka | 1.2 GB | 300 MB |
cx_Freeze | 320 MB | 80 MB |
Advanced Hybrid Approach¶
You can combine tools for optimal results:
# Use uv for dependency management
uv sync
# Try multiple tools and compare
uv run pyinstaller --onefile src/app/main.py
uv run python -m nuitka --onefile src/app/main.py
# Benchmark and choose the best for your use case
Multi-tool build script:
#!/usr/bin/env python3
"""
Compare multiple packaging tools
"""
import subprocess
import time
import os
from pathlib import Path
def benchmark_tool(tool_name, build_command, binary_path):
"""Benchmark a packaging tool"""
print(f"๐จ Testing {tool_name}...")
# Clean previous builds
if Path(binary_path).exists():
os.remove(binary_path)
# Measure build time
start_time = time.time()
result = subprocess.run(build_command, shell=True, capture_output=True)
build_time = time.time() - start_time
if result.returncode != 0:
print(f"โ {tool_name} build failed")
return None
# Measure binary size
if Path(binary_path).exists():
binary_size = Path(binary_path).stat().st_size / (1024 * 1024) # MB
else:
print(f"โ {tool_name} binary not found")
return None
# Measure startup time
start_time = time.time()
startup_result = subprocess.run([binary_path, '--help'],
capture_output=True, timeout=30)
startup_time = time.time() - start_time
return {
'tool': tool_name,
'build_time': build_time,
'binary_size': binary_size,
'startup_time': startup_time,
'success': startup_result.returncode == 0
}
def main():
tools = [
('PyInstaller', 'uv run pyinstaller --onefile src/app/main.py', 'dist/main'),
('Nuitka', 'uv run python -m nuitka --onefile src/app/main.py', 'main.bin'),
('cx_Freeze', 'uv run python setup_cx.py build_exe', 'build/exe.*/main'),
]
results = []
for tool_name, command, binary_path in tools:
result = benchmark_tool(tool_name, command, binary_path)
if result:
results.append(result)
# Display comparison
print("\n๐ Comparison Results:")
print(f"{'Tool':<12} {'Build Time':<12} {'Size (MB)':<10} {'Startup (s)':<12}")
print("-" * 50)
for result in results:
print(f"{result['tool']:<12} {result['build_time']:<12.1f} "
f"{result['binary_size']:<10.1f} {result['startup_time']:<12.2f}")
if __name__ == "__main__":
main()
Linux Distribution Compatibility¶
Understanding Linux Binary Dependencies¶
Critical Issue: PyInstaller binaries on Linux ARE dependent on the distribution and glibc version where they're built.
The Core Problem: PyInstaller does not bundle libc (the C standard library, usually glibc) with the app. Instead, the app expects to link dynamically to the libc from the local OS where it runs. The interface between any app and libc is forward compatible to newer releases, but it is not backward compatible to older releases.
What this means: - โ Forward compatible: Binary built on Ubuntu 18.04 (glibc 2.27) โ runs on Ubuntu 22.04 (glibc 2.35) - โ NOT backward compatible: Binary built on Ubuntu 22.04 (glibc 2.35) โ fails on Ubuntu 18.04 (glibc 2.27)
Common Error Messages¶
# Typical glibc version error
Error loading Python lib '/tmp/_MEI.../libpython3.8.so.1.0':
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.25' not found
# Architecture mismatch error
cannot execute binary file: Exec format error
# Missing shared library error
error while loading shared libraries: libz.so.1:
failed to map segment from shared object
Distribution Compatibility Matrix¶
Build System | glibc Version | Compatible Target Systems |
---|---|---|
CentOS 7 | 2.17 | CentOS 7+, RHEL 7+, Ubuntu 16.04+, Debian 9+ |
Ubuntu 18.04 | 2.27 | Ubuntu 18.04+, Debian 10+, CentOS 8+ |
Ubuntu 20.04 | 2.31 | Ubuntu 20.04+, Debian 11+, Fedora 32+ |
Ubuntu 22.04 | 2.35 | Ubuntu 22.04+, Debian 12+, Fedora 36+ |
Ubuntu 24.04 | 2.39 | Ubuntu 24.04+, Debian 13+, Fedora 40+ |
Architecture Dependencies¶
The GNU/Linux standard libraries such as glibc are distributed in 64-bit and 32-bit versions, and these are not compatible:
- x86_64 (64-bit Intel/AMD) โ Most common
- aarch64 (64-bit ARM) โ Growing (Apple M1, AWS Graviton)
- armv7l (32-bit ARM) โ Raspberry Pi, IoT devices
- i386 (32-bit Intel) โ Legacy systems
Rule: You cannot bundle your app on a 32-bit system and run it on a 64-bit installation, nor vice-versa.
Linux Compatibility Solutions¶
1. Build on Oldest Target System (Recommended)¶
Strategy: Always build your app on the oldest version of GNU/Linux you mean to support.
# For wide compatibility: Use CentOS 7 or Ubuntu 18.04
# For modern systems: Use Ubuntu 20.04+
# For latest features: Use current Ubuntu/Fedora
# Example: Building for CentOS 7 compatibility
uv sync
uv run pyinstaller --onefile src/app/main.py
2. Docker-Based Multi-Distro Builds¶
Create compatibility-focused Docker images:
# Dockerfile.ubuntu18-uv (for wide compatibility)
FROM ubuntu:18.04
# Install uv
RUN apt-get update && apt-get install -y curl software-properties-common binutils
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Add deadsnakes PPA for newer Python on old Ubuntu
RUN add-apt-repository ppa:deadsnakes/ppa
RUN apt-get update && apt-get install -y \
python3.11 \
python3.11-dev \
python3.11-venv
WORKDIR /app
COPY . .
# Build with uv
RUN uv python install 3.11
RUN uv sync
RUN uv run pyinstaller --onefile --strip src/app/main.py
# Test the binary
RUN ./dist/app --version
# Dockerfile.centos7-uv (for maximum compatibility)
FROM centos:7
# Install dependencies
RUN yum update -y && yum install -y curl gcc binutils
# Install uv
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Install Python 3.9 via Software Collections
RUN yum install -y centos-release-scl
RUN yum install -y rh-python39 rh-python39-python-devel
RUN echo "source /opt/rh/rh-python39/enable" >> ~/.bashrc
WORKDIR /app
COPY . .
# Build with uv (note: need to source SCL environment)
RUN source /opt/rh/rh-python39/enable && \
uv python install 3.9 && \
uv sync && \
uv run pyinstaller --onefile src/app/main.py
Build with Docker:
# Build for wide compatibility
docker build -f Dockerfile.ubuntu18-uv -t myapp-ubuntu18 .
docker run --rm -v $(pwd)/dist:/app/dist myapp-ubuntu18
# Build for maximum compatibility
docker build -f Dockerfile.centos7-uv -t myapp-centos7 .
docker run --rm -v $(pwd)/dist:/app/dist myapp-centos7
3. Compatibility Check Script¶
build_linux_compatible.py:
#!/usr/bin/env python3
"""
Linux-compatible PyInstaller build script using uv
"""
import subprocess
import platform
import sys
from pathlib import Path
def get_glibc_version():
"""Get current glibc version"""
try:
result = subprocess.run(['ldd', '--version'],
capture_output=True, text=True)
for line in result.stdout.split('\n'):
if 'ldd' in line and 'GNU libc' in line:
version = line.split()[-1]
return version
except:
pass
return "unknown"
def check_compatibility():
"""Check system compatibility for distribution"""
print("๐ง Linux Distribution Compatibility Check")
print(f"System: {platform.system()} {platform.release()}")
print(f"Architecture: {platform.machine()}")
print(f"glibc version: {get_glibc_version()}")
# Common glibc versions by distro
glibc_distros = {
"2.17": "CentOS 7, RHEL 7",
"2.23": "Ubuntu 16.04",
"2.27": "Ubuntu 18.04",
"2.31": "Ubuntu 20.04",
"2.35": "Ubuntu 22.04",
"2.39": "Ubuntu 24.04"
}
current_version = get_glibc_version()
print(f"\n๐ Target compatibility:")
print(f"Building with glibc {current_version}")
print("Will be compatible with:")
for version, distros in glibc_distros.items():
if version >= current_version:
print(f" โ
{distros} (glibc {version})")
else:
print(f" โ {distros} (glibc {version})")
def test_on_multiple_distros(binary_path):
"""Test binary on multiple distributions using Docker"""
test_distros = [
"ubuntu:18.04",
"ubuntu:20.04",
"ubuntu:22.04",
"centos:7",
"debian:10",
"debian:11"
]
print(f"\n๐งช Testing {binary_path} on multiple distributions:")
for distro in test_distros:
try:
cmd = [
"docker", "run", "--rm",
"-v", f"{Path.cwd()}:/app",
distro,
f"/app/{binary_path}", "--version"
]
result = subprocess.run(cmd, capture_output=True,
text=True, timeout=30)
if result.returncode == 0:
print(f" โ
{distro}: Working")
else:
print(f" โ {distro}: Failed - {result.stderr.strip()}")
except subprocess.TimeoutExpired:
print(f" โฐ {distro}: Timeout")
except FileNotFoundError:
print(f" โ ๏ธ Docker not available for testing")
break
except Exception as e:
print(f" โ {distro}: Error - {e}")
def build_with_checks():
"""Build with compatibility checks"""
check_compatibility()
print("\n๐จ Building with uv...")
# Sync dependencies
subprocess.run(["uv", "sync"], check=True)
# Build executable with optimization flags
subprocess.run([
"uv", "run", "pyinstaller",
"--onefile",
"--strip", # Remove debug symbols
"--exclude-module", "tkinter", # Common exclusions for size
"--exclude-module", "matplotlib",
"src/app/main.py"
], check=True)
# Test binary locally
binary_path = Path("dist/main")
if binary_path.exists():
print(f"โ
Binary created: {binary_path}")
print(f"๐ฆ Size: {binary_path.stat().st_size / (1024*1024):.1f} MB")
# Test execution locally
try:
result = subprocess.run([str(binary_path), "--version"],
capture_output=True, timeout=10, text=True)
if result.returncode == 0:
print("โ
Local binary test passed")
# Test on multiple distros if Docker available
test_on_multiple_distros(binary_path)
else:
print(f"โ Local binary test failed: {result.stderr}")
except subprocess.TimeoutExpired:
print("โ Binary test timed out")
except Exception as e:
print(f"โ Binary test error: {e}")
if __name__ == "__main__":
build_with_checks()
Usage:
# Check compatibility and build
uv run python build_linux_compatible.py
# Example output:
# ๐ง Linux Distribution Compatibility Check
# System: Linux 5.4.0-74-generic
# Architecture: x86_64
# glibc version: 2.31
#
# ๐ Target compatibility:
# Building with glibc 2.31
# Will be compatible with:
# โ CentOS 7, RHEL 7 (glibc 2.17)
# โ Ubuntu 16.04 (glibc 2.23)
# โ Ubuntu 18.04 (glibc 2.27)
# โ
Ubuntu 20.04 (glibc 2.31)
# โ
Ubuntu 22.04 (glibc 2.35)
4. GitHub Actions Multi-Distro Strategy¶
.github/workflows/build-linux-compatible.yml:
name: Build Linux Compatible Binaries
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
build-linux:
runs-on: ubuntu-latest
strategy:
matrix:
# Build on different base systems for compatibility
include:
- container: ubuntu:18.04
name: "ubuntu-18.04-glibc-2.27"
python: "3.11"
compatibility: "wide"
- container: ubuntu:20.04
name: "ubuntu-20.04-glibc-2.31"
python: "3.11"
compatibility: "modern"
- container: centos:7
name: "centos-7-glibc-2.17"
python: "3.9"
compatibility: "maximum"
container: ${{ matrix.container }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies (Ubuntu)
if: startsWith(matrix.container, 'ubuntu')
run: |
apt-get update
apt-get install -y curl software-properties-common binutils
add-apt-repository ppa:deadsnakes/ppa
apt-get update
apt-get install -y python${{ matrix.python }} python${{ matrix.python }}-dev python${{ matrix.python }}-venv
- name: Install dependencies (CentOS)
if: startsWith(matrix.container, 'centos')
run: |
yum update -y
yum install -y curl gcc binutils centos-release-scl
yum install -y rh-python39 rh-python39-python-devel
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Build binary (Ubuntu)
if: startsWith(matrix.container, 'ubuntu')
run: |
export PATH="/github/home/.local/bin:$PATH"
uv python install ${{ matrix.python }}
uv sync
uv run pyinstaller --onefile --strip src/app/main.py
mv dist/main dist/main-${{ matrix.name }}
- name: Build binary (CentOS)
if: startsWith(matrix.container, 'centos')
run: |
source /opt/rh/rh-python39/enable
export PATH="/github/home/.local/bin:$PATH"
uv python install ${{ matrix.python }}
uv sync
uv run pyinstaller --onefile --strip src/app/main.py
mv dist/main dist/main-${{ matrix.name }}
- name: Test binary and show info
run: |
./dist/main-${{ matrix.name }} --version
echo "glibc version:"
ldd --version | head -1
echo "Binary size:"
ls -lh dist/main-${{ matrix.name }}
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.name }}
path: dist/main-${{ matrix.name }}
retention-days: 30
- name: Create Release
if: startsWith(github.ref, 'refs/tags/') && matrix.compatibility == 'maximum'
uses: softprops/action-gh-release@v1
with:
files: dist/main-${{ matrix.name }}
name: Release ${{ github.ref_name }}
body: |
## Linux Compatibility
- **maximum**: Built on CentOS 7 (glibc 2.17) - works on most Linux systems
- **wide**: Built on Ubuntu 18.04 (glibc 2.27) - works on modern systems
- **modern**: Built on Ubuntu 20.04 (glibc 2.31) - latest systems only
Choose the binary with the best compatibility for your target systems.
5. Special Considerations¶
Red Hat Systems and /tmp
Execution¶
Some Red Hat-based systems don't allow execution from /tmp
as a security measure, which affects PyInstaller's onefile mode:
# Error on RHEL/CentOS
error while loading shared libraries: libz.so.1:
failed to map segment from shared object
# Solution: Override temp directory
export _MEIPASS2=/path/to/executable/temp
./myapp
# Or use onedir mode instead of onefile
uv run pyinstaller --onedir src/app/main.py
Static Linking with staticx¶
For maximum compatibility, you can post-process PyInstaller binaries:
# Install staticx
uv add --dev staticx
# Build with PyInstaller first
uv run pyinstaller --onefile src/app/main.py
# Post-process with staticx for static linking
uv run staticx dist/main dist/main-static
# Test static binary
./dist/main-static --version
6. Testing Strategy¶
Comprehensive testing approach:
# Test locally built binary on multiple distros
docker run --rm -v $(pwd):/app ubuntu:18.04 /app/dist/main --version
docker run --rm -v $(pwd):/app ubuntu:20.04 /app/dist/main --version
docker run --rm -v $(pwd):/app ubuntu:22.04 /app/dist/main --version
docker run --rm -v $(pwd):/app centos:7 /app/dist/main --version
docker run --rm -v $(pwd):/app debian:10 /app/dist/main --version
docker run --rm -v $(pwd):/app fedora:35 /app/dist/main --version
# Check dependencies
ldd dist/main
objdump -p dist/main | grep NEEDED
# Check glibc version requirements
objdump -T dist/main | grep GLIBC
Linux Compatibility Best Practices¶
1. Choose Build Strategy Based on Target¶
- Maximum Compatibility: Build on CentOS 7 (glibc 2.17)
- Wide Compatibility: Build on Ubuntu 18.04 (glibc 2.27)
- Modern Systems: Build on Ubuntu 20.04+ (glibc 2.31+)
2. Optimization for Linux¶
# Linux-optimized build flags
uv run pyinstaller \
--onefile \
--strip \ # Remove debug symbols
--exclude-module tkinter \ # Exclude GUI if not needed
--exclude-module test \ # Exclude test modules
src/app/main.py
3. Size and Performance Considerations¶
# Check binary dependencies
ldd dist/myapp
# Analyze size contributors
uv run pyinstaller --onefile --analyze src/app/main.py
# Consider onedir for faster startup on slower systems
uv run pyinstaller --onedir src/app/main.py
4. Distribution Recommendations¶
- Provide multiple binaries for different compatibility levels
- Clearly document glibc requirements in releases
- Test on actual target systems before release
- Consider AppImage for universal Linux distribution
- Use semantic versioning for binary compatibility
The combination of uv's fast dependency management with proper Linux compatibility strategies ensures your PyInstaller binaries work reliably across diverse Linux environments.