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.
Discussion
Leave a comment
No comments yet
Be the first to start the conversation.