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

11 KiB

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
  2. Context and State Edge Cases
  3. Error Handling Edge Cases
  4. Testing Edge Cases
  5. Platform-Specific Edge Cases

Parameter Handling Edge Cases

Case 1: Multiple Values with Same Option

Problem: User specifies the same option multiple times

cli --tag python --tag docker --tag kubernetes

Solution: Use multiple=True

@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

cli process --file=-myfile.txt  # -myfile.txt looks like option

Solution: Use -- separator or quotes

@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

@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

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

# 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

@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

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

# 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

@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

@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

# 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

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

# 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

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

@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

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

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

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

@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

@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

@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

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

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

@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 and GitHub issues.