Project Setup

Building Wheels and sdists for Python CLIs

Build wheels and source distributions for a Python CLI with the build tool, understand what each artifact contains, and verify they install before release.

Updated

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 build produces both a wheel and an sdist into dist/.
  • 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] in pyproject.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 ZIPunzip -l it 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.whlpy3 (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_EPOCH to 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 version in pyproject.toml, derive it from a git tag with hatch-vcs (or setuptools-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.