Build a fully functional todo app that runs in the terminal and persists tasks to a JSON file.

What You’ll Build

  $ python todo.py add "Buy groceries"
Added: Buy groceries

$ python todo.py list
[ ] 1. Buy groceries
[ ] 2. Write report

$ python todo.py done 1
Completed: Buy groceries

$ python todo.py delete 2
Deleted: Write report
  

Project Structure

  todo-cli/
├── todo.py
├── tasks.json        # created automatically
└── README.md
  

Step 1: Data Model

  import json
from pathlib import Path
from datetime import datetime

TASKS_FILE = Path("tasks.json")

def load_tasks():
    if TASKS_FILE.exists():
        return json.loads(TASKS_FILE.read_text())
    return []

def save_tasks(tasks):
    TASKS_FILE.write_text(json.dumps(tasks, indent=2))

def add_task(title):
    tasks = load_tasks()
    task = {
        "id": len(tasks) + 1,
        "title": title,
        "done": False,
        "created_at": datetime.now().isoformat(),
    }
    tasks.append(task)
    save_tasks(tasks)
    return task
  

Step 2: Core Operations

  def list_tasks(show_all=False):
    tasks = load_tasks()
    if not tasks:
        print("No tasks.")
        return
    for task in tasks:
        if not show_all and task["done"]:
            continue
        status = "x" if task["done"] else " "
        print(f"[{status}] {task['id']}. {task['title']}")

def complete_task(task_id):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            save_tasks(tasks)
            return task
    raise ValueError(f"Task {task_id} not found")

def delete_task(task_id):
    tasks = load_tasks()
    new_tasks = [t for t in tasks if t["id"] != task_id]
    if len(new_tasks) == len(tasks):
        raise ValueError(f"Task {task_id} not found")
    save_tasks(new_tasks)
  

Step 3: CLI with argparse

  import argparse

def main():
    parser = argparse.ArgumentParser(description="Todo CLI")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("list", help="List pending tasks")
    add_p = sub.add_parser("add", help="Add a task")
    add_p.add_argument("title")
    done_p = sub.add_parser("done", help="Mark task complete")
    done_p.add_argument("id", type=int)
    del_p = sub.add_parser("delete", help="Delete a task")
    del_p.add_argument("id", type=int)

    args = parser.parse_args()

    if args.command == "list":
        list_tasks()
    elif args.command == "add":
        task = add_task(args.title)
        print(f"Added: {task['title']}")
    elif args.command == "done":
        task = complete_task(args.id)
        print(f"Completed: {task['title']}")
    elif args.command == "delete":
        delete_task(args.id)
        print(f"Deleted task {args.id}")

if __name__ == "__main__":
    main()
  

Step 4: Error Handling

Wrap operations in try/except for user-friendly messages:

  try:
    complete_task(args.id)
except ValueError as e:
    print(f"Error: {e}")
    sys.exit(1)
  

Concepts Applied

Bonus Challenges

  1. Add --all flag to list completed tasks too
  2. Add edit command to change task titles
  3. Add due dates with datetime and sort by urgency
  4. Add priority levels (high/medium/low) with color output
  5. Write pytest tests for add_task and complete_task
  6. Refactor into a TodoManager class (OOP)

This project teaches the pattern used by countless CLI tools: parse args → call logic → persist state.