Files
gh-vanman2024-cli-builder-p…/skills/click-patterns/examples/patterns.md
2025-11-30 09:04:14 +08:00

11 KiB

Click Framework Common Patterns

Best practices and common patterns for building production-ready Click CLIs.

Table of Contents

  1. Command Structure Patterns
  2. Parameter Patterns
  3. Validation Patterns
  4. Error Handling Patterns
  5. Output Patterns
  6. Configuration Patterns
  7. Testing Patterns

Command Structure Patterns

Single Command CLI

For simple tools with one main function:

@click.command()
@click.option('--name', default='World')
def hello(name):
    """Simple greeting CLI"""
    click.echo(f'Hello, {name}!')

Command Group Pattern

For CLIs with multiple related commands:

@click.group()
def cli():
    """Main CLI entry point"""
    pass

@cli.command()
def cmd1():
    """First command"""
    pass

@cli.command()
def cmd2():
    """Second command"""
    pass

Nested Command Groups

For complex CLIs with logical grouping:

@click.group()
def cli():
    """Main CLI"""
    pass

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

@database.command()
def migrate():
    """Run migrations"""
    pass

@database.command()
def reset():
    """Reset database"""
    pass

Context-Aware Commands

Share state across commands:

@click.group()
@click.pass_context
def cli(ctx):
    """Main CLI with shared context"""
    ctx.ensure_object(dict)
    ctx.obj['config'] = load_config()

@cli.command()
@click.pass_context
def deploy(ctx):
    """Use shared config"""
    config = ctx.obj['config']

Parameter Patterns

Options vs Arguments

Options (optional, named):

@click.option('--name', '-n', default='World', help='Name to greet')
@click.option('--count', '-c', default=1, type=int)

Arguments (required, positional):

@click.argument('filename')
@click.argument('output', type=click.Path())

Required Options

@click.option('--api-key', required=True, help='API key (required)')
@click.option('--config', required=True, type=click.Path(exists=True))

Multiple Values

# Multiple option values
@click.option('--tag', multiple=True, help='Tags (can specify multiple times)')
def command(tag):
    for t in tag:
        click.echo(t)

# Variable arguments
@click.argument('files', nargs=-1, type=click.Path(exists=True))
def process(files):
    for f in files:
        click.echo(f)

Environment Variables

@click.option('--api-key', envvar='API_KEY', help='API key (from env: API_KEY)')
@click.option('--debug', envvar='DEBUG', is_flag=True)

Interactive Prompts

@click.option('--name', prompt=True, help='Your name')
@click.option('--password', prompt=True, hide_input=True)
@click.option('--confirm', prompt='Continue?', confirmation_prompt=True)

Validation Patterns

Built-in Validators

# Choice validation
@click.option('--env', type=click.Choice(['dev', 'staging', 'prod']))

# Range validation
@click.option('--port', type=click.IntRange(1, 65535))
@click.option('--rate', type=click.FloatRange(0.0, 1.0))

# Path validation
@click.option('--input', type=click.Path(exists=True, dir_okay=False))
@click.option('--output', type=click.Path(writable=True))
@click.option('--dir', type=click.Path(exists=True, file_okay=False))

Custom Validators with Callbacks

def validate_email(ctx, param, value):
    """Validate email format"""
    import re
    if value and not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
        raise click.BadParameter('Invalid email format')
    return value

@click.option('--email', callback=validate_email)
def command(email):
    pass

Custom Click Types

class EmailType(click.ParamType):
    """Custom email type"""
    name = 'email'

    def convert(self, value, param, ctx):
        import re
        if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
            self.fail(f'{value} is not a valid email', param, ctx)
        return value

@click.option('--email', type=EmailType())
def command(email):
    pass

Conditional Validation

@click.command()
@click.option('--ssl', is_flag=True)
@click.option('--cert', type=click.Path(exists=True))
@click.option('--key', type=click.Path(exists=True))
def server(ssl, cert, key):
    """Start server with SSL validation"""
    if ssl and (not cert or not key):
        raise click.UsageError('SSL requires --cert and --key')

Error Handling Patterns

Graceful Error Handling

@click.command()
def command():
    """Command with error handling"""
    try:
        # Operation that might fail
        result = risky_operation()
    except FileNotFoundError as e:
        raise click.FileError(str(e))
    except Exception as e:
        raise click.ClickException(f'Operation failed: {e}')

Custom Exit Codes

@click.command()
def deploy():
    """Deploy with custom exit codes"""
    if not check_prerequisites():
        ctx = click.get_current_context()
        ctx.exit(1)

    if not deploy_application():
        ctx = click.get_current_context()
        ctx.exit(2)

    click.echo('Deployment successful')
    ctx = click.get_current_context()
    ctx.exit(0)

Confirmation for Dangerous Operations

@click.command()
@click.option('--force', is_flag=True, help='Skip confirmation')
def delete(force):
    """Delete with confirmation"""
    if not force:
        click.confirm('This will delete all data. Continue?', abort=True)

    # Proceed with deletion
    click.echo('Deleting...')

