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.