Files
2025-11-30 09:04:14 +08:00

12 KiB

Complete Click CLI Example

A production-ready Click CLI demonstrating all major patterns and best practices.

Full Implementation

#!/usr/bin/env python3
"""
Production-ready Click CLI with all major patterns.

Features:
- Command groups and nested subcommands
- Options and arguments with validation
- Context sharing across commands
- Error handling and colored output
- Configuration management
- Environment-specific commands
"""

import click
from rich.console import Console
from pathlib import Path
import json

console = Console()


# Custom validators
def validate_email(ctx, param, value):
    """Validate email format"""
    import re
    if value and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', value):
        raise click.BadParameter('Invalid email format')
    return value


# Main CLI group
@click.group()
@click.version_option(version='1.0.0')
@click.option('--config', type=click.Path(), default='config.json',
              help='Configuration file path')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
@click.pass_context
def cli(ctx, config, verbose):
    """
    A powerful CLI tool for project management.

    Examples:
        cli init --template basic
        cli deploy production --mode safe
        cli config get api-key
        cli database migrate --create-tables
    """
    ctx.ensure_object(dict)
    ctx.obj['console'] = console
    ctx.obj['verbose'] = verbose
    ctx.obj['config_file'] = config

    if verbose:
        console.print(f"[dim]Config file: {config}[/dim]")


# Initialize command
@cli.command()
@click.option('--template', '-t',
              type=click.Choice(['basic', 'advanced', 'minimal']),
              default='basic',
              help='Project template')
@click.option('--name', prompt=True, help='Project name')
@click.option('--description', prompt=True, help='Project description')
@click.pass_context
def init(ctx, template, name, description):
    """Initialize a new project"""
    console = ctx.obj['console']
    verbose = ctx.obj['verbose']

    console.print(f"[cyan]Initializing project: {name}[/cyan]")
    console.print(f"[dim]Template: {template}[/dim]")
    console.print(f"[dim]Description: {description}[/dim]")

    # Create project structure
    project_dir = Path(name)
    if project_dir.exists():
        console.print(f"[red]✗[/red] Directory already exists: {name}")
        raise click.Abort()

    try:
        project_dir.mkdir(parents=True)
        (project_dir / 'src').mkdir()
        (project_dir / 'tests').mkdir()
        (project_dir / 'docs').mkdir()

        # Create config file
        config = {
            'name': name,
            'description': description,
            'template': template,
            'version': '1.0.0'
        }
        with open(project_dir / 'config.json', 'w') as f:
            json.dump(config, f, indent=2)

        console.print(f"[green]✓[/green] Project initialized successfully!")

        if verbose:
            console.print(f"[dim]Created directories: src/, tests/, docs/[/dim]")
            console.print(f"[dim]Created config.json[/dim]")

    except Exception as e:
        console.print(f"[red]✗[/red] Error: {e}")
        raise click.Abort()


# Deploy command
@cli.command()
@click.argument('environment',
                type=click.Choice(['dev', 'staging', 'production']))
@click.option('--force', '-f', is_flag=True, help='Force deployment')
@click.option('--mode', '-m',
              type=click.Choice(['fast', 'safe', 'rollback']),
              default='safe',
              help='Deployment mode')
@click.option('--skip-tests', is_flag=True, help='Skip test execution')
@click.pass_context
def deploy(ctx, environment, force, mode, skip_tests):
    """Deploy to specified environment"""
    console = ctx.obj['console']
    verbose = ctx.obj['verbose']

    console.print(f"[cyan]Deploying to {environment} in {mode} mode[/cyan]")

    if force:
        console.print("[yellow]⚠ Force mode enabled - skipping safety checks[/yellow]")

    # Pre-deployment checks
    if not skip_tests and not force:
        console.print("[dim]Running tests...[/dim]")
        # Simulate test execution
        if verbose:
            console.print("[green]✓[/green] All tests passed")

    # Deployment simulation
    steps = [
        "Building artifacts",
        "Uploading to server",
        "Running migrations",
        "Restarting services",
        "Verifying deployment"
    ]

    for step in steps:
        console.print(f"[dim]- {step}...[/dim]")

    console.print(f"[green]✓[/green] Deployment completed successfully!")

    if mode == 'safe':
        console.print("[dim]Rollback available for 24 hours[/dim]")


# Config group
@cli.group()
def config():
    """Manage configuration settings"""
    pass


@config.command()
@click.argument('key')
@click.pass_context
def get(ctx, key):
    """Get configuration value"""
    console = ctx.obj['console']
    config_file = ctx.obj['config_file']

    try:
        if Path(config_file).exists():
            with open(config_file) as f:
                config_data = json.load(f)
                if key in config_data:
                    console.print(f"[dim]Config[/dim] {key}: [green]{config_data[key]}[/green]")
                else:
                    console.print(f"[yellow]Key not found: {key}[/yellow]")
        else:
            console.print(f"[red]Config file not found: {config_file}[/red]")
    except Exception as e:
        console.print(f"[red]Error reading config: {e}[/red]")


