Project Setup

Bundling a Python CLI with PyInstaller

Freeze a Python CLI into a standalone executable - spec files, hidden imports, bundled data, resource paths, and the start-up cost of one-file builds.

Updated

PyInstaller bundles your code, its dependencies and the Python interpreter into one executable, so a user with no Python can run your tool. The build itself is one command; making it reliable comes down to two things static analysis cannot see — dynamic imports and file paths.

TL;DR

  • pyinstaller --onefile --name mytool src/mytool/__main__.py produces dist/mytool.
  • Commit a spec file rather than repeating flags; it is the only place complex builds stay readable.
  • Declare anything imported dynamically in hiddenimports, and bundle data in datas.
  • Read bundled files with importlib.resources, never from __file__.
  • --onefile re-extracts on every run; --onedir starts faster and is easier to debug.

Prerequisites

A working CLI installed in a virtual environment, and PyInstaller in that same environment:

uv add --dev pyinstaller        # or: pip install pyinstaller

Build from an environment containing exactly your runtime dependencies. Freezing from a kitchen-sink environment produces a much larger binary and hides missing declarations, because everything happens to be importable.

A first build

pyinstaller --onefile --name mytool --clean src/mytool/__main__.py
./dist/mytool --version
What PyInstaller does to your project PyInstaller analyses imports, collects the interpreter and dependencies, and writes a single executable that unpacks to a temporary directory at run time. What PyInstaller does to your project Analysis follows imports statically Collect interpreter + libraries Bundle one file or one directory Run unpacks, then executes spec file freeze user runs it Static analysis is the weak point: anything imported dynamically must be declared by hand.

Point it at __main__.py rather than at the console script. The generated shim is an installation artifact that assumes a specific environment layout; __main__.py is your own three-line entry point and freezes cleanly:

# src/mytool/__main__.py
from mytool.cli import app

def main() -> None:
    app()

if __name__ == "__main__":
    main()

Once the build works, move the configuration into a spec file and commit it. The spec is a Python script, so it can compute values — which is how the hidden-import list stays in sync with your command registry:

# mytool.spec
from PyInstaller.utils.hooks import collect_submodules

hiddenimports = collect_submodules("mytool.commands") + ["mytool.plugins"]

a = Analysis(
    ["src/mytool/__main__.py"],
    pathex=["src"],
    hiddenimports=hiddenimports,
    datas=[("src/mytool/templates", "mytool/templates")],
    excludes=["tkinter", "test", "pytest"],
)

pyz = PYZ(a.pure)
exe = EXE(pyz, a.scripts, a.binaries, a.datas, name="mytool", console=True, upx=False)
pyinstaller mytool.spec --clean --noconfirm

excludes is worth populating: tkinter alone adds several megabytes to a tool that will never draw a window.

The imports PyInstaller cannot see

What static analysis misses Imports and data files that PyInstaller cannot discover automatically, and the ways to declare them. What static analysis misses Declare these explicitly Plugins loaded through entry points Modules imported by name with importlib Package data read with importlib.resources Optional backends chosen at run time Symptoms of a missed one ModuleNotFoundError only in the frozen build A command that works until a plugin is used A missing template or schema file Works one-directory, fails one-file Every one of these fails at run time on a user machine, not at build time on yours.

Static analysis follows import statements. It does not follow importlib.import_module(name) where name is a string, and it does not know that entry_points() will return anything.

That means the two patterns this handbook otherwise recommends — lazy command loading and entry-point plugins — both need declaring:

# The registry your LazyGroup reads
COMMANDS = {
    "sync": "mytool.commands.sync:sync",
    "report": "mytool.commands.report:report",
}
# mytool.spec — derive the list rather than maintaining a second copy
import tomllib, pathlib
from mytool.registry import COMMANDS

hiddenimports = sorted({target.split(":")[0] for target in COMMANDS.values()})

Deriving it is the point. A hand-maintained list in the spec file is a second source of truth that falls behind the day someone adds a command, and the symptom is a ModuleNotFoundError that only happens for users.

For third-party packages that load things dynamically — httpx transports, database drivers, rich themes — PyInstaller ships hooks that already know. When one is missing, the fix is either a hiddenimports entry or a small hook file of your own in a hookspath directory.

Paths inside a bundle

Paths inside a frozen build Inside a frozen executable the bundle is extracted to a temporary directory, so the module file path is not where the user ran the program from. Paths inside a frozen build sys._MEIPASS (the unpack directory) only exists when frozen mytool/ your package, extracted templates/ bundled data files python3.12 runtime the interpreter itself sys.frozen is set when running bundled Read data with importlib.resources, not __file__ The user's working directory is unchanged Anything that computes a path from __file__ works in development and breaks once frozen.

At run time a one-file build extracts itself into a temporary directory and sets sys._MEIPASS to it. Anything that assumed your source tree exists on disk is now wrong:

# breaks once frozen
template = (Path(__file__).parent / "templates" / "report.html").read_text()

