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
| Feature | argparse | Click | Typer |
|---|---|---|---|
| External dependency | No | Yes | Yes |
| Type hints | Manual | Manual | Automatic |
| Subcommands | Manual | Easy | Easy |
| Auto-generated help | Basic | Rich | Rich |
| Shell completion | No | Yes | Yes |
| Testing utilities | No | Yes | Yes |
| Learning curve | Medium | Medium | Low |
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
| Click | Typer | argparse |
|---|---|---|
@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
- Learn to Publish to PyPI with your CLI
- See our Testing Guide for CLI testing
- Explore the Python Packaging Complete Guide for the full ecosystem
For more Python development guides, explore our complete Python packaging series.