NOETRION

Choose a topic

DAY 27 OF 30 · BUILD AND DEMONSTRATE

Project: a persistent to-do application

Combine lists, dictionaries, validation, and JSON persistence into a tested command-line task manager.

90–120 minutes suggested study timePython 3.12Not yet completed

Today's outcomes

  • Implement add, list, complete, and delete operations.
  • Use stable IDs and explicit failure behavior.
  • Verify saving and loading with a temporary file.

Before you start: Complete Day 26 and its practical challenge.

Understand the concept

Build around a small, testable contract

A to-do application is more than a list of strings. Each task needs an identity, a title, and completion state. Today you will build a command-line application that adds tasks, lists them, marks them complete, deletes them, and saves them across runs. The scope is one local user and one process writing the file at a time.

Use a positive integer ID for each task. A displayed list position is not a stable identity: deleting the first item shifts every later position. Keep next_id separately and increment it when a task is added. Deleting task 2 should not allow a new task to silently inherit ID 2. Stable identity makes commands and saved records understandable.

Use a Boolean done field, rather than mixing status into the title. Marking an already completed task is idempotent: repeating the action leaves it completed. Deleting an unknown ID is an error. These choices are part of the contract and should be tested, rather than left to accidental list behavior.

Separate commands from user interaction

The core functions should accept data and return results without asking for input. A thin command-line layer reads input, converts IDs, calls those functions, and displays outcomes. This separation lets a test verify add and complete without pretending to be a person typing at a keyboard.

Your storage boundary uses JSON from Day 25. A missing file means a new empty store. An existing malformed file is a failure to report, not an invitation to reset the user's records. Validate stored IDs, titles, status flags, uniqueness, and next_id before accepting a loaded document. Reject Boolean IDs even though bool inherits from int.

The project writes a complete JSON document using Path.write_text. This is sufficient for the exercise's single-process scope, but it is not a transactional database. A process interruption during writing can damage a file. If you extend the project, investigate atomic replacement, backups, and concurrency as explicit separate requirements. Do not advertise guarantees the current implementation has not earned.

Plan one behavior at a time

Start with an in-memory store and implement add. Test normalization and a blank title. Implement lookup, complete, and delete next. Add serialization only after state transitions work. Finally connect the menu. This order keeps failures small: a wrong ID lookup is easier to diagnose before it is buried beneath input and file operations.

Use descriptive function names and explicit exceptions. A broad exception handler around the entire program would also hide programming bugs. The menu catches ValueError for invalid commands and reports OSError when a save fails; other failures remain visible during development. Keep a copy of your test fixture before experimenting with persistence.

Worked example

store = {"version": 1, "next_id": 4, "tasks": [
    {"id": 1, "title": "Read functions", "done": True},
    {"id": 3, "title": "Practice files", "done": False},
]}
new_task = {"id": store["next_id"], "title": "Build project", "done": False}
store["tasks"].append(new_task)
store["next_id"] += 1
assert [task["id"] for task in store["tasks"]] == [1, 3, 4]
assert store["next_id"] == 5
print(store)
Action IDs afterward Important invariant
Start after deleting task 2 1, 3 A gap is valid
Add a task 1, 3, 4 Use next_id, not list length
Complete task 4 twice 1, 3, 4 Same completion state
Delete task 3 1, 4 Remaining IDs do not change
Reload file 1, 4 Values survive serialization

A gap does not need repairing. It records the fact that identity and list position are different concepts. Reassigning IDs for a tidier display would make previous commands ambiguous.

Guided practice

  1. Predict: why does len(tasks) + 1 create a duplicate in a store with IDs 1 and 3? Trace the faulty new ID before running it.
  2. Modify: show a checkbox marker when listing tasks. Keep the stored done value Boolean, rather than storing the display symbol.
  3. Reconstruct: write a lookup function that raises ValueError for an absent ID. Test the first ID, last ID, and one missing between them.

Keep a written acceptance checklist. It prevents the visual menu from distracting you from the underlying state transitions. Test with a temporary directory so a repeated test cannot erase your real practice tasks.

Daily challenge

Create the two files below in the same directory. First implement the application yourself, then compare with the reference. Running python todo_app.py starts the menu; running python test_todo.py checks the core with a temporary file. The application accepts a numeric ID, not a row number.

Requirement Evidence to collect
Add a trimmed nonempty title New task has the next unused ID
List all tasks ID, title, and status are visible
Complete a task twice State remains done
Delete a task Its ID is absent; counter is retained
Close and reopen Saved values and next_id agree
Malformed existing document Clear error; no silent empty reset

Demonstrate the last row with a separate disposable file. A screenshot of a menu is not enough to establish persistence: close the program and reload the actual stored data.

Reference solution

Compare after attempting the challenge
# file: todo_app.py
import json
from pathlib import Path

def new_store():
    return {"version": 1, "next_id": 1, "tasks": []}

