Chapter 9
Files
Everything so far has lived in memory and vanished the moment the script ended. This chapter puts your expenses somewhere they survive: a plain text file, read back in the next time the script runs.
Opening a file
open() gives you a file object, something you can read from or write
to:
with open("expenses.txt") as f:
first_line = f.readline()
print(first_line)
4.50,coffee,flat white before work
The with statement closes the file for you automatically once its block
ends, even if something inside the block goes wrong. Always open a file
inside a with block; it's the one habit in this chapter worth never
skipping.
Notice the blank line after the amount. readline() includes the newline
character at the end of the line, \n, which is why printing it leaves an
extra blank line: print() adds its own newline on top of the one already
there.
Reading line by line
Looping over a file object gives you one line at a time, which is the normal way to read a whole file:
with open("expenses.txt") as f:
for line in f:
print(line.strip())
4.50,coffee,flat white before work
32.10,groceries,weekly shop
12.00,transit,monthly pass top-up
8.75,coffee,catching up with sam
45.00,groceries,forgot the weekly shop had a gap.strip() removes whitespace from both ends of a string, including that
trailing \n, which is why the extra blank lines are gone this time.
.split(",") breaks a line into pieces wherever a comma appears, giving you
back a list of strings:
line = "4.50,coffee,flat white before work"
pieces = line.split(",")
print(pieces)
['4.50', 'coffee', 'flat white before work']Unpack that list straight into three names, matching this book's entry fields in order:
line = "4.50,coffee,flat white before work"
raw_amount, category, note = line.split(",")
print(raw_amount)
print(category)
print(note)
4.50
coffee
flat white before work.readlines(), and why this book avoids it
.readlines() reads the whole file at once and hands back a list of every
line:
with open("expenses.txt") as f:
lines = f.readlines()
print(len(lines))
print(lines[0])
5
4.50,coffee,flat white before work
That works, and for a small file like this one it's perfectly fine. But it means the entire file has to fit in memory at once, as one list, before you process a single line. Looping directly over the file object, the way every other example in this chapter does, reads one line at a time and never holds more than that in memory, which matters once a file gets large. For this book's tracker, either approach works; looping directly is the habit worth keeping by default.
Loading a whole file into entries
Put the pieces together: open the file, read each line, split it, convert the amount, and build an entry dict, this book's fixed three-key shape from Chapter 6.
def load_expenses(path):
expenses = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
raw_amount, category, note = line.split(",")
expenses.append({
"amount": float(raw_amount),
"category": category,
"note": note,
})
return expenses
expenses = load_expenses("expenses.txt")
print(len(expenses))
print(expenses[0])
5
{'amount': 4.5, 'category': 'coffee', 'note': 'flat white before work'}if not line: continue skips any blank lines rather than crashing trying to
unpack an empty string into three pieces.
Writing
"w" opens a file for writing, replacing whatever was there. "a" opens it
for appending, adding to the end without disturbing the existing content:
with open("scratch.txt", "w") as f:
f.write("first line\n")
with open("scratch.txt", "a") as f:
f.write("second line\n")
with open("scratch.txt") as f:
print(f.read())
first line
second line
f.write() doesn't add a newline for you the way print() does, which is
why each call above ends its own string with \n. f.read() pulls the
whole file back as one string in a single call, useful for a quick look like
this.
open()'s second argument is called the mode, and this chapter has now
used three of them:
| Mode | Meaning |
|---|---|
"r" |
read (the default; leaving the mode out means this) |
"w" |
write, replacing the file if it exists, creating it if it doesn't |
"a" |
append, adding to the end, creating the file if it doesn't exist |
A fourth, "x", creates a new file and raises FileExistsError if one is
already there, useful the rare time you specifically want to avoid
overwriting something by accident. "r" is the default: open(path) and
open(path, "r") mean exactly the same thing, which is why every reading
example in this book so far left the mode out entirely.
Saving entries back out
The mirror of load_expenses(): build one comma-joined line per entry,
matching the field order amount,category,note, and write them all out.
def save_expenses(path, expenses):
with open(path, "w") as f:
for entry in expenses:
f.write(f"{entry['amount']},{entry['category']},{entry['note']}\n")
expenses = [
{"amount": 4.50, "category": "coffee", "note": "flat white"},
{"amount": 12.00, "category": "transit", "note": ""},
]
save_expenses("saved.txt", expenses)
with open("saved.txt") as f:
print(f.read(), end="")
4.5,coffee,flat white
12.0,transit,Round-trip it to check the save and load functions agree with each other:
reloaded = load_expenses("saved.txt")
print(reloaded == expenses)
TrueAn honest limit
.split(",") has a real weakness: a note containing its own comma would
split into the wrong number of pieces and crash load_expenses() on
unpacking. This chapter's fixture notes were written without any commas in
them on purpose, so the approach here is genuinely correct for the data it's
given, not secretly broken. Handling a comma inside a field properly is
exactly what the csv module is for, covered in full in Python File
Handling, already part of this series. If your own notes need commas, that
book is where to go next; this chapter's plain approach is for learning the
shape of the problem, not the last word on solving it.
Practice
Try each of these before you read the solution under it.
- Load
expenses.txtwithload_expenses()and print the total of all the amounts, using thetotal()function from Chapter 7 (rewrite it here). - Write a function
count_by_category(expenses)that returns a dict mapping each category to how many entries have it, then run it on the loaded fixture data. - Load the fixture file, append one new entry for a $6.00
"coffee"expense with the note"treat", save it to"updated.txt", then load"updated.txt"back and print its length. - Write
append_note(path, text)that openspathin append mode and writestextfollowed by a newline, without disturbing what's already there. Call it twice on a new file,"log.txt", then print the file's full contents.
Solutions
1.
def total(expenses):
running = 0
for entry in expenses:
running += entry["amount"]
return running
expenses = load_expenses("expenses.txt")
print(total(expenses))
102.352.
def count_by_category(expenses):
counts = {}
for entry in expenses:
category = entry["category"]
counts[category] = counts.get(category, 0) + 1
return counts
expenses = load_expenses("expenses.txt")
print(count_by_category(expenses))
{'coffee': 2, 'groceries': 2, 'transit': 1}3. counts.get(category, 0) + 1 from the previous solution is the same
"default, then update" shape used here for the new entry.
expenses = load_expenses("expenses.txt")
expenses.append({"amount": 6.00, "category": "coffee", "note": "treat"})
save_expenses("updated.txt", expenses)
reloaded = load_expenses("updated.txt")
print(len(reloaded))
64.
def append_note(path, text):
with open(path, "a") as f:
f.write(text + "\n")
append_note("log.txt", "started tracking")
append_note("log.txt", "added first entry")
with open("log.txt") as f:
print(f.read(), end="")
started tracking
added first entryWhere this leaves you
You can read a text file line by line, split it into this book's entry
fields, and write entries back out, all with nothing but open(). You also
know exactly where this chapter's simple approach breaks and where to look
when you need more. Chapter 10 handles what happens when a file is missing
or a line doesn't parse, instead of letting the script crash.