@config.command()
@click.argument('key')
@click.argument('value')
@click.pass_context
def set(ctx, key, value):
    """Set configuration value"""
    console = ctx.obj['console']
    config_file = ctx.obj['config_file']

    try:
        config_data = {}
        if Path(config_file).exists():
            with open(config_file) as f:
                config_data = json.load(f)

        config_data[key] = value

        with open(config_file, 'w') as f:
            json.dump(config_data, f, indent=2)

        console.print(f"[green]✓[/green] Set {key} = {value}")
    except Exception as e:
        console.print(f"[red]Error writing config: {e}[/red]")


@config.command()
@click.pass_context
def list(ctx):
    """List all configuration settings"""
    console = ctx.obj['console']
    config_file = ctx.obj['config_file']

    try:
        if Path(config_file).exists():
            with open(config_file) as f:
                config_data = json.load(f)
                console.print("[cyan]Configuration Settings:[/cyan]")
                for key, value in config_data.items():
                    console.print(f"  {key}: [green]{value}[/green]")
        else:
            console.print("[yellow]No configuration file found[/yellow]")
    except Exception as e:
        console.print(f"[red]Error reading config: {e}[/red]")


# Database group
@cli.group()
def database():
    """Database management commands"""
    pass


@database.command()
@click.option('--create-tables', is_flag=True, help='Create tables')
@click.option('--seed-data', is_flag=True, help='Seed initial data')
@click.pass_context
def migrate(ctx, create_tables, seed_data):
    """Run database migrations"""
    console = ctx.obj['console']

    console.print("[cyan]Running migrations...[/cyan]")

    if create_tables:
        console.print("[dim]- Creating tables...[/dim]")
        console.print("[green]✓[/green] Tables created")

    if seed_data:
        console.print("[dim]- Seeding data...[/dim]")
        console.print("[green]✓[/green] Data seeded")

    console.print("[green]✓[/green] Migrations completed")


@database.command()
@click.option('--confirm', is_flag=True,
              prompt='This will delete all data. Continue?',
              help='Confirm reset')
@click.pass_context
def reset(ctx, confirm):
    """Reset database (destructive operation)"""
    console = ctx.obj['console']

    if not confirm:
        console.print("[yellow]Operation cancelled[/yellow]")
        raise click.Abort()

    console.print("[red]Resetting database...[/red]")
    console.print("[dim]- Dropping tables...[/dim]")
    console.print("[dim]- Clearing cache...[/dim]")
    console.print("[green]✓[/green] Database reset completed")


# User management group
@cli.group()
def user():
    """User management commands"""
    pass


@user.command()
@click.option('--email', callback=validate_email, prompt=True,
              help='User email address')
@click.option('--name', prompt=True, help='User full name')
@click.option('--role',
              type=click.Choice(['admin', 'user', 'guest']),
              default='user',
              help='User role')
@click.pass_context
def create(ctx, email, name, role):
    """Create a new user"""
    console = ctx.obj['console']

    console.print(f"[cyan]Creating user: {name}[/cyan]")
    console.print(f"[dim]Email: {email}[/dim]")
    console.print(f"[dim]Role: {role}[/dim]")

    console.print(f"[green]✓[/green] User created successfully")


@user.command()
@click.argument('email')
@click.pass_context
def delete(ctx, email):
    """Delete a user"""
    console = ctx.obj['console']

    if not click.confirm(f"Delete user {email}?"):
        console.print("[yellow]Operation cancelled[/yellow]")
        return

    console.print(f"[cyan]Deleting user: {email}[/cyan]")
    console.print(f"[green]✓[/green] User deleted")


# Error handling wrapper
def main():
    """Main entry point with error handling"""
    try:
        cli(obj={})
    except click.Abort:
        console.print("[yellow]Operation aborted[/yellow]")
    except Exception as e:
        console.print(f"[red]Unexpected error: {e}[/red]")
        raise


if __name__ == '__main__':
    main()

Usage Examples

Initialize a project

python cli.py init --template advanced --name myproject --description "My awesome project"

Deploy to production

python cli.py deploy production --mode safe
python cli.py deploy staging --force --skip-tests

Configuration management

python cli.py config set api-key abc123
python cli.py config get api-key
python cli.py config list

Database operations

python cli.py database migrate --create-tables --seed-data
python cli.py database reset --confirm

User management

python cli.py user create --email user@example.com --name "John Doe" --role admin
python cli.py user delete user@example.com

With verbose output

python cli.py --verbose deploy production

With custom config file

python cli.py --config /path/to/config.json config list

Key Features Demonstrated

  1. Command Groups: Organized commands into logical groups (config, database, user)
  2. Context Sharing: Using @click.pass_context to share state
  3. Input Validation: Custom validators for email, built-in validators for choices
  4. Colored Output: Using Rich console for beautiful output
  5. Error Handling: Graceful error handling and user feedback
  6. Interactive Prompts: Using prompt=True for interactive input
  7. Confirmation Dialogs: Using click.confirm() for dangerous operations
  8. File Operations: Reading/writing JSON configuration files
  9. Flags and Options: Boolean flags, default values, short flags
  10. Version Information: @click.version_option() decorator

Best Practices Applied

  • Clear help text for all commands and options
  • Sensible defaults for options
  • Validation for user inputs
  • Colored output for better UX
  • Verbose mode for debugging
  • Confirmation for destructive operations
  • Proper error handling and messages
  • Clean separation of concerns with command groups
  • Context object for sharing state