Project Setup

Packaging Python CLIs for Distribution

Turn a Python CLI into an installable package: define entry points in pyproject.toml, build wheels and sdists, install with pipx, and publish to PyPI.

Updated

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] in pyproject.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. pipx for end users who just want the command; PyPI + pip/uv for public distribution; a private index for internal tools.
  • Build once with python -m build, verify with twine check and a smoke install, then publish.
  • Read on for a minimal end-to-end example, then follow the three deep guides linked at the bottom.
From pyproject.toml to installable tool From pyproject.toml to an installable tool pyproject.toml [project.scripts] python -m build build step your_cli.whl wheel — installable your_cli.tar.gz sdist — source PyPI pipx install pip install one build produces the artifacts; every channel installs the same wheel

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 in site-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.whl that 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.

  1. pipx (end users who want the command, not the library). pipx install drops the CLI into its own isolated virtual environment and links the command onto the PATH, 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.
  2. PyPI + pip/uv (public distribution). Upload to the Python Package Index and anyone can pip install your-cli or add it as a dependency. This is table stakes for an open-source tool; it is what makes pipx install your-cli resolve at all. See publishing a Python CLI to PyPI.
  3. A private/internal index. For company-internal tools, run or rent a package index (Artifactory, a self-hosted devpi, GitHub/GitLab package registries) and point pip install --index-url or uv at 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_CLI and greet-cli collide). 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 like license = "MIT".
  • requires-python — the interpreter floor. Set it honestly; pip uses 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:

Production notes

  • Use a src/ layout. Putting your package under src/ 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].requires should 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.