Building CLI Tools with Click and Typer: A Complete Guide

Learn to build professional command-line tools using Python's Click and Typer libraries with practical examples, best practices, and testing strategies.

Python excels at automation. Scripts that once required manual intervention now run with a single command. Command-line interfaces (CLIs) turn these scripts into tools others can use without reading your code.

Two libraries dominate Python CLI development: Click and Typer. Click has been the standard for years, powering tools like Flask’s CLI and pip. Typer arrived later, built on top of Click but with a modern approach using type hints. Both are excellent choices, but they solve the same problem differently.

This tutorial walks through building CLI tools with both libraries. You’ll learn when to use each, how to handle complex commands, and how to add features users expect from professional tools.

Prerequisites

Before starting, make sure you have:

  • Python 3.7 or higher installed
  • Basic understanding of Python functions and decorators
  • Familiarity with command-line usage
  • pip for installing packages

Create a new project directory:

mkdir cli-tutorial
cd cli-tutorial
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

Install the required libraries:

pip install click typer rich

The rich library adds color, formatting, and progress bars. We’ll use it later to make our CLIs more polished.

Step 1: Click Basics (Commands, Arguments, Options)

Click uses decorators to transform functions into CLI commands. Start with a simple example:

# hello.py
import click

@click.command()
@click.option('--name', default='World', help='Name to greet')
@click.option('--count', default=1, help='Number of greetings')
def hello(name, count):
    """Simple program that greets NAME COUNT times."""
    for _ in range(count):
        click.echo(f'Hello, {name}!')

if __name__ == '__main__':
    hello()

Run it:

python hello.py --name Alice --count 3

Output:

Hello, Alice!
Hello, Alice!
Hello, Alice!

Click automatically generates help text:

python hello.py --help

The @click.command() decorator converts the function into a CLI command. The @click.option() decorators define flags users can pass. Click handles parsing, type conversion, and validation.

Arguments vs Options

Click distinguishes between arguments and options. Arguments are positional and required by default. Options use flags like --name and can be optional:

import click

@click.command()
@click.argument('filename')
@click.option('--format', type=click.Choice(['json', 'csv']), default='json')
def process(filename, format):
    """Process FILENAME and output in specified FORMAT."""
    click.echo(f'Processing {filename} as {format}')

if __name__ == '__main__':
    process()

Usage:

python process.py data.txt --format csv

Arguments work well for required inputs like filenames. Options work better for flags, settings, and optional parameters.

Type Validation

Click validates types automatically. Use Python types or Click’s built-in types:

import click

@click.command()
@click.option('--port', type=int, default=8000)
@click.option('--debug/--no-debug', default=False)
@click.option('--output', type=click.Path(exists=False))
def serve(port, debug, output):
    """Start server on PORT."""
    click.echo(f'Starting server on port {port}')
    click.echo(f'Debug mode: {debug}')
    if output:
        click.echo(f'Logging to {output}')

if __name__ == '__main__':
    serve()

Click provides specialized types like click.Path(), click.File(), and click.Choice() for common validation patterns.

Step 2: Building a Multi-Command CLI with Click

Real tools need multiple commands, like git’s git commit, git push, and git pull. Click groups commands using @click.group():

# mycli.py
import click

@click.group()
def cli():
    """My CLI tool with multiple commands."""
    pass

@cli.command()
@click.argument('name')
def greet(name):
    """Greet someone."""
    click.echo(f'Hello, {name}!')

@cli.command()
@click.option('--count', default=10, help='Number of items to list')
def list_items(count):
    """List some items."""
    for i in range(count):
        click.echo(f'Item {i+1}')

@cli.command()
@click.argument('source')
@click.argument('dest')
def copy(source, dest):
    """Copy file from SOURCE to DEST."""
    click.echo(f'Copying {source} to {dest}')

if __name__ == '__main__':
    cli()

Usage:

python mycli.py greet Alice
python mycli.py list-items --count 5
python mycli.py copy file1.txt file2.txt

The @click.group() decorator creates a command group. Each function decorated with @cli.command() becomes a subcommand.

Context and State Sharing

Commands sometimes need to share configuration or state. Click’s context object handles this:

import click

@click.group()
@click.option('--verbose', is_flag=True)
@click.pass_context
def cli(ctx, verbose):
    """CLI with shared context."""
    ctx.ensure_object(dict)
    ctx.obj['verbose'] = verbose

@cli.command()
@click.pass_context
def status(ctx):
    """Show status."""
    if ctx.obj['verbose']:
        click.echo('Verbose output enabled')
    click.echo('Status: OK')

@cli.command()
@click.option('--output', type=click.Path())
@click.pass_context
def export(ctx, output):
    """Export data."""
    if ctx.obj['verbose']:
        click.echo(f'Exporting to {output}')
    click.echo('Export complete')

if __name__ == '__main__':
    cli()

The @click.pass_context decorator gives commands access to the context object. Store shared state in ctx.obj.

Step 3: Typer: The Modern Alternative

Typer takes a different approach. Instead of decorators, it uses type hints. Functions look like normal Python functions:

