Shipping a Python CLI as a Real Wheel: Entry Points, Builds, and Release Proof
Copying tool.py to another machine and asking someone to run python path/to/tool.py proves that a script can execute. It does not prove that a CLI has been delivered. The path, working directory, interpreter, and PYTHONPATH leak into the interface, while users cannot easily tell which version they are running. A distributable command should be created by an installer, carry explicit metadata, and be tested from the artifact that users will receive.
This guide builds the smallest useful release path for a pure-Python tool named acme-report, from source tree to wheel.
Separate the three names
A project often has three related names: the distribution name published to an index (acme-report), the import package used in code (acme_report), and the terminal command (acme-report). They may differ. Record all three in the README instead of asking users to infer the mapping.
A src layout prevents tests from accidentally importing source directly from the repository root:
acme-report/
├── pyproject.toml
├── README.md
├── src/
│ └── acme_report/
│ ├── __init__.py
│ └── cli.py
└── tests/
When tests pass only after the project has been installed, they resemble the user’s environment. A green test from a flat source tree may merely mean that Python added the current directory to its import path.
Declare the build and command in pyproject.toml
Modern projects use [build-system] to select a build backend and its build dependencies. The [project] table contains metadata consumed by installers and indexes. This example uses Hatchling, although Setuptools, Flit, and other standards-compliant backends can serve the same role.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "acme-report"
version = "0.1.0"
description = "Generate a compact project report"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
acme-report = "acme_report.cli:main"
The [project.scripts] entry is not a shell alias. An installer uses acme_report.cli:main to create a platform-appropriate command wrapper, so users do not need to know the package path or construct a python -m command. Keep runtime dependencies here, but do not force every user to install test runners, formatters, and build tools.
A production project should also add license, author, project URL, and classifier metadata. Give the version one authoritative source: either declare it directly or configure the backend to derive it from code or version control. Manually synchronizing several version files eventually creates a release whose metadata disagrees with its output.
Treat main as a process contract
The entry function should accept optional arguments and return an integer. It parses the interface, invokes application logic, and routes output without doing work during module import:
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="acme-report")
parser.add_argument("project")
parser.add_argument("--format", choices=("text", "json"), default="text")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
report = generate_report(args.project, args.format)
except ReportError as error:
print(f"acme-report: {error}", file=sys.stderr)
return 2
print(report)
return 0
The entry-point specification maps a console_scripts reference to a callable that takes no arguments. The generated wrapper calls it and can use an integer return value as the process exit status. The default value for argv preserves that zero-argument call while allowing unit tests to supply a sequence directly.
Output is part of the contract too. Send normal results to stdout, diagnostics to stderr, return 0 on success, and use stable nonzero codes for expected failures. Avoid scattering sys.exit() through domain code; doing so makes that code harder to reuse and test. If the CLI ships templates or default configuration, declare them as package data and load them through importlib.resources rather than assuming repository-relative paths survive installation.
Build artifacts, not the working directory
Use a dedicated build frontend to create both a source distribution and a wheel:
python -m pip install --upgrade build
python -m build
By default, the results appear under dist/. A pure-Python project can often produce one platform-independent wheel. A native extension needs a wheel matrix covering the supported Python versions, operating systems, and processor architectures. Direct python setup.py sdist and bdist_wheel invocations should not be the release interface.
Clean old artifacts and confirm the version and Git state before every build. Otherwise, a wildcard upload can quietly include last week’s file. A wheel is a ZIP archive, so inspect it for package code, metadata, license, and required resources. The source distribution must contain everything required to build a wheel again from clean source.
Prove the final files in isolation
The most useful smoke test does not run python -m acme_report from the repository. It creates an empty virtual environment and installs only the candidate artifact:
python -m venv .venv-smoke
. .venv-smoke/bin/activate
python -m pip install dist/acme_report-0.1.0-py3-none-any.whl
acme-report --help
acme-report sample-project --format json
python -m pip show acme-report
Verify that the command exists, help is readable, the successful path returns zero, and an expected error writes to stderr with the documented exit code. Then create another clean environment and install the source distribution. That second test catches archives missing a README, type information, templates, or build configuration.
Unit tests can call main(["sample-project"]) to exercise parsing and return codes quickly, but retain at least one real subprocess test. It proves that the installer-generated wrapper works. Cross-platform projects must not hard-code a Unix wrapper path, path separator, or shell behavior.
CI should attach checksums to the files in dist/ and promote those exact bytes. Rebuilding immediately before upload breaks the evidence chain: the published file is no longer necessarily the artifact that passed tests.
Make publication a controlled promotion
A dependable release promotes one artifact set through explicit stages. Build once after review and tests, verify installation from a staging index or TestPyPI, and then publish the same files to the production index. For CI uploads to PyPI, prefer Trusted Publishing: the workflow exchanges a short-lived identity for upload permission instead of storing a long-lived API token.
Treat an uploaded version as immutable. If a release is wrong, increment the version and repeat the build and verification path rather than trying to replace the same version. Internal tools benefit from the same discipline even when they never reach public PyPI: store the wheel in an internal index or artifact registry and install a tested, versioned file instead of running source from a branch.
A release checklist that can fail usefully
Before publication, confirm that the distribution, import package, and command names are documented; pyproject.toml declares both its backend and project metadata; the entry module has no import-time side effects; stdout, stderr, and exit codes follow the documented contract; the wheel and sdist came from a clean commit; both install in isolated environments; help, success, and failure smoke tests pass; CI uploads with short-lived credentials; and the uploaded files match the checksums of the tested files.
The important step from script to product is not merely creating another archive. It is establishing boundaries: the backend interprets source, the installer creates commands from standard metadata, tests target final artifacts, and the release system promotes only bytes that have already passed verification. Once those boundaries hold, a CLI can leave the developer’s machine and become a tool that users can trust.