To-Do List CLI App

Python Projects — To-Do List CLI App
Python Projects (Beginner)
Course 4 · Chapter 4 · To-Do List CLI App

📝 To-Do List CLI App

Python Advanced's capstone (py3-8) built a task tracker using classes, @dataclass, and a dedicated storage abstraction. This chapter builds something genuinely similar — a to-do list that persists across runs — using nothing but plain functions and a list of dicts, proving real persistence doesn't require OOP at all.

🎮 What We're Building

A command-line to-do list: add tasks, list them, mark them done, and have the list still be there the next time you run the program — all stored in a plain JSON file.

Step 1: Representing a Task as a Dict

task = {"title": "Buy milk", "done": False} tasks = [task]

No class needed — a task is just a dict (Course 1, Chapter 7) with two keys, and the whole to-do list is a plain list of these dicts. This is the direct "no heavy OOP required" alternative to py3-8's Task dataclass — less structure, but perfectly workable for a project this size.

Step 2: Loading and Saving with JSON

import json from pathlib import Path FILE = Path("tasks.json") def load_tasks(): if not FILE.exists(): return [] return json.loads(FILE.read_text()) def save_tasks(tasks): FILE.write_text(json.dumps(tasks, indent=2))

pathlib and json here work exactly like Course 2, Chapters 4 and 7 — but note there's no separate Task class to reconstruct on load, unlike py3-8's Task(**item) step. json.loads() already returns plain dicts, which is exactly what this version's tasks list is made of — one less layer, at the cost of losing the type-checking and auto-generated __eq__ a dataclass would give.

Step 3: Adding, Listing, Completing Tasks

def add_task(tasks, title): tasks.append({"title": title, "done": False}) def list_tasks(tasks): if not tasks: print("No tasks yet!") return for i, task in enumerate(tasks, start=1): status = "x" if task["done"] else " " print(f"{i}. [{status}] {task['title']}") def complete_task(tasks, index): if 0 <= index < len(tasks): tasks[index]["done"] = True else: print("Invalid task number.")

enumerate(tasks, start=1) reuses Course 1, Chapter 4's numbered-list pattern for a human-friendly 1-based display. complete_task mutates the dict directly through its list position — tasks[index]["done"] = True — since tasks.append() is passed the same list object every function receives (Course 1's mutable-list-aliasing behavior, here working in the app's favor).

⚠ Using List Position as an ID Is Fragile

complete_task identifies a task purely by its position in the list. If tasks are ever deleted (see this chapter's extension ideas) or reordered, a previously-noted "task #3" can silently become a different task. py3-8's dataclass version sidestepped this entirely with a persistent id field that never changes — worth adding here too if the list ever needs deletion or reordering support.

Step 4: The Menu Loop

tasks = load_tasks() while True: print("\n1. Add 2. List 3. Complete 4. Quit") choice = input("Choose an option: ") if choice == "1": title = input("Task title: ") add_task(tasks, title) save_tasks(tasks) elif choice == "2": list_tasks(tasks) elif choice == "3": num = int(input("Task number to complete: ")) complete_task(tasks, num - 1) save_tasks(tasks) elif choice == "4": print("Goodbye!") break else: print("Not a valid option, try again.")

Same menu-loop shape as Chapters 1 and 2. save_tasks(tasks) is called after every mutation (add, complete) — skip it and the change would only exist in memory for this run, lost the moment the program exits, undermining the whole point of persistence.

🏁 The Complete To-Do App

import json from pathlib import Path FILE = Path("tasks.json") def load_tasks(): if not FILE.exists(): return [] return json.loads(FILE.read_text()) def save_tasks(tasks): FILE.write_text(json.dumps(tasks, indent=2)) def add_task(tasks, title): tasks.append({"title": title, "done": False}) def list_tasks(tasks): if not tasks: print("No tasks yet!") return for i, task in enumerate(tasks, start=1): status = "x" if task["done"] else " " print(f"{i}. [{status}] {task['title']}") def complete_task(tasks, index): if 0 <= index < len(tasks): tasks[index]["done"] = True else: print("Invalid task number.") tasks = load_tasks() while True: print("\n1. Add 2. List 3. Complete 4. Quit") choice = input("Choose an option: ") if choice == "1": title = input("Task title: ") add_task(tasks, title) save_tasks(tasks) elif choice == "2": list_tasks(tasks) elif choice == "3": num = int(input("Task number to complete: ")) complete_task(tasks, num - 1) save_tasks(tasks) elif choice == "4": print("Goodbye!") break else: print("Not a valid option, try again.")

Task as a dict

No class needed — {"title": ..., "done": ...} is enough.

load_tasks / save_tasks

JSON + pathlib persistence, functions instead of a storage class.

Mutating through a shared reference

Functions modify the same tasks list every caller shares.

Save after every mutation

Skipping save_tasks() loses the change when the program exits.

🚀 Extend This Project

Try these on your own:

  • Add a delete_task(tasks, index) function and menu option, using a list comprehension (Course 1/2) to build the filtered list.
  • Fix this chapter's own warn-box: give each task a persistent id (an incrementing counter, like py3-8's next_id logic) instead of relying on list position.
  • Add input validation with try/except (Course 2, Chapter 3) around the task-number input.
  • Add a due date field and sort list_tasks()'s output by it.

🎯 What's Next

Next chapter: Quiz Game — dictionaries, lists, and scoring logic.