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
- 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.
- Modify: show a checkbox marker when listing tasks. Keep the stored done value Boolean, rather than storing the display symbol.
- 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.
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.
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.