Python Basics: Package Small Command-Line Tools with zipapp
A small command-line utility may consist of several Python files but have no third-party dependencies. zipapp can bundle that source into one .pyz file that a suitable Python interpreter can run. It is useful for passing around a modest internal tool without turning it into a system installation.
These examples package only source written in the scripts themselves. They run that trusted local source, not downloaded archives, and do not install anything.
Prepare a scratch directory
These examples were tested on Linux with Python 3.12.14. Use Python 3.7 or newer for the archive filter and subprocess text mode. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Run without -O so the assertions remain enabled.
Create a fresh directory and keep using the same terminal:
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1If either setup command fails, stop before saving any files. Save each named source below as UTF-8. Avoid names such as zipapp.py or subprocess.py that shadow standard-library modules.
Each script creates a separate temporary directory underneath this scratch directory. Its source tree and archive are removed when the script leaves that context, so the examples can be rerun without overwriting a previous archive.
1. Package an explicit entry point and pass arguments
Save as package_greeting_tool.py:
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
import zipapp
with TemporaryDirectory(prefix="greeting-app-", dir=".") as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
(source / "__main__.py").write_text(
"import sys\n"
"name = sys.argv[1] if len(sys.argv) > 1 else 'reader'\n"
"print(f'Hello, {name}!')\n",
encoding="utf-8",
)
archive = root / "greeting.pyz"
zipapp.create_archive(source, target=archive)
assert zipapp.get_interpreter(archive) is None
for arguments, expected in [([], "Hello, reader!\n"), (["River"], "Hello, River!\n")]:
completed = subprocess.run(
[sys.executable, "-B", str(archive), *arguments],
check=True,
capture_output=True,
text=True,
timeout=10,
)
assert completed.stdout == expected
assert completed.stderr == ""
print(completed.stdout, end="")
print("Embedded interpreter:", zipapp.get_interpreter(archive))Run it:
python3 -B package_greeting_tool.pyOutput:
Hello, reader!
Hello, River!
Embedded interpreter: NonePython runs the archive’s top-level main.py. sys.executable selects the same interpreter that is running the outer script, and the argument list avoids a shell. No shebang is embedded, so the archive is launched through Python rather than as an executable file. See the zipapp API.
The default-input check matters: a packaged tool still needs ordinary command-line behavior tests. A successful archive build alone does not establish that its entry point works.
2. Generate an entry point and allow only intended files
Save as package_selected_sources.py:
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
import zipapp
from zipfile import ZipFile
with TemporaryDirectory(prefix="selected-app-", dir=".") as temporary:
root = Path(temporary)
source = root / "source"
source.mkdir()
(source / "toolbox.py").write_text(
"def main():\n"
" counts = [2, 3, 5]\n"
" print('Total:', sum(counts))\n",
encoding="utf-8",
)
(source / "draft-notes.txt").write_text("Do not bundle this fixture.\n", encoding="utf-8")
allowed = {"toolbox.py"}
archive = root / "toolbox.pyz"
zipapp.create_archive(
source,
target=archive,
main="toolbox:main",
filter=lambda relative_path: relative_path.as_posix() in allowed,
)
with ZipFile(archive) as bundle:
names = sorted(bundle.namelist())
assert names == ["__main__.py", "toolbox.py"]
completed = subprocess.run(
[sys.executable, "-B", str(archive)],
check=True,
capture_output=True,
text=True,
timeout=10,
)
assert completed.stdout == "Total: 10\n"
assert completed.stderr == ""
print("Members:", ", ".join(names))
print(completed.stdout, end="")Run it:
python3 -B package_selected_sources.pyOutput:
Members: __main__.py, toolbox.py
Total: 10main asks zipapp to generate main.py and call toolbox.main with no arguments. Do not also supply your own main.py. The filter receives paths relative to the source directory; this example accepts one known source file. Inspecting the finished member list checks the actual package rather than assuming the filter worked.
For a real project, build from a dedicated staging directory and review the files before distributing the archive. Keep credentials, private notes, local configuration, and generated caches out of that staging tree. The small allowlist above would need deliberate extension for packages or data assets.
Know what the archive does not include
A .pyz does not bundle the Python runtime. Recipients need an interpreter compatible with the code, and any dependencies must be available. Native extension modules cannot be imported directly from a zip archive; the zipimport documentation explains the supported module forms.
Use an archive only from a trusted source: running it executes Python code with the current user’s access. A zip file is not a sandbox, signature, or encrypted container. Source and timestamps can also make independently built archives differ byte for byte even when the application behavior is unchanged.
Clean up
The scripts remove their own temporary source trees and archives. In the same terminal, remove only the two saved scripts and the empty scratch directory:
rm -- package_greeting_tool.py package_selected_sources.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirIf an interrupted run left files behind, inspect them instead of deleting unknown contents.
References
Acknowledgements
This post was created with assistance from AI.