Skip to main content

Packaging Python CLI Tools: Setup.py & Distribution

Before publishing to PyPI, you need to package your CLI tool so users can install it easily. Packaging converts your source code into a distributable format—a wheel—that pip installs directly. This article covers the modern packaging approach with pyproject.toml and setuptools.

Project Structure for Packaging

Organize your code for packaging before you start:

mytool/
├── pyproject.toml
├── setup.py
├── setup.cfg
├── README.md
├── LICENSE
├── mytool/
│ ├── __init__.py
│ ├── cli.py
│ ├── utils.py
│ └── config.py
└── tests/
├── test_cli.py
└── test_utils.py

The mytool/ directory is your package. setup.py and pyproject.toml define how to build and install it.

Modern Packaging with pyproject.toml

The recommended approach uses pyproject.toml instead of setup.py:

[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mytool"
version = "1.0.0"
description = "A professional CLI tool for developers"
readme = "README.md"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "[email protected]"}]
requires-python = ">=3.7"
dependencies = [
"typer[all]>=0.9.0",
"rich>=13.0.0",
"pyyaml>=6.0",
]

[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
]

[project.scripts]
mytool = "mytool.cli:app"

[project.urls]
Homepage = "https://github.com/yourusername/mytool"
Documentation = "https://mytool.readthedocs.io"
Repository = "https://github.com/yourusername/mytool"
Issues = "https://github.com/yourusername/mytool/issues"

The [project.scripts] section creates a command-line entry point. When installed, users run mytool directly from the terminal instead of python -m mytool.cli.

Creating setup.py (Legacy Compatibility)

Some projects still use setup.py for compatibility:

from setuptools import setup, find_packages

setup(
name="mytool",
version="1.0.0",
description="A professional CLI tool for developers",
author="Your Name",
author_email="[email protected]",
url="https://github.com/yourusername/mytool",
packages=find_packages(),
install_requires=[
"typer[all]>=0.9.0",
"rich>=13.0.0",
"pyyaml>=6.0",
],
entry_points={
"console_scripts": [
"mytool=mytool.cli:app",
],
},
python_requires=">=3.7",
license="MIT",
)

entry_points defines command-line commands. install_requires lists runtime dependencies. packages=find_packages() automatically finds all Python packages in your project.

CLI Entry Points Pattern

Structure your CLI for packaging:

# mytool/cli.py
import typer

app = typer.Typer()

@app.command()
def main():
"""Main CLI command."""
typer.echo("Hello from mytool!")

if __name__ == "__main__":
app()

Your pyproject.toml or setup.py points to mytool.cli:app. When installed, the entry point calls app(), which runs the CLI.

Building Wheels and Distributions

Build your package locally:

# Install build tools
pip install build

# Build wheel and source distribution
python -m build

# Output: dist/mytool-1.0.0-py3-none-any.whl
# dist/mytool-1.0.0.tar.gz

This creates two files:

  • .whl (wheel) — a binary distribution, fast to install.
  • .tar.gz (source) — complete source code, users can build locally.

Version Management

Define version in one place:

# mytool/__init__.py
__version__ = "1.0.0"

Reference it in pyproject.toml:

[project]
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "mytool.__version__"}

This ensures version stays in sync everywhere—no manual duplication.

Dependencies and Requirements

Specify dependencies carefully:

[project]
dependencies = [
"typer[all]>=0.9.0", # Minimum version, [all] includes optional deps
"rich>=13.0.0", # Pin major version
"pyyaml>=6.0,<7.0", # Allow patch upgrades, block major
]

[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"black>=22.0",
"mypy>=0.990",
]

Use >= for minimum versions and < to avoid breaking changes. Optional dependencies are installed with pip install mytool[dev].

Comparison Table: Packaging Approaches

FilePurposeModernLegacyRecommended
pyproject.tomlBuild configYesYes
setup.pyLegacy configYesOptional
setup.cfgConfig optionsPartialYesRare
MANIFEST.inNon-code filesOptionalYesSometimes

Key Takeaways

  • Use pyproject.toml with [build-system] and [project] for modern packaging.
  • Define entry points in [project.scripts] so users run your CLI directly without python -m.
  • Use python -m build to create wheels and source distributions.
  • Pin dependency versions carefully: major versions with <, allow patches with >=.
  • Store version in code (__init__.py) and reference it dynamically in pyproject.toml.
  • Include optional dependencies for developers ([project.optional-dependencies]).

Frequently Asked Questions

What's the difference between a wheel and a source distribution?

A wheel (.whl) is pre-built and installs instantly. A source distribution (.tar.gz) is raw code; pip builds it on the user's machine. Always provide both.

How do I include non-Python files (config templates, data)?

Use MANIFEST.in to specify files, or in pyproject.toml with [tool.setuptools.package-data]. For example, *.yaml files in your package directory.

Can I have multiple entry points for different commands?

Yes. Add multiple entries to [project.scripts]:

[project.scripts]
mytool = "mytool.cli:app"
mytool-admin = "mytool.admin:admin_app"

How do I test the package locally before publishing?

Install it in development mode: pip install -e . from your project directory. This installs the package while letting you edit code without reinstalling.

What Python versions should I support?

Declare in requires-python = ">=3.7". Test with multiple versions using tox or GitHub Actions. Supporting 3.7+ covers most production systems.

Further Reading