A script that works on your machine is not a tool other people can use. To hand your CLI to a
teammate, a CI job, or a stranger on PyPI, you have to turn it into an installable package: a
single artifact that declares its command, its dependencies, and how to expose it on the
PATH. This overview walks the whole path — from a pyproject.toml that names your command,
through building a wheel, to the three ways people will actually install it — and then routes
you to the deep guides for each step.
TL;DR
- A "distributable" CLI is a package with a console entry point. Declare it under
[project.scripts]inpyproject.toml; that generates the launcher on install. - Two build artifacts. A wheel (
.whl) is the pre-built install; an sdist (.tar.gz) is the source fallback used to build a wheel when none fits. - Three delivery routes.
pipxfor end users who just want the command; PyPI +pip/uvfor public distribution; a private index for internal tools. - Build once with
python -m build, verify withtwine checkand a smoke install, then publish. - Read on for a minimal end-to-end example, then follow the three deep guides linked at the bottom.
What "distributable" actually means
The difference between a script and a distributable CLI is one table in pyproject.toml. A
console entry point maps a command name to a callable, and the installer writes a small
launcher script into the environment's bin/ (or Scripts\ on Windows) directory that calls
it. Once the package is installed, typing the command name Just Works — no python path/to/script.py, no fiddling with PYTHONPATH.
[project]
name = "greet-cli"
version = "0.1.0"
description = "A tiny greeting CLI"
requires-python = ">=3.11"
dependencies = ["click>=8.1"]
[project.scripts]
greet = "greet_cli.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
That greet = "greet_cli.__main__:main" line is the whole trick: the name left of = becomes
the shell command; the value is import.path:function. The mechanics of choosing that target
(and why __main__:main is a good default) are covered in
best practices for Python CLI entry points.
The matching source module is minimal:
# src/greet_cli/__main__.py
import click
@click.command()
@click.argument("name")
def main(name: str) -> None:
"""Say hello to NAME."""
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
main()
Wheel vs sdist at a glance
A build produces up to two artifacts, and it helps to know what each one is for before you ever run the build.
- Wheel (
.whl) — a ZIP with a specific name layout, already laid out the way it lands insite-packages. Installing it is basically an unzip, so it is fast and needs no build step on the user's machine. Pure-Python CLIs ship a single...-py3-none-any.whlthat works everywhere. - Source distribution / sdist (
.tar.gz) — your source tree plus metadata. Installers use it when no compatible wheel exists, building a wheel locally first. It is also the archival, auditable form of a release.
For a pure-Python CLI you publish both: the wheel is what nearly everyone installs, the sdist
is the fallback and the thing packagers (Linux distros, conda-forge) build from. The full
mechanics — the dist/ layout, inspecting a wheel, including package data — live in
building wheels and sdists for Python CLIs.
The three delivery routes
How your CLI reaches users shapes how you package and document it. There are three common routes, and most real tools use more than one.
pipx(end users who want the command, not the library).pipx installdrops the CLI into its own isolated virtual environment and links the command onto thePATH, so tools never fight over dependency versions. This is the right recommendation in your README for anyone who just wants to run your tool. See installing and distributing CLIs with pipx.- PyPI +
pip/uv(public distribution). Upload to the Python Package Index and anyone canpip install your-clior add it as a dependency. This is table stakes for an open-source tool; it is what makespipx install your-cliresolve at all. See publishing a Python CLI to PyPI. - A private/internal index. For company-internal tools, run or rent a package index
(Artifactory, a self-hosted
devpi, GitHub/GitLab package registries) and pointpip install --index-urloruvat it. The build and entry-point mechanics are identical; only the upload target and credentials change.
A fourth route worth knowing: you do not need an index at all for a quick handoff. A freshly
built wheel installs straight from disk with pipx install ./dist/your_cli-0.1.0-py3-none-any.whl,
which is perfect for sharing a pre-release with a colleague over Slack.
A minimal end-to-end example
Here is the whole loop, from a project directory to a working global command, with nothing
published anywhere. Assume the pyproject.toml and src/greet_cli/__main__.py from above.
$ pip install build # or: uv tool install build
$ python -m build # produces dist/*.whl and dist/*.tar.gz
$ ls dist/
greet_cli-0.1.0-py3-none-any.whl greet_cli-0.1.0.tar.gz
$ pipx install ./dist/greet_cli-0.1.0-py3-none-any.whl
installed package greet-cli 0.1.0, installed using Python 3.12.3
These apps are now globally available
- greet
$ greet World
Hello, World!
Four commands and the tool is on your PATH, isolated from every other Python tool you have
installed. Swap the last two steps for a twine upload and users run pipx install greet-cli
instead of pointing at a local file — same wheel, same entry point, public reach.
Versioning and metadata for a good listing
Packaging is not only mechanics; the metadata in [project] is your product page on PyPI and
the contract users depend on. Get these right before your first upload, because names and
released version numbers are effectively permanent:
name— must be globally unique on PyPI and is normalized (case- and separator- insensitive:Greet_CLIandgreet-clicollide). Check availability before you commit to it.version— follow semantic versioning and never reuse a number; PyPI rejects re-uploads of an existing version. Our guide on managing CLI versioning and changelogs covers keeping this in sync with a changelog.description,readme,license— the summary line, the long description rendered on the project page, and an SPDX license expression likelicense = "MIT".requires-python— the interpreter floor. Set it honestly;pipuses it to refuse installs on unsupported Pythons instead of failing at runtime.[project.urls]— Homepage, Source, and Changelog links that show up in the PyPI sidebar and build trust.
[project]
name = "greet-cli"
version = "0.2.0"
description = "A friendly greeting CLI"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
authors = [{ name = "Ada Lovelace", email = "ada@example.com" }]
keywords = ["cli", "greeting"]
[project.urls]
Homepage = "https://github.com/ada/greet-cli"
Source = "https://github.com/ada/greet-cli"
Changelog = "https://github.com/ada/greet-cli/blob/main/CHANGELOG.md"
Where to go next
Each step above has a dedicated deep guide. Read them in order the first time; jump straight to one when you already know the rest:
- Building wheels and sdists for Python CLIs —
python -m build, what each artifact contains, and smoke-testing before you release. - Installing and distributing CLIs with pipx —
the isolation model,
pipx install/run/inject, and installing from a wheel, git, or PyPI. - Publishing a Python CLI to PyPI —
names, TestPyPI, API tokens,
twine upload, and trusted publishing from CI.
Production notes
- Use a
src/layout. Putting your package undersrc/stops the build from accidentally importing your working tree instead of the installed package, which is exactly the kind of bug that only appears after you ship. Most scaffolds, including CLI project scaffolding with Cookiecutter, default to it. - Pin your build backend, not just your deps.
[build-system].requiresshould name a backend (hatchling,setuptools,flit-core) — backend defaults drift across versions, and a floating backend is the classic cause of a build that worked last month and not today. - Decide dependency strategy up front. A library pins loosely (
click>=8.1) so it composes in others' environments; an application distributed via pipx can afford tighter pins because it lives in its own isolated venv. See uv for Python CLI dependency management and Poetry workflows for CLI development for two ways to manage that lockfile. - Test the artifact, not the repo. A green test suite against your source tree does not prove the wheel installs and its entry point runs. Always install the built wheel into a throwaway environment and invoke the command once before tagging a release.
Related
- Project Setup & Dependency Management — the parent track: environments, dependencies, and release hygiene.
- uv for Python CLI dependency management — the fast resolver and installer that also builds and publishes.
- Poetry workflows for CLI development — an all-in-one alternative that manages deps, builds, and uploads.
- Best practices for Python CLI entry points — how the command name maps to your code.
- Managing CLI versioning and changelogs — keep versions and release notes honest before you publish.