Backend Systemsbeginner
Build a CLI Task Manager — Python Project
Build a complete command-line task manager in Python using argparse, JSON persistence, colorized output, and pytest tests.
Asma HafeezApril 17, 20266 min read
pythonprojectcliargparsepytest
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-doneProject Structure
tasks/
├── tasks.py # main CLI script
├── storage.py # JSON persistence
├── models.py # Task data model
└── tests/
└── test_tasks.pyStep 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) + 1Step 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-doneKey Takeaways
This project demonstrates:
argparsefor professional CLI interfacesdataclassfor clean data models- JSON persistence via
pathlib.Pathandjsonmodule - Separation of concerns — models, storage, and CLI are separate
pytestwithmonkeypatchto redirect file paths in tests- Terminal colors with ANSI escape codes (no library needed)
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.