Packaging a Python 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 foundafter install — the[project.scripts]name is wrong, or~/.local/binisn't onPATH(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. ModuleNotFoundErrorfor a dep at runtime — it's in[dev]extras or not declared at all. Move it todependencies.- Import works in tests but not after install — tests imported from
./(no src layout) and picked up the source tree. Adopt src layout andpip install -e .for dev. twine checkwarns about the long description — README content type;readme = "README.md"handles it with modern backends.- Version is
0.0.0or0.1.dev...— VCS versioning with no tags.git tag v0.1.0.
Related tools and reading¶
- On-site: Linux commands reference.
- Related posts: virtualenv vs venv vs uv, argparse patterns for CLI tools.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
asyncio for I/O-Bound Scripts
Async Python shines for hundreds of concurrent network calls and nothing else. Here's a correct pattern for a bulk-fetch script, with concurrency limits and error handling.
October 24, 2026Python Type Hints That Actually Pay Off
Type hints earn their keep at function boundaries and data models. Here's a pragmatic subset, a CI setup, and how to adopt them on an untyped codebase.
October 24, 2026Profiling Python Code
Don't guess at what's slow — measure. cProfile for a script, py-spy for a live process, memray for memory. Here's how to run each and read the output.
October 24, 2026