Jake Groszewski

Blog

Python Packaging in 2026: A Practical Guide to Shipping CLI Tools with pyproject.toml and Entry Points

You have a script. It works. Your teammates keep asking you to Slack it to them, and you keep saying "just clone the repo and run python utils/fetch_data.py." That workflow is fine until it isn't, and usually it stops being fine right around the time someone else modifies the file without telling you, or runs it with a different Python version, or doesn't have the dependencies installed.

The fix isn't complicated. Python packaging has matured enough that you can take that utility script and turn it into a proper installable CLI tool in an afternoon. Once it's packaged correctly, your team installs it with a single pip install and runs it by name, no python -m required, no cloned repos floating around.

This guide covers the modern approach: pyproject.toml as your single configuration file, setuptools for building, and console_scripts entry points to expose the command. The legacy setup.py era is effectively over for new projects.

Why pyproject.toml and Not setup.py

PEP 517 and PEP 518 established pyproject.toml as the standard way to define build system requirements, and by 2026, most packaging guides treat setup.py as a compatibility shim rather than a starting point. The Python Packaging and CLI Tools for Data Engineers guide from Drive Data Science is explicit about this: start with argparse for simple scripts, move to Click for more complex interfaces, and manage everything through pyproject.toml rather than the legacy file format.

Practically, pyproject.toml keeps all your metadata, dependencies, and build config in one place. You don't need a setup.cfg, a MANIFEST.in, and a setup.py all doing slightly different things. One file.

The Example: A Data Fetch Utility

We'll package a small tool called datafetch that pulls CSV data from a URL and prints summary statistics. Realistic enough to be useful, simple enough to focus on the packaging rather than the logic.

Project structure:

datafetch/
├── pyproject.toml
├── README.md
├── src/
│   └── datafetch/
│       ├── __init__.py
│       └── cli.py

Using src/ layout is optional but recommended. It prevents the package from being importable directly from the project root during development, which catches a class of import bugs before you ship.

The CLI Code

src/datafetch/cli.py:

import argparse
import sys
import urllib.request
import csv
import io

def fetch_and_summarize(url: str) -> None:
    with urllib.request.urlopen(url) as response:
        content = response.read().decode("utf-8")
    reader = csv.DictReader(io.StringIO(content))
    rows = list(reader)
    if not rows:
        print("No data found.")
        return
    print(f"Rows: {len(rows)}")
    print(f"Columns: {', '.join(rows[0].keys())}")

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Fetch a CSV from a URL and print summary stats."
    )
    parser.add_argument("url", help="The URL of the CSV file to fetch.")
    args = parser.parse_args()
    try:
        fetch_and_summarize(args.url)
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

Nothing exotic here. The main() function is the entry point that packaging will hook into.

Configuring pyproject.toml

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "datafetch"
version = "0.1.0"
description = "Fetch CSV data from a URL and print summary stats."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
    { name = "Jake Groszewski", email = "jake@example.com" }
]

[project.scripts]
datafetch = "datafetch.cli:main"

[tool.setuptools.packages.find]
where = ["src"]

The critical section is [project.scripts]. This is where console_scripts entry points live in pyproject.toml syntax. After install, pip writes a small wrapper script to the user's PATH so datafetch runs directly, pointing at the main() function in datafetch.cli. Your users never need to know what file it came from.

If you're adding Click-based commands with subcommands, the entry point works exactly the same way: just point it at whatever callable is the top-level group or command.

Building the Wheel

Install the build frontend:

pip install build

Then from the project root:

python -m build

This produces two artifacts in dist/:

dist/
├── datafetch-0.1.0-py3-none-any.whl
└── datafetch-0.1.0.tar.gz

The .whl file is what you actually want for distribution. Wheels install faster than source distributions because there's no build step on the recipient's machine. Share the wheel directly or upload it to a package index.

Installing and Testing Locally

Before publishing anywhere, verify the install works:

pip install dist/datafetch-0.1.0-py3-none-any.whl
datafetch https://people.sc.fsu.edu/~jburkardt/data/csv/airtravel.csv

If that prints row and column info, the entry point is wired correctly. If you get a command not found, check that your Python environment's bin directory is on your PATH.

For iterative development, install in editable mode instead:

pip install -e .

Editable installs let you modify the source files without reinstalling after every change, which is how you actually want to work during development.

Publishing: Private Index vs. PyPI

For internal team tools, you almost never want to publish to PyPI. The better approach is to host a private package index using something like Pypiserver, Gemfury, or an artifact registry in your cloud provider (AWS CodeArtifact, Google Artifact Registry, etc.). Upload your wheel once:

pip install twine
twine upload --repository-url https://your-private-index.example.com/simple/ dist/*

Then your teammates install it like any other package:

pip install --index-url https://your-private-index.example.com/simple/ datafetch

Versioning becomes trivial: bump version in pyproject.toml, rebuild, upload. No more "which version of the script do you have?" conversations.

For public tools, uploading to PyPI is the same twine upload command without the --repository-url flag, once you've created an account and configured an API token.

When to Add Click

The argparse approach above works fine for tools with a single command and a handful of flags. Once you need subcommands (datafetch pull, datafetch summarize, datafetch upload), Click's decorator syntax is substantially cleaner. The packaging configuration doesn't change at all: you still point the entry point at a single callable, which in Click's case is the root group.

import click

@click.group()
def cli():
    pass

@cli.command()
@click.argument("url")
def pull(url):
    """Pull CSV data from URL."""
    ...

And in pyproject.toml:

[project.scripts]
datafetch = "datafetch.cli:cli"

Add click to your dependencies:

[project]
dependencies = ["click>=8.0"]

That's the main migration path. Start with argparse, reach for Click when argument complexity starts causing pain.

A Note on the Broader Context

CLI tools have become more valuable as terminal-centric workflows have grown. Developers using tools like GitHub CLI, gh, or any of the AI coding agents that operate from the terminal (Pinggy's roundup of CLI-based AI coding agents covers this well) benefit from having their own composable utilities in the same environment. A well-packaged datafetch or report-gen command fits naturally alongside those tools in a shell alias or a Makefile target.

The Masai School's 2026 beginner projects list includes terminal-based utilities as explicitly recommended starting points, which reflects how mainstream this pattern has become. Learning to package a CLI tool is no longer advanced Python. It's just good practice.

The Minimal Checklist

Before you publish any CLI tool, run through this:

  • pyproject.toml has a [project.scripts] entry pointing at a real callable
  • The callable is importable (the package is properly structured)
  • python -m build produces a .whl with no errors
  • pip install dist/*.whl in a fresh virtual environment installs and the command runs
  • Version is pinned and meaningful (start at 0.1.0, not 1.0.0)
  • Dependencies are listed in [project] dependencies, not hardcoded assumptions

If all six pass, you have a shippable package. Everything else, documentation, CI, a changelog, a proper README, is worth adding but not a prerequisite for usefulness.