# hello_typer.py
import typer

def main(name: str = "World", count: int = 1):
    """Simple program that greets NAME COUNT times."""
    for _ in range(count):
        typer.echo(f"Hello, {name}!")

if __name__ == "__main__":
    typer.run(main)

Run it:

python hello_typer.py --name Alice --count 3

The output matches the Click version, but the code is simpler. Typer reads the type hints (str, int) and default values to configure the CLI automatically.

Arguments and Options in Typer

Typer distinguishes arguments from options based on whether they have default values:

import typer

def process(
    filename: str,  # Required argument (no default)
    format: str = "json"  # Optional option (has default)
):
    """Process FILENAME and output in specified FORMAT."""
    typer.echo(f"Processing {filename} as {format}")

if __name__ == "__main__":
    typer.run(process)

For more control, use typer.Argument() and typer.Option():

from typing import Optional
import typer

def serve(
    port: int = typer.Option(8000, help="Port to listen on"),
    debug: bool = typer.Option(False, help="Enable debug mode"),
    output: Optional[str] = typer.Option(None, help="Log file path")
):
    """Start server on PORT."""
    typer.echo(f"Starting server on port {port}")
    typer.echo(f"Debug mode: {debug}")
    if output:
        typer.echo(f"Logging to {output}")

if __name__ == "__main__":
    typer.run(serve)

Multi-Command CLIs with Typer

Typer uses typer.Typer() to create command groups:

# mycli_typer.py
import typer

app = typer.Typer()

@app.command()
def greet(name: str):
    """Greet someone."""
    typer.echo(f"Hello, {name}!")

@app.command()
def list_items(count: int = 10):
    """List some items."""
    for i in range(count):
        typer.echo(f"Item {i+1}")

@app.command()
def copy(source: str, dest: str):
    """Copy file from SOURCE to DEST."""
    typer.echo(f"Copying {source} to {dest}")

if __name__ == "__main__":
    app()

The syntax mirrors Click’s group structure but feels more Pythonic.

Typer Context and State

Typer also supports context for shared state:

import typer

app = typer.Typer()

@app.callback()
def main(
    ctx: typer.Context,
    verbose: bool = typer.Option(False, help="Verbose output")
):
    """CLI with shared context."""
    ctx.obj = {"verbose": verbose}

@app.command()
def status(ctx: typer.Context):
    """Show status."""
    if ctx.obj["verbose"]:
        typer.echo("Verbose output enabled")
    typer.echo("Status: OK")

@app.command()
def export(
    ctx: typer.Context,
    output: str = typer.Option(..., help="Output file")
):
    """Export data."""
    if ctx.obj["verbose"]:
        typer.echo(f"Exporting to {output}")
    typer.echo("Export complete")

if __name__ == "__main__":
    app()

The @app.callback() decorator runs before any command, setting up shared state in ctx.obj.

Step 4: Adding Rich Output, Progress Bars, and Colors

Plain text works, but professional tools use color, formatting, and progress indicators. The Rich library integrates well with both Click and Typer.

Colored Output

import typer
from rich import print as rprint
from rich.console import Console

console = Console()

def deploy(
    environment: str = typer.Argument(..., help="Target environment")
):
    """Deploy to specified environment."""
    if environment == "production":
        console.print("[bold red]Warning:[/] Deploying to production!")
    else:
        console.print(f"[green]Deploying to {environment}[/]")
    
    rprint("[blue]Deployment started...[/]")

if __name__ == "__main__":
    typer.run(deploy)

Rich’s markup syntax ([bold red], [green]) adds colors and styles. The output becomes more readable and professional.

Progress Bars

Long-running operations need progress feedback:

import typer
from rich.progress import track
import time

def process_files(
    count: int = typer.Option(10, help="Number of files to process")
):
    """Process multiple files with progress bar."""
    for i in track(range(count), description="Processing files..."):
        time.sleep(0.1)  # Simulate work
        
    typer.echo("Processing complete!")

if __name__ == "__main__":
    typer.run(process_files)

Rich’s track() function adds a progress bar automatically. For more control, use Progress:

import typer
from rich.progress import Progress
import time

def backup(
    source: str = typer.Argument(..., help="Source directory")
):
    """Backup files with detailed progress."""
    with Progress() as progress:
        task1 = progress.add_task("[cyan]Scanning files...", total=100)
        task2 = progress.add_task("[magenta]Copying files...", total=100)
        
        for i in range(100):
            time.sleep(0.02)
            progress.update(task1, advance=1)
            
        for i in range(100):
            time.sleep(0.02)
            progress.update(task2, advance=1)
    
    typer.echo("Backup complete!")

if __name__ == "__main__":
    typer.run(backup)

Tables and Formatting

Rich can format complex data:

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

console = Console()

def show_users():
    """Display user list in formatted table."""
    table = Table(title="Active Users")
    
    table.add_column("ID", style="cyan", no_wrap=True)
    table.add_column("Username", style="magenta")
    table.add_column("Status", style="green")
    
    table.add_row("1", "alice", "Active")
    table.add_row("2", "bob", "Inactive")
    table.add_row("3", "charlie", "Active")
    
    console.print(table)

