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 and sdist, side by side A comparison of the wheel and source distribution formats across what they contain, whether a build step runs at install time, and who consumes each. Wheel and sdist, side by side Format Wheel (.whl) Sdist (.tar.gz) Contains the installable tree the source plus metadata Build at install time none — just unpack runs the build backend Install speed fast slower, needs a toolchain Who wants it every end user distros and auditors Publish it? always yes — it is the fallback Publish both: the wheel is what people install, the sdist is what lets anyone rebuild it.
  • 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.

Three ways your CLI reaches a user Three delivery routes for a Python command line tool: PyPI with pipx or uv tool, a direct wheel or git install, and a single-file executable. Three ways your CLI reaches a user A built wheel the one artifact behind every route PyPI pipx install mytool — the default route Direct install a URL, a git ref, an internal index Frozen binary PyInstaller or shiv, no Python needed the same wheel feeds all three — nothing about the code changes per route Start with PyPI; reach for a frozen binary only when the audience genuinely has no Python.
  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.

Choosing and configuring a build backend

The backend is the component that turns your source tree into a wheel. For a pure-Python CLI, any of the current options works; what matters is declaring one explicitly and knowing how it finds your package.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Hatchling is a good default: fast, no configuration for a standard src/ layout, and it handles package data sensibly. setuptools remains everywhere and is the right answer for a project that already uses it. uv_build is worth considering if you already use uv. poetry-core comes with Poetry. None of these changes what users receive — the wheel is the wheel.

The one thing worth checking is discovery. With a src/ layout most backends find src/mytool/ automatically; if yours does not, say so explicitly rather than guessing:

[tool.hatch.build.targets.wheel]
packages = ["src/mytool"]

Non-code files need declaring too. A template, a schema, a bundled completion script — none of it ships unless the backend is told:

[tool.hatch.build.targets.wheel.force-include]
"src/mytool/templates" = "mytool/templates"

And read it back at run time through importlib.resources, never by computing a path relative to __file__:

from importlib.resources import files

template = files("mytool.templates").joinpath("report.html").read_text(encoding="utf-8")

The __file__ approach works from a source checkout and breaks inside a zipapp or a frozen binary, which is precisely when it is hardest to debug.

Metadata that makes a good listing

The metadata is the tool's shop window and its compatibility contract. Six fields do the work:

[project]
name = "mytool"
version = "1.4.0"
description = "Sync directories to object storage, quickly."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
keywords = ["cli", "sync", "s3"]
classifiers = [
  "Environment :: Console",
  "Intended Audience :: Developers",
  "Programming Language :: Python :: 3 :: Only",
]

[project.urls]
Homepage = "https://example.com/mytool"
Documentation = "https://example.com/mytool/docs"
Changelog = "https://github.com/you/mytool/blob/main/CHANGELOG.md"
Issues = "https://github.com/you/mytool/issues"

requires-python is the one that prevents real support tickets: it stops an install on an interpreter your code cannot run on, with a clear message, instead of failing at import time with a syntax error. readme becomes the project page — and twine check will tell you if it fails to render before anyone sees it.

The Changelog URL is worth including for a CLI specifically. Users evaluating an upgrade want to know whether flags changed, and a link straight to that answer is more useful than a homepage.

Reproducible builds in CI

Build once, verify, then publish the exact artifacts you verified:

  build:
    steps:
      - uses: actions/checkout@v4
      - run: pipx run build                 # isolated build, no ambient packages
      - run: pipx run twine check dist/*
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

  smoke:
    needs: build
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - run: python -m venv /tmp/smoke
      - run: /tmp/smoke/bin/pip install dist/*.whl
      - run: /tmp/smoke/bin/mytool --version

Two properties matter. The build is isolatedpython -m build creates a clean environment for the backend, so a package that happens to be installed on the runner cannot mask a missing declaration. And the smoke job installs the built artifact, not the source tree, which is the only test that catches a missing package, an unlisted dependency or a broken entry point.

Publish from a third job that depends on both, triggered by a tag, using trusted publishing so no long-lived token exists in the repository.

Writing install instructions users can follow

The README line that tells people how to install your tool decides how it behaves on their machine, so it is worth choosing deliberately.

Lead with the isolated install:

pipx install mytool          # or: uv tool install mytool

That gives the tool its own environment with only its command on PATH, which makes a dependency conflict with another tool impossible. pip install mytool puts your dependencies into whichever environment happens to be active — often the system interpreter — and is the arrangement behind most "your tool broke my other tool" reports.

Three more lines are worth including:

uvx mytool --help            # try it without installing anything
pipx upgrade mytool          # upgrade later
pipx uninstall mytool        # remove it cleanly

The first lowers the barrier to evaluation to nearly zero, and the last two answer questions people otherwise open an issue about. If your tool has optional extras, show the syntax explicitly — pipx install "mytool[aws]" — because the quoting trips people up in zsh, where an unquoted pair of square brackets is read as a glob pattern rather than as part of the requirement.

For teams distributing internally, add the private-index form and the git form, since both come up:

pipx install --index-url https://pypi.internal/simple mytool
pipx install "git+https://github.com/you/mytool@v1.4.0"

Frequently asked questions

Do I need to build a wheel per platform?

Not for a pure-Python CLI. One py3-none-any wheel covers every platform and interpreter that satisfies requires-python. Platform-specific wheels only enter the picture if you ship compiled extensions — at which point you need a build matrix and cibuildwheel, which is a substantially larger undertaking.

What is the difference between python -m build and pip wheel?

build is the standards-compliant front end: it creates an isolated environment, invokes your declared backend, and produces both an sdist and a wheel. pip wheel builds a wheel for installation purposes and pulls in dependencies too. For publishing, use build.

How do I check what is actually inside the wheel?

unzip -l dist/mytool-1.4.0-py3-none-any.whl lists every file in a second, and it is the fastest way to spot a missing package or a template that never made it in. python -m zipfile -l does the same without needing unzip installed.

Should the sdist contain the tests?

Including them is conventional and harmless: it lets distribution packagers run your suite while building. What must not be in either artifact is anything secret, large or generated — check the sdist contents once with tar tzf dist/*.tar.gz and add exclusions if something surprising appears.

Can I ship a tool that has no dependencies at all?

Yes, and it is a genuinely nice property for a CLI — the install cannot conflict with anything. An argparse-based tool with no third-party imports produces a wheel that installs anywhere in under a second. Whether that is worth the extra code depends on your audience; for developer tools it usually is not, for bootstrap and installer scripts it usually is.

Does the entry point name have to match the package name?

No. The key in [project.scripts] is the command users type, and the value is where it lives, so a package called mycompany-deploy-tools can install a command called deploy. Choose the command name for typing and the package name for the index, and remember the command name is a claim on everyone's PATH — make it distinctive.