Command Line Applications
In this chapter, we'll build a command-line interface (CLI) for our Task API. We'll cover:
- Building CLI tools with
argparse - Introduction to the
clicklibrary - Reading user input
- Exit codes and error handling
- Testing command-line applications
- Project structure for CLI apps
Why build a CLI?
Command-line interfaces are:
- Easy to automate and script
- Fast to use for power users
- Great for servers without GUI
- Perfect for developer tools
Getting started with argparse
Python's built-in argparse module handles command-line arguments:
import argparse
def main():
parser = argparse.ArgumentParser(description='Task Manager CLI')
parser.add_argument('command', choices=['list', 'add', 'complete'])
parser.add_argument('--title', '-t', help='Task title')
args = parser.parse_args()
if args.command == 'list':
list_tasks()
elif args.command == 'add':
add_task(args.title)
if __name__ == '__main__':
main()
Testing CLI with argparse
Write the test first
# tests/test_cli.py
import pytest
from unittest.mock import patch, MagicMock
from io import StringIO
from cli import main, list_tasks
def test_list_tasks_displays_tasks(capsys):
tasks = [
{'id': 1, 'title': 'Task 1', 'completed': False},
{'id': 2, 'title': 'Task 2', 'completed': True},
]
with patch('cli.get_tasks', return_value=tasks):
list_tasks()
captured = capsys.readouterr()
assert 'Task 1' in captured.out
assert 'Task 2' in captured.out
assert '✓' in captured.out # Completed marker
def test_main_with_list_command(capsys):
with patch('sys.argv', ['cli', 'list']):
with patch('cli.get_tasks', return_value=[]):
main()
captured = capsys.readouterr()
assert 'No tasks' in captured.out or captured.out == ''
Implement the CLI
# cli.py
import argparse
import requests
API_URL = 'http://localhost:5000/api'
def get_tasks():
response = requests.get(f'{API_URL}/tasks')
return response.json()
def list_tasks():
tasks = get_tasks()
if not tasks:
print('No tasks found.')
return
for task in tasks:
status = '✓' if task['completed'] else ' '
print(f"[{status}] {task['id']}: {task['title']}")
def add_task(title):
response = requests.post(
f'{API_URL}/tasks',
json={'title': title}
)
if response.status_code == 201:
task = response.json()
print(f"Created task {task['id']}: {task['title']}")
else:
print(f"Error: {response.json().get('error', 'Unknown error')}")
def complete_task(task_id):
response = requests.put(
f'{API_URL}/tasks/{task_id}',
json={'completed': True}
)
if response.status_code == 200:
print(f"Task {task_id} marked as complete")
else:
print(f"Error: Task not found")
def main():
parser = argparse.ArgumentParser(
description='Task Manager CLI',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s list List all tasks
%(prog)s add -t "Buy groceries" Add a new task
%(prog)s complete 1 Mark task 1 as complete
'''
)
subparsers = parser.add_subparsers(dest='command', help='Commands')
# List command
subparsers.add_parser('list', help='List all tasks')
# Add command
add_parser = subparsers.add_parser('add', help='Add a new task')
add_parser.add_argument('-t', '--title', required=True, help='Task title')
# Complete command
complete_parser = subparsers.add_parser('complete', help='Mark task as complete')
complete_parser.add_argument('task_id', type=int, help='Task ID')
args = parser.parse_args()
if args.command == 'list':
list_tasks()
elif args.command == 'add':
add_task(args.title)
elif args.command == 'complete':
complete_task(args.task_id)
else:
parser.print_help()
if __name__ == '__main__':
main()
Using click
click is a more powerful library for building CLIs:
pip install click
Basic click usage
import click
@click.group()
def cli():
"""Task Manager CLI"""
pass
@cli.command()
def list():
"""List all tasks"""
tasks = get_tasks()
for task in tasks:
status = '✓' if task['completed'] else ' '
click.echo(f"[{status}] {task['id']}: {task['title']}")
@cli.command()
@click.option('--title', '-t', required=True, help='Task title')
def add(title):
"""Add a new task"""
# Implementation
@cli.command()
@click.argument('task_id', type=int)
def complete(task_id):
"""Mark a task as complete"""
# Implementation
if __name__ == '__main__':
cli()
Testing click commands
from click.testing import CliRunner
from cli import cli
def test_list_command():
runner = CliRunner()
with patch('cli.get_tasks', return_value=[
{'id': 1, 'title': 'Task 1', 'completed': False}
]):
result = runner.invoke(cli, ['list'])
assert result.exit_code == 0
assert 'Task 1' in result.output
def test_add_command():
runner = CliRunner()
with patch('cli.requests.post') as mock_post:
mock_post.return_value.status_code = 201
mock_post.return_value.json.return_value = {
'id': 1, 'title': 'New Task'
}
result = runner.invoke(cli, ['add', '-t', 'New Task'])
assert result.exit_code == 0
assert 'Created task' in result.output
Interactive input
With click
@cli.command()
def interactive():
"""Add tasks interactively"""
while True:
title = click.prompt('Task title (or "done" to finish)')
if title.lower() == 'done':
break
add_task(title)
click.echo(f'Added: {title}')
@cli.command()
@click.confirmation_option(prompt='Are you sure you want to delete all tasks?')
def clear():
"""Delete all tasks"""
# Implementation
With argparse
def interactive_add():
while True:
title = input('Task title (or "done" to finish): ')
if title.lower() == 'done':
break
add_task(title)
print(f'Added: {title}')
Exit codes
Use exit codes to indicate success or failure:
import sys
def main():
try:
args = parse_args()
if args.command == 'list':
list_tasks()
# ...
sys.exit(0) # Success
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1) # Failure
Testing exit codes
def test_error_returns_nonzero_exit():
runner = CliRunner()
with patch('cli.get_tasks', side_effect=ConnectionError('No connection')):
result = runner.invoke(cli, ['list'])
assert result.exit_code != 0
assert 'Error' in result.output
Pretty output
Using colors with click
@cli.command()
def list():
"""List all tasks"""
tasks = get_tasks()
if not tasks:
click.echo(click.style('No tasks found.', fg='yellow'))
return
for task in tasks:
if task['completed']:
status = click.style('✓', fg='green')
title = click.style(task['title'], fg='green', strikethrough=True)
else:
status = click.style(' ', fg='white')
title = task['title']
click.echo(f"[{status}] {task['id']}: {title}")
Tables with tabulate
pip install tabulate
from tabulate import tabulate
def list_tasks():
tasks = get_tasks()
table_data = [
[
'✓' if t['completed'] else ' ',
t['id'],
t['title'],
t['created_at'][:10]
]
for t in tasks
]
print(tabulate(
table_data,
headers=['Done', 'ID', 'Title', 'Created'],
tablefmt='simple'
))
Progress bars
import click
import time
@cli.command()
def sync():
"""Sync tasks with server"""
tasks = get_tasks()
with click.progressbar(tasks, label='Syncing tasks') as bar:
for task in bar:
sync_task(task)
time.sleep(0.1) # Simulate work
Configuration
Environment variables
import os
API_URL = os.environ.get('TASK_API_URL', 'http://localhost:5000/api')
Config file
import os
import json
from pathlib import Path
def get_config():
config_path = Path.home() / '.taskrc'
if config_path.exists():
return json.loads(config_path.read_text())
return {}
@cli.command()
@click.option('--api-url', help='API URL')
def configure(api_url):
"""Configure the CLI"""
config = get_config()
if api_url:
config['api_url'] = api_url
config_path = Path.home() / '.taskrc'
config_path.write_text(json.dumps(config, indent=2))
click.echo('Configuration saved.')
Error handling
import click
import requests
class APIError(Exception):
pass
def api_request(method, endpoint, **kwargs):
try:
response = requests.request(method, f'{API_URL}{endpoint}', **kwargs)
response.raise_for_status()
return response
except requests.ConnectionError:
raise APIError('Cannot connect to API. Is the server running?')
except requests.HTTPError as e:
raise APIError(f'API error: {e.response.status_code}')
@cli.command()
def list():
"""List all tasks"""
try:
response = api_request('GET', '/tasks')
tasks = response.json()
display_tasks(tasks)
except APIError as e:
click.echo(click.style(f'Error: {e}', fg='red'), err=True)
raise SystemExit(1)
A complete CLI application
# cli.py
import click
import requests
import os
from tabulate import tabulate
API_URL = os.environ.get('TASK_API_URL', 'http://localhost:5000/api')
class TaskClient:
def __init__(self, base_url):
self.base_url = base_url
def get_tasks(self, completed=None):
params = {}
if completed is not None:
params['completed'] = str(completed).lower()
response = requests.get(f'{self.base_url}/tasks', params=params)
response.raise_for_status()
return response.json()
def create_task(self, title, description=''):
response = requests.post(
f'{self.base_url}/tasks',
json={'title': title, 'description': description}
)
response.raise_for_status()
return response.json()
def complete_task(self, task_id):
response = requests.put(
f'{self.base_url}/tasks/{task_id}',
json={'completed': True}
)
response.raise_for_status()
return response.json()
def delete_task(self, task_id):
response = requests.delete(f'{self.base_url}/tasks/{task_id}')
response.raise_for_status()
# Initialize client
client = TaskClient(API_URL)
@click.group()
@click.version_option(version='1.0.0')
def cli():
"""Task Manager - Manage your tasks from the command line."""
pass
@cli.command('list')
@click.option('--completed/--pending', default=None, help='Filter by status')
@click.option('--format', 'output_format', type=click.Choice(['table', 'simple']),
default='table', help='Output format')
def list_tasks(completed, output_format):
"""List all tasks."""
try:
tasks = client.get_tasks(completed=completed)
if not tasks:
click.echo('No tasks found.')
return
if output_format == 'table':
table = [
['✓' if t['completed'] else ' ', t['id'], t['title']]
for t in tasks
]
click.echo(tabulate(table, headers=['', 'ID', 'Title']))
else:
for t in tasks:
status = '✓' if t['completed'] else ' '
click.echo(f'[{status}] {t["id"]}: {t["title"]}')
except requests.RequestException as e:
click.echo(f'Error: {e}', err=True)
raise SystemExit(1)
@cli.command('add')
@click.argument('title')
@click.option('--description', '-d', default='', help='Task description')
def add_task(title, description):
"""Add a new task."""
try:
task = client.create_task(title, description)
click.echo(f'Created task {task["id"]}: {task["title"]}')
except requests.RequestException as e:
click.echo(f'Error: {e}', err=True)
raise SystemExit(1)
@cli.command('done')
@click.argument('task_id', type=int)
def mark_done(task_id):
"""Mark a task as complete."""
try:
task = client.complete_task(task_id)
click.echo(f'Completed: {task["title"]}')
except requests.RequestException as e:
click.echo(f'Error: {e}', err=True)
raise SystemExit(1)
@cli.command('delete')
@click.argument('task_id', type=int)
@click.confirmation_option(prompt='Are you sure?')
def delete_task(task_id):
"""Delete a task."""
try:
client.delete_task(task_id)
click.echo(f'Deleted task {task_id}')
except requests.RequestException as e:
click.echo(f'Error: {e}', err=True)
raise SystemExit(1)
if __name__ == '__main__':
cli()
Full test suite
# tests/test_cli.py
import pytest
from click.testing import CliRunner
from unittest.mock import patch, MagicMock
from cli import cli, TaskClient
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_client():
with patch('cli.client') as mock:
yield mock
def test_list_empty(runner, mock_client):
mock_client.get_tasks.return_value = []
result = runner.invoke(cli, ['list'])
assert result.exit_code == 0
assert 'No tasks found' in result.output
def test_list_tasks(runner, mock_client):
mock_client.get_tasks.return_value = [
{'id': 1, 'title': 'Task 1', 'completed': False},
{'id': 2, 'title': 'Task 2', 'completed': True},
]
result = runner.invoke(cli, ['list'])
assert result.exit_code == 0
assert 'Task 1' in result.output
assert 'Task 2' in result.output
def test_add_task(runner, mock_client):
mock_client.create_task.return_value = {'id': 1, 'title': 'New Task'}
result = runner.invoke(cli, ['add', 'New Task'])
assert result.exit_code == 0
assert 'Created task 1' in result.output
mock_client.create_task.assert_called_with('New Task', '')
def test_mark_done(runner, mock_client):
mock_client.complete_task.return_value = {'id': 1, 'title': 'Done Task'}
result = runner.invoke(cli, ['done', '1'])
assert result.exit_code == 0
assert 'Completed' in result.output
def test_delete_with_confirmation(runner, mock_client):
result = runner.invoke(cli, ['delete', '1'], input='y\n')
assert result.exit_code == 0
mock_client.delete_task.assert_called_with(1)
Wrapping up
We've covered:
- argparse - Python's built-in CLI argument parser
- click - A powerful alternative for building CLIs
- Testing CLIs - Using CliRunner and mocking
- Interactive input - Prompts and confirmations
- Exit codes - Indicate success or failure
- Pretty output - Colors, tables, progress bars
- Error handling - Graceful error messages
Key takeaways
- Use subcommands for complex CLIs
- Test CLI output and exit codes
- Handle errors gracefully with helpful messages
- Support both scripting and interactive use
- Use environment variables for configuration
You now have a complete Task Manager application with:
- A REST API (Flask)
- Database persistence (SQLAlchemy)
- A command-line interface (click)
This is a solid foundation for building real-world Python applications with TDD!