I've built a handful of CLI tools over the past year. Not complex ones—deployment scripts, data processing pipelines, internal utilities. But I've learned what makes a CLI pleasant to build and use. Here's what I wish I knew when I started.

Choosing Your Framework

Python has three main options for building CLIs: argparse (built-in), Click, and Typer.

argparse

Built into Python. Works fine for simple scripts.

import argparse
 
def main():
    parser = argparse.ArgumentParser(description="Process some files")
    parser.add_argument("files", nargs="+", help="Files to process")
    parser.add_argument("-v", "--verbose", action="store_true")
    parser.add_argument("-o", "--output", default="output.txt")
    
    args = parser.parse_args()
    
    if args.verbose:
        print(f"Processing {len(args.files)} files")
    
    for file in args.files:
        process(file, args.output)
 
if __name__ == "__main__":
    main()

Pros: No dependencies, everyone knows it. Cons: Verbose, subcommands are painful, no type hints.

Click

The established choice. Decorator-based and feature-rich.

import click
 
@click.command()
@click.argument("files", nargs=-1, required=True)
@click.option("-v", "--verbose", is_flag=True)
@click.option("-o", "--output", default="output.txt")
def main(files, verbose, output):
    """Process some files."""
    if verbose:
        click.echo(f"Processing {len(files)} files")
    
    for file in files:
        process(file, output)
 
if __name__ == "__main__":
    main()

Pros: Clean API, great docs, handles complex cases. Cons: Decorators can stack up, types are strings by default.

Typer

Built on Click but uses type hints for everything.

import typer
 
app = typer.Typer()
 
@app.command()
def main(
    files: list[str],
    verbose: bool = False,
    output: str = "output.txt",
):
    """Process some files."""
    if verbose:
        typer.echo(f"Processing {len(files)} files")
    
    for file in files:
        process(file, output)
 
if __name__ == "__main__":
    app()

Pros: Type hints drive the CLI, less boilerplate, modern Python feel. Cons: Extra dependency, some Click features need workarounds.

My Recommendation

  • Simple script, no dependencies: argparse
  • Complex CLI, established codebase: Click
  • New project, modern Python: Typer

I use Typer for everything now. It fits how I think about code.

Structuring CLI Apps

Small scripts can stay in one file. Anything beyond that needs structure.

my-cli/
├── pyproject.toml
├── src/
│   └── mycli/
│       ├── __init__.py
│       ├── __main__.py      # Entry point
│       ├── cli.py           # Command definitions
│       ├── commands/        # Subcommand modules
│       │   ├── __init__.py
│       │   ├── init.py
│       │   └── run.py
│       ├── config.py        # Config handling
│       └── utils.py         # Shared utilities
└── tests/
    └── ...

Entry Points

In __main__.py:

from mycli.cli import app
 
if __name__ == "__main__":
    app()

This lets you run python -m mycli during development.

Organizing Subcommands

For a CLI with multiple commands, keep each in its own module:

# cli.py
import typer
from mycli.commands import init, run
 
app = typer.Typer()
app.add_typer(init.app, name="init")
app.add_typer(run.app, name="run")
 
@app.callback()
def main():
    """My CLI tool for doing things."""
    pass
# commands/init.py
import typer
 
app = typer.Typer()
 
@app.command()
def project(name: str, template: str = "default"):
    """Initialize a new project."""
    typer.echo(f"Creating {name} with {template} template")
 
@app.command()
def config():
    """Create default config file."""
    typer.echo("Writing config.toml")

Now you have mycli init project myapp and mycli init config.

Handling Config Files

Most CLIs need configuration. I prefer TOML for config files—it's readable and Python has built-in support since 3.11.

Loading Config

import tomllib
from pathlib import Path
from dataclasses import dataclass
 
@dataclass
class Config:
    api_url: str
    api_key: str
    timeout: int = 30
    verbose: bool = False
 
def load_config(path: Path | None = None) -> Config:
    """Load config from file, falling back to defaults."""
    
    # Search order: explicit path, current dir, home dir
    search_paths = [
        path,
        Path("config.toml"),
        Path.home() / ".mycli" / "config.toml",
    ]
    
    for p in search_paths:
        if p and p.exists():
            with open(p, "rb") as f:
                data = tomllib.load(f)
            return Config(**data)
    
    # Return defaults if no config found
    return Config(api_url="https://api.example.com", api_key="")

Environment Variable Overrides

Environment variables should override config files:

import os
 
def load_config(path: Path | None = None) -> Config:
    config = _load_from_file(path)
    
    # Environment overrides
    if api_key := os.environ.get("MYCLI_API_KEY"):
        config.api_key = api_key
    if api_url := os.environ.get("MYCLI_API_URL"):
        config.api_url = api_url
    
    return config

Priority Order

I follow this hierarchy (later wins):

  1. Built-in defaults
  2. System config (/etc/mycli/config.toml)
  3. User config (~/.mycli/config.toml)
  4. Project config (./config.toml)
  5. Environment variables
  6. Command-line flags

Most tools don't need all levels. Pick what makes sense.

Output Formatting with Rich

Plain print() works, but Rich makes CLI output beautiful with minimal effort.

Basic Usage

from rich import print
from rich.console import Console
 
console = Console()
 
# Styled text
print("[bold green]Success![/bold green] File processed.")
print("[red]Error:[/red] File not found")
 
# Progress bars
from rich.progress import track
 