def validate(data):
    if not isinstance(data, dict) or set(data) != {"version", "next_id", "tasks"}:
        raise ValueError("invalid store fields")
    if type(data["version"]) is not int or data["version"] != 1:
        raise ValueError("unsupported store version")
    if not isinstance(data["tasks"], list):
        raise ValueError("tasks must be a list")
    seen = set()
    for task in data["tasks"]:
        if not isinstance(task, dict) or set(task) != {"id", "title", "done"}:
            raise ValueError("invalid task fields")
        task_id = task["id"]
        if type(task_id) is not int or task_id < 1 or task_id in seen:
            raise ValueError("invalid or duplicate task ID")
        if not isinstance(task["title"], str) or not task["title"].strip():
            raise ValueError("invalid task title")
        if type(task["done"]) is not bool:
            raise ValueError("done must be Boolean")
        seen.add(task_id)
    if type(data["next_id"]) is not int or data["next_id"] <= max(seen, default=0):
        raise ValueError("next_id must exceed all stored IDs")
    return data

def load(path):
    if not path.exists():
        return new_store()
    return validate(json.loads(path.read_text(encoding="utf-8")))

def save(path, store):
    path.write_text(json.dumps(validate(store), indent=2, ensure_ascii=False), encoding="utf-8")

def add(store, title):
    if not isinstance(title, str) or not title.strip():
        raise ValueError("title must be nonempty text")
    task = {"id": store["next_id"], "title": title.strip(), "done": False}
    store["tasks"].append(task)
    store["next_id"] += 1
    return task["id"]

def find(store, task_id):
    if type(task_id) is not int:
        raise ValueError("ID must be an integer")
    for task in store["tasks"]:
        if task["id"] == task_id:
            return task
    raise ValueError("task ID not found")

def complete(store, task_id):
    find(store, task_id)["done"] = True

def delete(store, task_id):
    store["tasks"].remove(find(store, task_id))

def run_cli(path):
    store = load(path)
    while True:
        command = input("add / list / done / delete / quit: ").strip().lower()
        if command == "quit":
            return
        try:
            if command == "add":
                task_id = add(store, input("Title: "))
                save(path, store)
                print("Added", task_id)
            elif command == "list":
                for task in store["tasks"]:
                    marker = "x" if task["done"] else " "
                    print(f'{task["id"]}: [{marker}] {task["title"]}')
                if not store["tasks"]:
                    print("No tasks yet")
            elif command in {"done", "delete"}:
                task_id = int(input("Task ID: "))
                if command == "done":
                    complete(store, task_id)
                else:
                    delete(store, task_id)
                save(path, store)
                print("Saved")
            else:
                print("Unknown command")
        except (ValueError, OSError) as error:
            print("Could not finish command:", error)

if __name__ == "__main__":
    try:
        run_cli(Path("tasks.json"))
    except (ValueError, OSError) as error:
        print("Could not load store:", error)
# file: test_todo.py
from pathlib import Path
from tempfile import TemporaryDirectory
from todo_app import load, save, add, complete, delete

with TemporaryDirectory() as folder:
    path = Path(folder) / "tasks.json"
    store = load(path)
    first = add(store, " Learn functions ")
    second = add(store, "Practice files")
    complete(store, first)
    complete(store, first)
    delete(store, second)
    third = add(store, "Build project")
    assert third == 3
    save(path, store)
    restored = load(path)
    assert restored == store
    assert restored["tasks"][0] == {"id": 1, "title": "Learn functions", "done": True}
    for action in [lambda: add(store, " "), lambda: complete(store, 99)]:
        try:
            action()
        except ValueError:
            pass
        else:
            raise AssertionError("Invalid action accepted")
    path.write_text('{"version":1,"next_id":1,"tasks":[{"id":2,"title":"x","done":false}]}')
    try:
        load(path)
    except ValueError:
        pass
    else:
        raise AssertionError("Invalid next_id accepted")
print("Task transitions and persistence passed")

The menu catches failures but a save failure can leave the in-memory state ahead of the file. The message makes that failure visible; this small project does not implement rollback. A useful extension is to prepare a candidate store, save it successfully, and only then replace the active state.

Debug and explain

If a command affects the wrong task, inspect ID lookup before the menu. If tasks disappear on startup, check for an exception handler that returns an empty store for every failure. If completed status changes type, inspect the JSON validator. Explain why next_id survives deletion and why a failed save must not be reported as a durable success.

Review and next step

Save a demonstration transcript and the passing test output. Your project now connects functions, collections, exceptions, modules, files, and JSON around a concrete user workflow. Day 28 builds a generator with a different requirement: randomness appropriate for password material.

Sources and verification

Original teaching examples checked with Python 3.12.14. Library behavior reviewed against these primary references on October 3, 2026. Day 26's examples also use Beautiful Soup 4.14.3.

DAILY CHECKPOINT

Check your understanding

Five questions, 20 points each. Pass at 85% or above, which requires all five correct. Feedback explains each answer; revise the lesson and retry when needed.

01. Why are task IDs separate from list positions?

02. What happens to next_id after deleting a task?

03. What does completing a task twice do?

04. How should malformed existing storage be handled?

05. Does this project guarantee concurrent or transactional writes?

Review your practical work

Check these statements after running your own challenge. This is your self-review; the website does not execute or assess your Python code.

Pass the knowledge check and finish the practical self-review to save completion.