Making Your Study Log Persistent
Learning Objectives
- You can load existing sessions from a CSV file at startup, and handle the first-run case where the file does not exist.
- You can save sessions to a CSV file so they survive between runs.
- You can convert between a list-of-rows representation and CSV file rows.
- You can write
load_sessionsandsave_sessionsfunctions with clear specifications, and test them as a round-trip. - You can apply the defensive-programming habits from this part to the project’s own data file.
This is the project checkpoint for Part 4. The behaviour change is small to describe: when you finish a session of using the study log, your sessions are saved. The next time you start the program, your previous sessions are still there. The data persists between runs. This is the moment the project becomes something you could actually keep using — not a demo that forgets you the moment you quit.
The change is small to describe and slightly bigger to build. It involves:
- a small refactor of the in-memory shape of the data, from two parallel lists to a list of rows,
- two new functions (
load_sessionsandsave_sessions) with their own specifications, tests, and defensive handling, - a startup step that loads existing data if any exists,
- and a save step that writes the data back at the right moment.
A Small Refactor: From Parallel Lists to a List of Rows
Up to now, the study log has held sessions as two parallel lists:
subjects = ["Math", "Physics"]minutes_list = [45, 90]These two lists move together — the first subject corresponds to the first minutes value, the second to the second, and so on. This works, but it has been quietly awkward: you have to keep the two lists in sync at every append, and any function that needs both has to take both.
CSV rows are one row per record. The shape on disk is one line per session, with the subject and minutes together. The natural in-memory shape that matches the file is a list of rows:
sessions = [ ["Math", 45], ["Physics", 90],]From here on, the study log holds its data as a list of rows rather than as parallel lists. The immediate payoff is that loading and saving are considerably simpler when the in-memory shape matches the file shape.
The functions built in part 3 adapt cleanly. total_minutes(minutes_list) still wants a list of just the minutes, not the full rows, but you can extract that column when you need it:
def get_minutes(sessions): result = []
for row in sessions: result.append(row[1])
return result
all_minutes = get_minutes(sessions)total = total_minutes(all_minutes)The function get_minutes walks the sessions and pulls out the minutes from each row. The functions stay untouched — the project just feeds them the column they want, when it wants them.
Designing the File
A small design decision before writing any code: what does each session row contain?
For this part, the minimal useful row is:
| Column | Type | Notes |
|---|---|---|
subject | string | What you studied. |
minutes | integer | How long the session was. |
date | string | When the session happened, in YYYY-MM-DD format. |
The date is new compared to the previous parts. The program can read today’s date from the system, and it unlocks a great deal of functionality in the next few parts.
To get today’s date, the standard-library datetime module is enough:
from datetime import date
today = date.today().isoformat()# e.g. "2026-05-12"The call .isoformat() produces a date in the YYYY-MM-DD format, which follows the ISO 8601 specification. Most applications like spreadsheets and databases understand the format, and it sorts correctly as text.
The CSV file then looks like:
subject,minutes,dateMath,45,2026-05-10Physics,90,2026-05-10History,30,2026-05-11A header row, then one data row per session.
Required Specifications
Before writing the load and save functions, write their specifications — the practice from the Specifying What You Want chapter from the previous part. Two specs for the two new functions:
load_sessions(path: str) -> list of [str, int, str] rows
Reads sessions from a CSV file at the given path and returns themas a list of rows. Each row is [subject, minutes, date], withminutes converted from text to int.
Edge cases: - File does not exist (first run): returns an empty list. Not an error. - File is empty or has only a header: returns an empty list. - File has a row with the wrong number of fields: that row is skipped. - File has a row where minutes is not a valid integer: that row is skipped.
Raises: - UnicodeDecodeError if the file is not valid UTF-8. (This indicates a real problem with the file, not a normal condition; re-raise it.)save_sessions(path: str, sessions: list of [str, int, str] rows) -> None
Writes the given sessions to a CSV file at the given path, replacingthe file's previous contents. Writes a header row first, then onedata row per session.
Edge cases: - Empty list: writes a file containing only the header row. - File already exists: it is overwritten.Two design choices in these specs are worth pointing out, because they are not the only valid ones.
First, load_sessions skips malformed rows rather than raising an exception. This is a non-crashing recovery: the program keeps going with the rows that did parse, and the user does not see a crash. The alternative — raising on the first bad row — would be less forgiving. Either is defensible; the spec makes the choice explicit.
A real production version of this code would probably log the skipped rows somewhere so they are not invisible. Logging is beyond this course; for now, a
Second, save_sessions overwrites. When the program saves, it writes the current state of sessions to the file. The previous file contents are not preserved separately.
load_sessions
The implementation, with the defensive cases from the spec:
import csv
def load_sessions(path): """Load sessions from a CSV file. See spec for behaviour.""" sessions = [] try: f = open(path, encoding="utf-8") except FileNotFoundError: return sessions # first-run case: no file yet
with f: reader = csv.reader(f) try: next(reader) # skip header except StopIteration: return sessions # empty file
for row in reader: if len(row) != 3: print(f"Skipping malformed row: {row}") continue subject = row[0].strip() try: minutes = int(row[1].strip()) except ValueError: print(f"Skipping row with bad minutes: {row}") continue date = row[2].strip() sessions.append([subject, minutes, date])
return sessionsMost of this is the defensive programming chapter applied. Two structural points are worth pointing out.
The try around open(path, ...) catches the FileNotFoundError and returns an empty list. This is the first-run case: the file does not exist yet because the user has not saved anything yet. That is not an error, it is the expected state for a new install, and the function handles it cleanly.
The slightly unusual with f: (rather than with open(...) as f:) is because the open call had to happen inside a try to catch the FileNotFoundError. Once the file is open, with f: ensures it still gets closed properly when the block ends. The pattern is uncommon but exactly right for this case.
save_sessions
The save function is much shorter, because there is less that can go wrong on the write side:
def save_sessions(path, sessions): """Save sessions to a CSV file. See spec for behaviour.""" with open(path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["subject", "minutes", "date"]) for row in sessions: writer.writerow(row)Notice "w" — write mode, which empties the file before writing. This is the right choice for “save the current state” but it is exactly the mode the previous chapter flagged as dangerous. Here the danger is intentional: each save fully replaces the file with the current state of the program, which is consistent and easy to reason about.
The
newline=""is used as CSV writes can produce blank lines between rows on some systems.
Round-Trip Test
The way to test the load and save functions is to round-trip them:
def test_round_trip(): sessions_in = [ ["Math", 45, "2026-05-10"], ["Physics", 90, "2026-05-10"], ["History", 30, "2026-05-11"], ] save_sessions("test.csv", sessions_in) sessions_out = load_sessions("test.csv")
assert sessions_out == sessions_in, ( f"round-trip failed: got {sessions_out}" )
def test_first_run(): """A missing file should return an empty list, not raise.""" sessions = load_sessions("does-not-exist.csv") assert sessions == []
def test_empty_save(): """Saving an empty list should produce a header-only file.""" save_sessions("empty.csv", []) sessions = load_sessions("empty.csv") assert sessions == []
test_round_trip()test_first_run()test_empty_save()print("All file tests passed.")Three tests, each checking a different aspect of the spec. The round-trip is the workhorse — if save_sessions and load_sessions agree with each other on regular data, the file format is consistent. The first-run test exercises the most important edge case for the project. The empty-save test exercises the most degenerate normal case.
You could write more (a malformed row, a row with three fields where minutes is text, a date with whitespace) — and these are good exercises to take on — but these three together check most of what matters.
When to Save
The remaining design choice is when the program saves. Two reasonable options:
After each completed session. Every time the user adds a session, the program writes the whole sessions list to disk before asking for the next session. The cost is a small amount of slowness per session (writing a small file is fast). The benefit is that if the program crashes or is closed without going through the normal exit, no data is lost.
On exit only. The program collects sessions in memory through the run, and writes them all out once when the user is done. The cost is that a crash mid-run loses every session from that run. The benefit is that the program writes the file fewer times, which feels cleaner.
For the project, after each completed session is recommended. Files are small, writes are fast, and the safety margin against losing data is worth it. A user who logs four sessions and then has their laptop battery die does not want to lose the four sessions.
while True: subject, minutes = get_session() if subject is None: break
sessions.append([subject, minutes, date.today().isoformat()]) save_sessions("sessions.csv", sessions) # save after each session
label = classify_session(minutes) ...A note on risk: if the save itself fails mid-write — say, the disk runs out of space, or the program is killed at the worst possible instant — you can end up with a half-written file.
The pattern that defends against this is called atomic write (write to a temp file, then rename it over the real file). This is out of scope for the course.
The Main Script
Putting it all together, the main script for the persistent study log:
from datetime import dateimport csv
# Functions from Part 3 (unchanged)def classify_session(minutes): if minutes < 15: return "short" elif minutes <= 90: return "solid" else: return "long"
def total_minutes(minutes_list): total = 0 for m in minutes_list: total += m return total
def longest_session(minutes_list): if len(minutes_list) == 0: return None longest = minutes_list[0] for m in minutes_list: if m > longest: longest = m return longest
def get_session(): subject = input("Subject (or 'done' to finish): ").strip().lower() if subject == "done": return None, None
while True: minutes_text = input("For how many minutes? ").strip() try: minutes = int(minutes_text) break except ValueError: print("Please enter a whole number of minutes.")
return subject, minutes
# New in Part 4def get_minutes(sessions): result = []
for row in sessions: result.append(row[1])
return result
def load_sessions(path): sessions = [] try: f = open(path, encoding="utf-8") except FileNotFoundError: return sessions
with f: reader = csv.reader(f) try: next(reader) except StopIteration: return sessions
for row in reader: if len(row) != 3: print(f"Skipping malformed row: {row}") continue subject = row[0].strip() try: minutes = int(row[1].strip()) except ValueError: print(f"Skipping row with bad minutes: {row}") continue date_str = row[2].strip() sessions.append([subject, minutes, date_str])
return sessions
def save_sessions(path, sessions): with open(path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["subject", "minutes", "date"]) for row in sessions: writer.writerow(row)
# Main scriptSESSIONS_FILE = "sessions.csv"
print("=== Study Log ===")print()
sessions = load_sessions(SESSIONS_FILE)if sessions: print(f"Loaded {len(sessions)} previous sessions.")else: print("No previous sessions yet.")print()
while True: subject, minutes = get_session() if subject is None: break
today = date.today().isoformat() sessions.append([subject, minutes, today]) save_sessions(SESSIONS_FILE, sessions)
label = classify_session(minutes) if label == "short": print(f" Short session on {subject} -- every bit counts.") elif label == "solid": print(f" Solid session on {subject}.") else: print(f" Long session on {subject} -- remember to take breaks!") print()
print("=== Summary ===")
if len(sessions) == 0: print("No sessions logged today.")else: all_minutes = get_minutes(sessions) total = total_minutes(all_minutes) longest = longest_session(all_minutes) print(f"Sessions on record: {len(sessions)}") print(f"Total minutes: {total}") print(f"Longest session: {longest} minutes")A few things to point out about the shape of this program.
The functions from part 3 appear unchanged. classify_session, total_minutes, longest_session, and get_session are exactly as you wrote them in the previous part. The only adjustment at the call site is get_minutes(sessions), which extracts the minutes column from the rows before passing it to total_minutes and longest_session. The functions themselves did not change.
The constant SESSIONS_FILE is at the top. This is a small thing, but having the filename in one named place makes it easy to change later — and easier to test, because tests can point at a different file.
The flow is: load at startup, append after each session, save after each append, summarise at the end. The pattern is the file always reflects the current in-memory state. After each save, the file on disk is exactly what sessions holds in memory.
Work on the following program locally on your own computer. Work on the functions one by one. You may return the program for checks periodically — no need to wait until you have finished the whole program to receive feedback from the automated assessment system.
Persistent study log
0 / 45 points
Build the persistent study log as one complete main.py program.
The program stores sessions in sessions.csv with the CSV module. The file is declared as SESSIONS_FILE = "sessions.csv". The file has header subject,minutes,date, and each in-memory row has this shape:
[subject, minutes, date]subject is a string, minutes is an integer, and date is a YYYY-MM-DD string.
Required helpers:
| Helper | Contract |
|---|---|
classify_session(minutes) | "short" below 15, "solid" from 15 through 90, "long" above 90 |
total_minutes(minutes_list) | total minutes, or 0 for an empty list |
longest_session(minutes_list) | largest minute value, or None for an empty list |
get_session() | ask for subject, normalize with .strip().lower(), return (None, None) for done, otherwise keep asking for whole-number minutes |
get_minutes(sessions) | extract the minutes column from session rows |
load_sessions(path) | use csv.reader; open with encoding="utf-8"; return [] for missing, empty, or header-only files; strip fields; convert minutes; skip malformed rows with Skipping malformed row; skip bad-minute rows with Skipping row with bad minutes |
save_sessions(path, sessions) | use csv.writer; open for writing with encoding="utf-8" and newline=""; write header ["subject", "minutes", "date"]; write all rows; return None |
Do not use .split(",") for CSV handling. Subjects may contain commas, so csv.reader and csv.writer are required.
Complete program behavior:
- use
sessions.csvin the current working directory, - print
=== Study Log ===when the program starts, - load previous sessions before asking for input,
- print
Loaded {n} previous sessions.when previous sessions exist, - print
No previous sessions yet.when none exist, - keep asking for subjects until the user types
done, - accept variants such as
Done,DONE, anddone, - ask for minutes only after a non-quit subject,
- re-prompt with
Please enter a whole number of minutes.after non-numeric minutes, - save immediately after every successfully recorded session,
- use
date.today().isoformat()for new session dates, - preserve existing rows and append new rows,
- print one response line for each new session,
- print
=== Summary ===after the input loop, - print
No sessions logged today.if there are no sessions on record, - otherwise print
Sessions on record: {n},Total minutes: {total}, andLongest session: {minutes} minutes.
Required response lines, with the actual subject substituted:
Short session on math -- every bit counts. Solid session on math. Long session on math -- remember to take breaks!For example, with input Math, nope, 45, done, the output must include:
=== Study Log ===No previous sessions yet.Please enter a whole number of minutes. Solid session on math.=== Summary ===Sessions on record: 1Total minutes: 45Longest session: 45 minutesThe exact input prompts may differ. The required output lines above must appear exactly as written.
A Note on Using AI for This Project
If you are using an AI assistant to help with the load and save functions, the natural workflow from this part is:
- Spec. Write the specifications above (or your own variants) before asking the assistant for code. The spec is the part you cannot delegate — it is where the decisions live.
- Generate. Ask the assistant for an implementation of one function at a time, with the spec attached.
- Read. Read the generated code carefully against the spec. Does it handle the edge cases the spec demands? Does it use
with? Does it specify the encoding? Does it use"w"or"a", and is that intentional? - Verify with the round-trip test. Run the test from this chapter on the generated code. If round-tripping fails, fix it before doing anything else.
- Probe the unhappy path. Missing file, empty file, malformed row, non-ASCII subject, second run. Each takes seconds; together they catch the failure modes that look correct on the happy path.
This is the four-skill arc in one project: spec, read, verify, detect. The AI does the typing; you do the engineering.
Things to Try Beyond the Minimum
A few open suggestions, none required:
- Sort the loaded sessions by date when the program starts, so the most recent are at the end. You can try what happens with:
def by_date(row): return row[2]
sessions.sort(key=by_date)- Print today’s total at the end, in addition to the all-time summary. This requires filtering the sessions to just today’s, then extracting the minutes and passing them to
total_minutesandlongest_sessionas before. - Move
SESSIONS_FILEto a subfolder likedata/sessions.csv— usingpathlib.Path, not a hardcoded slash. - Add a small “delete last session” command in the input loop. It is a one-line
sessions.pop()plus a save, and it exercises the same load/save machinery.
These are good places to apply the four AI skills in practice. They are also good places to discover that even small features have edge cases.
Check Your Understanding
- Why does the project move from parallel lists to a list of rows in this part?
- What happens the very first time the user runs the persistent study log? Where in the code is this case handled?
- The
save_sessionsfunction uses"w"mode. Why is this safe, given the previous chapter’s warning about"w"? - Why does
load_sessionsskip malformed rows instead of raising an exception? What is the alternative, and what is the trade-off? - The old functions (
total_minutes,longest_session) are reused unchanged in this part. What was needed at the call site to make them work with the new list-of-rows structure?
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.