Introduction to Python · Lesson 5 of 5

Project: CLI Task Manager

Build a CLI Task Manager — Python Project

This project applies Python fundamentals: functions, data structures, file I/O, and error handling. The result is a real CLI tool you can actually use.


What We're Building

Bash
# Add tasks
python tasks.py add "Write blog post" --priority high
python tasks.py add "Buy groceries"

# List tasks
python tasks.py list
python tasks.py list --status pending
python tasks.py list --priority high

# Complete a task
python tasks.py complete 1

# Delete a task
python tasks.py delete 2

# Clear all done tasks
python tasks.py clear-done

Project Structure

tasks/
├── tasks.py        # main CLI script
├── storage.py      # JSON persistence
├── models.py       # Task data model
└── tests/
    └── test_tasks.py

Step 1: models.py

Python
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal

Priority = Literal["low", "medium", "high"]
Status = Literal["pending", "done"]

@dataclass
class Task:
    title: str
    priority: Priority = "medium"
    status: Status = "pending"
    id: int = field(default_factory=lambda: 0)
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())
    completed_at: str | None = None

    def complete(self) -> "Task":
        return Task(
            id=self.id,
            title=self.title,
            priority=self.priority,
            status="done",
            created_at=self.created_at,
            completed_at=datetime.now().isoformat()
        )

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "title": self.title,
            "priority": self.priority,
            "status": self.status,
            "created_at": self.created_at,
            "completed_at": self.completed_at,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Task":
        return cls(**data)

Step 2: storage.py

Python
import json
from pathlib import Path
from models import Task

DATA_FILE = Path.home() / ".tasks.json"

def load_tasks() -> list[Task]:
    if not DATA_FILE.exists():
        return []
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        data = json.load(f)
    return [Task.from_dict(t) for t in data]

def save_tasks(tasks: list[Task]) -> None:
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump([t.to_dict() for t in tasks], f, indent=2)

def next_id(tasks: list[Task]) -> int:
    return max((t.id for t in tasks), default=0) + 1

Step 3: tasks.py (Main CLI)

Python
import argparse
import sys
from models import Task
from storage import load_tasks, save_tasks, next_id

# ── Terminal Colors ──────────────────────────────────────────────────
RESET  = "\033[0m"
BOLD   = "\033[1m"
RED    = "\033[31m"
GREEN  = "\033[32m"
YELLOW = "\033[33m"
CYAN   = "\033[36m"
GRAY   = "\033[90m"

PRIORITY_COLORS = {"high": RED, "medium": YELLOW, "low": CYAN}

def colored(text: str, *codes: str) -> str:
    return "".join(codes) + text + RESET

# ── Commands ──────────────────────────────────────────────────────────
def cmd_add(args) -> None:
    tasks = load_tasks()
    task = Task(
        id=next_id(tasks),
        title=args.title,
        priority=args.priority,
    )
    tasks.append(task)
    save_tasks(tasks)
    print(colored(f"✓ Task #{task.id} added: {task.title}", GREEN))

def cmd_list(args) -> None:
    tasks = load_tasks()

    # Apply filters
    if args.status:
        tasks = [t for t in tasks if t.status == args.status]
    if args.priority:
        tasks = [t for t in tasks if t.priority == args.priority]

    if not tasks:
        print(colored("No tasks found.", GRAY))
        return

    # Sort: pending first, then by priority (high > medium > low)
    priority_order = {"high": 0, "medium": 1, "low": 2}
    tasks.sort(key=lambda t: (t.status == "done", priority_order[t.priority]))

    print(f"\n{colored('ID', BOLD)}  {colored('STATUS', BOLD)}   {colored('PRI', BOLD)}     {colored('TITLE', BOLD)}")
    print("" * 60)
    for t in tasks:
        status_str = colored("✓ done   ", GREEN) if t.status == "done" else colored("○ pending", YELLOW)
        pri_color = PRIORITY_COLORS[t.priority]
        pri_str = colored(f"{t.priority:<6}", pri_color)
        title = colored(t.title, GRAY) if t.status == "done" else t.title
        print(f"{t.id:>3}  {status_str}  {pri_str}  {title}")
    print()

def cmd_complete(args) -> None:
    tasks = load_tasks()
    task_id = args.id
    updated = []
    found = False
    for task in tasks:
        if task.id == task_id:
            if task.status == "done":
                print(colored(f"Task #{task_id} is already done.", GRAY))
                return
            updated.append(task.complete())
            found = True
        else:
            updated.append(task)
    if not found:
        print(colored(f"Task #{task_id} not found.", RED))
        sys.exit(1)
    save_tasks(updated)
    print(colored(f"✓ Task #{task_id} marked as done.", GREEN))

