In the previous tutorial, we learned about logging and debugging. Now let’s build our first real project — a command-line task manager using Click for argument parsing and Rich for colored output.
This project uses everything we have learned so far: dataclasses, file I/O, error handling, testing, and more. By the end, you will have a working CLI tool you can install with pip install.
What We Are Building
A task manager that runs in the terminal:
# Add tasks
tasks add "Learn Python" --priority high
tasks add "Build a CLI tool"
tasks add "Write unit tests"
# List tasks
tasks list
tasks list --pending
tasks list --priority high
# Complete and delete
tasks done 1
tasks delete 3
# Search and stats
tasks search "Python"
tasks stats
Project Structure
task-manager/
src/
py22_cli.py # Core logic (no CLI dependencies)
tests/
test_py22.py # Tests for core logic
pyproject.toml # Package configuration
requirements.txt # Dependencies
Key design decision: We separate the core logic (TaskManager class) from the CLI layer (Click commands). This makes the core logic easy to test without running CLI commands.
Step 1: The Task Model
We start with a simple dataclass for tasks:
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Any
@dataclass
class Task:
"""A single task in the task manager."""
id: int
title: str
done: bool = False
priority: str = "medium" # low, medium, high
created_at: str = ""
def __post_init__(self) -> None:
if not self.created_at:
self.created_at = datetime.now().strftime("%Y-%m-%d %H:%M")
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Task":
return cls(**data)
The to_dict() and from_dict() methods let us save tasks to JSON and load them back.
Step 2: JSON File Storage
We store tasks in a JSON file. This is simple and works for a CLI tool:
import json
from pathlib import Path
class TaskStorage:
"""Manages task persistence to a JSON file."""
def __init__(self, filepath: str | Path) -> None:
self.filepath = Path(filepath)
def load(self) -> list[Task]:
"""Load tasks from the JSON file."""
if not self.filepath.exists():
return []
try:
data = json.loads(self.filepath.read_text())
return [Task.from_dict(item) for item in data]
except (json.JSONDecodeError, KeyError, TypeError):
return []
def save(self, tasks: list[Task]) -> None:
"""Save tasks to the JSON file."""
self.filepath.parent.mkdir(parents=True, exist_ok=True)
data = [task.to_dict() for task in tasks]
self.filepath.write_text(json.dumps(data, indent=2))
Notice the error handling: if the JSON file is corrupted, we return an empty list instead of crashing. The mkdir(parents=True) creates the directory if it does not exist.
Step 3: The Task Manager
The core logic lives in a TaskManager class. It does not know about Click or Rich — it just manages tasks:
class TaskManager:
"""Core task management logic."""
def __init__(self, storage: TaskStorage) -> None:
self.storage = storage
self.tasks: list[Task] = storage.load()
def _next_id(self) -> int:
if not self.tasks:
return 1
return max(task.id for task in self.tasks) + 1
def add(self, title: str, priority: str = "medium") -> Task:
"""Add a new task."""
if not title.strip():
raise ValueError("Task title cannot be empty")
if priority not in ("low", "medium", "high"):
raise ValueError(f"Invalid priority: {priority}")
task = Task(id=self._next_id(), title=title.strip(), priority=priority)
self.tasks.append(task)
self.storage.save(self.tasks)
return task
def list_tasks(
self, show_done: bool = True, priority: str | None = None
) -> list[Task]:
"""List tasks with optional filters."""
result = self.tasks
if not show_done:
result = [t for t in result if not t.done]
if priority:
result = [t for t in result if t.priority == priority]
return result
def complete(self, task_id: int) -> Task:
"""Mark a task as done."""
task = self._find_task(task_id)
task.done = True
self.storage.save(self.tasks)
return task
def delete(self, task_id: int) -> Task:
"""Delete a task."""
task = self._find_task(task_id)
self.tasks.remove(task)
self.storage.save(self.tasks)
return task
def search(self, keyword: str) -> list[Task]:
"""Search tasks by title."""
keyword_lower = keyword.lower()
return [t for t in self.tasks if keyword_lower in t.title.lower()]
def clear_done(self) -> int:
"""Remove all completed tasks."""
done_tasks = [t for t in self.tasks if t.done]
for task in done_tasks:
self.tasks.remove(task)
self.storage.save(self.tasks)
return len(done_tasks)
def stats(self) -> dict[str, int]:
"""Return task statistics."""
total = len(self.tasks)
done = sum(1 for t in self.tasks if t.done)
return {
"total": total,
"done": done,
"pending": total - done,
"high_priority": sum(
1 for t in self.tasks if t.priority == "high" and not t.done
),
}
def _find_task(self, task_id: int) -> Task:
for task in self.tasks:
if task.id == task_id:
return task
raise KeyError(f"Task #{task_id} not found")
Every method validates input, saves to disk, and raises clear errors. This is testable code.
Step 4: Click — Command-Line Interface
Click is a Python library for building CLI applications. It is simpler and more powerful than argparse:
pip install click
Here is how we add a CLI layer on top of our TaskManager:
import click
from pathlib import Path
TASKS_FILE = Path.home() / ".tasks" / "tasks.json"
def get_manager() -> TaskManager:
return TaskManager(TaskStorage(TASKS_FILE))
@click.group()
def cli():
"""A simple task manager for the command line."""
pass
@cli.command()
@click.argument("title")
@click.option("--priority", "-p", default="medium",
type=click.Choice(["low", "medium", "high"]))
def add(title: str, priority: str):
"""Add a new task."""
manager = get_manager()
task = manager.add(title, priority)
click.echo(f"Added task #{task.id}: {task.title} [{task.priority}]")
@cli.command("list")
@click.option("--pending", is_flag=True, help="Show pending tasks only")
@click.option("--priority", "-p", type=click.Choice(["low", "medium", "high"]))
def list_tasks(pending: bool, priority: str | None):
"""List all tasks."""
manager = get_manager()
tasks = manager.list_tasks(show_done=not pending, priority=priority)
if not tasks:
click.echo("No tasks found.")
return
for task in tasks:
status = "done" if task.done else " "
click.echo(f" [{task.id}] {status} {task.title} ({task.priority})")
@cli.command()
@click.argument("task_id", type=int)
def done(task_id: int):
"""Mark a task as done."""
manager = get_manager()
try:
task = manager.complete(task_id)
click.echo(f"Completed: {task.title}")
except KeyError as e:
click.echo(f"Error: {e}", err=True)
@cli.command()
@click.argument("task_id", type=int)
def delete(task_id: int):
"""Delete a task."""
manager = get_manager()
try:
task = manager.delete(task_id)
click.echo(f"Deleted: {task.title}")
except KeyError as e:
click.echo(f"Error: {e}", err=True)
@cli.command()
@click.argument("keyword")
def search(keyword: str):
"""Search tasks by keyword."""
manager = get_manager()
results = manager.search(keyword)
click.echo(f"Found {len(results)} task(s):")
for task in results:
click.echo(f" [{task.id}] {task.title}")
@cli.command()
def stats():
"""Show task statistics."""
manager = get_manager()
s = manager.stats()
click.echo(f"Total: {s['total']} | Done: {s['done']} | "
f"Pending: {s['pending']} | High: {s['high_priority']}")
if __name__ == "__main__":
cli()
Click features used:
@click.group()— creates a command group (likegit commit,git push)@cli.command()— adds a command to the group@click.argument()— required positional argument@click.option()— optional flag with--namesyntaxclick.Choice()— restricts values to a listclick.echo()— prints output (works correctly on all terminals)
Step 5: Rich — Colored Output
Rich adds colors, tables, and panels to your CLI:
pip install rich
Replace the plain list_tasks command with a colored table:
from rich.console import Console
from rich.table import Table
console = Console()
@cli.command("list")
@click.option("--pending", is_flag=True)
@click.option("--priority", "-p", type=click.Choice(["low", "medium", "high"]))
def list_tasks(pending: bool, priority: str | None):
"""List all tasks."""
manager = get_manager()
tasks = manager.list_tasks(show_done=not pending, priority=priority)
if not tasks:
console.print("[yellow]No tasks found.[/yellow]")
return
table = Table(title="Tasks")
table.add_column("ID", style="cyan", justify="right")
table.add_column("Title", style="white")
table.add_column("Priority")
table.add_column("Status")
for task in tasks:
# Color priority
priority_color = {"high": "red", "medium": "yellow", "low": "green"}
p_style = priority_color.get(task.priority, "white")
# Status with emoji
status = "[green]Done[/green]" if task.done else "Pending"
table.add_row(
str(task.id),
task.title,
f"[{p_style}]{task.priority}[/{p_style}]",
status,
)
console.print(table)
Rich uses markup tags like [red]text[/red] for colors. The Table class creates aligned, bordered tables automatically.
Rich Panels for Stats
from rich.panel import Panel
@cli.command()
def stats():
"""Show task statistics."""
manager = get_manager()
s = manager.stats()
text = (
f"Total: [bold]{s['total']}[/bold]\n"
f"Done: [green]{s['done']}[/green]\n"
f"Pending: [yellow]{s['pending']}[/yellow]\n"
f"High Priority: [red]{s['high_priority']}[/red]"
)
console.print(Panel(text, title="Task Stats", border_style="blue"))
Step 6: Packaging with pyproject.toml
To make your CLI tool installable with pip install, create a pyproject.toml:
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "task-manager"
version = "1.0.0"
description = "A CLI task manager"
requires-python = ">=3.12"
dependencies = ["click>=8.0", "rich>=13.0"]
[project.scripts]
tasks = "src.py22_cli:cli"
The [project.scripts] section tells pip to create a tasks command that runs the cli function. After pip install -e ., you can run tasks add "My task" from anywhere.
Step 7: Testing
We test the core logic (TaskManager) directly, without running CLI commands:
import pytest
from src.py22_cli import Task, TaskManager, TaskStorage
class TestTaskManager:
@pytest.fixture
def manager(self, tmp_path):
storage = TaskStorage(tmp_path / "tasks.json")
return TaskManager(storage)
def test_add_task(self, manager):
task = manager.add("Learn Python")
assert task.id == 1
assert task.title == "Learn Python"
def test_add_empty_title_raises(self, manager):
with pytest.raises(ValueError, match="empty"):
manager.add("")
def test_complete_task(self, manager):
manager.add("Task 1")
task = manager.complete(1)
assert task.done is True
def test_delete_nonexistent_raises(self, manager):
with pytest.raises(KeyError, match="not found"):
manager.delete(999)
def test_search_case_insensitive(self, manager):
manager.add("Learn Python")
results = manager.search("PYTHON")
assert len(results) == 1
def test_persistence(self, tmp_path):
filepath = tmp_path / "tasks.json"
manager1 = TaskManager(TaskStorage(filepath))
manager1.add("Persistent task")
manager2 = TaskManager(TaskStorage(filepath))
assert len(manager2.list_tasks()) == 1
def test_stats(self, manager):
manager.add("Task 1", priority="high")
manager.add("Task 2", priority="low")
manager.complete(2)
stats = manager.stats()
assert stats["total"] == 2
assert stats["done"] == 1
assert stats["pending"] == 1
assert stats["high_priority"] == 1
Notice how we use tmp_path (a pytest fixture) to create temporary files. This keeps tests isolated — each test gets a fresh, empty task list.
Run the tests:
python -m pytest tests/test_py22.py -v
Common Mistakes
1. Mixing Core Logic and CLI Code
# BAD — business logic depends on Click
@cli.command()
def add(title):
if not title:
click.echo("Error: empty title") # UI code in logic
return
# Save task...
# GOOD — core logic is separate and testable
class TaskManager:
def add(self, title: str) -> Task:
if not title.strip():
raise ValueError("Title cannot be empty")
# Save task...
2. No Input Validation
# BAD — accepts anything
def add(self, title: str, priority: str) -> Task:
task = Task(id=self._next_id(), title=title, priority=priority)
# What if priority is "urgent"? Or title is empty?
# GOOD — validate everything
def add(self, title: str, priority: str = "medium") -> Task:
if not title.strip():
raise ValueError("Task title cannot be empty")
if priority not in ("low", "medium", "high"):
raise ValueError(f"Invalid priority: {priority}")
3. No Error Handling in CLI
# BAD — crashes with traceback on bad input
@cli.command()
def done(task_id):
manager.complete(task_id) # KeyError if not found!
# GOOD — catch errors and show user-friendly message
@cli.command()
def done(task_id):
try:
task = manager.complete(task_id)
click.echo(f"Completed: {task.title}")
except KeyError as e:
click.echo(f"Error: {e}", err=True)
Source Code
You can find all the code from this tutorial on GitHub:
GitHub: python-tutorial/tutorial-22-cli
What’s Next?
In the next tutorial, we will build a REST API with FastAPI — a web API for managing bookmarks with Pydantic validation, SQLAlchemy database, and automatic API documentation.
Related Articles
- Python Tutorial #12: File I/O — reading and writing JSON files
- Python Tutorial #10: Dataclasses and Pydantic — the Task model uses dataclasses
- Python Tutorial #17: Testing with pytest — testing the core logic
- Linux Cheat Sheet — terminal basics for CLI tools