Initial commit
This commit is contained in:
405
skills/click-patterns/examples/complete-example.md
Normal file
405
skills/click-patterns/examples/complete-example.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Complete Click CLI Example
|
||||
|
||||
A production-ready Click CLI demonstrating all major patterns and best practices.
|
||||
|
||||
## Full Implementation
|
||||
|
||||
```python
|
||||
#!/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
|
||||
```bash
|
||||
python cli.py init --template advanced --name myproject --description "My awesome project"
|
||||
```
|
||||
|
||||
### Deploy to production
|
||||
```bash
|
||||
python cli.py deploy production --mode safe
|
||||
python cli.py deploy staging --force --skip-tests
|
||||
```
|
||||
|
||||
### Configuration management
|
||||
```bash
|
||||
python cli.py config set api-key abc123
|
||||
python cli.py config get api-key
|
||||
python cli.py config list
|
||||
```
|
||||
|
||||
### Database operations
|
||||
```bash
|
||||
python cli.py database migrate --create-tables --seed-data
|
||||
python cli.py database reset --confirm
|
||||
```
|
||||
|
||||
### User management
|
||||
```bash
|
||||
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
|
||||
```bash
|
||||
python cli.py --verbose deploy production
|
||||
```
|
||||
|
||||
### With custom config file
|
||||
```bash
|
||||
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
|
||||
482
skills/click-patterns/examples/edge-cases.md
Normal file
482
skills/click-patterns/examples/edge-cases.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Click Framework Edge Cases and Solutions
|
||||
|
||||
Common edge cases, gotchas, and their solutions when working with Click.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Parameter Handling Edge Cases](#parameter-handling-edge-cases)
|
||||
2. [Context and State Edge Cases](#context-and-state-edge-cases)
|
||||
3. [Error Handling Edge Cases](#error-handling-edge-cases)
|
||||
4. [Testing Edge Cases](#testing-edge-cases)
|
||||
5. [Platform-Specific Edge Cases](#platform-specific-edge-cases)
|
||||
|
||||
---
|
||||
|
||||
## Parameter Handling Edge Cases
|
||||
|
||||
### Case 1: Multiple Values with Same Option
|
||||
|
||||
**Problem**: User specifies the same option multiple times
|
||||
|
||||
```bash
|
||||
cli --tag python --tag docker --tag kubernetes
|
||||
```
|
||||
|
||||
**Solution**: Use `multiple=True`
|
||||
|
||||
```python
|
||||
@click.option('--tag', multiple=True)
|
||||
def command(tag):
|
||||
"""Handle multiple values"""
|
||||
for t in tag:
|
||||
click.echo(t)
|
||||
```
|
||||
|
||||
### Case 2: Option vs Argument Ambiguity
|
||||
|
||||
**Problem**: Argument that looks like an option
|
||||
|
||||
```bash
|
||||
cli process --file=-myfile.txt # -myfile.txt looks like option
|
||||
```
|
||||
|
||||
**Solution**: Use `--` separator or quotes
|
||||
|
||||
```python
|
||||
@click.command()
|
||||
@click.argument('filename')
|
||||
def process(filename):
|
||||
pass
|
||||
|
||||
# Usage:
|
||||
# cli process -- -myfile.txt
|
||||
# cli process "-myfile.txt"
|
||||
```
|
||||
|
||||
### Case 3: Empty String vs None
|
||||
|
||||
**Problem**: Distinguishing between no value and empty string
|
||||
|
||||
```python
|
||||
@click.option('--name')
|
||||
def command(name):
|
||||
# name is None when not provided
|
||||
# name is '' when provided as empty
|
||||
if name is None:
|
||||
click.echo('Not provided')
|
||||
elif name == '':
|
||||
click.echo('Empty string provided')
|
||||
```
|
||||
|
||||
**Solution**: Use callback for custom handling
|
||||
|
||||
```python
|
||||
def handle_empty(ctx, param, value):
|
||||
if value == '':
|
||||
return None # Treat empty as None
|
||||
return value
|
||||
|
||||
@click.option('--name', callback=handle_empty)
|
||||
def command(name):
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 4: Boolean Flag with Default True
|
||||
|
||||
**Problem**: Need a flag that's True by default, but can be disabled
|
||||
|
||||
```python
|
||||
# Wrong approach:
|
||||
@click.option('--enable', is_flag=True, default=True) # Doesn't work as expected
|
||||
|
||||
# Correct approach:
|
||||
@click.option('--disable', is_flag=True)
|
||||
def command(disable):
|
||||
enabled = not disable
|
||||
```
|
||||
|
||||
**Better Solution**: Use flag_value
|
||||
|
||||
```python
|
||||
@click.option('--ssl/--no-ssl', default=True)
|
||||
def command(ssl):
|
||||
"""SSL is enabled by default, use --no-ssl to disable"""
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 5: Required Option with Environment Variable
|
||||
|
||||
**Problem**: Make option required unless env var is set
|
||||
|
||||
```python
|
||||
def require_if_no_env(ctx, param, value):
|
||||
"""Require option if environment variable not set"""
|
||||
if value is None:
|
||||
import os
|
||||
env_value = os.getenv('API_KEY')
|
||||
if env_value:
|
||||
return env_value
|
||||
raise click.MissingParameter(param=param)
|
||||
return value
|
||||
|
||||
@click.option('--api-key', callback=require_if_no_env)
|
||||
def command(api_key):
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context and State Edge Cases
|
||||
|
||||
### Case 6: Context Not Available in Callbacks
|
||||
|
||||
**Problem**: Need context in parameter callback
|
||||
|
||||
```python
|
||||
# This doesn't work - context not yet initialized:
|
||||
def my_callback(ctx, param, value):
|
||||
config = ctx.obj['config'] # Error: ctx.obj is None
|
||||
return value
|
||||
```
|
||||
|
||||
**Solution**: Use command decorator to set up context first
|
||||
|
||||
```python
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['config'] = load_config()
|
||||
|
||||
@cli.command()
|
||||
@click.option('--value', callback=validate_with_config)
|
||||
@click.pass_context
|
||||
def subcommand(ctx, value):
|
||||
# Now ctx.obj is available
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 7: Sharing State Between Command Groups
|
||||
|
||||
**Problem**: State not persisting across nested groups
|
||||
|
||||
```python
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def cli(ctx):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['data'] = 'test'
|
||||
|
||||
@cli.group()
|
||||
@click.pass_context
|
||||
def subgroup(ctx):
|
||||
# ctx.obj is still available here
|
||||
assert ctx.obj['data'] == 'test'
|
||||
|
||||
@subgroup.command()
|
||||
@click.pass_context
|
||||
def command(ctx):
|
||||
# ctx.obj is still available here too
|
||||
assert ctx.obj['data'] == 'test'
|
||||
```
|
||||
|
||||
### Case 8: Mutating Context Objects
|
||||
|
||||
**Problem**: Changes to context not persisting
|
||||
|
||||
```python
|
||||
# This works:
|
||||
ctx.obj['key'] = 'value' # Modifying dict
|
||||
|
||||
# This doesn't persist:
|
||||
ctx.obj = {'key': 'value'} # Replacing dict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Edge Cases
|
||||
|
||||
### Case 9: Graceful Handling of Ctrl+C
|
||||
|
||||
**Problem**: Ugly traceback on keyboard interrupt
|
||||
|
||||
```python
|
||||
def main():
|
||||
try:
|
||||
cli()
|
||||
except KeyboardInterrupt:
|
||||
click.echo('\n\nOperation cancelled by user')
|
||||
raise SystemExit(130) # Standard exit code for Ctrl+C
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
### Case 10: Custom Error Messages for Validation
|
||||
|
||||
**Problem**: Default error messages aren't user-friendly
|
||||
|
||||
```python
|
||||
# Default error:
|
||||
@click.option('--port', type=click.IntRange(1, 65535))
|
||||
# Error: Invalid value for '--port': 70000 is not in the range 1<=x<=65535
|
||||
|
||||
# Custom error:
|
||||
def validate_port(ctx, param, value):
|
||||
if not 1 <= value <= 65535:
|
||||
raise click.BadParameter(
|
||||
f'Port {value} is out of range. Please use a port between 1 and 65535.'
|
||||
)
|
||||
return value
|
||||
|
||||
@click.option('--port', type=int, callback=validate_port)
|
||||
```
|
||||
|
||||
### Case 11: Handling Mutually Exclusive Options
|
||||
|
||||
**Problem**: Options that can't be used together
|
||||
|
||||
```python
|
||||
def validate_exclusive(ctx, param, value):
|
||||
"""Ensure mutually exclusive options"""
|
||||
if value and ctx.params.get('other_option'):
|
||||
raise click.UsageError(
|
||||
'Cannot use --option and --other-option together'
|
||||
)
|
||||
return value
|
||||
|
||||
@click.command()
|
||||
@click.option('--option', callback=validate_exclusive)
|
||||
@click.option('--other-option')
|
||||
def command(option, other_option):
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 12: Dependent Options
|
||||
|
||||
**Problem**: One option requires another
|
||||
|
||||
```python
|
||||
@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):
|
||||
"""Validate dependent options"""
|
||||
if ssl:
|
||||
if not cert or not key:
|
||||
raise click.UsageError(
|
||||
'--ssl requires both --cert and --key'
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Edge Cases
|
||||
|
||||
### Case 13: Testing with Environment Variables
|
||||
|
||||
**Problem**: Tests failing due to environment pollution
|
||||
|
||||
```python
|
||||
def test_with_clean_env():
|
||||
"""Test with isolated environment"""
|
||||
runner = CliRunner()
|
||||
|
||||
# This isolates environment variables:
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
['command'],
|
||||
env={'API_KEY': 'test'},
|
||||
catch_exceptions=False
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
```
|
||||
|
||||
### Case 14: Testing Interactive Prompts with Validation
|
||||
|
||||
**Problem**: Prompts with retry logic
|
||||
|
||||
```python
|
||||
def test_interactive_retry():
|
||||
"""Test prompt with retry on invalid input"""
|
||||
runner = CliRunner()
|
||||
|
||||
# Provide multiple inputs (first invalid, second valid)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
['create'],
|
||||
input='invalid-email\nvalid@email.com\n'
|
||||
)
|
||||
|
||||
assert 'Invalid email' in result.output
|
||||
assert result.exit_code == 0
|
||||
```
|
||||
|
||||
### Case 15: Testing File Operations
|
||||
|
||||
**Problem**: Tests creating actual files
|
||||
|
||||
```python
|
||||
def test_file_operations():
|
||||
"""Test with isolated filesystem"""
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
# Create test file
|
||||
with open('input.txt', 'w') as f:
|
||||
f.write('test data')
|
||||
|
||||
result = runner.invoke(cli, ['process', 'input.txt'])
|
||||
|
||||
# Verify output file
|
||||
assert Path('output.txt').exists()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Edge Cases
|
||||
|
||||
### Case 16: Windows Path Handling
|
||||
|
||||
**Problem**: Backslashes in Windows paths
|
||||
|
||||
```python
|
||||
@click.option('--path', type=click.Path())
|
||||
def command(path):
|
||||
# Use pathlib for cross-platform compatibility
|
||||
from pathlib import Path
|
||||
p = Path(path) # Handles Windows/Unix paths
|
||||
```
|
||||
|
||||
### Case 17: Unicode in Command Line Arguments
|
||||
|
||||
**Problem**: Non-ASCII characters in arguments
|
||||
|
||||
```python
|
||||
@click.command()
|
||||
@click.argument('name')
|
||||
def greet(name):
|
||||
"""Handle unicode properly"""
|
||||
# Click handles unicode automatically on Python 3
|
||||
click.echo(f'Hello, {name}!')
|
||||
|
||||
# This works:
|
||||
# cli greet "José"
|
||||
# cli greet "北京"
|
||||
```
|
||||
|
||||
### Case 18: Terminal Width Detection
|
||||
|
||||
**Problem**: Output formatting for different terminal sizes
|
||||
|
||||
```python
|
||||
@click.command()
|
||||
def status():
|
||||
"""Adapt to terminal width"""
|
||||
terminal_width = click.get_terminal_size()[0]
|
||||
|
||||
if terminal_width < 80:
|
||||
# Compact output for narrow terminals
|
||||
click.echo('Status: OK')
|
||||
else:
|
||||
# Detailed output for wide terminals
|
||||
click.echo('=' * terminal_width)
|
||||
click.echo('Detailed Status Information')
|
||||
click.echo('=' * terminal_width)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Edge Cases
|
||||
|
||||
### Case 19: Dynamic Command Registration
|
||||
|
||||
**Problem**: Register commands at runtime
|
||||
|
||||
```python
|
||||
class DynamicGroup(click.Group):
|
||||
"""Group that discovers commands dynamically"""
|
||||
|
||||
def list_commands(self, ctx):
|
||||
"""List available commands"""
|
||||
# Dynamically discover commands
|
||||
return ['cmd1', 'cmd2', 'cmd3']
|
||||
|
||||
def get_command(self, ctx, name):
|
||||
"""Load command on demand"""
|
||||
if name in self.list_commands(ctx):
|
||||
# Import and return command
|
||||
module = __import__(f'commands.{name}')
|
||||
return getattr(module, name).cli
|
||||
return None
|
||||
|
||||
@click.command(cls=DynamicGroup)
|
||||
def cli():
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 20: Command Aliases
|
||||
|
||||
**Problem**: Support command aliases
|
||||
|
||||
```python
|
||||
class AliasedGroup(click.Group):
|
||||
"""Group that supports command aliases"""
|
||||
|
||||
def get_command(self, ctx, cmd_name):
|
||||
"""Resolve aliases"""
|
||||
aliases = {
|
||||
'ls': 'list',
|
||||
'rm': 'remove',
|
||||
'cp': 'copy'
|
||||
}
|
||||
|
||||
# Resolve alias
|
||||
resolved = aliases.get(cmd_name, cmd_name)
|
||||
return super().get_command(ctx, resolved)
|
||||
|
||||
@click.group(cls=AliasedGroup)
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
def list():
|
||||
"""List items (alias: ls)"""
|
||||
pass
|
||||
```
|
||||
|
||||
### Case 21: Progress Bar with Unknown Length
|
||||
|
||||
**Problem**: Show progress when total is unknown
|
||||
|
||||
```python
|
||||
@click.command()
|
||||
def process():
|
||||
"""Process with indeterminate progress"""
|
||||
import time
|
||||
|
||||
# For unknown length, use length=None
|
||||
with click.progressbar(
|
||||
range(100),
|
||||
length=None,
|
||||
label='Processing'
|
||||
) as bar:
|
||||
for _ in bar:
|
||||
time.sleep(0.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key takeaways for handling edge cases:
|
||||
|
||||
1. **Parameters**: Use callbacks and custom types for complex validation
|
||||
2. **Context**: Ensure context is initialized before accessing ctx.obj
|
||||
3. **Errors**: Provide clear, actionable error messages
|
||||
4. **Testing**: Use CliRunner's isolation features
|
||||
5. **Platform**: Use pathlib and Click's built-in utilities for portability
|
||||
|
||||
For more edge cases, consult the [Click documentation](https://click.palletsprojects.com/) and [GitHub issues](https://github.com/pallets/click/issues).
|
||||
521
skills/click-patterns/examples/patterns.md
Normal file
521
skills/click-patterns/examples/patterns.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Click Framework Common Patterns
|
||||
|
||||
Best practices and common patterns for building production-ready Click CLIs.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Command Structure Patterns](#command-structure-patterns)
|
||||
2. [Parameter Patterns](#parameter-patterns)
|
||||
3. [Validation Patterns](#validation-patterns)
|
||||
4. [Error Handling Patterns](#error-handling-patterns)
|
||||
5. [Output Patterns](#output-patterns)
|
||||
6. [Configuration Patterns](#configuration-patterns)
|
||||
7. [Testing Patterns](#testing-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Command Structure Patterns
|
||||
|
||||
### Single Command CLI
|
||||
|
||||
For simple tools with one main function:
|
||||
|
||||
```python
|
||||
@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:
|
||||
|
||||
```python
|
||||
@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:
|
||||
|
||||
```python
|
||||
@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:
|
||||
|
||||
```python
|
||||
@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):
|
||||
```python
|
||||
@click.option('--name', '-n', default='World', help='Name to greet')
|
||||
@click.option('--count', '-c', default=1, type=int)
|
||||
```
|
||||
|
||||
**Arguments** (required, positional):
|
||||
```python
|
||||
@click.argument('filename')
|
||||
@click.argument('output', type=click.Path())
|
||||
```
|
||||
|
||||
### Required Options
|
||||
|
||||
```python
|
||||
@click.option('--api-key', required=True, help='API key (required)')
|
||||
@click.option('--config', required=True, type=click.Path(exists=True))
|
||||
```
|
||||
|
||||
### Multiple Values
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
```python
|
||||
@click.option('--api-key', envvar='API_KEY', help='API key (from env: API_KEY)')
|
||||
@click.option('--debug', envvar='DEBUG', is_flag=True)
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
@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
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```python
|
||||
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](https://click.palletsprojects.com/).
|
||||
Reference in New Issue
Block a user