Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Files, Paths & Encodings  ›  Lesson

pathlib

Paths 20 minPaths as objects, not strings you glue together
You're building a piece ofQuarry — the yard
This piece — find_sources(): Walks the yard and lists every source file worth reading, skipping the hidden ones.
Scenario Quarry has been pointed at a shared folder full of logs, exports and somebody's hidden editor cache. Before anything is read, it needs the list of files that are actually sources.
Your task
Build find_sources(files, suffixes). The helper plant() already creates the files in a temporary folder; you walk that folder with rglob, keep files only, skip anything inside a hidden part (a name starting with a dot), match suffixes case-insensitively, and return the sorted POSIX paths relative to the folder. Example: find_sources({"logs/a.log": "", "notes.txt": "", "data/X.CSV": ""}, [".log", ".csv"]) → ["data/X.CSV", "logs/a.log"].

pathlib

In short: a Path is an object, not a string — it knows its own name, stem, suffix and parent, and the / operator joins pieces using the right separator for whatever system you're on. Paths are immutable: with_suffix, with_name and friends all return a new path. path.glob(pattern) and path.rglob(pattern) find files by pattern instead of by hand-filtering — * matches within one folder level, ** descends through every level, and rglob(p) is shorthand for glob("**/" + p). Joining is the one place paths surprise you: joining an absolute path throws away everything to its left, the same rule os.path.join follows.

A till-style diagram: yard/logs/app.log and yard/.cache/stale.log, with three queries — "*.log" from the top finding nothing, "logs/*.log" finding one, and "rglob(\"*.log\")" finding all three including the one inside the hidden .cache folder

Rename every export to .json

A nightly job converts each CSV export to JSON, same name, new extension. Hand-written string surgery breaks the moment a filename has more than one dot:

What does this print?
from pathlib import PurePosixPath

p = PurePosixPath("exports/2026/sales.backup.csv")
print(p.stem)

with_suffix does the swap correctly, because it only ever touches the last suffix, exactly like stem only ever strips the last one:

python

Paths are immutable, like strings — every one of those calls returns a new path, and src itself never changes. relative_to is the method Quarry leans on hardest: it walks a folder with absolute paths on disk, but a report should say 2026/sales.backup.csv, not /tmp/tmpk2x9d/exports/2026/.... Stripping the root off with relative_to(root) is exact, where slicing the string by its length breaks the moment there's a trailing slash.

Join a report path from a config file

Quarry reads a filename out of a config file and joins it onto the output folder. The / operator looks like string concatenation, but it isn't one:

broken — fix itTypeError

Run it and read the TypeError. Then join the two parts the pathlib way.

/ has one rule that surprises everybody once, and it's exactly the kind of thing a config file can trigger by accident:

What does this print?
from pathlib import PurePosixPath

print(PurePosixPath("yard") / "logs" / "/etc/hosts")

That's why "just join whatever the config says" is a security bug: a filename of /etc/hosts or ../../etc/passwd silently escapes the folder Quarry meant to write into. A safe join checks is_absolute() first and walks .parts to refuse any .. that climbs out — you'll build exactly that in the exercises.

Find every log in a shared yard

A yard holds logs/app.log, logs/old/app.log, a hidden editor cache .cache/stale.log, and notes.txt. glob and rglob find files by pattern instead of listing everything and filtering by hand:

pattern matches
*.log any name ending .log, in this folder only
**/*.log any .log at any depth, this folder included
data/*/q?.csv q1.csv…q9.csv, one level under data/

* never crosses a /; ** is the part that descends. rglob("*.log") is shorthand for glob("**/*.log") — recursive glob. Here is the yard the next example builds, as a picture:

Drawing diagram…

