147. Click (App V0.8)
RAM Manager v0.8 — Click CLI upgrade
147. Click (App V0.8)
argparse works, but Click is cleaner for multi-command CLIs.
🤔 Socratic question: Why do tools like
githave subcommands (git commit,git push,git log)? Why not just one command with a hundred flags? UX. Click makes subcommands easy. You’re about to build a CLI that works like git.
import click
@click.group()
def cli():
'''RAM Manager — monitor memory and get AI advice.'''
@cli.command()
@click.option('--threshold', type=int, default=None)
@click.option('--top-n', type=int, default=None)
@click.option('--ask-ai', is_flag=True, help='Get LLM advice')
def monitor(threshold, top_n, ask_ai):
'''Monitor RAM usage.'''
...
@cli.command('init-config')
def init_config():
'''Write default config file.'''
...
if __name__ == '__main__':
cli()Now he has subcommands:
python ram_manager.py monitor --threshold 85 --ask-ai
python ram_manager.py init-configClick’s CliRunner makes testing straightforward:
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli, ['monitor', '--threshold', '60'])
assert result.exit_code == 0
assert 'RAM' in result.output💡 Real-world: Flask (the web framework) was built by the same creator as Click — Armin Ronacher. Both use decorators to define behavior. If you’ve ever used
@app.route('/'), you already understand the pattern. Click and Flask share the same philosophy: simple decorators for powerful behavior.
💡 Fun fact: Click was created by Armin Ronacher (also the creator of Flask and Jinja2) after he found argparse too painful for building the Flask command-line tools. “Click” stands for “Command Line Interface Creation Kit.” The decorator-based API was inspired by Flask’s route decorators — the same mental model for web routes and CLI commands.
⚠️ Watch out: Click decorator order is applied bottom-up. The decorator closest to def is applied first. So @cli.command() must come directly above def monitor(...), and @click.option() decorators stack above that. If you put @cli.command() on top, Click registers a function that isn’t decorated with options yet — the options are silently ignored.
🤔 Think about it: The solution uses @cli.command() instead of @click.command() followed by cli.add_command(). Both achieve the same result — but which is more readable? What does this tell you about Python decorators being equivalent to function calls applied at import time?
🖱️ Aryan upgrades from argparse to Click. The CLI now has subcommands (monitor, kill, report). His Click decorators are in the wrong order, types are missing, and he doesn’t use click.echo() for output.
Learning objectives
- Use @cli.command() to register subcommands on a group
- Keep decorator order correct (@cli.command directly above def)
- Always add type=int to integer arguments and options
- Use click.echo() for output (respects –no-color, pipes)
- Test Click CLI with CliRunner
Key concepts
- @click.group() — creates a CLI group with subcommands
- @cli.command() — registers subcommand on group
- click.argument(type=int) — positional arg with type
- click.echo() — Click-aware print
- CliRunner — test CLI without subprocess
Try it
Concept detail
App v0.8 — Click subcommands
$ python ram_manager.py --help
Usage: ram_manager.py [OPTIONS] COMMAND [ARGS]...
RAM Manager — monitor memory and get AI advice.
Commands:
kill Kill a process by PID.
monitor Monitor RAM usage.
report Show a RAM snapshot report.
$ python ram_manager.py monitor --threshold 70 --top-n 5
$ python ram_manager.py kill 812
$ python ram_manager.py reportGroup + subcommands pattern
@click.group()
def cli():
'''My CLI tool.'''
@cli.command() # registers as subcommand of cli
@click.option('--foo', type=int, default=5)
def bar(foo):
click.echo(f'foo={foo}')
@cli.command()
@click.argument('name')
def greet(name):
click.echo(f'Hello {name}')
if __name__ == '__main__':
cli()argparse → click comparison
argparse subparsers: verbose, manual wiring
click @cli.command(): clean, auto-registered, testableSolution
import click
import psutil
import os
import requests
from datetime import datetime
from dotenv import load_dotenv
from rich.console import Console
from rich.table import Table
from rich import box
load_dotenv()
console = Console()
def take_snapshot(n: int = 10) -> dict:
mem = psutil.virtual_memory()
processes = []
for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
try:
rss_mb = proc.info['memory_info'].rss / 1e6
processes.append({'name': proc.info['name'], 'pid': proc.info['pid'], 'rss_mb': rss_mb})
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return {
'percent': mem.percent,
'used_gb': mem.used / 1e9,
'total_gb': mem.total / 1e9,
'processes': sorted(processes, key=lambda p: p['rss_mb'], reverse=True)[:n],
'timestamp': datetime.now().isoformat(),
}
def make_process_table(snapshot: dict) -> Table:
table = Table(box=box.ROUNDED, title='Top Processes')
table.add_column('Process', style='cyan')
table.add_column('PID', justify='right')
table.add_column('RSS MB', justify='right')
for p in snapshot['processes'][:10]:
rss = p['rss_mb']
color = 'red' if rss > 500 else ('yellow' if rss > 100 else 'green')
table.add_row(p['name'], str(p['pid']), f'[{color}]{rss:.0f}[/]')
return table
@click.group()
def cli():
'''RAM Manager — monitor memory and get AI advice.'''
@cli.command()
@click.option('--threshold', type=int, default=80, help='Alert threshold %')
@click.option('--top-n', type=int, default=10, help='Processes to show')
@click.option('--no-save', is_flag=True, help='Skip saving report')
def monitor(threshold, top_n, no_save):
'''Monitor RAM usage and alert if above threshold.'''
snapshot = take_snapshot(n=top_n)
pct = snapshot['percent']
color = 'red' if pct > threshold else 'green'
click.echo(f'RAM: {pct:.1f}% ({snapshot["used_gb"]:.1f}/{snapshot["total_gb"]:.1f} GB)')
if pct > threshold:
click.echo(f'⚠️ Above {threshold}%', err=False)
console.print(make_process_table(snapshot))
@cli.command()
@click.argument('pid', type=int)
def kill(pid):
'''Kill a process by PID.'''
try:
proc = psutil.Process(pid)
name = proc.name()
proc.terminate()
click.echo(f'Terminated {name} (PID {pid})')
except psutil.NoSuchProcess:
click.echo(f'No process with PID {pid}', err=True)
raise click.Abort()
@cli.command()
@click.option('--top-n', type=int, default=5)
def report(top_n):
'''Show a RAM snapshot report.'''
snapshot = take_snapshot(n=top_n)
console.print(make_process_table(snapshot))
if __name__ == '__main__':
cli()Tests
from click.testing import CliRunner
def test_monitor_runs_with_defaults():
runner = CliRunner()
result = runner.invoke(cli, ['monitor'])
assert result.exit_code == 0, result.output
def test_monitor_custom_threshold():
runner = CliRunner()
result = runner.invoke(cli, ['monitor', '--threshold', '70'])
assert result.exit_code == 0
def test_kill_requires_pid():
runner = CliRunner()
result = runner.invoke(cli, ['kill'])
assert result.exit_code != 0, 'kill without PID should fail'
def test_kill_invalid_pid():
runner = CliRunner()
result = runner.invoke(cli, ['kill', '999999999'])
assert result.exit_code != 0 or 'No process' in result.output
def test_decorator_order_correct():
import inspect
src = inspect.getsource(monitor)
# @click.command / @cli.command must appear before @click.option in source
# (remember: decorators apply bottom-up, so command decorator is closest to def)
cmd_pos = src.find('@cli.command') if '@cli.command' in src else src.find('@click.command')
opt_pos = src.find('@click.option')
assert cmd_pos < opt_pos or opt_pos == -1, (
'@cli.command() must be immediately above def, with @click.option above that'
)
def test_kill_pid_is_int():
import inspect
src = inspect.getsource(kill)
assert 'type=int' in src, 'pid argument must have type=int'
def test_cli_has_subcommands():
runner = CliRunner()
result = runner.invoke(cli, ['--help'])
assert 'monitor' in result.output
assert 'kill' in result.output