Home/Blog/Building Python CLI Tools: Click, Typer, and argparse for Distributable Commands
Software Engineering

Building Python CLI Tools: Click, Typer, and argparse for Distributable Commands

Build professional Python CLI tools with Click, Typer, and argparse. Learn entry points, packaging, testing, and best practices for distributable command-line applications.

By InventiveHQ Team

Command-line interfaces (CLIs) are how users interact with your Python tools. Building professional CLIs requires proper argument parsing, helpful error messages, and correct packaging. This guide covers everything from choosing a framework to distributing your CLI.

Choosing a CLI Framework

┌─────────────────────────────────────────────────────────────┐
│                    CLI Framework Decision                    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  Need external dependencies?                                 │
│  ├── No → argparse (built-in)                               │
│  └── Yes → Continue...                                       │
│                                                              │
│  Using type hints and modern Python?                        │
│  ├── Yes → Typer (modern, automatic)                        │
│  └── No → Click (mature, flexible)                          │
│                                                              │
│  Note: Typer is built on Click, so Click knowledge applies  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Framework Comparison

FeatureargparseClickTyper
External dependencyNoYesYes
Type hintsManualManualAutomatic
SubcommandsManualEasyEasy
Auto-generated helpBasicRichRich
Shell completionNoYesYes
Testing utilitiesNoYesYes
Learning curveMediumMediumLow

argparse: The Standard Library

argparse is built into Python—no dependencies needed.

Basic CLI

# src/my_package/cli.py
import argparse
import sys

def main():
    parser = argparse.ArgumentParser(
        description="Process files with optional transformations",
        epilog="Example: my-tool input.txt -o output.txt --verbose"
    )

    # Positional argument (required)
    parser.add_argument(
        "input",
        help="Input file to process"
    )

    # Optional argument with short flag
    parser.add_argument(
        "-o", "--output",
        help="Output file (default: stdout)",
        default="-"
    )

    # Boolean flag
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output"
    )

    # Argument with choices
    parser.add_argument(
        "--format",
        choices=["json", "csv", "text"],
        default="text",
        help="Output format"
    )

    args = parser.parse_args()

    # Use the arguments
    if args.verbose:
        print(f"Processing {args.input}")

    # Your logic here
    process(args.input, args.output, args.format)

    return 0

if __name__ == "__main__":
    sys.exit(main())

Subcommands with argparse

# src/my_package/cli.py
import argparse

def cmd_create(args):
    print(f"Creating {args.name}")

def cmd_delete(args):
    print(f"Deleting {args.name}")

def cmd_list(args):
    print("Listing all items")

def main():
    parser = argparse.ArgumentParser(description="Manage items")
    subparsers = parser.add_subparsers(dest="command", required=True)

    # Create command
    create_parser = subparsers.add_parser("create", help="Create an item")
    create_parser.add_argument("name", help="Item name")
    create_parser.set_defaults(func=cmd_create)

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete an item")
    delete_parser.add_argument("name", help="Item name")
    delete_parser.add_argument("--force", action="store_true")
    delete_parser.set_defaults(func=cmd_delete)

    # List command
    list_parser = subparsers.add_parser("list", help="List all items")
    list_parser.set_defaults(func=cmd_list)

    args = parser.parse_args()
    args.func(args)

if __name__ == "__main__":
    main()

Click: Powerful and Mature

Click uses decorators for a clean, composable CLI definition.

Installation

pip install click

Basic CLI

# src/my_package/cli.py
import click

@click.command()
@click.argument("input", type=click.Path(exists=True))
@click.option("-o", "--output", default="-", help="Output file")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.option(
    "--format",
    type=click.Choice(["json", "csv", "text"]),
    default="text",
    help="Output format"
)
def main(input, output, verbose, format):
    """Process files with optional transformations.

    INPUT is the file to process.
    """
    if verbose:
        click.echo(f"Processing {input}")

    # Your logic here
    result = process(input, format)

    if output == "-":
        click.echo(result)
    else:
        with open(output, "w") as f:
            f.write(result)

