Skip to main content

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 click library
  • 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

  1. Use subcommands for complex CLIs
  2. Test CLI output and exit codes
  3. Handle errors gracefully with helpful messages
  4. Support both scripting and interactive use
  5. 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!