Predict what each pattern finds before you run it: *.log from yard/ finds nothing (there is no log at the top), logs/*.log finds one, and rglob("*.log") finds all three — including the one inside the hidden .cache/ folder:

python

That last line is the trap Quarry has to handle: rglob happily walks into hidden folders too. Editor caches, version-control internals and trash folders are all "hidden" on POSIX systems by the convention that their name starts with a dot — and rglob doesn't know or care about that convention. Checking rel.parts for any part starting with . catches a hidden file or a file anywhere inside a hidden folder:

What does this print?
from pathlib import PurePosixPath

rel = PurePosixPath("srv/.cache/x.log")
print(any(part.startswith(".") for part in rel.parts))

Patterns match names, and you can test that matching with no disk at all. Step through this and watch which names survive — notice that * refuses to swallow the old/ level:

step through it
1from pathlib import PurePosixPath
2 
3names = ["logs/app.log", "logs/old/app.log", "notes.txt", "logs/app.LOG"]
4hits = []
5for name in names:
6 p = PurePosixPath(name)
7 if p.match("logs/*.log"):
8 hits.append(name)
9print(hits)

Two lessons in one trace: * stays inside one level, and matching is case-sensitive on POSIX paths, so app.LOG is not a .log. Real yards contain both spellings, which is why Quarry lowers the suffix before comparing.

Quick practice

What does this print?
from pathlib import PurePosixPath

p = PurePosixPath("archive.tar.gz")
print(p.suffix, p.suffixes)
broken — fix itTypeError

Run it, read the error, then join the pathlib way.

What does this print?
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    yard = Path(tmp)
    (yard / "a" / "b").mkdir(parents=True)
    (yard / "a" / "b" / "deep.log").write_text("x", encoding="utf-8")
    (yard / "top.log").write_text("x", encoding="utf-8")
    print(sorted(p.name for p in yard.glob("*.log")))
What does this print?
from pathlib import PurePosixPath

print(PurePosixPath("data") / "/backup" / "q1.csv")

🎯 Your turn

Build find_sources(files, suffixes):

  • find_sources({"logs/a.log": "", "notes.txt": "", "data/X.CSV": ""}, [".log", ".csv"]) → ["data/X.CSV", "logs/a.log"]
  • find_sources({".git/config.log": "", "srv/app.LOG": ""}, [".log"]) → ["srv/app.LOG"]

Hint — walk with rglob("*"), keep is_file(), skip hidden parts of relative_to(root), compare suffix.lower(), and sort the POSIX strings.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upTake a path apart

Write path_parts(path) returning [parent, name, stem, suffix] for a POSIX path string, each as a string. Use PurePosixPath so nothing touches a disk. path_parts("logs/2026/app.log") → ["logs/2026", "app.log", "app", ".log"].

filesystem-pathspath-suffix
DrillSame file, new extension

Write swap_suffix(path, new_suffix) returning the POSIX path string with its suffix replaced (or added, if there was none). swap_suffix("data/report.csv", ".json") → "data/report.json".

filesystem-pathspath-suffix
BuildDraw the yard as a list

Write list_tree(files). Plant the files in a temporary folder (use the helper in the starter), then return every path under it — files AND folders — as sorted relative POSIX strings, with folders marked by a trailing /. list_tree({"logs/a.log": ""}) → ["logs/", "logs/a.log"].

filesystem-pathsrglob
BossStay inside the yard

Quarry reads relative paths out of config files, and a hostile one might say ../../etc/passwd. Write safe_join(root, relative): return the joined POSIX path string with every . and .. resolved, or None if relative is absolute or climbs out of root. Work on the path's parts only — no disk. safe_join("yard", "logs/../b.txt") → "yard/b.txt".

filesystem-pathspath-traversal
CapstoneInventory of the yard

Write inventory(files). Plant the files, walk them, and return {"count": n, "by_suffix": {...}, "deepest": path} where by_suffix counts files per lower-cased suffix (use "(none)" for no suffix) and deepest is the relative POSIX path with the most parts (alphabetically first on a tie, "" if there are no files).

filesystem-pathsrglobdict
ExplainWhy Quarry never glues paths together

A teammate writes folder + "/" + name everywhere and says pathlib is just decoration. Explain, with at least one concrete failure, why Quarry keeps paths as Path objects inside the program and only turns them into strings at the edges.

find_sources.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
find_sources(files, suffixes) → listSorted relative POSIX paths of every non-hidden file whose suffix is wanted.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.