Before your CLI can reach anyone — via PyPI, pipx, or a colleague's laptop — you have to build
it into distributable artifacts: a wheel and a source distribution. This is a single command,
python -m build, but understanding what it produces (and how to verify the results before you
ship) is what separates a release that installs cleanly from one that fails on someone else's
machine. This guide covers both artifact types, the build itself, inspecting the output, and
smoke-testing a wheel in a throwaway environment.
TL;DR
python -m buildproduces both a wheel and an sdist intodist/.- Wheel (
.whl) is a ZIP that installs by unzip — fast, no build step for the user. sdist (.tar.gz) is your source plus metadata, the fallback and the archival form. - Configure once via
[build-system](hatchling or setuptools) and[project]inpyproject.toml. - Verify before release:
twine check dist/*for metadata, then install the wheel into a fresh venv and run the command. - A wheel is just a ZIP —
unzip -lit to see exactly what you are shipping.
Wheel vs sdist: what each one is
A build yields two files with different jobs.
A wheel is a pre-built distribution: a ZIP archive, named to encode the Python and platform
it targets, whose contents are already arranged the way they land in site-packages.
Installing a wheel is essentially "unzip into the environment and write the launcher scripts,"
so it is fast and requires no build tooling on the user's machine. A pure-Python CLI produces
one universal wheel named ...-py3-none-any.whl — py3 (any Python 3), none (no ABI
constraint), any (any OS) — that installs everywhere.
A source distribution, or sdist, is a .tar.gz of your source tree plus the metadata
needed to build it. Installers fall back to the sdist when no compatible wheel exists, building
a wheel locally first. It is also the auditable, from-source form that Linux distributions and
conda-forge repackage. You publish both: nearly everyone installs the wheel; the sdist is the
safety net and the source of record.
For a pure-Python CLI the wheel is trivially portable, so why bother with the sdist? Because it is the only artifact that guarantees a build from source is possible — some ecosystems require it, and it protects users on a platform or Python you never built a wheel for.
Configure the build backend
The build is driven by two tables in pyproject.toml: [build-system] names the backend that
turns your source into artifacts, and [project] supplies the metadata. Hatchling is a good
modern default; setuptools is the venerable alternative.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "greet-cli"
version = "0.1.0"
description = "A friendly greeting CLI"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
dependencies = ["click>=8.1"]
[project.scripts]
greet = "greet_cli.__main__:main"
The equivalent with setuptools only changes [build-system]:
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
With a src/ layout, hatchling finds src/greet_cli/ automatically; setuptools needs
[tool.setuptools.packages.find] where = ["src"]. The [project.scripts] entry is what makes
the installed wheel expose a greet command — see
best practices for Python CLI entry points
for how to choose that target.
Run python -m build
build is a small front-end that creates an isolated environment, installs your declared build
backend into it, and invokes it. That isolation is the point: it builds against exactly the
[build-system].requires you declared, not whatever happens to be in your current environment.
$ pip install build # or: uv tool install build
$ python -m build
* Creating isolated environment: venv+pip...
* Building sdist...
* Building wheel from sdist...
Successfully built greet_cli-0.1.0.tar.gz and greet_cli-0.1.0-py3-none-any.whl
Note the sequence: build makes the sdist first, then builds the wheel from that sdist. This
is a feature — it proves your sdist is complete enough to produce a wheel. Restrict to one
artifact with python -m build --wheel or --sdist when you need only one. If you use uv,
uv build does the same job with the same output.
The dist/ layout and inspecting a wheel
After a build, dist/ holds both artifacts:
$ ls dist/
greet_cli-0.1.0-py3-none-any.whl
greet_cli-0.1.0.tar.gz
A wheel is a ZIP, so you can look inside without installing it — the fastest way to confirm you shipped what you meant to and nothing you didn't:
$ unzip -l dist/greet_cli-0.1.0-py3-none-any.whl
Archive: dist/greet_cli-0.1.0-py3-none-any.whl
Length Name
-------- ----
312 greet_cli/__init__.py
684 greet_cli/__main__.py
1024 greet_cli-0.1.0.dist-info/METADATA
... greet_cli-0.1.0.dist-info/RECORD
... greet_cli-0.1.0.dist-info/WHEEL
... greet_cli-0.1.0.dist-info/entry_points.txt
The .dist-info/ directory holds the metadata: METADATA (your [project] fields),
entry_points.txt (your console script — check the greet line is there), and RECORD (a
manifest with hashes). If your command is missing from entry_points.txt, the wheel will
install but the command won't exist — catch that here, not from a user's bug report. Peek at
the sdist the same way with tar tzf dist/greet_cli-0.1.0.tar.gz.
Including package data
Code files are picked up automatically; non-code files are not, and forgetting them is the most
common "works locally, broken once installed" bug. If your CLI ships templates, a bundled
config, or a py.typed marker, tell the backend to include them.
With hatchling, force-include files or whole directories:
[tool.hatch.build.targets.wheel]
include = ["src/greet_cli"]
[tool.hatch.build.targets.wheel.force-include]
"src/greet_cli/templates" = "greet_cli/templates"
With setuptools, use package-data (and a MANIFEST.in for what goes into the sdist):
[tool.setuptools.package-data]
greet_cli = ["templates/*.txt", "py.typed"]
Then always confirm with unzip -l that the data actually landed in the wheel. At runtime,
read those files with importlib.resources, never a relative filesystem path — the installed
package may live inside a ZIP or a location you cannot guess.
Verify with twine check and a smoke test
Two cheap checks catch most release failures. First, twine check validates that the metadata
will render on PyPI — a malformed README description is a classic reason an upload is accepted
but displays as raw text:
$ pip install twine
$ twine check dist/*
Checking dist/greet_cli-0.1.0-py3-none-any.whl: PASSED
Checking dist/greet_cli-0.1.0.tar.gz: PASSED
Second — and this is the check people skip — actually install the wheel into a clean, throwaway environment and run the command. A passing test suite against your source tree does not prove the built artifact works; only installing it does.
$ python -m venv /tmp/smoke
$ /tmp/smoke/bin/pip install dist/greet_cli-0.1.0-py3-none-any.whl
$ /tmp/smoke/bin/greet World
Hello, World!
$ rm -rf /tmp/smoke
If you use pipx, pipx install ./dist/greet_cli-0.1.0-py3-none-any.whl is an even quicker
isolated smoke test — see
installing and distributing CLIs with pipx.
Once both checks pass, you are ready to
publish to PyPI.
Production notes
- Clean
dist/between builds.twine upload dist/*uploads everything in the directory, so a stale wheel from a previous version can sneak into a release.rm -rf dist/before building, or upload a specific file. - Reproducibility. Set
SOURCE_DATE_EPOCHto pin timestamps if you need byte-identical wheels across builds; hatchling and setuptools both honor it. For most CLIs this is optional, but it matters for supply-chain verification. - Version single-sourcing. Rather than hand-editing
versioninpyproject.toml, derive it from a git tag withhatch-vcs(orsetuptools-scm) so the built artifact's version always matches the tag. This dovetails with managing CLI versioning and changelogs. - Pin the backend.
requires = ["hatchling"]without a floor can pull a new major backend that changes defaults; pin a lower bound (hatchling>=1.25) once you find a version that works. - Pure vs compiled. Everything here assumes a pure-Python CLI, which yields one universal
wheel. If you add a compiled extension you enter the world of per-platform wheels and
cibuildwheel— a different and larger topic.
Related
- Packaging Python CLIs for Distribution — the overview this guide sits under.
- Publishing a Python CLI to PyPI — upload the artifacts you just built and verified.
- Installing and distributing CLIs with pipx — install a local wheel as a quick isolated smoke test.
- uv for Python CLI dependency management —
uv buildproduces the same wheel and sdist. - Managing CLI versioning and changelogs — single-source the version your build stamps in.