PythonMastery
beginner 30 min read · lesson 3 of 15 in Projects

Project: TODO List CLI (with Persistence)

1 · The lesson

read

You're going to build a command-line TODO list — add tasks, list them, mark them done, delete them. It will remember tasks across runs by saving to a file.

This is the first project with persistence. The shape of this program (load state → loop → save state) is the same shape as ~80% of real-world Python scripts.

What you'll practice: lists, functions, file I/O, JSON, exception handling, dispatch dictionaries.


Step 1 — In-Memory Version

Start without persistence. Get the data structure right first.

python
todos = []          # a list of tasks

def add(task):
    todos.append({"text": task, "done": False})
    print(f"+ added: {task}")

def show():
    if not todos:
        print("(no tasks)")
        return
    for i, t in enumerate(todos, start=1):
        marker = "✓" if t["done"] else " "
        print(f"  {i}. [{marker}] {t['text']}")

def complete(index):
    if 1 <= index <= len(todos):
        todos[index - 1]["done"] = True
        print(f"✓ done: {todos[index - 1]['text']}")
    else:
        print(f"⚠️  no task #{index}")

def delete(index):
    if 1 <= index <= len(todos):
        removed = todos.pop(index - 1)
        print(f"× deleted: {removed['text']}")
    else:
        print(f"⚠️  no task #{index}")

# Drive it
add("Buy groceries")
add("Write blog post")
add("Call mom")
show()
complete(1)
delete(2)
show()

Each task is a dict with text and done — small but expressive. Lists of dicts is the most common shape in beginner Python.

Note enumerate(todos, start=1) — counts from 1 instead of 0, friendlier for humans.


Step 2 — Add a Command Loop

Instead of calling functions directly, accept user commands.

python
todos = []

def parse_command(raw):
    """Return (command, argument_str). Tolerant of leading/trailing spaces."""
    raw = raw.strip()
    if not raw:
        return "", ""
    parts = raw.split(maxsplit=1)
    cmd = parts[0].lower()
    arg = parts[1] if len(parts) > 1 else ""
    return cmd, arg

# Simulated commands (in a real terminal you'd use input() in a while loop)
commands = [
    "add Buy groceries",
    "add Write blog post",
    "add Call mom",
    "list",
    "done 1",
    "delete 2",
    "list",
]

for raw in commands:
    cmd, arg = parse_command(raw)
    print(f"\n> {raw}")

    if cmd == "add":
        todos.append({"text": arg, "done": False})
        print(f"+ {arg}")
    elif cmd == "list":
        if not todos:
            print("(no tasks)")
        for i, t in enumerate(todos, start=1):
            marker = "✓" if t["done"] else " "
            print(f"  {i}. [{marker}] {t['text']}")
    elif cmd == "done":
        idx = int(arg)
        todos[idx - 1]["done"] = True
    elif cmd == "delete":
        idx = int(arg)
        todos.pop(idx - 1)
    elif cmd in ("quit", "exit"):
        break
    else:
        print(f"⚠️  unknown command: {cmd}")

In a real terminal, swap the for raw in commands: loop for:

python
# while True:
#     raw = input("> ")
#     cmd, arg = parse_command(raw)
#     if cmd in ("quit", "exit"):
#         break
#     # ... same dispatch ...

Step 3 — Persist to a File (JSON)

Right now the tasks disappear every time the program ends. Let's save and load.

We'll use JSON (JavaScript Object Notation) — it's a text format that's perfect for lists-of-dicts, and Python has it built-in.

python
import json
import io           # (just for the browser simulation — see note below)

# In real code, this is a real file path:
#   TODO_FILE = "todos.json"
# In the browser sandbox we keep it in memory:
fake_storage = io.StringIO('[]')

def load_todos():
    """Read the todo file. Return [] if it doesn't exist or is corrupt."""
    try:
        fake_storage.seek(0)
        return json.loads(fake_storage.read())
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save_todos(todos):
    """Write the todo list as JSON, prettily indented."""
    fake_storage.seek(0)
    fake_storage.truncate()
    fake_storage.write(json.dumps(todos, indent=2))

# The real-file version is just:
#
#   def load_todos():
#       try:
#           with open("todos.json", "r", encoding="utf-8") as f:
#               return json.load(f)
#       except (FileNotFoundError, json.JSONDecodeError):
#           return []
#
#   def save_todos(todos):
#       with open("todos.json", "w", encoding="utf-8") as f:
#           json.dump(todos, f, indent=2)

todos = load_todos()
todos.append({"text": "Buy groceries", "done": False})
todos.append({"text": "Write blog post", "done": False})
save_todos(todos)

# Pretend the program restarted — load again
reloaded = load_todos()
print(reloaded)

