pyguides

Building Installable CLI Tools

When you build a Python CLI tool, there’s a moment when it works on your machine but you need to share it with others. You could ask them to download your script and run it with python, but that’s fragile and assumes they have Python configured correctly. The better approach is making your tool installable so it works like git or pip — a single command on the PATH, callable from anywhere.

This tutorial shows you how to do exactly that using pyproject.toml and Python’s entry points system.

How Entry Points Work

Python’s packaging system lets you define commands that get installed as standalone executables when your package is installed. The mechanism is straightforward: your pyproject.toml maps a command name to a function, and when someone installs your package, the installer creates a small wrapper script that calls that function.

The mapping happens in the [project.scripts] table inside pyproject.toml:

[project.scripts]
mycli = "mypackage.cli:main"

The left side (mycli) is the command name users type in their terminal. The right side (mypackage.cli:main) is the Python path to a callable — module mypackage.cli, function main. When pip installs your package, it generates a platform-appropriate launcher script that invokes mypackage.cli:main whenever someone types mycli.

This is part of the modern Python packaging standard (PEP 517/518), so it works consistently across operating systems and doesn’t require any shebang lines or manual script management.

Setting Up the Project Structure

A minimal installable CLI package needs only two things: a pyproject.toml and a package directory containing your code. Here’s the layout:

greeter/
├── pyproject.toml
└── src/
    └── greeter/
        └── __init__.py
        └── cli.py

The src/ layout (also called “src layout”) keeps your package separate from build configuration, which is the recommended approach for modern Python projects.

Create the directory structure:

mkdir -p greeter/src/greeter
touch greeter/src/greeter/__init__.py

The __init__.py can be empty — it just tells Python that greeter is a package.

Writing the CLI Logic

Create greeter/src/greeter/cli.py with argparse-based CLI logic:

"""greeter/cli.py — A simple greet command."""
import argparse


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Greet someone by name",
    )
    parser.add_argument("name", help="The name to greet")
    parser.add_argument("--upper", action="store_true", help="Output in uppercase")

    args = parser.parse_args()

    greeting = f"Hello, {args.name}!"
    if args.upper:
        greeting = greeting.upper()

    print(greeting)


if __name__ == "__main__":
    main()

The main() function is the entry point. Note that it takes no arguments — the wrapper script generated by pip invokes it with zero arguments, so any configuration must come from argparse.parse_args() reading sys.argv.

Writing pyproject.toml

Create greeter/pyproject.toml:

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "greeter"
version = "0.1.0"
requires-python = ">=3.8"
description = "A simple greet command"

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

The [build-system] section tells pip how to build your package. setuptools.build_meta is the standard backend.

The [project] section defines your package metadata. The name field is what appears on PyPI if you publish. The requires-python constraint ensures users have a compatible Python version.

The [project.scripts] section defines the installed command. It maps greeter (the command users type) to greeter.cli:main (the function that gets called). This is the entry point.

Installing and Testing

With the structure in place, install the package in development mode:

cd greeter
pip install -e .

The -e flag means “editable install” — your source files are linked rather than copied, so you can edit them without reinstalling. For sharing with others, they’d run pip install . or pip install your-package.tar.gz.

After installation, try the command:

greeter Alice
# output
Hello, Alice!

greeter Bob --upper
# output
HELLO, BOB!

The greeter command is now on PATH, just like any system tool. It works from any directory and accepts arguments just like a C program would.

Adding Third-Party Libraries

Your CLI can depend on packages beyond the standard library. Edit pyproject.toml:

[project]
name = "greeter"
version = "0.1.0"
requires-python = ">=3.8"
description = "A simple greet command"
dependencies = [
    "click>=8.0.0",
]

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

Now your CLI tool can use Click for more sophisticated argument handling while still being installable via pip.

Exporting Click or Typer Apps

If you’re using Click or Typer, export the CLI object directly rather than a plain function. With Click:

# greeter/cli.py
import click

@click.command()
@click.argument("name")
@click.option("--upper", is_flag=True, help="Uppercase output")
def main(name: str, upper: bool) -> None:
    """Greet someone by name."""
    greeting = f"Hello, {name}!"
    if upper:
        greeting = greeting.upper()
    click.echo(greeting)


if __name__ == "__main__":
    main()

And the entry point in pyproject.toml:

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

Click’s @click.command() decorator wraps your function into a command object that’s callable with no arguments — exactly what the entry point system expects.

Typer works similarly:

# greeter/cli.py
import typer

app = typer.Typer()

@app.command()
def main(name: str, upper: bool = False) -> None:
    """Greet someone by name."""
    greeting = f"Hello, {name}!"
    if upper:
        greeting = greeting.upper()
    print(greeting)


if __name__ == "__main__":
    app()

Then in pyproject.toml:

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

Typer’s app object is a Typer instance that implements the callable protocol needed by the entry point system. When the generated launcher calls greeter.cli:app, Typer handles the argument parsing and execution.

Common Problems

The function gets no arguments. Entry point functions are invoked with zero arguments by the generated wrapper. Don’t try to accept parameters in main() — use argparse to read them from the command line instead.

Namespace packages cause import failures. Always include an __init__.py in your package directory. Namespace packages (packages without __init__.py) can fail to import correctly when used as entry point targets.

Windows line endings break shebangs. When building wrapper scripts on POSIX systems, ensure your source files use \n line endings. The generated scripts use #!/usr/bin/env python3 internally, and \r\n can cause the interpreter lookup to fail.

Editable installs don’t update entry points. If you add a new command to [project.scripts] after installing with -e, you need to reinstall: pip install -e . again. Editable installs don’t automatically rebuild wrapper scripts.

What Gets Installed

When pip installs your package, it creates a launcher script in the user’s PATH. On Linux, that’s typically in ~/.local/bin/ or /usr/local/bin/. On macOS, it’s in the user site bin directory. On Windows, it’s in the Scripts directory within the Python installation.

The launcher script is tiny — it just imports your module and calls your function:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import sys
from greeter.cli import main
sys.exit(main())

You don’t see this file — it’s created and managed by pip. If you ever uninstall the package (pip uninstall greeter), pip removes the launcher script too.

Conclusion

Building installable CLI tools comes down to two files: a pyproject.toml that defines your entry points, and a callable function that your entry point references. When users install your package with pip, they get a real command on their PATH — no manual script management, no shebang lines, no path configuration.

The pattern scales from simple one-file tools to complex multi-command CLIs built on Click or Typer. Once you understand entry points, you have everything you need to distribute Python command-line tools that feel native to the systems where they’re installed.

See Also