The storage module¶
Goal: build and test todo_storage.py, the part of the app that turns tasks into lines, lines into tasks, and loads and saves the file.
Why this matters¶
The plan splits the app into two files, and this lesson builds the one the user never sees. That is deliberate. The storage module has no menu and asks no questions, so it can be finished and tested on its own, before a single line of the menu exists. By the end of this lesson you will know that saving and loading work, and next lesson can build the user's side of the app on solid ground. Building the invisible part first, and testing it, is how professionals work.
Setting up the project folder¶
Make a folder for the app and put two things in it. The first is helpers.py from Module 8, with ask_yes_no()
from the Module 8 exercises added above the if __name__ == "__main__": line, because delete_task() will need it
next lesson. The second is tasks_sample.txt from last lesson, for testing. todo_storage.py and, next lesson,
todo.py go in the same folder, so that the imports find each other, as Module 8 explained.
The whole module¶
Here is todo_storage.py, complete. It is longer than most examples in this course, so read the sections below
alongside it, one function at a time:
"""Loading and saving for the to-do app.
A task is a dictionary with four keys: title, due (a date), priority (1 to 3) and done (True or False).
The file has one task per line: title|YYYY-MM-DD|priority|yes or no
"""
import os
from datetime import date
from helpers import read_lines
FILENAME = "tasks.txt"
def task_to_line(task):
"""Return a task as one line of text: title|YYYY-MM-DD|priority|yes or no."""
if task["done"]:
done = "yes"
else:
done = "no"
return f"{task['title']}|{task['due']}|{task['priority']}|{done}"
def line_to_task(line):
"""Return the task described by one line of the file. A malformed line causes a ValueError."""
title, due_text, priority_text, done_text = line.split("|")
return {
"title": title,
"due": date.fromisoformat(due_text),
"priority": int(priority_text),
"done": done_text == "yes",
}
def load_tasks(filename=FILENAME):
"""Return the saved tasks as a list, skipping bad lines. Empty list if there is no file."""
tasks = []
try:
lines = read_lines(filename)
except FileNotFoundError:
return tasks
line_number = 0
for line in lines:
line_number += 1
try:
tasks.append(line_to_task(line))
except ValueError as error:
print(f"Skipping line {line_number} of {filename}: {error}")
return tasks
def save_tasks(tasks, filename=FILENAME):
"""Write every task to the file, replacing what was there."""
with open(filename, "w") as file:
for task in tasks:
file.write(task_to_line(task) + "\n")
if __name__ == "__main__":
print("Testing todo_storage.py")
# 1. A round trip: what we save is what we load.
original = [
{"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False},
{"title": "Book flights, hotel and car", "due": date(2026, 11, 1), "priority": 1, "done": True},
]
save_tasks(original, "test_tasks.txt")
loaded = load_tasks("test_tasks.txt")
print(f"Round trip gives the same tasks back: {loaded == original}")
os.remove("test_tasks.txt")
# 2. A missing file gives an empty list, not a crash.
print(f"Missing file gives: {load_tasks('no_such_file.txt')}")
# 3. Bad lines are skipped with a message, good lines survive.
tasks = load_tasks("tasks_broken.txt")
print(f"Loaded {len(tasks)} tasks from the broken file.")
Run it, and its self-test at the bottom prints:
Testing todo_storage.py
Round trip gives the same tasks back: True
Missing file gives: []
Skipping line 2 of tasks_broken.txt: Invalid isoformat string: 'next week'
Skipping line 3 of tasks_broken.txt: invalid literal for int() with base 10: 'three'
Skipping line 4 of tasks_broken.txt: not enough values to unpack (expected 4, got 1)
Loaded 2 tasks from the broken file.
That output is this lesson's finish line. The rest of the lesson explains how each part earns it.
Lines and tasks¶
task_to_line() and line_to_task() are the two functions from the last exercise, unchanged. They are the only
functions in the whole app that know what the file looks like. task_to_line() builds the line with an f-string,
which writes the date in ISO form, and turns the boolean into yes or no. line_to_task() splits on |, unpacks
the four parts into four names, and converts each part back to its proper type.
One line of it deserves a second look: "done": done_text == "yes". A comparison produces a boolean, as you learned
in Module 1, so this is a one-line way of saying True if the text is yes, otherwise False. There is no if needed.
Loading¶
load_tasks() has two jobs that both come from Module 7: cope with a missing file, and cope with bad lines.
The missing file is handled first. read_lines() from helpers does the reading, and if it fails with
FileNotFoundError, the function returns the empty list at once. On the very first run there is no file, and an
empty to-do list is the correct answer, not an error.
Then each line goes through line_to_task() inside its own try. Any of the three conversions can fail on a bad line,
and whichever one fails, the result is a ValueError: fromisoformat() for a bad date, int() for a bad priority,
and the unpacking for a line with the wrong number of parts. The except block prints which line and why, using the
error's own message, and the loop moves on. One bad line costs the user one task, not the whole file. The self-test
shows all three kinds of failure, from tasks_broken.txt:
Buy milk|2026-09-21|2|no
Finish Module 9|next week|1|no
Call the dentist|2026-09-18|three|yes
Water the plants
Pay the rent|2026-10-01|1|no
Lines 1 and 5 are fine, and they are the two tasks that survive.
Saving¶
save_tasks() is the shortest function in the file: open for writing, which empties the file, and write every task
as a line. Because the list in memory is the truth, replacing the file entirely is right, as it was in Module 6.
Filenames with a default¶
Both functions take a filename parameter with a default of FILENAME, using the default values from Module 5.
The app will call load_tasks() and save_tasks(tasks) with no filename and get tasks.txt. The self-test calls them
with other filenames, so that testing never touches the user's real list. That small decision is what makes the
module testable, and it costs nothing.
Testing the module¶
The block under if __name__ == "__main__": is the module's proof that it works. It checks the three things that can
go wrong, in order of importance:
- The round trip. Two tasks are saved to a test file and loaded back, and
loaded == originalcompares the two lists. Because the second task has commas in its title and a done value ofTrue, this one comparison checks the separator, the date, the priority and the boolean all at once. Afterwards,os.remove()deletes the test file.osis the module from Module 6, and this is its second function you have used:remove()deletes a file, with no undo, so it is used here only on a file the test created itself. - The missing file. Loading a file that does not exist gives
[], not a traceback. - The broken file. Bad lines are reported and skipped, and the good ones are loaded.
Any time you change the module, run it, and read those lines again. If the round trip ever says False, the change
broke something, and you know before the app does.
Using the module from another file¶
Import it, and the file format disappears from view. This is the whole program that the menu will grow from:
from todo_storage import load_tasks
tasks = load_tasks("tasks_sample.txt")
for task in tasks:
print(f"{task['title']} (priority {task['priority']}, done: {task['done']})")
Buy milk (priority 2, done: False)
Finish Module 9 (priority 1, done: False)
Call the dentist (priority 3, done: True)
Book flights, hotel and car (priority 1, done: False)
The program does not know that the file uses |, or that dates are stored as text, or that done is a word. It asks for
tasks and gets a list of dictionaries with real dates and real booleans in it. That is the point of a storage module.
Try it¶
Open tasks_sample.txt in your editor and break one line in a new way: swap two of the parts, or delete the |
between two of them. Before you run anything, write down which function inside line_to_task() will complain and what
the message will say. Then run use_storage.py and check. Fix the file afterwards.
Common mistakes¶
ModuleNotFoundError: No module named 'helpers'
todo_storage.py imports from helpers, so helpers.py must be in the same folder. Copy it in from Module 8.
ValueError: too many values to unpack (expected 4)
A title with a | in it, so the line split into five parts. The self-test does not check for this, because the
app will not let the user type one. If you edit the file by hand, avoid | in titles.
The round trip prints False
Something is lost between saving and loading. Print original and loaded and compare them by eye. The usual
culprits are a date stored in a different format from the one fromisoformat() reads, or a done value that is
written one way and read another.
Every line is skipped with: not enough values to unpack
The file uses a different separator from the code, probably commas from an older version. Either the file or
line_to_task() needs to change, and the file is easier.
The test file is left behind, or FileNotFoundError from os.remove()
The test crashed before reaching os.remove(), or it ran twice and the file was already gone. Delete the file by
hand and fix the crash; the remove only runs when everything above it succeeds.
Exercises¶
- Count what is done. Write a program that imports
load_tasks, loadstasks_sample.txt, and prints how many tasks are done and how many are still to do. - Your own broken file. Write a program that creates a file with three bad lines, each broken in a different way
from the ones in
tasks_broken.txt, and one good line. Before running it, write your prediction of each skip message as a comment. Then load the file and see how many you got right. - Priority words. Change the file format so that priority is stored as
high,mediumorlowinstead of1,2or3, while the task dictionary keeps using the numbers. Only two functions should change. Prove it with the round trip. - Backup on save. Make
save_tasks()copy the existing file totasks.txt.bakbefore replacing it, so that one bad save can always be undone by hand. It should still work when there is no existing file.
Solution 1
# Save this next to todo_storage.py and helpers.py.
from todo_storage import load_tasks
tasks = load_tasks("tasks_sample.txt")
done = 0
for task in tasks:
if task["done"]:
done += 1
print(f"{len(tasks)} tasks: {done} done, {len(tasks) - done} to do.")
Four tasks: one done, three to do. Save it next to todo_storage.py, or the import fails.
Solution 2
# Save this next to todo_storage.py and helpers.py.
from todo_storage import load_tasks
# Each line has a different fault. Predictions, checked by running:
# line 1: too many parts -> too many values to unpack (expected 4)
# line 2: an empty date -> Invalid isoformat string: ''
# line 3: a decimal priority -> invalid literal for int() with base 10: '2.5'
with open("my_broken.txt", "w") as file:
file.write("Buy milk|2026-09-21|2|no|extra\n")
file.write("Finish Module 9||1|no\n")
file.write("Call the dentist|2026-09-18|2.5|yes\n")
file.write("Pay the rent|2026-10-01|1|no\n")
tasks = load_tasks("my_broken.txt")
print(f"{len(tasks)} task survived: {tasks[0]['title']}")
Too many parts, an empty date, and a decimal priority. The messages come from the split, fromisoformat() and
int() respectively. If you predicted the type of complaint correctly, that is what matters; the exact wording
is Python's.
Solution 3
# The two functions that change in todo_storage.py, plus a round-trip test.
from datetime import date
PRIORITY_WORDS = {1: "high", 2: "medium", 3: "low"}
PRIORITY_NUMBERS = {"high": 1, "medium": 2, "low": 3}
def task_to_line(task):
"""Return a task as one line of text: title|YYYY-MM-DD|high, medium or low|yes or no."""
if task["done"]:
done = "yes"
else:
done = "no"
return f"{task['title']}|{task['due']}|{PRIORITY_WORDS[task['priority']]}|{done}"
def line_to_task(line):
"""Return the task described by one line of the file. A malformed line causes a ValueError or KeyError."""
title, due_text, priority_text, done_text = line.split("|")
return {
"title": title,
"due": date.fromisoformat(due_text),
"priority": PRIORITY_NUMBERS[priority_text],
"done": done_text == "yes",
}
task = {"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False}
line = task_to_line(task)
print(line)
print(line_to_task(line) == task)
Two small dictionaries translate in each direction. A word that is not in PRIORITY_NUMBERS causes a KeyError,
not a ValueError, so load_tasks() would need except (ValueError, KeyError) to skip such a line. That is the
one change outside the two functions, and a good example of why naming the error type matters.
Solution 4
# The changed save_tasks() for todo_storage.py. The module already has `import os` at the top.
def save_tasks(tasks, filename=FILENAME):
"""Write every task to the file, replacing what was there, after backing up the old file."""
if os.path.exists(filename):
with open(filename) as old_file:
old_contents = old_file.read()
with open(filename + ".bak", "w") as backup:
backup.write(old_contents)
with open(filename, "w") as file:
for task in tasks:
file.write(task_to_line(task) + "\n")
The old contents are read into a string and written to the backup before the real file is opened for writing.
os.path.exists() skips the backup on the first run, when there is nothing to back up.
Summary¶
todo_storage.pyholds the file format in two functions,task_to_line()andline_to_task(), and nothing else in the app knows it.load_tasks()returns[]for a missing file and skips bad lines with a message, onetryfor each job.save_tasks()replaces the file with the current list. The list in memory is the truth.- A
filenameparameter with a default lets the self-test use its own files and leave the real one alone. - The self-test under
if __name__ == "__main__":checks the round trip, the missing file and the broken file. Run it after every change.