if __name__ == "__main__":
    main()

Click Groups (Subcommands)

# src/my_package/cli.py
import click

@click.group()
@click.option("--debug/--no-debug", default=False)
@click.pass_context
def cli(ctx, debug):
    """My awesome CLI tool."""
    ctx.ensure_object(dict)
    ctx.obj["DEBUG"] = debug

@cli.command()
@click.argument("name")
@click.pass_context
def create(ctx, name):
    """Create a new item."""
    if ctx.obj["DEBUG"]:
        click.echo(f"Debug: Creating {name}")
    click.echo(f"Created: {name}")

@cli.command()
@click.argument("name")
@click.option("--force", is_flag=True, help="Force deletion")
def delete(name, force):
    """Delete an item."""
    if force:
        click.echo(f"Force deleting: {name}")
    else:
        click.echo(f"Deleted: {name}")

@cli.command("list")  # Custom command name
def list_items():
    """List all items."""
    click.echo("Items: ...")

if __name__ == "__main__":
    cli()

Click Features

import click

@click.command()
# File handling
@click.argument("input", type=click.File("r"))
@click.option("--output", type=click.File("w"), default="-")
# Path handling
@click.option("--config", type=click.Path(exists=True, dir_okay=False))
# Multiple values
@click.option("--include", "-i", multiple=True)
# Counted flag
@click.option("-v", "--verbose", count=True)
# Prompted value
@click.option("--password", prompt=True, hide_input=True)
# Confirmation
@click.option("--yes", is_flag=True, callback=lambda c, p, v: v or click.confirm("Proceed?"))
def main(input, output, config, include, verbose, password, yes):
    """Demonstrate Click features."""
    # input is an open file object
    content = input.read()

    # output is also a file object (or stdout)
    output.write(content)

    # verbose is a count: -v = 1, -vv = 2, -vvv = 3
    if verbose >= 2:
        click.echo("Very verbose mode")

Progress Bars

import click
import time

@click.command()
@click.argument("files", nargs=-1, type=click.Path(exists=True))
def process(files):
    """Process multiple files with progress bar."""
    with click.progressbar(files, label="Processing") as bar:
        for file in bar:
            time.sleep(0.1)  # Simulate work
            # Process file

Typer: Modern Python CLIs

Typer uses type hints for automatic argument parsing.

Installation

pip install typer

Basic CLI

# src/my_package/cli.py
import typer
from pathlib import Path
from typing import Optional
from enum import Enum

app = typer.Typer(help="Process files with optional transformations.")

class OutputFormat(str, Enum):
    json = "json"
    csv = "csv"
    text = "text"

@app.command()
def main(
    input: Path = typer.Argument(..., help="Input file to process"),
    output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file"),
    verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
    format: OutputFormat = typer.Option(OutputFormat.text, help="Output format"),
):
    """Process INPUT file and write results."""
    if verbose:
        typer.echo(f"Processing {input}")

    result = process(input, format.value)

    if output:
        output.write_text(result)
    else:
        typer.echo(result)

if __name__ == "__main__":
    app()

Typer Groups (Subcommands)

# src/my_package/cli.py
import typer
from typing import Optional

app = typer.Typer(help="Manage users and resources.")
users_app = typer.Typer(help="User management commands.")
app.add_typer(users_app, name="users")

@users_app.command("create")
def create_user(
    name: str = typer.Argument(..., help="Username"),
    email: Optional[str] = typer.Option(None, help="User email"),
    admin: bool = typer.Option(False, "--admin", help="Make user admin"),
):
    """Create a new user."""
    typer.echo(f"Creating user: {name}")
    if email:
        typer.echo(f"  Email: {email}")
    if admin:
        typer.echo("  Admin: Yes")