Output Patterns

Colored Output with Click

@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)
    click.echo(click.style('Info', fg='cyan'))

Rich Console Integration

from rich.console import Console
console = Console()

@click.command()
def deploy():
    """Deploy with Rich output"""
    console.print('[cyan]Starting deployment...[/cyan]')
    console.print('[green]✓[/green] Build successful')
    console.print('[yellow]⚠[/yellow] Warning: Cache cleared')

Progress Bars

@click.command()
@click.argument('files', nargs=-1)
def process(files):
    """Process with progress bar"""
    with click.progressbar(files, label='Processing files') as bar:
        for file in bar:
            # Process file
            time.sleep(0.1)

Verbose Mode Pattern

@click.command()
@click.option('--verbose', '-v', is_flag=True)
def command(verbose):
    """Command with verbose output"""
    click.echo('Starting operation...')

    if verbose:
        click.echo('Debug: Loading configuration')
        click.echo('Debug: Connecting to database')

    # Main operation
    click.echo('Operation completed')

Configuration Patterns

Configuration File Loading

import json
from pathlib import Path

@click.group()
@click.option('--config', type=click.Path(), default='config.json')
@click.pass_context
def cli(ctx, config):
    """CLI with config file"""
    ctx.ensure_object(dict)
    if Path(config).exists():
        with open(config) as f:
            ctx.obj['config'] = json.load(f)
    else:
        ctx.obj['config'] = {}

@cli.command()
@click.pass_context
def deploy(ctx):
    """Use config"""
    config = ctx.obj['config']
    api_key = config.get('api_key')

Environment-Based Configuration

@click.command()
@click.option('--env', type=click.Choice(['dev', 'staging', 'prod']), default='dev')
def deploy(env):
    """Deploy with environment config"""
    config_file = f'config.{env}.json'
    with open(config_file) as f:
        config = json.load(f)
    # Use environment-specific config

Configuration Priority

def get_config_value(ctx, param_value, env_var, config_key, default):
    """Get value with priority: param > env > config > default"""
    if param_value:
        return param_value
    if env_var in os.environ:
        return os.environ[env_var]
    if config_key in ctx.obj['config']:
        return ctx.obj['config'][config_key]
    return default

Testing Patterns

Basic Testing with CliRunner

from click.testing import CliRunner
import pytest

def test_command():
    """Test Click command"""
    runner = CliRunner()
    result = runner.invoke(cli, ['--help'])
    assert result.exit_code == 0
    assert 'Usage:' in result.output

def test_command_with_args():
    """Test with arguments"""
    runner = CliRunner()
    result = runner.invoke(cli, ['deploy', 'production'])
    assert result.exit_code == 0
    assert 'Deploying to production' in result.output

Testing with Temporary Files

def test_with_file():
    """Test with temporary file"""
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open('test.txt', 'w') as f:
            f.write('test content')

        result = runner.invoke(cli, ['process', 'test.txt'])
        assert result.exit_code == 0

Testing Interactive Prompts

def test_interactive():
    """Test interactive prompts"""
    runner = CliRunner()
    result = runner.invoke(cli, ['create'], input='username\npassword\n')
    assert result.exit_code == 0
    assert 'User created' in result.output

Testing Environment Variables

def test_with_env():
    """Test with environment variables"""
    runner = CliRunner()
    result = runner.invoke(cli, ['deploy'], env={'API_KEY': 'test123'})
    assert result.exit_code == 0

Advanced Patterns

Plugin System

@click.group()
def cli():
    """CLI with plugin support"""
    pass

# Allow plugins to register commands
def register_plugin(group, plugin_name):
    """Register plugin commands"""
    plugin_module = importlib.import_module(f'plugins.{plugin_name}')
    for name, cmd in plugin_module.commands.items():
        group.add_command(cmd, name)

Lazy Loading

class LazyGroup(click.Group):
    """Lazy load commands"""

    def get_command(self, ctx, cmd_name):
        """Load command on demand"""
        module = importlib.import_module(f'commands.{cmd_name}')
        return module.cli

@click.command(cls=LazyGroup)
def cli():
    """CLI with lazy loading"""
    pass

Middleware Pattern

def with_database(f):
    """Decorator to inject database connection"""
    @click.pass_context
    def wrapper(ctx, *args, **kwargs):
        ctx.obj['db'] = connect_database()
        try:
            return f(*args, **kwargs)
        finally:
            ctx.obj['db'].close()
    return wrapper

@cli.command()
@with_database
@click.pass_context
def query(ctx):
    """Command with database"""
    db = ctx.obj['db']

Summary

These patterns cover the most common use cases for Click CLIs:

  1. Structure: Choose between single command, command group, or nested groups
  2. Parameters: Use options for named parameters, arguments for positional
  3. Validation: Leverage built-in validators or create custom ones
  4. Errors: Handle errors gracefully with proper messages
  5. Output: Use colored output and progress bars for better UX
  6. Config: Load configuration from files with proper priority
  7. Testing: Test thoroughly with CliRunner

For more patterns and advanced usage, see the Click documentation.