Build Cli Applications
Problem
Building command-line applications requires parsing arguments, handling user input, and providing clear error messages. Python’s argparse offers comprehensive features but verbose syntax. Modern frameworks like Click and Typer provide cleaner APIs but require learning framework patterns. Balancing functionality with usability is essential.
This guide shows effective CLI application development in Python.
argparse for Standard CLI
Basic Argument Parsing
import argparse
def create_parser():
parser = argparse.ArgumentParser(
description='Process data files',
epilog='Example: python app.py input.txt -o output.txt'
)
# Positional argument
parser.add_argument('input', help='Input file path')
# Optional argument
parser.add_argument(
'--output', '-o',
default='output.txt',
help='Output file path (default: output.txt)'
)
# Boolean flag
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
return parser
parser = create_parser()
args = parser.parse_args()
print(f"Input: {args.input}")
print(f"Output: {args.output}")
if args.verbose:
print("Verbose mode enabled")Why it matters: argparse is part of the standard library, requiring no dependencies. Provides automatic help generation and type validation.
Argument Types and Validation
import argparse
from pathlib import Path
def positive_int(value):
"""Validate positive integer."""
try:
ivalue = int(value)
if ivalue <= 0:
raise argparse.ArgumentTypeError(f"{value} must be positive")
return ivalue
except ValueError:
raise argparse.ArgumentTypeError(f"{value} must be an integer")
def existing_file(value):
"""Validate file exists."""
path = Path(value)
if not path.exists():
raise argparse.ArgumentTypeError(f"{value} does not exist")
if not path.is_file():
raise argparse.ArgumentTypeError(f"{value} is not a file")
return path
parser = argparse.ArgumentParser()
parser.add_argument(
'--count',
type=positive_int,
default=10,
help='Number of items (must be positive)'
)
parser.add_argument(
'--config',
type=existing_file,
help='Configuration file path'
)
parser.add_argument(
'--format',
choices=['json', 'xml', 'csv'],
default='json',
help='Output format'
)
parser.add_argument(
'--tags',
nargs='+',
help='One or more tags'
)
args = parser.parse_args()Subcommands
import argparse
def create_parser():
parser = argparse.ArgumentParser(description='Data management tool')
subparsers = parser.add_subparsers(dest='command', required=True)
# Add command
add_parser = subparsers.add_parser('add', help='Add new record')
add_parser.add_argument('name', help='Record name')
add_parser.add_argument('--priority', type=int, default=0)
# List command
list_parser = subparsers.add_parser('list', help='List all records')
list_parser.add_argument('--filter', help='Filter by pattern')
# Delete command
delete_parser = subparsers.add_parser('delete', help='Delete record')
delete_parser.add_argument('id', type=int, help='Record ID')
return parser
def main():
parser = create_parser()
args = parser.parse_args()
if args.command == 'add':
add_record(args.name, args.priority)
elif args.command == 'list':
list_records(args.filter)
elif args.command == 'delete':
delete_record(args.id)
def add_record(name, priority):
print(f"Adding {name} with priority {priority}")
def list_records(filter_pattern):
print(f"Listing records (filter: {filter_pattern})")
def delete_record(record_id):
print(f"Deleting record {record_id}")
if __name__ == '__main__':
main()Click Framework
Note: Click 8.3+ requires Python 3.10 or later. For Python 3.7-3.9, use Click 8.1.x.
Basic Click Application
import click
@click.command()
@click.argument('input_file', type=click.Path(exists=True))
@click.option('--output', '-o', default='output.txt', help='Output file')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
def process(input_file, output, verbose):
"""Process INPUT_FILE and write results to output."""
if verbose:
click.echo(f"Processing {input_file}...")
# Process file
with open(input_file) as f:
data = f.read()
with open(output, 'w') as f:
f.write(data.upper())
click.echo(f"Results written to {output}")
if __name__ == '__main__':
process()Why it matters: Click provides declarative syntax with automatic help generation. Decorator-based API is cleaner than argparse for complex applications.
Click Options and Types
import click
@click.command()
@click.option('--count', type=int, default=1, help='Number of iterations')
@click.option('--name', prompt='Your name', help='Name to greet')
@click.option('--greeting', default='Hello', help='Greeting word')
@click.option('--loud', is_flag=True, help='Uppercase output')
@click.option(
'--format',
type=click.Choice(['json', 'xml', 'csv']),
default='json',
help='Output format'
)
@click.option(
'--config',
type=click.File('r'),
help='Configuration file'
)
def greet(count, name, greeting, loud, format, config):
"""Greet NAME COUNT times."""
message = f"{greeting}, {name}!"
if loud:
message = message.upper()
for _ in range(count):
click.echo(message)
if config:
click.echo(f"Config: {config.read()}")
@click.command()
@click.option('--username', prompt=True)
@click.option('--password', prompt=True, hide_input=True,
confirmation_prompt=True)
def login(username, password):
"""Login with credentials."""
click.echo(f"Logging in as {username}")
@click.command()
@click.confirmation_option(prompt='Are you sure you want to delete?')
def delete():
"""Delete all data."""
click.echo('Deleting...')Click Groups and Subcommands
import click
@click.group()
def cli():
"""Data management tool."""
pass
@cli.command()
@click.argument('name')
@click.option('--priority', type=int, default=0)
def add(name, priority):
"""Add a new record."""
click.echo(f"Adding {name} with priority {priority}")
@cli.command()
@click.option('--filter', help='Filter pattern')
def list(filter):
"""List all records."""
click.echo(f"Listing records (filter: {filter})")
@cli.command()
@click.argument('record_id', type=int)
def delete(record_id):
"""Delete a record."""
click.echo(f"Deleting record {record_id}")
if __name__ == '__main__':
cli()Click Utilities
import click
import time
@click.command()
def download():
"""Download files."""
items = range(100)
with click.progressbar(items, label='Downloading') as bar:
for item in bar:
time.sleep(0.01) # Simulate work
@click.command()
def status():
"""Show status with colors."""
click.secho('Success!', fg='green', bold=True)
click.secho('Warning!', fg='yellow')
click.secho('Error!', fg='red', bold=True)
# Conditional styling
click.echo(click.style('Info', fg='blue'))
@click.command()
@click.option('--yes', is_flag=True, help='Skip confirmation')
def dangerous(yes):
"""Perform dangerous operation."""
if not yes:
click.confirm('Are you sure?', abort=True)
click.echo('Performing operation...')
@click.command()
def logs():
"""Show logs with pagination."""
lines = [f"Log line {i}" for i in range(1000)]
click.echo_via_pager('\n'.join(lines))Typer Framework
Basic Typer Application
import typer
from pathlib import Path
app = typer.Typer()
@app.command()
def process(
input_file: Path = typer.Argument(..., help="Input file path"),
output: Path = typer.Option(Path("output.txt"), help="Output file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output")
):
"""Process INPUT_FILE and write results to output."""
if verbose:
typer.echo(f"Processing {input_file}...")
# Process file
data = input_file.read_text()
output.write_text(data.upper())
typer.echo(f"Results written to {output}")
if __name__ == "__main__":
app()Why it matters: Typer combines Click’s ease-of-use with modern Python type hints. Type annotations provide automatic validation and IDE support.
Typer with Type Hints
import typer
from typing import Optional, List
from enum import Enum
from pathlib import Path
class Format(str, Enum):
json = "json"
xml = "xml"
csv = "csv"
app = typer.Typer()
@app.command()
def convert(
input_file: Path,
output_format: Format,
output_file: Optional[Path] = None,
tags: Optional[List[str]] = typer.Option(None, "--tag", help="Add tags"),
count: int = typer.Option(1, min=1, max=100, help="Iteration count"),
verbose: bool = False
):
"""Convert INPUT_FILE to specified format."""
if verbose:
typer.echo(f"Converting {input_file} to {output_format.value}")
if tags:
typer.echo(f"Tags: {', '.join(tags)}")
typer.echo(f"Processing {count} times...")
@app.command()
def login(
username: str = typer.Option(..., prompt=True),
password: str = typer.Option(..., prompt=True, hide_input=True)
):
"""Login with credentials."""
typer.echo(f"Logging in as {username}")
if __name__ == "__main__":
app()Typer Subcommands
import typer
app = typer.Typer()
@app.command()
def add(
name: str,
priority: int = typer.Option(0, help="Priority level")
):
"""Add a new record."""
typer.echo(f"Adding {name} with priority {priority}")
@app.command()
def list(
filter_pattern: Optional[str] = typer.Option(None, "--filter", help="Filter")
):
"""List all records."""
typer.echo(f"Listing records (filter: {filter_pattern})")
@app.command()
def delete(
record_id: int,
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation")
):
"""Delete a record."""
if not force:
confirmed = typer.confirm("Are you sure?")
if not confirmed:
raise typer.Abort()
typer.echo(f"Deleting record {record_id}")
if __name__ == "__main__":
app()stdin, stdout, stderr
Reading from stdin
import sys
import argparse
def read_input(filename=None):
if filename and filename != '-':
with open(filename) as f:
return f.read()
else:
# Read from stdin
return sys.stdin.read()
def process_lines():
for line in sys.stdin:
# Process each line
print(line.upper(), end='')
parser = argparse.ArgumentParser()
parser.add_argument('input', nargs='?', default='-', help='Input file or - for stdin')
args = parser.parse_args()
data = read_input(args.input)
print(data)Writing to stdout and stderr
import sys
print("Normal output")
sys.stdout.write("Output to stdout\n")
print("Error message", file=sys.stderr)
sys.stderr.write("Error to stderr\n")
def process_data(data):
# Progress/status to stderr (won't interfere with piping)
print(f"Processing {len(data)} items...", file=sys.stderr)
# Actual data to stdout (can be piped)
for item in data:
print(item) # Goes to stdout
def main():
try:
process_data(data)
sys.exit(0) # Success
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1) # FailureClick stdin/stdout
import click
@click.command()
@click.argument('input', type=click.File('r'), default='-')
@click.argument('output', type=click.File('w'), default='-')
def transform(input, output):
"""Transform INPUT to OUTPUT (use - for stdin/stdout)."""
data = input.read()
result = data.upper()
output.write(result)Entry Points with setuptools
Creating Entry Points
from setuptools import setup, find_packages
setup(
name='mytool',
version='1.0.0',
packages=find_packages(),
install_requires=[
'click>=8.0',
'requests>=2.25',
],
entry_points={
'console_scripts': [
'mytool=mytool.cli:main',
'mytool-admin=mytool.admin:admin_main',
],
},
)
import click
@click.command()
def main():
"""Main CLI entry point."""
click.echo("MyTool CLI")
if __name__ == '__main__':
main()pyproject.toml Entry Points
[project]
name = "mytool"
version = "1.0.0"
dependencies = [
"click>=8.0",
"requests>=2.25",
]
[project.scripts]
mytool = "mytool.cli:main"
mytool-admin = "mytool.admin:admin_main"Testing CLI Applications
Testing argparse
import argparse
import pytest
from io import StringIO
import sys
def create_parser():
parser = argparse.ArgumentParser()
parser.add_argument('input')
parser.add_argument('--output', default='output.txt')
return parser
def test_parser_with_all_args():
parser = create_parser()
args = parser.parse_args(['input.txt', '--output', 'result.txt'])
assert args.input == 'input.txt'
assert args.output == 'result.txt'
def test_parser_with_defaults():
parser = create_parser()
args = parser.parse_args(['input.txt'])
assert args.input == 'input.txt'
assert args.output == 'output.txt'
def test_parser_missing_required():
parser = create_parser()
with pytest.raises(SystemExit):
parser.parse_args([])Testing Click
import click
from click.testing import CliRunner
@click.command()
@click.argument('name')
@click.option('--greeting', default='Hello')
def greet(name, greeting):
"""Greet NAME."""
click.echo(f"{greeting}, {name}!")
def test_greet_default():
runner = CliRunner()
result = runner.invoke(greet, ['Alice'])
assert result.exit_code == 0
assert result.output == "Hello, Alice!\n"
def test_greet_custom():
runner = CliRunner()
result = runner.invoke(greet, ['Bob', '--greeting', 'Hi'])
assert result.exit_code == 0
assert result.output == "Hi, Bob!\n"
@click.command()
@click.argument('input', type=click.File('r'))
def process_file(input):
"""Process input file."""
data = input.read()
click.echo(data.upper())
def test_process_file():
runner = CliRunner()
with runner.isolated_filesystem():
# Create test file
with open('test.txt', 'w') as f:
f.write('hello world')
result = runner.invoke(process_file, ['test.txt'])
assert result.exit_code == 0
assert result.output == "HELLO WORLD\n"Testing Typer
import typer
from typer.testing import CliRunner
app = typer.Typer()
@app.command()
def hello(name: str, greeting: str = "Hello"):
"""Greet NAME."""
typer.echo(f"{greeting}, {name}!")
def test_hello_default():
runner = CliRunner()
result = runner.invoke(app, ["Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.stdout
def test_hello_custom():
runner = CliRunner()
result = runner.invoke(app, ["Bob", "--greeting", "Hi"])
assert result.exit_code == 0
assert "Hi, Bob!" in result.stdoutBest Practices
Error Handling
import click
import sys
@click.command()
@click.argument('filename', type=click.Path(exists=True))
def process(filename):
"""Process file."""
try:
with open(filename) as f:
data = f.read()
# Process data
result = transform(data)
click.echo(result)
except FileNotFoundError:
click.echo(f"Error: File not found: {filename}", err=True)
sys.exit(1)
except PermissionError:
click.echo(f"Error: Permission denied: {filename}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
def transform(data):
return data.upper()Configuration Files
import click
from pathlib import Path
import yaml
@click.command()
@click.option('--config', type=click.Path(), help='Config file path')
@click.option('--api-key', help='API key')
@click.option('--endpoint', help='API endpoint')
def api_client(config, api_key, endpoint):
"""API client with config file support."""
settings = {}
# Load from config file
if config:
config_path = Path(config)
if config_path.exists():
with config_path.open() as f:
settings = yaml.safe_load(f)
# Command line overrides config file
if api_key:
settings['api_key'] = api_key
if endpoint:
settings['endpoint'] = endpoint
click.echo(f"API Key: {settings.get('api_key')}")
click.echo(f"Endpoint: {settings.get('endpoint')}")Shell Completion
import click
@click.command()
@click.argument('name')
def greet(name):
"""Greet NAME."""
click.echo(f"Hello, {name}!")
if __name__ == '__main__':
greet()
import typer
app = typer.Typer()
@app.command()
def main():
"""Application with completion."""
typer.echo("Hello!")
if __name__ == "__main__":
app()Summary
CLI applications in Python use argparse for standard library approach, Click for decorator-based syntax, or Typer for type-hint driven development. argparse requires no dependencies, Click provides cleaner API, Typer adds type safety through annotations.
argparse handles arguments through add_argument() with types, choices, and custom validators. Subparsers enable git-style commands. Verbose but comprehensive - suitable for simple scripts and maximum compatibility.
Click uses decorators for commands, @click.argument for positional args, @click.option for flags. Automatic help generation, type validation, and utilities like progress bars, styled output, confirmation prompts. Clean API for complex CLI applications.
Typer builds on Click with type hints replacing decorators’ explicit types. Path, Optional, List, Enum from typing provide validation. Modern Python approach with IDE support and automatic documentation.
stdin/stdout/stderr handling separates data flow from user messages. Read stdin with sys.stdin.read(), write errors to sys.stderr, output data to sys.stdout. Enables piping and redirection - fundamental Unix philosophy.
Entry points through setuptools or pyproject.toml install commands globally. console_scripts map command names to Python functions. Users run installed commands without knowing Python paths.
Testing CLI apps uses CliRunner from Click/Typer or direct argparse parsing. isolated_filesystem() creates temporary directories. Capture output, verify exit codes, test error handling. Essential for reliable CLI tools.
Best practices include graceful error handling with clear messages, configuration file support, shell completion, progress indication for long operations. Exit codes communicate success/failure to shell scripts.
Choose argparse for simple scripts with no dependencies, Click for feature-rich CLI with clean syntax, Typer for modern type-safe development. All three produce professional command-line interfaces.