Programming with PythonFiles, Errors, and Object-Oriented Python › Day 64

Hands-on lab — Day 64: 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

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 tracemalloc and show that streaming reads do not scale with file size while whole-file reads do.
  • Trigger a UnicodeDecodeError on purpose and recover with errors="replace", returning a flag so the recovery can be logged rather than hidden.
  • Implement atomic_write_text with a same-directory temp file, flush, os.fsync, and os.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.rglob and 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 python3 from 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

  • python3 3.8 or newer (tested on 3.14.0)
  • bash for 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 a workspace/ 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 the pathlib tree 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, importing starter/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 with mktemp -d and 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.py runs 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 UnicodeDecodeError and then reports recovered: True.
  • Part 4's during: line shows both config.txt and config.txt.tmp, with config.txt still holding version = 1.
  • Part 5 lists exactly three relative paths.
  • All five exercises in starter/fileio_toolkit.py are complete and python3 starter/demo.py matches the reference.
  • bash tests/run_tests.sh prints 0 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

  1. Add an index to the record store. Record handle.tell() before each append into a companion file, then implement get(path, key) that seek()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.
  2. 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.
  3. Rotate the store. Past a size threshold, atomically rename it to store.1 and start fresh, then make the reader read across rotated files oldest-first with Path.glob.
  4. Measure the cost of durability. Time 10,000 appends with fsync on every record against fsync once at the end. The gap is why databases group commits, and why you reserve fsync for the moments that matter.
  • 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.

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 run rm -rf workspace. It makes no network connections, needs no privileges, and touches nothing outside workspace/. The test runner does its work in a throwaway directory created with mktemp -d and 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 ../, so open(base + "/" + name) can be steered anywhere on the filesystem — ../../.ssh/id_rsa turns "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 the csv and json modules. Never reach for eval() or exec() 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 x is 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 mode w destroys without asking, mode x fails 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, tempfile creates 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, and tests/run_tests.sh before 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 that tests/run_tests.sh runs rm -rf on a directory it created itself with mktemp -d — read that line and satisfy yourself it can only remove its own scratch directory.