The pattern: load at startup, save after every change (or at clean exit, depending on how paranoid you want to be about crashes).

Why JSON over plain text: JSON keeps the structure. We need to remember done: True/False per task. A plain text file would need extra parsing logic. JSON gives us back exactly the same shape we saved.


Step 4 — Putting It All Together

The full program:

python
import json
import io

# Browser-sandbox storage (real version uses a real file)
fake_storage = io.StringIO('[]')

def load_todos():
    try:
        fake_storage.seek(0)
        return json.loads(fake_storage.read())
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save_todos(todos):
    fake_storage.seek(0)
    fake_storage.truncate()
    fake_storage.write(json.dumps(todos, indent=2))

def show(todos):
    if not todos:
        print("(no tasks)")
        return
    for i, t in enumerate(todos, start=1):
        marker = "✓" if t["done"] else " "
        print(f"  {i}. [{marker}] {t['text']}")

def parse_command(raw):
    raw = raw.strip()
    if not raw:
        return "", ""
    parts = raw.split(maxsplit=1)
    return parts[0].lower(), parts[1] if len(parts) > 1 else ""

def run(commands):
    todos = load_todos()
    for raw in commands:
        cmd, arg = parse_command(raw)
        try:
            if cmd == "add":
                if not arg:
                    print("⚠️  add what?")
                    continue
                todos.append({"text": arg, "done": False})
                save_todos(todos)
                print(f"+ {arg}")
            elif cmd == "list":
                show(todos)
            elif cmd == "done":
                idx = int(arg)
                todos[idx - 1]["done"] = True
                save_todos(todos)
                print(f"✓ task {idx} done")
            elif cmd == "delete":
                idx = int(arg)
                removed = todos.pop(idx - 1)
                save_todos(todos)
                print(f"× deleted: {removed['text']}")
            elif cmd in ("quit", "exit"):
                break
            else:
                print(f"⚠️  unknown: {cmd}")
        except (ValueError, IndexError):
            print(f"⚠️  bad argument for '{cmd}': {arg!r}")

# Demo session
session = [
    "add Buy groceries",
    "add Write blog post",
    "add Call mom",
    "list",
    "done 1",
    "delete 2",
    "list",
]
run(session)

That's a complete, useful program in ~50 lines. It validates input, persists state, handles bad arguments, and has a clear command vocabulary.


Stretch Goals

1. Due dates: extend each task to {"text": ..., "done": ..., "due": "2026-06-01"}. Sort by date when listing.
2. Categories / tags: support add @work Buy printer paper. List by tag.
3. Search: find blog shows all matching tasks.
4. Undo: keep a history of the last 5 states. undo reverts.
5. Priority: add !high Pay rent. Sort priorities at the top.
6. Export: export todos.txt writes a plain-text version anyone can read.


🎯 Your Turn — Add a "search" Command

Extend the dispatch with a search <term> command that prints only tasks whose text contains the search term (case-insensitive).

python
todos = [
    {"text": "Buy groceries", "done": False},
    {"text": "Write blog post", "done": True},
    {"text": "Call mom", "done": False},
    {"text": "Buy printer paper", "done": False},
]

def search_todos(todos, term):
    # TODO: return a list of tasks whose 'text' contains `term` (case-insensitive)
    # Hint: use a list comprehension and .lower()
    pass

# Test
results = search_todos(todos, "buy")
for t in results:
    print(t)
# Should print both "Buy groceries" and "Buy printer paper"
Hint 1 — Case-insensitive contains term.lower() in t["text"].lower() — lowercase both sides, then use Python's in operator.
Hint 2 — List comprehension [t for t in todos if <condition>] — keep only the items that match.
Show full solution
python
def search_todos(todos, term):
    q = term.lower()
    return [t for t in todos if q in t["text"].lower()]

# Hooking it into the dispatch loop (excerpt):
# elif cmd == "search":
#     matches = search_todos(todos, arg)
#     if not matches:
#         print(f"(no matches for '{arg}')")
#     for t in matches:
#         marker = "✓" if t["done"] else " "
#         print(f"  [{marker}] {t['text']}")

print(search_todos([
    {"text": "Buy groceries", "done": False},
    {"text": "Write blog post", "done": True},
    {"text": "Buy printer paper", "done": False},
], "buy"))

What You Learned

  • List of dicts as the canonical "table of records" data structure
  • A dispatch loop (parse command → run the matching branch) — the shape of every CLI tool
  • enumerate(seq, start=1) for human-friendly numbering
  • JSON persistence — load → mutate → save
  • try/except around the whole dispatch so one bad command doesn't crash the program

You can now write small CLI tools that remember things across runs. That's a huge step up — most useful Python scripts are exactly this shape.

Next: Password Generator.