def cmd_delete(args) -> None:
    tasks = load_tasks()
    original_count = len(tasks)
    tasks = [t for t in tasks if t.id != args.id]
    if len(tasks) == original_count:
        print(colored(f"Task #{args.id} not found.", RED))
        sys.exit(1)
    save_tasks(tasks)
    print(colored(f"✓ Task #{args.id} deleted.", GREEN))

def cmd_clear_done(args) -> None:
    tasks = load_tasks()
    remaining = [t for t in tasks if t.status != "done"]
    removed = len(tasks) - len(remaining)
    save_tasks(remaining)
    print(colored(f"✓ Removed {removed} completed task(s).", GREEN))

# ── Argument Parser ───────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="tasks",
        description="A simple CLI task manager",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    # add
    add_p = sub.add_parser("add", help="Add a new task")
    add_p.add_argument("title", help="Task title")
    add_p.add_argument(
        "--priority", "-p",
        choices=["low", "medium", "high"],
        default="medium",
        help="Task priority (default: medium)"
    )
    add_p.set_defaults(func=cmd_add)

    # list
    list_p = sub.add_parser("list", help="List tasks")
    list_p.add_argument("--status", choices=["pending", "done"])
    list_p.add_argument("--priority", choices=["low", "medium", "high"])
    list_p.set_defaults(func=cmd_list)

    # complete
    done_p = sub.add_parser("complete", help="Mark task as done")
    done_p.add_argument("id", type=int, help="Task ID")
    done_p.set_defaults(func=cmd_complete)

    # delete
    del_p = sub.add_parser("delete", help="Delete a task")
    del_p.add_argument("id", type=int, help="Task ID")
    del_p.set_defaults(func=cmd_delete)

    # clear-done
    clear_p = sub.add_parser("clear-done", help="Remove all completed tasks")
    clear_p.set_defaults(func=cmd_clear_done)

    return parser

if __name__ == "__main__":
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)

Step 4: Tests

Python
# tests/test_tasks.py
import json
import pytest
from pathlib import Path
from unittest.mock import patch
from models import Task
import storage

@pytest.fixture
def tmp_storage(tmp_path, monkeypatch):
    """Redirect storage to a temp file for each test."""
    tmp_file = tmp_path / "tasks.json"
    monkeypatch.setattr(storage, "DATA_FILE", tmp_file)
    return tmp_file

def test_add_and_load_task(tmp_storage):
    tasks = [Task(id=1, title="Test task")]
    storage.save_tasks(tasks)
    loaded = storage.load_tasks()
    assert len(loaded) == 1
    assert loaded[0].title == "Test task"
    assert loaded[0].status == "pending"

def test_complete_task():
    task = Task(id=1, title="Test", priority="high")
    done = task.complete()
    assert done.status == "done"
    assert done.completed_at is not None
    assert task.status == "pending"  # original unchanged

def test_task_round_trip():
    task = Task(id=5, title="Round trip", priority="low", status="done")
    restored = Task.from_dict(task.to_dict())
    assert restored.id == task.id
    assert restored.title == task.title
    assert restored.priority == task.priority
    assert restored.status == task.status

def test_next_id_empty():
    assert storage.next_id([]) == 1

def test_next_id_existing():
    tasks = [Task(id=3, title="A"), Task(id=7, title="B")]
    assert storage.next_id(tasks) == 8

def test_load_tasks_missing_file(tmp_storage):
    # File doesn't exist yet — should return empty list
    assert storage.load_tasks() == []

Running the App

Bash
# Install dependencies (none required for core app)
# Optional: install pytest for tests
pip install pytest

# Run tests
pytest tests/ -v

# Try it out
python tasks.py add "Learn Python" --priority high
python tasks.py add "Read a book" --priority low
python tasks.py add "Exercise"
python tasks.py list
python tasks.py complete 1
python tasks.py list --status pending
python tasks.py clear-done

Key Takeaways

This project demonstrates:

  • argparse for professional CLI interfaces
  • dataclass for clean data models
  • JSON persistence via pathlib.Path and json module
  • Separation of concerns — models, storage, and CLI are separate
  • pytest with monkeypatch to redirect file paths in tests
  • Terminal colors with ANSI escape codes (no library needed)