Programming with Python › Files, Errors, and Object-Oriented Python › Day 64
Hands-on lab — Day 64: Reading and Writing Files
- ← Back to the Day 64 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-064-reading-and-writing-files/
Commands
Setup
cd labs/sections/programming-with-python/day-064-reading-and-writing-files
python3 --version Run
python3 examples/demo.py
head -2 workspace/big.log
wc -l < workspace/big.log
python3 starter/demo.py
rm -rf workspace Test
bash tests/run_tests.sh File tree
examples/demo.py examples/fileio_toolkit.py expected-output/sample-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md security.md starter/demo.py starter/fileio_toolkit.py tests/run_tests.sh troubleshooting.md
Lab README
Day 064 lab — Safe File I/O Toolkit
Lesson
- Lesson title: Reading and Writing Files
- Day number: 64 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-064-reading-and-writing-files
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-064-reading-and-writing-fileswhen the site is running.
Purpose
Build a small toolkit of six functions that cover everything file handling
asks of you in practice, and prove each one by running it against real files.
You write text with an explicit encoding and read it back unchanged; you
measure — not guess — what a whole-file read costs against a streaming read
on a generated 8.2 MB log; you meet a genuine UnicodeDecodeError and
recover from it deliberately; you implement the atomic write and catch it
mid-operation to see that a reader always gets a complete file; and you walk
a directory tree with pathlib. These are the habits every later lab
depends on, because from here on your programs read and write data that
matters.
Learning objectives
By the end of this lab you can:
- Round-trip text through an explicit
encoding="utf-8"and explain why the byte count exceeds the character count. - Measure peak memory with
tracemallocand show that streaming reads do not scale with file size while whole-file reads do. - Trigger a
UnicodeDecodeErroron purpose and recover witherrors="replace", returning a flag so the recovery can be logged rather than hidden. - Implement
atomic_write_textwith a same-directory temp file,flush,os.fsync, andos.replace, and demonstrate that the target file is never observed in a partial state. - Contrast that with a naive
mode="w"write caught holding a fragment. - Walk a directory tree with
Path.rgloband return sorted relative paths.
Prerequisites
- Day 64's lesson, "Reading and Writing Files" (read it first — this lab is its exercise).
- Days 57-63: functions, modules and imports, the standard library, and designing a small program well.
- Comfort running
python3from a terminal and editing a text file.
Supported operating systems
macOS and Linux run every command as written. On Windows, use WSL — the Python is portable, but the test runner is a bash script.
Hardware requirements
Any machine that runs Python 3. The lab generates an 8.2 MB log file, so allow about 10 MB of free disk space. Peak memory during the deliberately wasteful whole-file read is roughly 25 MB.
Required software
python33.8 or newer (tested on 3.14.0)bashfor the test runner
Nothing else. No pip install, no network access, no privileges. See
requirements/README.md for details.
Free and open-source options
Everything here is free and open source. Python is released under the PSF
License; the only modules used — os, pathlib, tracemalloc, sys — are
part of the standard library. There is no paid tier for reading a file, and
no account to create.
Installation
cd labs/sections/programming-with-python/day-064-reading-and-writing-files
python3 --version
If that prints Python 3.8 or higher, you are ready. There is nothing to
install.
File structure
day-064-reading-and-writing-files/
README.md this file
metadata.yml lab metadata and the recorded execution evidence
examples/
fileio_toolkit.py the reference implementation of all six functions
demo.py the five-part driver that proves each one
starter/
fileio_toolkit.py your copy, with five numbered exercises to complete
demo.py the same driver, importing your version
tests/
run_tests.sh assert-based suite; 28 checks against real files
expected-output/
sample-run.txt captured output of examples/demo.py
test-run.txt captured output of the test suite
requirements/README.md dependencies and platform notes
troubleshooting.md symptom-by-symptom fixes
security.md path traversal, safe parsing, and why atomicity matters
How to run
First drive the finished reference so you know the target:
python3 examples/demo.py
Inspect the evidence it left behind:
ls -1 workspace workspace/config workspace/tree
head -2 workspace/big.log
wc -l < workspace/big.log
rm -rf workspace
Now open starter/fileio_toolkit.py, complete its five numbered exercises,
and run the same driver against your version:
python3 starter/demo.py
Finally run the suite:
bash tests/run_tests.sh
What the commands do
python3 examples/demo.py— runs all five parts in aworkspace/directory it creates beside the lab: the text round trip, the memory comparison (which generates the 200,000-line log), the decoding failure and recovery, the atomic write with a mid-operation inspection, and thepathlibtree walk.head -2 workspace/big.log/wc -l < workspace/big.log— confirm the generated log is real: two sample lines, and 200,000 of them.rm -rf workspace— removes everything the demo created. The lab writes nowhere else.python3 starter/demo.py— the identical driver, importingstarter/fileio_toolkit.py, so your implementation is held to the same output as the reference.bash tests/run_tests.sh— 28 assertions against real files in a throwaway directory created withmktemp -dand removed on exit.
Expected output
python3 examples/demo.py produces (captured on the authoring machine; byte
counts are deterministic, memory peaks vary by a few kilobytes between runs):
Safe File I/O Toolkit — workspace: workspace
[1] text round-trip with an explicit encoding
wrote notes.txt: 70 bytes on disk, 62 characters in memory
lines read back: 3
round-trip identical: True
[2] whole-file read vs line-by-line streaming
generated big.log: 200000 lines, 8200020 bytes (7.82 MiB)
read() + splitlines() -> longest line 60 chars, peak memory 24.82 MiB
for line in handle -> longest line 60 chars, peak memory 0.14 MiB
same answer: True
streaming used 175x less memory
[3] decoding trouble and how to survive it
strict utf-8 read raised UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte
errors='replace' read: 'caf� was logged by a latin-1 program'
recovered: True; U+FFFD present: True
[4] atomic write — proved by looking at the directory mid-operation
before: ['config.txt'] content='version = 1'
during: ['config.txt', 'config.txt.tmp'] content='version = 1'
after : ['config.txt'] content='version = 2'
[5] walking a directory tree with pathlib
a.txt
sub/b.txt
sub/deeper/c.txt
3 file(s) found by Path.rglob
The full capture, including the explanatory notes the driver prints, is in
expected-output/sample-run.txt; the suite's output is in
expected-output/test-run.txt.
Validation steps
You are done when every box is checked:
-
python3 examples/demo.pyruns all five parts and exits 0. - Part 1 reports
round-trip identical: True, with 70 bytes for 62 characters. - Part 2 reports the same longest-line length from both strategies, with the streaming peak far below the whole-file peak.
- Part 3 raises a real
UnicodeDecodeErrorand then reportsrecovered: True. - Part 4's
during:line shows bothconfig.txtandconfig.txt.tmp, withconfig.txtstill holdingversion = 1. - Part 5 lists exactly three relative paths.
- All five exercises in
starter/fileio_toolkit.pyare complete andpython3 starter/demo.pymatches the reference. -
bash tests/run_tests.shprints0 failure(s).and exits 0.
Tests
bash tests/run_tests.sh
The suite checks real behaviour, not file existence: that mode w replaces
while mode a appends, that write() adds no newline of its own, that both
reading strategies return the same answer while only one stays flat in
memory, that a strict read really raises and the fallback really recovers,
that during an atomic write the target still holds the old content while
the temp file sits in the same directory, that the naive writer is caught
holding a fragment, and that list_tree returns sorted relative paths for
files only. It exits 0 on success and non-zero on any failure.
Recorded evidence: on the authoring machine (macOS, Apple Silicon, Python
3.14.0) the suite reports 28 checks, 0 failure(s). and exits 0.
Cleanup
rm -rf workspace
git checkout -- starter/fileio_toolkit.py # optional: reset your work
The test suite cleans up after itself automatically — its scratch directory
is created with mktemp -d and removed by an EXIT trap.
Troubleshooting
See troubleshooting.md for symptom-by-symptom fixes, including
FileNotFoundError from relative paths, memory figures that differ from the
capture, UnicodeDecodeError on unexpected files, Invalid cross-device link from os.replace, and stray .tmp files.
Security notes
See security.md. The essentials: never build a path from untrusted input
by concatenation (path traversal), never eval() file contents, use mode
x when a file must not be overwritten, keep the atomic-write temp file in
the target's own directory, and remember that anything you write outlives
the program that wrote it.
Extension exercises
- Add an index to the record store. Record
handle.tell()before each append into a companion file, then implementget(path, key)thatseek()s straight to the offset and reads one line. Time it against a full scan on 100,000 records — you will have built, in miniature, the reason databases have indexes. - Prove crash-safety under compaction. Kill the process between writing
the temp file and
os.replace, then show every record is still present and the only trace is a stray.tmp. Add a startup step that cleans such strays safely. - Rotate the store. Past a size threshold, atomically rename it to
store.1and start fresh, then make the reader read across rotated files oldest-first withPath.glob. - Measure the cost of durability. Time 10,000 appends with
fsyncon every record againstfsynconce at the end. The gap is why databases group commits, and why you reservefsyncfor the moments that matter.
Navigation
- Previous day: Day 063 — Designing a Small Program Well
- Next day: Day 065 — CSV and JSON in the Real World
Expected output
sample-run.txt
$ python3 examples/demo.py
Safe File I/O Toolkit — workspace: workspace
[1] text round-trip with an explicit encoding
wrote notes.txt: 70 bytes on disk, 62 characters in memory
lines read back: 3
round-trip identical: True
note: 70 bytes > 62 characters — non-ASCII characters
take more than one byte each in UTF-8
[2] whole-file read vs line-by-line streaming
generated big.log: 200000 lines, 8200020 bytes (7.82 MiB)
read() + splitlines() -> longest line 60 chars, peak memory 24.82 MiB
for line in handle -> longest line 60 chars, peak memory 0.14 MiB
same answer: True
streaming used 175x less memory
[3] decoding trouble and how to survive it
wrote broken.log as raw bytes: b'caf\xe9 was logged by a latin-1 program\n'
strict utf-8 read raised UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte
errors='replace' read: 'caf� was logged by a latin-1 program'
recovered: True; U+FFFD present: True
[4] atomic write — proved by looking at the directory mid-operation
before: ['config.txt'] content='version = 1'
during: ['config.txt', 'config.txt.tmp'] content='version = 1'
the new bytes are all in config.txt.tmp; readers of config.txt still get the OLD file, whole
after : ['config.txt'] content='version = 2'
contrast: mode 'w' straight onto the real file was caught mid-write holding 'version = 2 wit'
— a crash there destroys the old file and leaves that fragment
[5] walking a directory tree with pathlib
a.txt
sub/b.txt
sub/deeper/c.txt
3 file(s) found by Path.rglob
tree exists: True; is a directory: True
Done. Remove everything with: rm -rf workspace
$ ls -1 workspace workspace/config workspace/tree
workspace:
big.log
broken.log
config
notes.txt
tree
workspace/config:
config.txt
unsafe.txt
workspace/tree:
a.txt
sub
$ head -2 workspace/big.log
0000001 log line padding-padding-padding
0000002 log line padding-padding-padding
$ wc -l < workspace/big.log
200000
$ rm -rf workspace
test-run.txt
$ bash tests/run_tests.sh
Testing the toolkit in <repo>/labs/sections/programming-with-python/day-064-reading-and-writing-files/examples (real files in a temporary directory) ...
ok: 1. text round-trips through an explicit utf-8 encoding
ok: 1b. mode 'w' replaces rather than appends
ok: 1c. write() adds no newline of its own
ok: 2. streaming and whole-file reads agree on the longest line
ok: 2b. streaming peak memory is far below the whole-file peak
ok: 3. a strict utf-8 read of a latin-1 byte raises UnicodeDecodeError
ok: 3b. errors='replace' recovers the file with U+FFFD
ok: 3c. a clean utf-8 file needs no fallback
ok: 4. atomic write leaves the OLD file intact until os.replace
ok: 4b. after os.replace the new content is there and no .tmp remains
ok: 4c. the temp file sits in the SAME directory as the target
ok: 4d. the naive write is caught holding a half-written file
ok: 5. list_tree finds every file as a sorted relative path
ok: 5b. list_tree returns files only, never directories
Testing <repo>/labs/sections/programming-with-python/day-064-reading-and-writing-files/examples/demo.py end to end ...
ok: examples: driver exits 0
ok: examples: driver output contains "round-trip identical: True"
ok: examples: driver output contains "same answer: True"
ok: examples: driver output contains "UnicodeDecodeError"
ok: examples: driver output contains "recovered: True"
ok: examples: driver output contains "config.txt', 'config.txt.tmp'"
ok: examples: driver output contains "3 file(s) found by Path.rglob"
Testing starter/fileio_toolkit.py ...
ok: starter toolkit is valid Python
Note: starter/fileio_toolkit.py still has unfinished exercises — testing structure only.
ok: starter defines write_text_file
ok: starter defines read_text_file
ok: starter defines longest_line_streaming
ok: starter defines read_text_surviving_bad_bytes
ok: starter defines atomic_write_text
ok: starter defines list_tree
28 checks, 0 failure(s).
$ echo $?
0
Source files
examples/demo.py (5646 bytes)
"""Driver for the Safe File I/O Toolkit — the thin shell around the toolkit.
Run it from the lab directory:
python3 examples/demo.py # workspace defaults to ./workspace
python3 examples/demo.py my-scratch # or name your own workspace directory
Everything it creates lives inside the workspace directory, so cleanup is a
single `rm -rf workspace`. It makes no network calls and needs no
privileges.
"""
import sys
from pathlib import Path
import fileio_toolkit as toolkit
LINE_COUNT = 200_000
LONG_LINE_AT = 100_000
def mib(byte_count):
"""Bytes as a human-readable MiB string (1 MiB = 1024 * 1024 bytes)."""
return f"{byte_count / (1024 * 1024):.2f} MiB"
def part_1_round_trip(workspace):
print("[1] text round-trip with an explicit encoding")
notes = workspace / "notes.txt"
text = "Day 64: reading and writing files\ncafé, naïve, 日本語\nthird line\n"
size = toolkit.write_text_file(notes, text)
back = toolkit.read_text_file(notes)
print(f" wrote {notes.name}: {size} bytes on disk, {len(text)} characters in memory")
print(f" lines read back: {len(back.splitlines())}")
print(f" round-trip identical: {back == text}")
print(f" note: {size} bytes > {len(text)} characters — non-ASCII characters")
print(" take more than one byte each in UTF-8")
print()
def part_2_memory(workspace):
print("[2] whole-file read vs line-by-line streaming")
big = workspace / "big.log"
size = toolkit.make_big_file(big, LINE_COUNT, long_line_at=LONG_LINE_AT)
print(f" generated {big.name}: {LINE_COUNT} lines, {size} bytes ({mib(size)})")
whole_len, whole_peak = toolkit.longest_line_whole_file(big)
stream_len, stream_peak = toolkit.longest_line_streaming(big)
print(f" read() + splitlines() -> longest line {whole_len} chars, "
f"peak memory {mib(whole_peak)}")
print(f" for line in handle -> longest line {stream_len} chars, "
f"peak memory {mib(stream_peak)}")
print(f" same answer: {whole_len == stream_len}")
print(f" streaming used {whole_peak / max(stream_peak, 1):.0f}x less memory")
print()
def part_3_decoding(workspace):
print("[3] decoding trouble and how to survive it")
broken = workspace / "broken.log"
# Written as RAW BYTES so we control exactly what lands on disk. 0xE9 is
# "é" in latin-1, but on its own it is not valid UTF-8.
broken.write_bytes(b"caf\xe9 was logged by a latin-1 program\n")
print(f" wrote {broken.name} as raw bytes: {broken.read_bytes()!r}")
try:
toolkit.read_text_file(broken)
print(" strict utf-8 read: no error (unexpected)")
except UnicodeDecodeError as err:
print(f" strict utf-8 read raised UnicodeDecodeError: {err}")
text, recovered = toolkit.read_text_surviving_bad_bytes(broken)
print(f" errors='replace' read: {text.strip()!r}")
print(f" recovered: {recovered}; U+FFFD present: {chr(0xFFFD) in text}")
print()
def part_4_atomic(workspace):
print("[4] atomic write — proved by looking at the directory mid-operation")
box = workspace / "config"
box.mkdir(exist_ok=True)
config = box / "config.txt"
toolkit.write_text_file(config, "version = 1\n")
print(f" before: {sorted(p.name for p in box.iterdir())} "
f"content={toolkit.read_text_file(config).strip()!r}")
def peek(temp_path):
during = sorted(p.name for p in box.iterdir())
still_old = toolkit.read_text_file(config).strip()
print(f" during: {during} content={still_old!r}")
print(f" the new bytes are all in {temp_path.name}; readers of "
f"{config.name} still get the OLD file, whole")
toolkit.atomic_write_text(config, "version = 2\n", after_temp=peek)
print(f" after : {sorted(p.name for p in box.iterdir())} "
f"content={toolkit.read_text_file(config).strip()!r}")
# The contrast: the naive write, caught in the act.
unsafe = box / "unsafe.txt"
toolkit.write_text_file(unsafe, "version = 1\n")
seen = {}
def peek_unsafe(path):
seen["mid"] = toolkit.read_text_file(path)
toolkit.unsafe_write_text(unsafe, "version = 2 with more content\n", after_open=peek_unsafe)
print(f" contrast: mode 'w' straight onto the real file was caught mid-write "
f"holding {seen['mid']!r}")
print(" — a crash there destroys the old file and leaves that fragment")
print()
def part_5_pathlib(workspace):
print("[5] walking a directory tree with pathlib")
tree = workspace / "tree"
(tree / "sub" / "deeper").mkdir(parents=True, exist_ok=True)
toolkit.write_text_file(tree / "a.txt", "a\n")
toolkit.write_text_file(tree / "sub" / "b.txt", "b\n")
toolkit.write_text_file(tree / "sub" / "deeper" / "c.txt", "c\n")
found = toolkit.list_tree(tree)
for relative in found:
print(f" {relative}")
print(f" {len(found)} file(s) found by Path.rglob")
print(f" tree exists: {tree.exists()}; is a directory: {tree.is_dir()}")
print()
def main(argv):
workspace = Path(argv[1] if len(argv) > 1 else "workspace")
workspace.mkdir(parents=True, exist_ok=True)
print(f"Safe File I/O Toolkit — workspace: {workspace}")
print()
part_1_round_trip(workspace)
part_2_memory(workspace)
part_3_decoding(workspace)
part_4_atomic(workspace)
part_5_pathlib(workspace)
print(f"Done. Remove everything with: rm -rf {workspace}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
examples/fileio_toolkit.py (7543 bytes)
"""Safe file I/O toolkit — the reference implementation.
Nine small functions covering the five habits of reading and writing files
well:
1. round-trip text with an EXPLICIT encoding write_text_file / read_text_file
2. compare whole-file reading with line-by-line reading longest_line_whole_file /
longest_line_streaming
3. survive undecodable bytes read_text_surviving_bad_bytes
4. write without ever leaving a half-written file atomic_write_text
5. walk a directory tree with pathlib list_tree
Standard library only: os, tracemalloc, pathlib. Nothing here touches the
network, and every file this module opens is closed by a `with` block even
if an error is raised part-way through.
"""
import os
import tracemalloc
from pathlib import Path
# One padded record, so every generated line has the same length.
FILLER = "log line padding-padding-padding"
def write_text_file(path, text, encoding="utf-8"):
"""Write `text` to `path`, replacing anything already there.
Mode "w" truncates the file to zero bytes before the first write, so
this is a replace, not an append. The encoding is explicit on purpose:
without it Python picks a locale-dependent default and the same code
produces different bytes on different machines. `newline="\\n"` pins
the line ending so the output is byte-identical everywhere.
Returns the size of the finished file in bytes.
"""
with open(path, "w", encoding=encoding, newline="\n") as handle:
handle.write(text)
return os.path.getsize(path)
def read_text_file(path, encoding="utf-8"):
"""Read the whole of `path` back as one string, decoding with `encoding`."""
with open(path, "r", encoding=encoding) as handle:
return handle.read()
def make_big_file(path, line_count, long_line_at=None):
"""Generate a large-ish log file: one numbered record per line.
Every line has the same length except the one numbered `long_line_at`,
which gets extra padding so the "longest line" question has a single
correct answer. Returns the size of the file in bytes.
"""
with open(path, "w", encoding="utf-8", newline="\n") as handle:
for number in range(1, line_count + 1):
extra = "!" * 20 if number == long_line_at else ""
handle.write(f"{number:07d} {FILLER}{extra}\n")
return os.path.getsize(path)
def longest_line_whole_file(path):
"""Length of the longest line, read the EXPENSIVE way.
`handle.read()` pulls the entire file into one string, and
`.splitlines()` then builds a list holding every line at once. Peak
memory therefore scales with the size of the file: a 2 GB log needs
more than 2 GB of RAM before a single line is examined.
Returns (longest_length, peak_bytes); the peak is measured by
`tracemalloc`, Python's built-in allocation tracker.
"""
tracemalloc.start()
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
lines = text.splitlines()
longest = max((len(line) for line in lines), default=0)
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
return longest, peak
def longest_line_streaming(path):
"""Length of the longest line, read the CHEAP way.
Iterating the file object hands you one line at a time and lets the
previous line be reclaimed, so peak memory is the size of the longest
single line plus a read buffer — flat, no matter how big the file is.
Returns (longest_length, peak_bytes), the same answer as
`longest_line_whole_file` for far less memory.
"""
tracemalloc.start()
longest = 0
with open(path, "r", encoding="utf-8") as handle:
for line in handle:
length = len(line.rstrip("\n"))
if length > longest:
longest = length
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
return longest, peak
def read_text_surviving_bad_bytes(path, encoding="utf-8"):
"""Read `path` as text, surviving bytes that are not valid `encoding`.
The first attempt decodes strictly, which is what you want by default:
a `UnicodeDecodeError` tells you the file is not what you assumed, and
silence would be worse. If it fails, the second attempt uses
`errors="replace"`, which substitutes the replacement character U+FFFD
(shown as an inverted question mark) for each undecodable byte instead
of raising.
Returns (text, recovered) where `recovered` is True when the fallback
was needed.
"""
try:
with open(path, "r", encoding=encoding) as handle:
return handle.read(), False
except UnicodeDecodeError:
with open(path, "r", encoding=encoding, errors="replace") as handle:
return handle.read(), True
def atomic_write_text(path, text, encoding="utf-8", after_temp=None):
"""Replace `path` with `text` so no reader ever sees a half-written file.
The pattern has four steps:
1. write the new content to a temporary file IN THE SAME DIRECTORY
(same directory so the final rename stays inside one filesystem),
2. `flush()` so Python's buffer reaches the operating system,
3. `os.fsync()` so the operating system writes it to the disk itself,
4. `os.replace()` — an atomic rename: every reader sees either the
complete old file or the complete new one, never a mixture.
A crash before step 4 leaves the old file untouched and a stray `.tmp`
beside it; a crash after step 4 leaves the complete new file.
`after_temp`, if given, is called with the temporary path between steps
3 and 4 — that is the moment the lab inspects, to show both files
existing side by side.
Returns the path that was written.
"""
path = Path(path)
temp = path.with_name(path.name + ".tmp")
with open(temp, "w", encoding=encoding, newline="\n") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
if after_temp is not None:
after_temp(temp)
os.replace(temp, path)
return path
def unsafe_write_text(path, text, encoding="utf-8", after_open=None):
"""The naive alternative, kept for contrast — do NOT copy this.
Opening the real file in mode "w" truncates it immediately, so from
that instant until the write completes the file on disk is empty or
partial. A crash in that window destroys the old content and leaves
nothing usable behind. `after_open` is called while the file is open
and still incomplete, so the lab can look at the damage.
"""
with open(path, "w", encoding=encoding, newline="\n") as handle:
handle.write(text[: len(text) // 2])
handle.flush()
if after_open is not None:
after_open(path)
handle.write(text[len(text) // 2 :])
return path
def list_tree(root):
"""Every file under `root`, as sorted relative paths, using pathlib.
`Path.rglob("*")` walks the whole tree recursively and yields `Path`
objects; `is_file()` filters out the directories; `relative_to(root)`
turns each absolute path back into a short readable one. The same job
with `os.path` string juggling needs `os.walk` plus `os.path.join` plus
`os.path.relpath` and a lot of care about separators.
"""
root = Path(root)
return sorted(str(p.relative_to(root)) for p in root.rglob("*") if p.is_file())
metadata.yml (703 bytes)
lesson_id: D064
day: 64
kind: python-program
languages: [python]
setup_commands:
- cd labs/sections/programming-with-python/day-064-reading-and-writing-files
- python3 --version
run_commands:
- python3 examples/demo.py
- head -2 workspace/big.log
- wc -l < workspace/big.log
- python3 starter/demo.py
- rm -rf workspace
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -rf workspace
- 'git checkout -- starter/fileio_toolkit.py # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 28 checks, 0 failure(s), exit 0'
requirements/README.md (1565 bytes)
# Dependencies — Day 064 lab
**Python 3 only. No third-party packages, no network, no privileges.**
- `python3` (3.8 or newer; tested on 3.14.0). Preinstalled on most Linux
distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used: `os`, `pathlib`, `tracemalloc`,
and `sys`. There is deliberately no `requirements.txt` — reading and
writing files is a built-in capability, and the point of this lab is that
the tools you need for it ship with Python.
Check your Python is present and new enough:
```bash
python3 --version
```
If that prints `Python 3.8` or higher, you are ready.
## Disk space
The lab generates a `workspace/` directory containing an 8.2 MB log file
(200,000 lines) so the memory comparison has something real to measure. Allow
about 10 MB of free space. Everything the lab creates lives inside
`workspace/`, and `rm -rf workspace` removes all of it.
## Platform notes
- **macOS and Linux:** every command runs as written.
- **Windows:** run the commands inside WSL. The Python code is portable —
`pathlib` handles separators and the code passes `newline="\n"` explicitly
where line endings matter — but the test runner is a bash script.
- `os.fsync` is available on macOS, Linux, and Windows. On network
filesystems some devices acknowledge a sync before the bytes are truly on
physical media; that is a property of the hardware, not of Python, and it
does not affect anything this lab measures.
starter/demo.py (5902 bytes)
"""Driver for the Safe File I/O Toolkit — provided complete; do not edit it.
It imports `fileio_toolkit` from the directory next to it, so this copy
drives YOUR toolkit in `starter/fileio_toolkit.py`. Run it from the lab
directory:
python3 starter/demo.py # workspace defaults to ./workspace
python3 starter/demo.py my-scratch # or name your own workspace directory
Until you finish an exercise, the part that needs it stops with
`NotImplementedError` — that is the expected behaviour, not a bug.
Everything it creates lives inside the workspace directory, so cleanup is a
single `rm -rf workspace`. It makes no network calls and needs no
privileges.
"""
import sys
from pathlib import Path
import fileio_toolkit as toolkit
LINE_COUNT = 200_000
LONG_LINE_AT = 100_000
def mib(byte_count):
"""Bytes as a human-readable MiB string (1 MiB = 1024 * 1024 bytes)."""
return f"{byte_count / (1024 * 1024):.2f} MiB"
def part_1_round_trip(workspace):
print("[1] text round-trip with an explicit encoding")
notes = workspace / "notes.txt"
text = "Day 64: reading and writing files\ncafé, naïve, 日本語\nthird line\n"
size = toolkit.write_text_file(notes, text)
back = toolkit.read_text_file(notes)
print(f" wrote {notes.name}: {size} bytes on disk, {len(text)} characters in memory")
print(f" lines read back: {len(back.splitlines())}")
print(f" round-trip identical: {back == text}")
print(f" note: {size} bytes > {len(text)} characters — non-ASCII characters")
print(" take more than one byte each in UTF-8")
print()
def part_2_memory(workspace):
print("[2] whole-file read vs line-by-line streaming")
big = workspace / "big.log"
size = toolkit.make_big_file(big, LINE_COUNT, long_line_at=LONG_LINE_AT)
print(f" generated {big.name}: {LINE_COUNT} lines, {size} bytes ({mib(size)})")
whole_len, whole_peak = toolkit.longest_line_whole_file(big)
stream_len, stream_peak = toolkit.longest_line_streaming(big)
print(f" read() + splitlines() -> longest line {whole_len} chars, "
f"peak memory {mib(whole_peak)}")
print(f" for line in handle -> longest line {stream_len} chars, "
f"peak memory {mib(stream_peak)}")
print(f" same answer: {whole_len == stream_len}")
print(f" streaming used {whole_peak / max(stream_peak, 1):.0f}x less memory")
print()
def part_3_decoding(workspace):
print("[3] decoding trouble and how to survive it")
broken = workspace / "broken.log"
# Written as RAW BYTES so we control exactly what lands on disk. 0xE9 is
# "é" in latin-1, but on its own it is not valid UTF-8.
broken.write_bytes(b"caf\xe9 was logged by a latin-1 program\n")
print(f" wrote {broken.name} as raw bytes: {broken.read_bytes()!r}")
try:
toolkit.read_text_file(broken)
print(" strict utf-8 read: no error (unexpected)")
except UnicodeDecodeError as err:
print(f" strict utf-8 read raised UnicodeDecodeError: {err}")
text, recovered = toolkit.read_text_surviving_bad_bytes(broken)
print(f" errors='replace' read: {text.strip()!r}")
print(f" recovered: {recovered}; U+FFFD present: {chr(0xFFFD) in text}")
print()
def part_4_atomic(workspace):
print("[4] atomic write — proved by looking at the directory mid-operation")
box = workspace / "config"
box.mkdir(exist_ok=True)
config = box / "config.txt"
toolkit.write_text_file(config, "version = 1\n")
print(f" before: {sorted(p.name for p in box.iterdir())} "
f"content={toolkit.read_text_file(config).strip()!r}")
def peek(temp_path):
during = sorted(p.name for p in box.iterdir())
still_old = toolkit.read_text_file(config).strip()
print(f" during: {during} content={still_old!r}")
print(f" the new bytes are all in {temp_path.name}; readers of "
f"{config.name} still get the OLD file, whole")
toolkit.atomic_write_text(config, "version = 2\n", after_temp=peek)
print(f" after : {sorted(p.name for p in box.iterdir())} "
f"content={toolkit.read_text_file(config).strip()!r}")
# The contrast: the naive write, caught in the act.
unsafe = box / "unsafe.txt"
toolkit.write_text_file(unsafe, "version = 1\n")
seen = {}
def peek_unsafe(path):
seen["mid"] = toolkit.read_text_file(path)
toolkit.unsafe_write_text(unsafe, "version = 2 with more content\n", after_open=peek_unsafe)
print(f" contrast: mode 'w' straight onto the real file was caught mid-write "
f"holding {seen['mid']!r}")
print(" — a crash there destroys the old file and leaves that fragment")
print()
def part_5_pathlib(workspace):
print("[5] walking a directory tree with pathlib")
tree = workspace / "tree"
(tree / "sub" / "deeper").mkdir(parents=True, exist_ok=True)
toolkit.write_text_file(tree / "a.txt", "a\n")
toolkit.write_text_file(tree / "sub" / "b.txt", "b\n")
toolkit.write_text_file(tree / "sub" / "deeper" / "c.txt", "c\n")
found = toolkit.list_tree(tree)
for relative in found:
print(f" {relative}")
print(f" {len(found)} file(s) found by Path.rglob")
print(f" tree exists: {tree.exists()}; is a directory: {tree.is_dir()}")
print()
def main(argv):
workspace = Path(argv[1] if len(argv) > 1 else "workspace")
workspace.mkdir(parents=True, exist_ok=True)
print(f"Safe File I/O Toolkit — workspace: {workspace}")
print()
part_1_round_trip(workspace)
part_2_memory(workspace)
part_3_decoding(workspace)
part_4_atomic(workspace)
part_5_pathlib(workspace)
print(f"Done. Remove everything with: rm -rf {workspace}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
starter/fileio_toolkit.py (7573 bytes)
"""Safe file I/O toolkit — YOUR working file.
Five numbered exercises. Each unfinished function raises
`NotImplementedError` on purpose, so an empty function can never be
mistaken for a working one. Replace each `raise NotImplementedError(...)`
line with the real body described above it, keeping the docstring's promise
exactly — the docstring is the contract the tests check.
Work in order; run the driver after each exercise to see your progress:
python3 starter/demo.py
and check your work at any time with:
bash tests/run_tests.sh
The reference solution is in `examples/fileio_toolkit.py`. Use it when you
are stuck, not before — the point is the typing.
"""
import os
import tracemalloc
from pathlib import Path
# One padded record, so every generated line has the same length.
FILLER = "log line padding-padding-padding"
# --- Exercise 1 of 5: round-trip text with an EXPLICIT encoding -------------
#
# Fill in both functions.
#
# write_text_file: open `path` in mode "w" with `encoding=encoding` and
# `newline="\n"`, inside a `with ... as handle:` block, and `handle.write`
# the text. Then return `os.path.getsize(path)`.
# Reminder: `write()` adds NO newline of its own — whatever you pass is
# exactly what lands on disk.
#
# read_text_file: open `path` in mode "r" with `encoding=encoding`, inside a
# `with` block, and return `handle.read()`.
#
# Both must name the encoding explicitly. Never rely on the platform default.
def write_text_file(path, text, encoding="utf-8"):
"""Write `text` to `path`, replacing anything already there.
Returns the size of the finished file in bytes.
"""
raise NotImplementedError("Exercise 1a: open in mode 'w' with an explicit encoding and write")
def read_text_file(path, encoding="utf-8"):
"""Read the whole of `path` back as one string, decoding with `encoding`."""
raise NotImplementedError("Exercise 1b: open in mode 'r' with an explicit encoding and read")
# --- Provided complete: the file generator and the expensive reader ---------
# You do not edit these two. They exist so Exercise 2 has something big to
# read and something to be compared against.
def make_big_file(path, line_count, long_line_at=None):
"""Generate a large-ish log file: one numbered record per line.
Every line has the same length except the one numbered `long_line_at`,
which gets extra padding. Returns the size of the file in bytes.
"""
with open(path, "w", encoding="utf-8", newline="\n") as handle:
for number in range(1, line_count + 1):
extra = "!" * 20 if number == long_line_at else ""
handle.write(f"{number:07d} {FILLER}{extra}\n")
return os.path.getsize(path)
def longest_line_whole_file(path):
"""Length of the longest line, read the EXPENSIVE way.
Returns (longest_length, peak_bytes) — peak memory measured with
`tracemalloc`, Python's built-in allocation tracker.
"""
tracemalloc.start()
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
lines = text.splitlines()
longest = max((len(line) for line in lines), default=0)
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
return longest, peak
# --- Exercise 2 of 5: the same answer, one line at a time -------------------
#
# Give the SAME answer as longest_line_whole_file for a fraction of the
# memory. Between `tracemalloc.start()` and the peak reading:
#
# 1. set `longest = 0`
# 2. open `path` in mode "r" with `encoding="utf-8"` inside a `with` block
# 3. `for line in handle:` — iterating the file object hands you one line
# at a time and lets the previous one be reclaimed
# 4. measure `len(line.rstrip("\n"))` and keep the largest
#
# Do NOT call `handle.read()` or `handle.readlines()` anywhere in here —
# both pull the whole file into memory and defeat the exercise.
def longest_line_streaming(path):
"""Length of the longest line, read the CHEAP way.
Returns (longest_length, peak_bytes), the same answer as
`longest_line_whole_file` for far less memory.
"""
tracemalloc.start()
raise NotImplementedError("Exercise 2: iterate the file object one line at a time")
# --- Exercise 3 of 5: survive bytes that are not valid UTF-8 ----------------
#
# 1. `try:` reading `path` strictly (a plain `with open(..., encoding=...)`
# and `handle.read()`), and return `(text, False)` when it works.
# 2. `except UnicodeDecodeError:` open it again, this time adding
# `errors="replace"`, read it, and return `(text, True)`.
#
# `errors="replace"` swaps each undecodable byte for the replacement
# character U+FFFD instead of raising. Strict-first is the right default:
# an error tells you the file is not what you assumed, and silence is worse.
def read_text_surviving_bad_bytes(path, encoding="utf-8"):
"""Read `path` as text, surviving bytes that are not valid `encoding`.
Returns (text, recovered) where `recovered` is True when the
`errors="replace"` fallback was needed.
"""
raise NotImplementedError("Exercise 3: strict read first, then errors='replace' on failure")
# --- Exercise 4 of 5: the atomic write --------------------------------------
#
# Replace `path` with `text` so that no reader ever sees a half-written
# file. Four steps, in this order:
#
# 1. `path = Path(path)` and `temp = path.with_name(path.name + ".tmp")`
# — the temp file must sit in the SAME directory, so the final rename
# stays inside one filesystem and therefore stays atomic.
# 2. `with open(temp, "w", encoding=encoding, newline="\n") as handle:`
# write the text, then `handle.flush()` (Python buffer -> operating
# system) and `os.fsync(handle.fileno())` (operating system -> disk).
# 3. if `after_temp is not None:` call `after_temp(temp)` — that is the
# moment the driver inspects the directory and finds both files.
# 4. `os.replace(temp, path)` — the atomic rename. Return `path`.
def atomic_write_text(path, text, encoding="utf-8", after_temp=None):
"""Replace `path` with `text` so no reader ever sees a half-written file.
Returns the path that was written.
"""
raise NotImplementedError("Exercise 4: temp file -> flush -> fsync -> os.replace")
def unsafe_write_text(path, text, encoding="utf-8", after_open=None):
"""The naive alternative, provided complete for contrast — do NOT copy it.
Mode "w" truncates the real file immediately, so until the write
finishes the file on disk is empty or partial. `after_open` is called
while it is still incomplete, so the driver can look at the damage.
"""
with open(path, "w", encoding=encoding, newline="\n") as handle:
handle.write(text[: len(text) // 2])
handle.flush()
if after_open is not None:
after_open(path)
handle.write(text[len(text) // 2 :])
return path
# --- Exercise 5 of 5: walk a tree with pathlib ------------------------------
#
# Return every FILE under `root` as sorted relative path strings.
#
# 1. `root = Path(root)`
# 2. `root.rglob("*")` yields every entry in the tree, recursively
# 3. keep only those where `p.is_file()` is true
# 4. turn each into `str(p.relative_to(root))`
# 5. wrap the whole thing in `sorted(...)`
#
# One generator expression inside `sorted(...)` does all of it.
def list_tree(root):
"""Every file under `root`, as sorted relative paths, using pathlib."""
raise NotImplementedError("Exercise 5: sorted(str(p.relative_to(root)) for p in ...)")
tests/run_tests.sh (9247 bytes)
#!/usr/bin/env bash
# Tests for the Day 064 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# These checks exercise real file behaviour, not file existence: text is
# written and read back byte for byte, a real large file is generated and
# read both ways with tracemalloc measuring both peaks, a genuinely
# undecodable byte is written and recovered from, the atomic writer is
# interrupted mid-operation and the directory inspected, and a real
# directory tree is walked with pathlib. Every check runs in a throwaway
# temporary directory that is removed afterwards. No network, no
# privileges, non-interactive. Exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
scratch="$(mktemp -d -t day064-tests.XXXXXX)"
cleanup() { rm -rf "${scratch}"; }
trap cleanup EXIT
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# check_toolkit <label> <toolkit_dir> <python-body>
# Runs an assertion body against fileio_toolkit imported from toolkit_dir.
# A clean exit (every assert passed) is a pass.
check_toolkit() {
local label="$1" toolkit_dir="$2" body="$3"
local out
if out="$(PYTHONPATH="${toolkit_dir}" SCRATCH="${scratch}" python3 -c "
import os
from pathlib import Path
import fileio_toolkit as t
scratch = Path(os.environ['SCRATCH'])
${body}" 2>&1)"; then
check "${label}" "yes"
else
check "${label}" "no"
echo " (${out##*$'\n'})"
fi
}
run_toolkit_checks() {
local dir="$1"
echo "Testing the toolkit in ${dir} (real files in a temporary directory) ..."
check_toolkit "1. text round-trips through an explicit utf-8 encoding" "${dir}" "
p = scratch / 'round.txt'
text = 'line one\ncafé\n'
size = t.write_text_file(p, text)
assert t.read_text_file(p) == text, 'round-trip changed the text'
assert size == len(text.encode('utf-8')), f'size {size} is not the utf-8 byte count'
assert size > len(text), 'café must take more bytes than characters in utf-8'
"
check_toolkit "1b. mode 'w' replaces rather than appends" "${dir}" "
p = scratch / 'replace.txt'
t.write_text_file(p, 'first\n')
t.write_text_file(p, 'second\n')
assert t.read_text_file(p) == 'second\n', 'mode w must truncate first'
"
check_toolkit "1c. write() adds no newline of its own" "${dir}" "
p = scratch / 'nonewline.txt'
size = t.write_text_file(p, 'abc')
assert size == 3, f'expected 3 bytes, got {size}'
assert t.read_text_file(p) == 'abc'
"
check_toolkit "2. streaming and whole-file reads agree on the longest line" "${dir}" "
p = scratch / 'big.log'
t.make_big_file(p, 20000, long_line_at=10000)
whole_len, whole_peak = t.longest_line_whole_file(p)
stream_len, stream_peak = t.longest_line_streaming(p)
assert whole_len == stream_len == 60, f'{whole_len} != {stream_len} != 60'
assert stream_peak > 0, 'tracemalloc peak should be measured, not zero'
"
check_toolkit "2b. streaming peak memory is far below the whole-file peak" "${dir}" "
p = scratch / 'big.log'
t.make_big_file(p, 20000, long_line_at=10000)
_, whole_peak = t.longest_line_whole_file(p)
_, stream_peak = t.longest_line_streaming(p)
assert stream_peak * 4 < whole_peak, f'streaming {stream_peak} vs whole {whole_peak}'
"
check_toolkit "3. a strict utf-8 read of a latin-1 byte raises UnicodeDecodeError" "${dir}" "
p = scratch / 'broken.log'
p.write_bytes(b'caf\xe9 logged\n')
try:
t.read_text_file(p)
raise SystemExit('strict read should have raised')
except UnicodeDecodeError as err:
assert '0xe9' in str(err), str(err)
"
check_toolkit "3b. errors='replace' recovers the file with U+FFFD" "${dir}" "
p = scratch / 'broken.log'
p.write_bytes(b'caf\xe9 logged\n')
text, recovered = t.read_text_surviving_bad_bytes(p)
assert recovered is True, 'the fallback should have been needed'
assert text == 'caf� logged\n', repr(text)
"
check_toolkit "3c. a clean utf-8 file needs no fallback" "${dir}" "
p = scratch / 'clean.txt'
t.write_text_file(p, 'café\n')
text, recovered = t.read_text_surviving_bad_bytes(p)
assert recovered is False and text == 'café\n', (recovered, text)
"
check_toolkit "4. atomic write leaves the OLD file intact until os.replace" "${dir}" "
box = scratch / 'atomic'
box.mkdir(exist_ok=True)
target = box / 'config.txt'
t.write_text_file(target, 'version = 1\n')
seen = {}
def peek(temp):
seen['names'] = sorted(p.name for p in box.iterdir())
seen['content'] = t.read_text_file(target)
seen['temp'] = t.read_text_file(temp)
t.atomic_write_text(target, 'version = 2\n', after_temp=peek)
assert seen['names'] == ['config.txt', 'config.txt.tmp'], seen['names']
assert seen['content'] == 'version = 1\n', seen['content']
assert seen['temp'] == 'version = 2\n', seen['temp']
"
check_toolkit "4b. after os.replace the new content is there and no .tmp remains" "${dir}" "
box = scratch / 'atomic2'
box.mkdir(exist_ok=True)
target = box / 'config.txt'
t.write_text_file(target, 'version = 1\n')
t.atomic_write_text(target, 'version = 2\n')
assert t.read_text_file(target) == 'version = 2\n'
assert sorted(p.name for p in box.iterdir()) == ['config.txt'], 'a .tmp file was left behind'
"
check_toolkit "4c. the temp file sits in the SAME directory as the target" "${dir}" "
box = scratch / 'atomic3'
box.mkdir(exist_ok=True)
target = box / 'config.txt'
t.write_text_file(target, 'old\n')
seen = {}
t.atomic_write_text(target, 'new\n', after_temp=lambda temp: seen.update(parent=temp.parent))
assert seen['parent'] == box, f\"temp was in {seen['parent']}, not {box}\"
"
check_toolkit "4d. the naive write is caught holding a half-written file" "${dir}" "
p = scratch / 'unsafe.txt'
t.write_text_file(p, 'version = 1\n')
seen = {}
t.unsafe_write_text(p, 'version = 2 with more content\n',
after_open=lambda path: seen.update(mid=t.read_text_file(path)))
assert seen['mid'] != 'version = 1\n', 'the old content should already be gone'
assert seen['mid'] != 'version = 2 with more content\n', 'it should still be incomplete'
"
check_toolkit "5. list_tree finds every file as a sorted relative path" "${dir}" "
tree = scratch / 'tree'
(tree / 'sub' / 'deeper').mkdir(parents=True, exist_ok=True)
t.write_text_file(tree / 'a.txt', 'a\n')
t.write_text_file(tree / 'sub' / 'b.txt', 'b\n')
t.write_text_file(tree / 'sub' / 'deeper' / 'c.txt', 'c\n')
found = t.list_tree(tree)
assert found == ['a.txt', os.path.join('sub', 'b.txt'), os.path.join('sub', 'deeper', 'c.txt')], found
"
check_toolkit "5b. list_tree returns files only, never directories" "${dir}" "
tree = scratch / 'tree'
found = t.list_tree(tree)
assert all(not (tree / f).is_dir() for f in found), found
assert 'sub' not in found, 'directories must be filtered out'
"
}
run_demo_checks() {
local script="$1" label_prefix="$2"
echo "Testing ${script} end to end ..."
local out code demo_ws
demo_ws="${scratch}/demo-$(basename "$(dirname "${script}")")"
out="$(python3 "${script}" "${demo_ws}" 2>&1)"
code=$?
if [ "${code}" -eq 0 ]; then
check "${label_prefix}: driver exits 0" "yes"
else
check "${label_prefix}: driver exits 0" "no"
echo " (exit ${code}; last line: ${out##*$'\n'})"
fi
for needle in \
'round-trip identical: True' \
'same answer: True' \
'UnicodeDecodeError' \
'recovered: True' \
"config.txt', 'config.txt.tmp'" \
'3 file(s) found by Path.rglob'
do
if printf '%s' "${out}" | grep -qF "${needle}"; then
check "${label_prefix}: driver output contains \"${needle}\"" "yes"
else
check "${label_prefix}: driver output contains \"${needle}\"" "no"
fi
done
rm -rf "${demo_ws}"
}
# --- Reference: always tested strictly ---
run_toolkit_checks "${lab_dir}/examples"
run_demo_checks "${lab_dir}/examples/demo.py" "examples"
# --- Learner starter ---
echo "Testing starter/fileio_toolkit.py ..."
starter_toolkit="${lab_dir}/starter/fileio_toolkit.py"
if python3 -c "compile(open('${starter_toolkit}').read(), '${starter_toolkit}', 'exec')" 2>/dev/null; then
check "starter toolkit is valid Python" "yes"
else
check "starter toolkit is valid Python" "no"
fi
if grep -q 'NotImplementedError' "${starter_toolkit}"; then
echo "Note: starter/fileio_toolkit.py still has unfinished exercises — testing structure only."
for name in write_text_file read_text_file longest_line_streaming \
read_text_surviving_bad_bytes atomic_write_text list_tree; do
if grep -q "def ${name}" "${starter_toolkit}"; then
check "starter defines ${name}" "yes"
else
check "starter defines ${name}" "no"
fi
done
else
run_toolkit_checks "${lab_dir}/starter"
run_demo_checks "${lab_dir}/starter/demo.py" "starter"
# A streaming reader that secretly slurps the file is not streaming.
if grep -A 20 'def longest_line_streaming' "${starter_toolkit}" | grep -qE '\.read\(\)|\.readlines\(\)'; then
check "starter longest_line_streaming avoids read()/readlines()" "no"
else
check "starter longest_line_streaming avoids read()/readlines()" "yes"
fi
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 064 lab
python: command not found
Use python3 explicitly, as every command in this lab does. On macOS and
most Linux systems, bare python may be missing or point to an old version.
Check with python3 --version.
The starter raises NotImplementedError when I run it
That is expected until you finish the five exercises in
starter/fileio_toolkit.py. Each unfinished function raises
NotImplementedError on purpose so you cannot mistake an empty function for
a working one. Replace each raise NotImplementedError(...) line with the
real body described in the comment above it. Once all five are done,
python3 starter/demo.py produces the same five-part report as the
reference.
FileNotFoundError when opening a file for reading
The path is wrong, or it is relative to somewhere you did not expect. A
relative path is resolved from the directory you launched the program
in, not the directory the script lives in. Print Path.cwd() to see where
the process actually is. Every command in this lab is meant to be run from
the lab directory itself:
cd labs/sections/programming-with-python/day-064-reading-and-writing-files
ModuleNotFoundError: No module named 'fileio_toolkit'
demo.py imports the toolkit that sits beside it. Run the driver by its
path (python3 examples/demo.py or python3 starter/demo.py) from the lab
directory rather than copying one file somewhere else, and the import
resolves.
Part 2's memory numbers do not match the captured output
They will not match exactly, and they are not supposed to. Allocation peaks vary by a few kilobytes between runs, Python versions, and platforms. What must hold is the shape of the result: both strategies report the same longest-line length, and the streaming peak is far below the whole-file peak (roughly 0.14 MiB against roughly 25 MiB on the generated log). The test suite checks that shape, never the exact digits.
Part 2 is slow, or the machine starts swapping
The lab generates 200,000 lines (about 8.2 MB) so there is something real to
measure. Generation takes a second or two. If your machine is very
constrained, pass a smaller workspace and regenerate with fewer lines by
editing the make_big_file(..., line_count=...) call in demo.py — the
memory ratio is the lesson, and it survives a smaller file, though the
175x figure will shrink because the fixed overheads stay constant.
UnicodeDecodeError where I did not expect one
That is the machinery working correctly — the file is not what you assumed.
Before reaching for errors="replace", find out what encoding it really
uses. Look at the raw bytes:
python3 -c "print(open('workspace/broken.log','rb').read(40))"
Single high bytes such as \xe9 suggest Latin-1; pairs such as \xc3\xa9
are UTF-8. Part 3 of the lab creates exactly such a file on purpose.
ValueError: I/O operation on closed file
You used the file object outside its with block. Everything you do with
the handle must happen inside the block — take the data out, not the
handle.
A second read() returns an empty string
The cursor is at the end of the file. Call handle.seek(0) before reading
again, or better, read once and keep the value in a variable.
My file ended up as one enormous line
write() never adds a newline; only print() does. Every newline in the
file is one you typed, so write "text\n" rather than "text".
Part 4 leaves a .tmp file behind
That is the failure mode the pattern is designed around, and seeing it means
something interrupted the write between fsync and os.replace. The
original file is intact — that is the guarantee. Delete the stray .tmp
and re-run. Production code does this cleanup at startup, which is the
Extension Challenge in the lesson.
os.replace fails with OSError: [Errno 18] Invalid cross-device link
The temp file and the target are on different filesystems, so the rename
cannot be atomic. Put the temp file in the same directory as the target
— never in /tmp. This is exactly why atomic_write_text derives the temp
name from the target path with path.with_name(...).
PermissionError when writing
You are running somewhere you cannot write, or the file is owned by another
user. Check with ls -l and confirm you are in the lab directory. Nothing
in this lab needs sudo; if you find yourself reaching for it, the working
directory is wrong.
The test suite fails but the demo looks fine
Read the first failing ok:/FAIL: line — each check names the behaviour it
tested. The most common cause is a starter exercise that returns the right
value but with the wrong type (for example list_tree returning Path
objects rather than relative strings, or a function returning only the text
instead of the (text, recovered) pair that Part 3 expects).
Security notes
Security notes — Day 064 lab
-
What the lab does: creates a
workspace/directory beneath the lab directory, writes text files and one 8.2 MB generated log into it, reads them back, and removes nothing until you runrm -rf workspace. It makes no network connections, needs no privileges, and touches nothing outsideworkspace/. The test runner does its work in a throwaway directory created withmktemp -dand removes it on exit. -
Never build a path from untrusted input by concatenation. This is the central file-handling vulnerability, and it is called path traversal. A filename that arrived from a user, a request, or a dataset may contain
../, soopen(base + "/" + name)can be steered anywhere on the filesystem —../../.ssh/id_rsaturns "read a file from my data directory" into "read the user's private key". Resolve the candidate path and confirm it really is inside the directory you meant before opening it:from pathlib import Path def safe_open(base, name): base = Path(base).resolve() candidate = (base / name).resolve() if base not in candidate.parents: raise ValueError(f"path escapes {base}: {name!r}") return open(candidate, "r", encoding="utf-8") -
Treat file contents as untrusted data. Parse them with safe converters —
int(),float(), and from Day 65 thecsvandjsonmodules. Never reach foreval()orexec()to "read" a value out of a file; those execute the text as Python, so anyone who can write to the file can run code as you. This lab uses only safe conversions. -
Mode
xis a security tool. Exclusive creation refuses to open a file that already exists, which is what you want for lock files, first-run markers, and outputs that must not be silently overwritten. Where modewdestroys without asking, modexfails loudly. -
The atomic write is an availability property. Overwriting a file in place means a crash, a full disk, or a killed process can leave you with a fragment and no original. Writing beside the target and renaming over it means there is no instant at which a reader can observe a partial file. For anything whose loss would cost real work — a checkpoint, a configuration, a record store — that is the difference between an inconvenience and an incident.
-
Temporary files inherit their directory's permissions. The atomic-write temp file deliberately lives beside its target rather than in a shared temporary directory. That is required for the rename to be atomic, and it also avoids writing your data into a world-readable location. When you do need scratch space,
tempfilecreates it with restrictive permissions — use it rather than inventing predictable names in/tmp, which are vulnerable to symlink attacks. -
Files are where personal data comes to rest. Anything you write persists after your program forgets it, including into backups you did not think about. Write only what you need, keep personal data where you can find and delete it again, and remember that deleting a file removes the name far more reliably than it removes the bytes.
-
Reading before running: every file in this lab is short and commented. Read
examples/fileio_toolkit.py,examples/demo.py, andtests/run_tests.shbefore running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first. Note in particular thattests/run_tests.shrunsrm -rfon a directory it created itself withmktemp -d— read that line and satisfy yourself it can only remove its own scratch directory.