You will learn: Build and inspect paths, distinguish files from directories, traverse a workspace in a stable order, manage file handles, choose between whole-file and streaming reads, and select a safe writing mode.
Practice in: Google Colab, JupyterLab, or a local editor using temporary workspaces
A wildlife team returns from the field with notes from several observation stations. Your task is to find the notes, read them without leaking resources, and create a daily summary. That sounds simple, but the first version can fail by starting in a different folder, including yesterday’s generated report as new input, reading a directory as though it were a file, or erasing an existing log with the wrong mode.
Keep these questions beside your notebook:
What root directory is this path relative to?
Does the path exist, and is it the kind of object the operation needs?
Who closes the file if reading or processing fails?
Is the file small enough to read at once, or should records be streamed?
Does writing replace, append, or require a new destination?
Is traversal order part of the observable result?
1. Paths describe locations; files and directories occupy them
A filesystem organizes named entries inside directories. A path is a route to an entry, not the file’s contents. pathlib.Path represents that route with rules appropriate to the operating system.
from pathlib import Pathreport_path = Path("field-notes") /"north"/"sightings.txt"print(report_path)print(report_path.name)print(report_path.stem)print(report_path.suffix)print(report_path.parent)print(report_path.parts)
The / operator here joins path components; it does not divide numbers. For the last component sightings.txt:
.name is "sightings.txt";
.stem is "sightings";
.suffix is ".txt";
.parent is the path ending in north; and
.parts exposes every component as a tuple.
A suffix is only part of a name. Renaming a file from .txt to .json does not convert its contents to JSON. Formats require parsing and serialization, which you will practice in Lesson 3.
Path construction does not create or open anything:
bool(imagined) is True even when the path is missing; a Path is an object, not a filesystem-state test. Notice also that .stem removes only the final suffix (.gz), while .suffixes reports ['.tar', '.gz']. Names with several dots need a documented naming rule rather than repeated string slicing.
Do not join components with str(root) + "/north/sightings.txt". Separators, drives, and absolute components vary by platform. Let Path perform path composition, and convert to text only when an external API actually requires a string.
A relative path begins at the process’s current working directory; an absolute path begins at a filesystem anchor such as a root or drive.
flowchart TD
cwd["Current working directory"] --> project["field-notes/"]
project --> north["north/"]
north --> note["sightings.txt"]
absolute["Filesystem root or drive"] --> cwd
If the current working directory changes, the same relative spelling can refer to a different location. The Path object does not search for the intended project automatically.
Inspect rather than assume:
from pathlib import Pathworking_directory = Path.cwd()candidate = Path("field-notes") /"north"/"sightings.txt"print("working directory:", working_directory)print("relative?:", not candidate.is_absolute())print("candidate from cwd:", working_directory / candidate)
Path.resolve() can produce an absolute path and resolve symbolic components, but its exact filesystem behavior depends on the strict argument and the platform. It does not prove that you selected the correct project root. The best repair for an ambiguous relative path is usually to pass the intended root into the function.
def notes_path(workspace, station_name):"""Return the notes path for one station under an explicit workspace."""return Path(workspace) /"field-notes"/ station_name /"sightings.txt"
The function no longer depends silently on wherever the process happened to start.
2. Create a safe field-notes workspace
The next fixture creates every file it needs. Run the cell again whenever you want a clean starting point.
from pathlib import Pathfrom tempfile import TemporaryDirectorytemporary_directory = TemporaryDirectory()workspace = Path(temporary_directory.name)notes_root = workspace /"field-notes"(notes_root /"north").mkdir(parents=True)(notes_root /"south").mkdir(parents=True)(notes_root /"empty-station").mkdir()(notes_root /"north"/"sightings.txt").write_text("otter,3\nheron,2\n", encoding="utf-8",)(notes_root /"south"/"sightings.txt").write_text("fox,1\notter,2\n", encoding="utf-8",)(notes_root /"README.md").write_text("Field notes are recorded once per station.\n", encoding="utf-8",)print("workspace:", workspace)
parents=True permits Python to create missing parents. Without it, creating field-notes/north would fail if field-notes did not exist. By default, mkdir() also fails if the final directory already exists. exist_ok=True is appropriate only when an existing directory satisfies the contract; do not use it to silence a collision that ought to be investigated.
exists() alone is not enough. A directory exists, but read_text() requires a file. A path can also disappear between a check and the following operation, so handle the operation’s exception at a boundary where absence is genuinely expected.
Do not rely on the order returned by the filesystem. Sort when order affects summaries, identifiers, tests, or reproducibility. Sorting Path objects is usually enough for paths with a shared root; an explicit key documents another policy such as case-insensitive names.
Pattern matching should describe intended inputs, not merely all text files. This broad search can accidentally include generated output on the next run:
output_path = notes_root /"daily-summary.txt"output_path.write_text("generated output\n", encoding="utf-8")broad_matches =sorted(notes_root.rglob("*.txt"))print([path.relative_to(notes_root) for path in broad_matches])
The pattern */sightings.txt encodes two useful constraints: one station directory and the exact input filename. You can also filter explicitly:
Choose a rule that remains correct after output exists. “Delete the output before discovery” is fragile because a crash can leave it behind.
4. A file handle is a managed, stateful resource
Path.read_text() is convenient for a small file. Underneath, Python opens a text stream, reads it, and closes it. When you need line-by-line processing or more control, open the file explicitly with a with statement:
A handle has a cursor. readline() advances past one line; the following read() begins from that position, not from the start. Opening the file again creates a new handle with a new cursor.
Make cursor exhaustion observable:
with first_note.open(encoding="utf-8") as handle: first_pass = handle.read() second_pass = handle.read()print("first pass:", repr(first_pass))print("second pass:", repr(second_pass))assert second_pass ==""
You could call handle.seek(0) before the second read, but reopening is often clearer when you truly need two independent passes. The built-in form open(first_note, encoding="utf-8") also accepts Path objects; Path.open() is used here because it keeps the location and operation visually together.
The context manager closes the resource even when code inside the block raises an exception. It does not suppress that exception:
try:with first_note.open(encoding="utf-8") as handle: raw_count = handle.readline().split(",")[1]int("not-a-number")exceptValueErroras error:print(type(error).__name__, error)print("closed after failure?:", handle.closed)
That separation is valuable: resource cleanup is guaranteed, while the unexpected or deliberately caught failure remains observable.
WarningA closed handle cannot read more data
Store the values you need inside the with block. Returning a handle from a helper whose context has already ended returns a closed resource, not deferred file contents.
5. Read small files whole; stream large or record-oriented files
For a small configuration or note, whole-file methods are clear:
text = first_note.read_text(encoding="utf-8")lines = text.splitlines()print(lines)
read_text() returns one string. It is then possible to split, search, or parse that value. The complete contents must fit comfortably in memory.
For a large log or naturally line-oriented input, iterate over the handle:
def count_animals(path):"""Return total observed animals from a small comma-separated note.""" total =0with Path(path).open(encoding="utf-8") as handle:for line_number, raw_line inenumerate(handle, start=1): line = raw_line.rstrip("\n") animal, raw_count = line.split(",")try: total +=int(raw_count)exceptValueErroras error:raiseValueError(f"{path}:{line_number}: invalid count {raw_count!r}" ) from errorreturn totalfor path in station_files:print(path.parent.name, count_animals(path))
Only one line at a time needs to be held by the loop. enumerate preserves line context for diagnostics. The narrow rstrip("\n") removes the line terminator without deleting meaningful leading or trailing spaces. Lesson 2 develops newline policies further, and Lesson 3 replaces the deliberately simple comma split with the CSV parser needed for quoted fields.
Choose from the data shape and size, not from a rule that one method is always superior.
mode_demo = workspace /"mode-demo.txt"with mode_demo.open("w", encoding="utf-8") as handle: handle.write("north") handle.write("south")print(repr(mode_demo.read_text(encoding="utf-8")))
write() returns the number of text characters accepted by the stream. The return value can support a focused check, although reopening and comparing the artifact is stronger evidence of the saved result:
with mode_demo.open("a", encoding="utf-8") as handle: character_count = handle.write("\nnight shift\n")assert character_count ==len("\nnight shift\n")assert mode_demo.read_text(encoding="utf-8").endswith("night shift\n")
Run that cell twice and the line appears twice. Append mode preserves existing content, but it does not make retries idempotent.
The result is 'northsouth'. Include the separator required by the file contract:
with mode_demo.open("w", encoding="utf-8", newline="\n") as handle: handle.write("north\n") handle.write("south\n")
Opening with "w" immediately truncates an existing file, even if a later calculation fails before the first write. Compute and validate the complete text before opening the destination when practical:
Appending is suitable for an event log only when duplicate appends on retry are acceptable or prevented by another rule. Exclusive creation is useful when replacing an existing destination would indicate a mistake:
reservation = workspace /"reserved-name.txt"reservation.write_text("first owner\n", encoding="utf-8")try:with reservation.open("x", encoding="utf-8") as handle: handle.write("second owner\n")exceptFileExistsError:print("destination already exists; original preserved")
Lesson 2 will build a temporary-write, verify, and replace sequence for outputs that should become official only when complete.
7. Handle expected filesystem failures at the right boundary
Common filesystem exceptions carry different evidence:
FileNotFoundError: a required path or parent is missing;
FileExistsError: exclusive creation or directory creation collided;
IsADirectoryError: an operation expected a file but received a directory;
NotADirectoryError: some path component expected as a directory is not one;
PermissionError: the operating system denied the operation;
broader OSError: other operating-system or filesystem conditions.
Catch only a failure the caller can meaningfully handle:
def read_optional_note(path):"""Return optional note text, or an empty string when it is absent."""try:return Path(path).read_text(encoding="utf-8")exceptFileNotFoundError:return""print(repr(read_optional_note(workspace /"optional.txt")))
This policy is valid only because the docstring declares absence normal. A required configuration should instead let the failure propagate or add useful context. Do not catch Exception and report “file missing”; decoding failures, permissions, and programming bugs require different responses.
8. Build the daily field-note summary
Complete a clean, self-contained lab:
Create a temporary workspace with incoming/north, incoming/south, and output directories.
Write at least two sightings.txt inputs and one unrelated .txt file.
Implement discover_notes(root) to return only station notes in sorted order.
Implement summarize_note(path) to stream records and return one station total with source-and-line context for invalid counts.
Build the complete report in memory, end it with one newline, then write it to output/daily-summary.txt.
Run the pipeline twice and assert that discovery never includes the output and that the report text is identical.
Change one count to "many". Confirm the exception identifies its file and line while the previous report remains available because calculation occurs before writing.
Start from these contracts:
from pathlib import Pathdef discover_notes(root):"""Return station sightings files beneath root in deterministic order.""" root = Path(root)returnsorted(root.glob("*/sightings.txt"))def summarize_note(path):"""Return (station_name, total_count) for one sightings file.""" path = Path(path) total =0with path.open(encoding="utf-8") as handle:for line_number, raw_line inenumerate(handle, start=1): animal, raw_count = raw_line.rstrip("\n").split(",")ifnot animal:raiseValueError(f"{path}:{line_number}: empty animal name")try: total +=int(raw_count)exceptValueErroras error:raiseValueError(f"{path}:{line_number}: invalid count {raw_count!r}" ) from errorreturn path.parent.name, total
Compare one complete integration after finishing your own
from pathlib import Pathfrom tempfile import TemporaryDirectorywith TemporaryDirectory() as temporary_name: root = Path(temporary_name) incoming = root /"incoming" output = root /"output" (incoming /"north").mkdir(parents=True) (incoming /"south").mkdir() output.mkdir() (incoming /"north"/"sightings.txt").write_text("otter,3\nheron,2\n", encoding="utf-8" ) (incoming /"south"/"sightings.txt").write_text("fox,1\notter,2\n", encoding="utf-8" ) (incoming /"ignore.txt").write_text("not an input\n", encoding="utf-8") paths = discover_notes(incoming) rows = [summarize_note(path) for path in paths] report ="\n".join(f"{station}: {total}"for station, total in rows) +"\n" report_path = output /"daily-summary.txt" report_path.write_text(report, encoding="utf-8", newline="\n")assert [path.parent.name for path in paths] == ["north", "south"]assert report =="north: 5\nsouth: 3\n"assert report_path.read_text(encoding="utf-8") == reportassert discover_notes(incoming) == paths
The narrow discovery rule makes generated output irrelevant. The report is fully constructed before write_text opens the destination. The next lessons will replace the simple record grammar with robust external formats and add a verified replacement step.
Next, keep the same path and resource discipline while investigating what turns stored bytes into characters—and why the wrong encoding or newline assumption can damage otherwise correct text.