Server

Packaging a Python CLI

By Prabath Thalangama· October 24, 2026· 3 min read
#python#packaging#cli

Introduction

A single .py file is fine until other people need it. Packaging gives you a versioned, installable command with declared dependencies.

Project layout

mytool/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── mytool/
│       ├── __init__.py
│       ├── __main__.py       # enables `python -m mytool`
│       └── cli.py            # the entry point function
└── tests/
    └── test_cli.py

The src layout (package under src/) prevents accidentally importing the package from the working directory instead of the installed version — tests then exercise what users actually get.

pyproject.toml

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

[project]
name = "mytool"
version = "0.1.0"
description = "Does the thing"
readme = "README.md"
requires-python = ">=3.10"
license = { file = "LICENSE" }
authors = [{ name = "Your Name", email = "[email protected]" }]
dependencies = [
    "click>=8.1",
    "httpx>=0.27",
]

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

[project.scripts]
mytool = "mytool.cli:main"       # the console_scripts entry point

[project.urls]
Homepage = "https://github.com/you/mytool"

hatchling, flit-core, setuptools, and pdm-backend are all fine backends. hatchling is a good modern default.

The entry point

# src/mytool/cli.py
import click

@click.command()
@click.argument("target")
@click.option("--verbose", "-v", is_flag=True)
def main(target: str, verbose: bool) -> None:
    """Do the thing to TARGET."""
    if verbose:
        click.echo(f"processing {target}")
    ...

if __name__ == "__main__":
    main()
# src/mytool/__main__.py
from mytool.cli import main

if __name__ == "__main__":
    main()

[project.scripts] generates a wrapper on install so mytool is on PATH. argparse works equally well if you'd rather not add click (see argparse patterns for CLI tools).

Build and test locally

uv build            # or: python -m build   -> dist/mytool-0.1.0-py3-none-any.whl + .tar.gz
uv pip install dist/mytool-0.1.0-py3-none-any.whl
mytool --help

# editable install while developing
uv pip install -e ".[dev]"

Versioning

Either bump version by hand, or derive it from git tags:

[tool.hatch.version]
source = "vcs"          # version from `git describe` / the latest tag

Pair with the release automation from Semantic versioning in practice.

Publishing to PyPI

# Get an API token from pypi.org, store it (uv reads UV_PUBLISH_TOKEN / ~/.pypirc)
uv publish            # or: twine upload dist/*
# Test first against TestPyPI:
uv publish --publish-url https://test.pypi.org/legacy/

Better: a GitHub Actions job with PyPI Trusted Publishing (OIDC, no token to leak — see secrets in CI).

How users should install

pipx install mytool        # isolated env, command on PATH — the right way for CLIs
uv tool install mytool     # same idea, faster
pip install mytool         # into the current env — fine for library-style use

Tell users pipx/uv tool in your README; a CLI in a shared env causes dependency conflicts.

Verification and troubleshooting

python -m build && twine check dist/*
pip install dist/*.whl && mytool --help
unzip -l dist/*.whl        # is your package actually in there?
  • mytool: command not found after install — the [project.scripts] name is wrong, or ~/.local/bin isn't on PATH (pip user install), or you're in a different venv.
  • Wheel is empty / missing modules — src layout not declared. With hatchling, [tool.hatch.build.targets.wheel] packages = ["src/mytool"] if auto-detection fails.
  • ModuleNotFoundError for a dep at runtime — it's in [dev] extras or not declared at all. Move it to dependencies.
  • Import works in tests but not after install — tests imported from ./ (no src layout) and picked up the source tree. Adopt src layout and pip install -e . for dev.
  • twine check warns about the long description — README content type; readme = "README.md" handles it with modern backends.
  • Version is 0.0.0 or 0.1.dev... — VCS versioning with no tags. git tag v0.1.0.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.