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.
Frequently asked questions
Do I have to publish an sdist as well as a wheel?
You should. The wheel is what everyone installs, but the sdist is what lets a distribution packager, an auditor or a user on an unusual platform rebuild your tool from source. It costs nothing extra — python -m build produces both in one command — and PyPI keeps them side by side.
What does py3-none-any in the filename mean?
It is the compatibility tag: pure Python 3, no ABI requirement, any platform. That is what a CLI written in Python should produce. If you see a platform tag such as cp312-manylinux, your project is compiling an extension, which changes distribution completely — you then need a wheel per platform and a build matrix in CI.
Why does my wheel install but the command not run?
Nine times in ten the module named by the entry point is not in the wheel. Run unzip -l on the artifact and look for your package directory; if it is missing or half there, the build backend's package discovery is not finding your source layout. The fix is in pyproject.toml, and the test that catches it is installing the wheel into an empty environment.
Should dist/ be committed or cleaned between builds?
Never commit it, and always clean it. A stale wheel from an earlier version sitting in dist/ is the classic way to upload the wrong artifact, because twine uploads everything it is given. Delete the directory in the build step and let CI produce it fresh.
What does twine check actually verify?
That the metadata is well formed and the long description will render on PyPI — nothing more. It will not tell you the package is importable or the entry point resolves. Treat it as a lint for your project page, and pair it with an install-and-run smoke test for everything it does not cover.
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.