@users_app.command("delete")
def delete_user(
    name: str = typer.Argument(..., help="Username"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
):
    """Delete a user."""
    if not force:
        confirm = typer.confirm(f"Delete user {name}?")
        if not confirm:
            raise typer.Abort()
    typer.echo(f"Deleted: {name}")

@users_app.command("list")
def list_users():
    """List all users."""
    typer.echo("Users: alice, bob, charlie")

@app.command()
def version():
    """Show version."""
    typer.echo("My CLI v1.0.0")

if __name__ == "__main__":
    app()

Typer Features

import typer
from typing import List, Optional
from pathlib import Path

app = typer.Typer()

@app.command()
def process(
    # Required argument
    input_file: Path = typer.Argument(..., help="File to process"),

    # Optional with default
    output: Path = typer.Option(Path("output.txt"), help="Output file"),

    # Multiple values
    tags: Optional[List[str]] = typer.Option(None, "--tag", "-t"),

    # Boolean flags
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    dry_run: bool = typer.Option(False, "--dry-run"),

    # Auto-prompted
    password: str = typer.Option(..., prompt=True, hide_input=True),
):
    """Process files with various options."""

    if verbose:
        typer.secho("Verbose mode enabled", fg=typer.colors.GREEN)

    if dry_run:
        typer.secho("Dry run - no changes made", fg=typer.colors.YELLOW)
        raise typer.Exit()

    if tags:
        for tag in tags:
            typer.echo(f"Tag: {tag}")

Rich Integration

import typer
from rich.console import Console
from rich.table import Table
from rich.progress import track

app = typer.Typer()
console = Console()

@app.command()
def show_users():
    """Display users in a table."""
    table = Table(title="Users")
    table.add_column("Name", style="cyan")
    table.add_column("Email", style="green")
    table.add_column("Role")

    table.add_row("Alice", "[email protected]", "Admin")
    table.add_row("Bob", "[email protected]", "User")

    console.print(table)

@app.command()
def process_files(files: List[Path]):
    """Process files with progress bar."""
    for file in track(files, description="Processing..."):
        # Process file
        pass

Packaging CLI Tools

pyproject.toml Entry Points

[project]
name = "my-cli-tool"
version = "1.0.0"
description = "A useful CLI tool"
dependencies = [
    "click>=8.0",
    # or "typer>=0.9"
]

[project.scripts]
my-tool = "my_package.cli:main"       # Single command
my-app = "my_package.cli:app"         # Typer app

# Multiple commands from same package
[project.scripts]
my-tool = "my_package.cli:main"
my-tool-admin = "my_package.admin:main"

Project Structure

my-cli-tool/
├── src/
│   └── my_package/
│       ├── __init__.py
│       ├── cli.py           # Main CLI
│       ├── commands/        # Subcommand modules
│       │   ├── __init__.py
│       │   ├── users.py
│       │   └── config.py
│       └── utils.py
├── tests/
│   ├── test_cli.py
│   └── test_commands.py
├── pyproject.toml
└── README.md

Installation and Usage

# Install locally for development
pip install -e .

# Now command is available
my-tool --help
my-tool process input.txt --verbose

# Install from PyPI (after publishing)
pip install my-cli-tool

Testing CLI Applications

Testing Click

# tests/test_cli.py
from click.testing import CliRunner
from my_package.cli import cli

def test_create_command():
    runner = CliRunner()
    result = runner.invoke(cli, ["create", "test-item"])

    assert result.exit_code == 0
    assert "Created: test-item" in result.output

def test_delete_with_force():
    runner = CliRunner()
    result = runner.invoke(cli, ["delete", "item", "--force"])

    assert result.exit_code == 0
    assert "Force deleting" in result.output

def test_file_processing():
    runner = CliRunner()

    with runner.isolated_filesystem():
        # Create test file
        with open("input.txt", "w") as f:
            f.write("test content")

        result = runner.invoke(cli, ["process", "input.txt"])
        assert result.exit_code == 0

def test_debug_mode():
    runner = CliRunner()
    result = runner.invoke(cli, ["--debug", "create", "item"])

    assert "Debug:" in result.output

Testing Typer

# tests/test_cli.py
from typer.testing import CliRunner
from my_package.cli import app

runner = CliRunner()

def test_main_command():
    result = runner.invoke(app, ["input.txt"])
    assert result.exit_code == 0

def test_verbose_flag():
    result = runner.invoke(app, ["input.txt", "--verbose"])
    assert result.exit_code == 0
    assert "Processing" in result.output

def test_users_create():
    result = runner.invoke(app, ["users", "create", "alice", "--email", "[email protected]"])
    assert result.exit_code == 0
    assert "alice" in result.output
    assert "[email protected]" in result.output

def test_users_delete_confirmation():
    result = runner.invoke(app, ["users", "delete", "bob"], input="y\n")
    assert result.exit_code == 0
    assert "Deleted" in result.output

Best Practices

1. Consistent Exit Codes

import sys

EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_USAGE_ERROR = 2

def main():
    try:
        result = do_work()
        return EXIT_SUCCESS
    except UsageError as e:
        print(f"Error: {e}", file=sys.stderr)
        return EXIT_USAGE_ERROR
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return EXIT_ERROR

if __name__ == "__main__":
    sys.exit(main())

2. Helpful Error Messages

import click

@click.command()
@click.argument("config", type=click.Path(exists=True))
def main(config):
    try:
        data = load_config(config)
    except ConfigError as e:
        raise click.ClickException(
            f"Invalid config file '{config}': {e}\n"
            f"Run 'my-tool init' to create a valid config."
        )

3. Logging Integration

import click
import logging

@click.command()
@click.option("-v", "--verbose", count=True, help="Increase verbosity")
def main(verbose):
    # Set log level based on verbosity
    levels = [logging.WARNING, logging.INFO, logging.DEBUG]
    level = levels[min(verbose, len(levels) - 1)]
    logging.basicConfig(level=level)

    logging.debug("Debug message")
    logging.info("Info message")

4. Configuration Files

import click
from pathlib import Path
import tomllib

def get_config():
    """Load config from standard locations."""
    config_paths = [
        Path("./my-tool.toml"),
        Path.home() / ".config" / "my-tool" / "config.toml",
        Path("/etc/my-tool/config.toml"),
    ]

    for path in config_paths:
        if path.exists():
            with open(path, "rb") as f:
                return tomllib.load(f)

    return {}

@click.command()
@click.option("--config", type=click.Path(), help="Config file path")
@click.pass_context
def main(ctx, config):
    ctx.ensure_object(dict)

    if config:
        with open(config, "rb") as f:
            ctx.obj["config"] = tomllib.load(f)
    else:
        ctx.obj["config"] = get_config()

5. Shell Completion

# Click
import click

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

# Generate completion script
# $ my-tool --install-completion bash
# $ my-tool --install-completion zsh
# Typer
import typer

app = typer.Typer()

# Typer includes completion automatically
# $ my-tool --install-completion
# $ my-tool --show-completion

Standalone Executables

PyInstaller

pip install pyinstaller

# Create single executable
pyinstaller --onefile src/my_package/cli.py --name my-tool

# Result: dist/my-tool (or dist/my-tool.exe on Windows)
# spec file for customization
# my-tool.spec
a = Analysis(
    ['src/my_package/cli.py'],
    pathex=[],
    binaries=[],
    datas=[('src/my_package/data/*', 'data')],
    hiddenimports=['my_package.plugins'],
)

Shiv (Zipapp)

pip install shiv

# Create zipapp
shiv -c my-tool -o my-tool.pyz my-cli-tool

# Run without installation
./my-tool.pyz --help

Command Reference

ClickTyperargparse
@click.command()@app.command()parser.add_subparsers()
@click.argument()typer.Argument()add_argument("name")
@click.option()typer.Option()add_argument("--name")
@click.group()typer.Typer()Subparsers
click.echo()typer.echo()print()
click.File()typer.FileText()type=argparse.FileType()

Next Steps

For more Python development guides, explore our complete Python packaging series.

Frequently Asked Questions

Find answers to common questions

Use argparse for simple tools with no dependencies. Use Click for complex CLIs with subcommands and rich features. Use Typer for modern Python with type hints and automatic documentation. Typer is built on Click, so both work well.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.