← Home

131. Click

Command-line interfaces with Click

131. Click

🖱️ Aryan is turning the RAM manager into a proper CLI tool. He needs flags like –threshold, –model, and –top-n so users can configure it from the command line. His Click decorators are in the wrong order and the types are incorrect.

💡 Fun fact: Click was created by Armin Ronacher, the same developer who built Flask. The name stands for “Command Line Interface Creation Kit.” It was originally written because argparse and optparse couldn’t handle complex multi-command CLIs well. Today, tools like the AWS CLI, Ansible, and Black (the Python formatter) all use Click under the hood.

⚠️ Watch out: Python decorators are applied bottom-up — the decorator closest to the def runs first. For Click, @click.command() must be the decorator directly above the function definition, and @click.option() decorators go above that. Getting this backwards is the single most common Click mistake. The error message when you get it wrong is not obvious.

🤔 Think about it: Click automatically converts --threshold 80 from the string "80" to the integer 80 when you add type=int. Without type=int, what type does threshold have inside your function? What would break silently if you compared it to a number?

Learning objectives

  • Apply Click decorators in correct order (@click.command() innermost)
  • Use type= parameter to auto-convert and validate option values
  • Raise click.BadParameter for validation errors
  • Distinguish arguments (positional) from options (named flags)
  • Test CLI commands with CliRunner

Key concepts

  • @click.command() — declare a CLI command
  • @click.option() — named flag with default and type
  • @click.argument() — positional argument
  • type=int / type=click.Choice — automatic type conversion
  • click.BadParameter — user-facing validation error
  • CliRunner — test CLI without subprocess

Try it

Concept detail

CLI Tools with Click

pip install click — the standard for Python CLI tools.

Basic command

import click

@click.command()
@click.option('--name', default='World', help='Who to greet')
@click.option('--count', default=1, type=int)
@click.option('--verbose', is_flag=True)
def hello(name, count, verbose):
    """Say hello."""
    for _ in range(count):
        click.echo(f'Hello, {name}!')

if __name__ == '__main__':
    hello()

Decorator order (critical!)

@click.command()          # innermost — closest to def
@click.option('--foo')   # outer options
@click.option('--bar')
def cmd(foo, bar): ...

Option types

@click.option('--port', type=int, default=8080)
@click.option('--file', type=click.Path(exists=True))
@click.option('--choice', type=click.Choice(['a', 'b', 'c']))
@click.option('--verbose', is_flag=True)

Arguments vs Options

@click.argument('filename')           # positional, required
@click.argument('pid', type=int)
@click.option('--output', '-o')       # named flag, optional

Error handling

raise click.BadParameter('must be > 0', param_hint='--count')
raise click.UsageError('conflicting options')
click.echo('Error: ...', err=True)   # stderr
sys.exit(1)

Testing CLI with CliRunner

from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(my_cmd, ['--foo', 'bar'])
assert result.exit_code == 0
assert 'expected output' in result.output

Solution

import click

# @click.command() is innermost (right above def), options are outer
@click.command()
@click.option('--threshold', default=80, type=int, help='RAM % to trigger alert')
@click.option('--top-n', default=5, type=int, help='Number of top processes to show')
@click.option('--model', default='claude-3-haiku-20240307', help='LLM model name')
@click.option('--json-output', is_flag=True, default=False, help='Output as JSON')
def run(threshold, top_n, model, json_output):
    """RAM Manager — monitors memory and asks an AI for advice."""
    click.echo(f'Monitoring RAM... threshold={threshold}% top_n={top_n}')
    click.echo(f'Model: {model}  JSON: {json_output}')

def validate_threshold(value: int) -> int:
    """Validate that threshold is between 1 and 100."""
    if not 1 <= value <= 100:
        raise click.BadParameter(f'must be 1-100, got {value}', param_hint='threshold')
    return value

@click.command()
@click.argument('pid', type=int)  # type=int converts arg from string automatically
def kill_process(pid):
    """Kill a process by PID."""
    click.echo(f'Killing PID {pid}')
    # pid is now an int — safe for os.kill(pid, signal.SIGTERM)

Tests

from click.testing import CliRunner

def test_run_default_options():
    runner = CliRunner()
    result = runner.invoke(run, [])
    assert result.exit_code == 0, f'Command failed: {result.output}'
    assert 'threshold=80' in result.output

def test_run_custom_threshold():
    runner = CliRunner()
    result = runner.invoke(run, ['--threshold', '70'])
    assert result.exit_code == 0
    assert 'threshold=70' in result.output

def test_run_json_flag():
    runner = CliRunner()
    result = runner.invoke(run, ['--json-output'])
    assert result.exit_code == 0
    assert 'JSON: True' in result.output

def test_validate_threshold_valid():
    assert validate_threshold(80) == 80
    assert validate_threshold(1) == 1
    assert validate_threshold(100) == 100

def test_validate_threshold_invalid():
    try:
        validate_threshold(0)
        assert False, 'Should raise click.BadParameter'
    except click.BadParameter:
        pass

def test_validate_threshold_invalid_raises_click_not_value_error():
    try:
        validate_threshold(101)
        assert False, 'Should raise click.BadParameter not ValueError'
    except click.BadParameter:
        pass
    except ValueError:
        assert False, 'Should raise click.BadParameter, not ValueError'

def test_kill_process_takes_int():
    runner = CliRunner()
    result = runner.invoke(kill_process, ['1234'])
    assert result.exit_code == 0
    assert '1234' in result.output

Resources