# works installed, zipped and frozen
from importlib.resources import files
template = files("mytool.templates").joinpath("report.html").read_text(encoding="utf-8")

importlib.resources needs no branch and no knowledge of freezing, which is why it is worth adopting long before you bundle anything. When you genuinely need the bundle root — to locate a sibling binary you shipped, say — read it explicitly:

import sys
from pathlib import Path

def bundle_root() -> Path:
    return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))

Note what does not change: the user's working directory. Relative paths the user typed still resolve against where they ran the command, which is correct and occasionally surprising when you are debugging a path problem.

One file or one directory

One file or one directory? A decision diagram: choose a single file for easy distribution and a directory build for faster start-up and simpler debugging. One file or one directory? Is start-up time or ease of download more important? Ease — one artifact people can curl and run --onefile unpacks on every start Speed — the tool is run repeatedly --onedir ship it in an archive One-file re-extracts the whole bundle on every invocation, which can add hundreds of milliseconds.

--onefile is a single executable that unpacks its whole bundle to a temporary directory on every invocation. For a tool with a large dependency tree that can add hundreds of milliseconds, charged on --help and on every tab completion.

--onedir produces a directory containing the executable and its libraries. It starts much faster, it is far easier to debug — you can look at what was actually included — and it ships as a tar.gz or zip, which is barely less convenient than a bare file.

The usual compromise: --onedir inside an archive for the download page, and --onefile only if you specifically need a curl … | sh-style install.

UX considerations

Three details make a frozen tool feel finished.

Say what version and build this is. Frozen binaries get copied around and lose their provenance, so --version should print more than a number:

typer.echo(f"mytool {get_version()} ({'frozen' if getattr(sys, 'frozen', False) else 'source'})")

Keep start-up honest. If the extraction cost is unavoidable, make sure nothing else is added to it — the lazy-import discipline from startup performance matters more in a frozen build, not less.

Expect the antivirus question. Unsigned one-file executables are a common false-positive for Windows Defender and SmartScreen, because self-extracting binaries look like packers. Signing is the real fix; a --onedir build in a zip is a partial mitigation.

Testing the behaviour

The build succeeding proves almost nothing. What proves the bundle works is running the paths that use dynamic imports and bundled data:

./dist/mytool --version
./dist/mytool --help
./dist/mytool report --period 7d       # a lazily-loaded command
./dist/mytool render --template report # reads a bundled data file
env -i ./dist/mytool --version         # empty environment: nothing leaks in from outside

Run those in the release job, on the platform that produced the artifact, on a runner that has never had your project installed. A job with your package importable will happily satisfy a missing hidden import from the environment and tell you nothing.

Conclusion

PyInstaller is reliable once you accept what it cannot infer. Commit a spec file, derive the hidden imports from the same registry your code uses, read bundled data through importlib.resources, and verify the artifact by running the interesting commands rather than by checking that the build exited zero.

Frequently asked questions

Why does my frozen build fail with ModuleNotFoundError?

Something is imported by name rather than by an import statement — a lazily loaded command, a plugin, an optional backend. Add it to hiddenimports, or better, derive the list from the registry that drives the dynamic import so the two cannot drift.

Can I build a Windows binary on Linux?

No. PyInstaller bundles the platform's interpreter and libraries, so each target needs a build on that platform. A CI matrix is the standard answer; Wine is occasionally suggested and is not worth the maintenance.

How do I make the binary smaller?

Freeze from a minimal environment, populate excludes with things you never use (tkinter, test, notebook machinery), and check what got pulled in with --onedir before compressing. UPX compression shrinks the file further but slows start-up and increases antivirus false positives — usually a bad trade for a CLI.

Does PyInstaller work with Typer and Rich?

Yes, both freeze without special handling. The one thing to check is any dynamic theme or lexer lookup — Pygments styles, for instance, are resolved by name, which is exactly the pattern that needs a hiddenimports entry.

Should the spec file be committed?

Yes. It is the build configuration, it is a Python file so it belongs in review, and it is the only readable way to express hidden imports, bundled data and exclusions. Repeating a dozen command-line flags in a CI workflow is how build configuration diverges between machines.

How do I debug a frozen build that fails on a user's machine?

Build a --onedir version of the same commit and reproduce there first: you can inspect the collected files, and tracebacks reference real paths. Then run the failing command with PyInstaller's debug bootloader (--debug=imports), which prints every import as it resolves and names the one that is missing. Keeping a doctor command that prints the bundle root, the resolved version and the loaded command list turns most user reports into a single paste.

Does freezing protect my source code?

No. The bundle contains compiled bytecode, which is straightforward to extract and decompile. Freezing is a distribution mechanism, not an obfuscation one — if source confidentiality matters, that is a different problem and one Python solves badly.

Can I bundle a tool that shells out to another program?

Yes, but decide whether that program travels with you. If it does, add it to binaries in the spec and resolve its path through the bundle root at run time; if it does not, look it up on PATH and fail with a message naming what is missing. What causes support tickets is the middle case, where the tool silently uses whatever version happens to be installed.