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.

What is inside the two artifacts The contents of a built wheel: the package tree, the dist-info metadata directory, the entry points file and the record of installed files. What is inside the two artifacts mytool-1.4.0-py3-none-any.whl a zip with a strict layout mytool/ the importable package mytool-1.4.0.dist-info/ METADATA, WHEEL, RECORD entry_points.txt where console scripts come from licenses/ shipped alongside the metadata py3-none-any means pure Python, any platform entry_points.txt is generated from pyproject.toml unzip -l is the fastest way to audit a build A wheel is just a zip with rules — inspecting one is the quickest way to catch missing package data.

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.

A clean build, start to finish A terminal session running python -m build, showing the sdist and wheel being written into the dist directory. A clean build, start to finish bash $ python -m build * Creating isolated environment: venv+pip... * Building sdist... * Building wheel from sdist... Successfully built mytool-1.4.0.tar.gz and mytool-1.4.0-py3-none-any.whl # both artifacts land in dist/ The isolated environment is the point: the build never silently uses a package you happen to have installed.
$ 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:

Verifying a build before it ships A verification chain: check the metadata with twine, install the wheel into a throwaway environment, and run the installed command once. Verifying a build before it ships twine check metadata renders Fresh venv nothing else installed Install the wheel from dist/, not the repo Run --version the shim works passes create smoke test Installing from dist/ into an empty environment is the only test that catches a missing package or module.
$ 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.

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.