for item in track(items, description="Processing..."):
    process(item)

Tables

from rich.table import Table
 
table = Table(title="Results")
table.add_column("File", style="cyan")
table.add_column("Status", style="green")
table.add_column("Size", justify="right")
 
table.add_row("data.csv", "✓ Processed", "1.2 MB")
table.add_row("config.json", "✓ Processed", "4.5 KB")
 
console.print(table)

Output:

                  Results
┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┓
┃ File        ┃ Status      ┃   Size ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━┩
│ data.csv    │ ✓ Processed │ 1.2 MB │
│ config.json │ ✓ Processed │ 4.5 KB │
└─────────────┴─────────────┴────────┘

Handling Non-Interactive Output

Disable fancy output when piping to files:

import sys
from rich.console import Console
 
console = Console(force_terminal=sys.stdout.isatty())
 
# Or check manually
if sys.stdout.isatty():
    # Interactive: use colors, progress bars
    for item in track(items):
        process(item)
else:
    # Piped: plain output
    for item in items:
        process(item)
        print(f"Processed: {item}")

JSON Output Mode

For scriptable CLIs, add a --json flag:

import json
 
@app.command()
def status(json_output: bool = typer.Option(False, "--json")):
    """Show current status."""
    data = get_status()
    
    if json_output:
        print(json.dumps(data, indent=2))
    else:
        table = Table()
        # ... build pretty table
        console.print(table)

Testing CLIs

CLI testing has its own patterns. Here's what works.

Using Typer's Test Runner

Typer (and Click) includes a test runner that captures output:

from typer.testing import CliRunner
from mycli.cli import app
 
runner = CliRunner()
 
def test_basic_command():
    result = runner.invoke(app, ["process", "file.txt"])
    assert result.exit_code == 0
    assert "Processing file.txt" in result.stdout
 
def test_verbose_flag():
    result = runner.invoke(app, ["process", "file.txt", "--verbose"])
    assert result.exit_code == 0
    assert "Debug info" in result.stdout
 
def test_missing_argument():
    result = runner.invoke(app, ["process"])
    assert result.exit_code != 0
    assert "Missing argument" in result.stdout

Testing with Files

Use tmp_path for file operations:

def test_reads_config(tmp_path):
    config_file = tmp_path / "config.toml"
    config_file.write_text('''
    api_url = "https://test.example.com"
    api_key = "test-key"
    ''')
    
    result = runner.invoke(app, ["--config", str(config_file), "status"])
    assert result.exit_code == 0
 
def test_writes_output(tmp_path):
    output_file = tmp_path / "output.txt"
    
    result = runner.invoke(app, ["export", "--output", str(output_file)])
    assert result.exit_code == 0
    assert output_file.exists()
    assert "expected content" in output_file.read_text()

Testing Environment Variables

import os
 
def test_api_key_from_env(monkeypatch):
    monkeypatch.setenv("MYCLI_API_KEY", "secret-key")
    
    result = runner.invoke(app, ["status"])
    assert result.exit_code == 0
    # Verify it used the env var (check logs, mock the API, etc.)

Integration Tests

For full integration tests, actually run the CLI:

import subprocess
 
def test_full_run():
    result = subprocess.run(
        ["python", "-m", "mycli", "process", "test.txt"],
        capture_output=True,
        text=True,
    )
    assert result.returncode == 0
    assert "Success" in result.stdout

Packaging for Distribution

Getting your CLI installable by others takes a few steps.

pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
 
[project]
name = "mycli"
version = "0.1.0"
description = "My awesome CLI tool"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "typer>=0.9.0",
    "rich>=13.0.0",
]
 
[project.scripts]
mycli = "mycli.cli:app"
 
[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-cov",
]

The [project.scripts] section creates the mycli command when installed.

Building and Publishing

# Install build tools
pip install build twine
 
# Build the package
python -m build
 
# Upload to PyPI (need an account)
twine upload dist/*
 
# Or upload to TestPyPI first
twine upload --repository testpypi dist/*

Installing from GitHub

Before publishing to PyPI, users can install directly:

pip install git+https://github.com/username/mycli.git

Single-File Distribution

For internal tools, sometimes a single file is easiest. Use PEX or shiv:

# Create a self-contained executable
shiv -c mycli -o mycli.pyz .
 
# Now distribute mycli.pyz—runs anywhere with Python installed
./mycli.pyz process file.txt

Quick Tips

A few things I've learned:

  1. Fail fast: Validate inputs early, exit with clear errors
  2. Exit codes matter: 0 for success, non-zero for errors—scripts depend on this
  3. Help text is documentation: Write good --help output, it's often all users read
  4. Support --version: Always include it
  5. Be quiet by default: Success shouldn't print unless asked (--verbose)
  6. Respect NO_COLOR: Check the NO_COLOR standard
@app.callback(invoke_without_command=True)
def main(
    version: bool = typer.Option(False, "--version", "-V"),
):
    if version:
        print(f"mycli {__version__}")
        raise typer.Exit()

What I'd Do Differently

Starting over, I'd:

  • Use Typer from day one (not migrate from argparse later)
  • Structure commands in separate files immediately
  • Add --json output early—retrofitting is annoying
  • Write CLI tests before unit tests—they catch more real bugs

Building CLIs is more craft than science. Start simple, add features as needed, and always think about the person typing commands at 2 AM trying to fix production.

React to this post: