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 (like git commit, git push)
  • @cli.command() — adds a command to the group
  • @click.argument() — required positional argument
  • @click.option() — optional flag with --name syntax
  • click.Choice() — restricts values to a list
  • click.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.