if __name__ == "__main__":
    typer.run(show_users)

Tables make structured data easier to read than plain text output.

Step 5: Testing and Packaging Your CLI Tool

Professional tools need tests and easy installation.

Testing with Click

Click provides a test runner:

# test_cli.py
from click.testing import CliRunner
from mycli import cli

def test_greet():
    runner = CliRunner()
    result = runner.invoke(cli, ['greet', 'Alice'])
    assert result.exit_code == 0
    assert 'Hello, Alice!' in result.output

def test_list_items():
    runner = CliRunner()
    result = runner.invoke(cli, ['list-items', '--count', '3'])
    assert result.exit_code == 0
    assert 'Item 1' in result.output
    assert 'Item 3' in result.output

Run tests with pytest:

pip install pytest
pytest test_cli.py

The CliRunner simulates command execution without launching the CLI as a subprocess. It captures output and exit codes for assertions.

Testing with Typer

Typer uses Click’s runner internally:

# test_typer_cli.py
from typer.testing import CliRunner
from mycli_typer import app

runner = CliRunner()

def test_greet():
    result = runner.invoke(app, ['greet', 'Alice'])
    assert result.exit_code == 0
    assert 'Hello, Alice!' in result.stdout

def test_list_items():
    result = runner.invoke(app, ['list-items', '--count', '3'])
    assert result.exit_code == 0
    assert 'Item 1' in result.stdout

The pattern is identical. Tests ensure commands work correctly and catch regressions.

Packaging Your CLI

Create a pyproject.toml file:

[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mycli"
version = "0.1.0"
description = "My CLI tool"
authors = [{name = "Your Name", email = "you@example.com"}]
dependencies = [
    "click>=8.0",
    "typer>=0.9",
    "rich>=13.0"
]

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

For setuptools, also create setup.py:

from setuptools import setup, find_packages

setup(
    name="mycli",
    version="0.1.0",
    py_modules=["mycli"],
    install_requires=[
        "click>=8.0",
        "typer>=0.9",
        "rich>=13.0",
    ],
    entry_points={
        "console_scripts": [
            "mycli=mycli:cli",
        ],
    },
)

Install in development mode:

pip install -e .

Now you can run mycli from anywhere:

mycli greet Alice

For distribution, build a wheel:

pip install build
python -m build

This creates a .whl file in the dist/ directory. Users can install it with pip install mycli-0.1.0-py3-none-any.whl.

Common Pitfalls

Avoid these mistakes when building CLIs:

Forgetting error handling. CLIs should exit cleanly when things go wrong:

import sys
import typer

def process_file(filename: str):
    """Process a file."""
    try:
        with open(filename, 'r') as f:
            content = f.read()
        typer.echo(f"Processed {len(content)} bytes")
    except FileNotFoundError:
        typer.echo(f"Error: File {filename} not found", err=True)
        raise typer.Exit(code=1)
    except PermissionError:
        typer.echo(f"Error: Permission denied for {filename}", err=True)
        raise typer.Exit(code=1)

if __name__ == "__main__":
    typer.run(process_file)

Always catch expected exceptions and exit with appropriate codes. Zero means success, non-zero indicates an error.

Unclear command names. Use descriptive names that match user expectations. mycli delete-user is clearer than mycli rm.

Missing documentation. Write docstrings. Both Click and Typer use them to generate help text. Good help text makes tools usable.

Overusing options. Too many flags confuse users. If a command has more than five or six options, consider splitting it into multiple commands.

Ignoring stdin/stdout. CLIs should work in pipelines:

import sys
import typer

def filter_lines(pattern: str):
    """Filter lines matching PATTERN from stdin."""
    for line in sys.stdin:
        if pattern in line:
            typer.echo(line.rstrip())

if __name__ == "__main__":
    typer.run(filter_lines)

This command reads from stdin, allowing usage like:

cat file.txt | python filter.py "error"

Not testing edge cases. Test what happens with empty input, missing files, invalid arguments, and interrupted operations. Users will hit these cases.

Summary

Click and Typer both excel at building Python CLIs. Click uses decorators and gives fine-grained control. Typer uses type hints and feels more Pythonic. For new projects, Typer offers a gentler learning curve. For complex tools needing deep customization, Click provides more flexibility.

Both libraries handle the tedious parts: parsing arguments, validating types, generating help text. Add Rich for professional output with colors, progress bars, and formatted tables.

Good CLIs share common traits. They have clear command names, helpful error messages, and detailed help text. They handle errors gracefully and work in pipelines. They respect conventions like --help and --version.

Start simple. Build a single-command tool. Add options as needed. When complexity grows, split into multiple commands. Test early and often. Package your tool so others can install it with pip.

CLI tools amplify your scripts. What once required explaining your code becomes a command anyone can run. That’s the power of a well-built interface.

Spread The Article

Share this guide

Send this article to your network or keep a copy of the direct link.

X Facebook LinkedIn Reddit Telegram

Discussion

Leave a comment

No comments yet

Be the first to start the conversation.