On this page
article
Project: Todo CLI App
Build a command-line todo application with Python — add, list, complete, and delete tasks with JSON persistence.
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
- Add
--allflag to list completed tasks too - Add
editcommand to change task titles - Add due dates with
datetimeand sort by urgency - Add priority levels (high/medium/low) with color output
- Write pytest tests for
add_taskandcomplete_task - Refactor into a
TodoManagerclass (OOP)
This project teaches the pattern used by countless CLI tools: parse args → call logic → persist state.