Python Module 8 – Files, paths and data formats
pathlib: paths, folders and file operations
Build and split paths with pathlib.Path, list and search folders with glob and walk, copy, move and delete files, and refuse paths that escape a folder.
What you will learn
- Build and take apart paths with pathlib.Path
- Predict where a relative path points from the current working directory
- List, search and walk folders, and sort what they return
- Copy, move and delete files safely, with a dry run first
- Validate paths from outside, and use TemporaryDirectory for scratch work
Before you start
What you’ll learn · Before you start
What you will learn
- Build and take apart paths with pathlib.Path
- Predict where a relative path points from the current working directory
- List, search and walk folders, and sort what they return
- Copy, move and delete files safely, with a dry run first
- Validate paths from outside, and use TemporaryDirectory for scratch work
Before you start
On this page
A script that opens data/marks.csv works when you start it from its own folder and fails from any other, because a
relative path: A path that does not start at a root, such as data/marks.csv. Python reads it from the current working directory each time it is used, not from the folder of the script that holds it. follows the current folder. A name such as ../../etc/passwd, joined
to a folder you trust, climbs two levels out of it, and one call to shutil.rmtree() deletes a whole folder tree
without asking.
pathlib is the standard library’s module for file system paths. A pathlib.Path stands for a path such as
data/raw/marks.csv: you build one with the / operator, take it apart with .name, .suffix and .parent, and
call its methods to list folders (iterdir(), glob(), walk()), create them (mkdir()), read and write files
(read_text(), write_text()), and copy, move or delete them (copy(), move(), unlink()). The same code runs
on Windows, macOS and Linux. Below you will watch a relative path change its meaning, walk a folder tree, plan file
moves before you make them, and refuse names that leave their folder.
A Path is built from parts and taken apart by its attributes
Path("data") makes a path object; on Linux and macOS it is a PosixPath and on Windows a WindowsPath, which
differ only in how they write and compare paths. The / operator joins parts, and attributes give back the pieces:
from pathlib import Path, PureWindowsPath
report = Path("data") / "term-1" / "marks.final.csv" # / joins the parts
print(report)
print("name: ", report.name)
print("stem: ", report.stem)
print("suffix: ", report.suffix)
print("suffixes: ", report.suffixes)
print("parent: ", report.parent)
print("parents: ", [str(p) for p in report.parents])
print("parts: ", report.parts)
# The with_ methods return a new path; a Path never changes.
print(report.with_suffix(".json"))
print(report.with_name("summary.txt"))
print(report.with_stem("marks"))
# Windows paths can be handled on any computer with PureWindowsPath.
win = PureWindowsPath(r"C:\Users\asha\Documents\marks.csv")
print(win.drive, win.name, win.parent)
print(win.as_posix()) Output
data/term-1/marks.final.csv
name: marks.final.csv
stem: marks.final
suffix: .csv
suffixes: ['.final', '.csv']
parent: data/term-1
parents: ['data/term-1', 'data', '.']
parts: ('data', 'term-1', 'marks.final.csv')
data/term-1/marks.final.json
data/term-1/summary.txt
data/term-1/marks.csv
C: marks.csv C:\Users\asha\Documents
C:/Users/asha/Documents/marks.csv
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 path_parts.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The table names each piece of the path the example built, data/term-1/marks.final.csv:
| Attribute | Its value here | What it is |
|---|---|---|
name |
marks.final.csv |
the last part |
suffix |
.csv |
the name from its last dot |
stem |
marks.final |
the name without that suffix |
suffixes |
['.final', '.csv'] |
every dotted ending, as in backup.tar.gz |
parent |
data/term-1 |
the path without its last part |
parts |
('data', 'term-1', 'marks.final.csv') |
every part, in order |
A name with two dots keeps one of them in its stem, and parents lists every folder above the path. A Path is
immutable: Unable to change once made. Strings, numbers and tuples are immutable: an operation that seems to change one, such as upper(), returns a new object instead. More in “Strings: literals, indexing and slicing”: with_suffix(), with_name() and with_stem() return a new path. Most
functions that take a file name, open() included, accept a Path directly, and str(path) gives the text when
something needs a string.
PureWindowsPath and PurePosixPath work with paths of either system on any computer, without touching any file
system. That helps when a program reads Windows paths from a file on a Linux server. as_posix() writes any path
with forward slashes, which is how this lesson’s examples print paths so that every system shows the same text.
Joining paths with + or f-strings
folder + "/" + name gives data//marks.csv when folder is "data/", and it puts / into Windows paths, which
Windows writes with \. Join Path objects with / instead: Path(folder) / name gives data/marks.csv, written
data\marks.csv on Windows.
Checkpoint
Check: Building and splitting paths
A relative path starts from the current working directory
A path that does not start at a root, such as data/marks.csv, is a
relative path: it is resolved from the
current working directory: The folder that a program's relative paths start from: by default the folder the program was started from. Path.cwd() returns it, and os.chdir() changes it for the whole program., the folder the program was started from, and
not from the folder that holds the script. Step through one relative path before and after the current folder
changes:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A relative path. Path("data") / "marks.csv" does not start at a root, so is_absolute() is False. It names data/marks.csv inside whichever folder is current when it is used.
- Make the file. mkdir() creates data in the current folder, and write_text() creates marks.csv inside it. marks.exists() is True.
- Change folder. os.chdir("data") makes data the current working directory. The Path object has not changed; it still says data/marks.csv.
- Same path, new place. Read from the new current folder, data/marks.csv means data/data/marks.csv. No such file exists, so marks.exists() is now False.
- The file is still there. marks.csv has not moved. Only the meaning of the relative path changed, which is why a script that relies on the current folder breaks when it is started from somewhere else.
The program behind the figure, to run:
import os
from pathlib import Path
marks = Path("data") / "marks.csv"
print("absolute?", marks.is_absolute())
start = Path.cwd() # the folder the program was started from
Path("data").mkdir()
marks.write_text("name,score\n", encoding="utf-8")
print("exists?", marks.exists())
os.chdir("data") # change the current working directory
print("now in:", Path.cwd().relative_to(start))
print("exists?", marks.exists()) # the same relative path now means data/data/marks.csv
print("points to:", marks.absolute().relative_to(start)) Output
absolute? False exists? True now in: data exists? False points to: data/data/marks.csv
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 relative.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
After os.chdir(), the same Path object pointed one folder deeper and no longer found the file. A script that
opens Path("data/marks.csv") therefore works when you run it from its own folder and fails when a scheduler or a
colleague starts it from somewhere else. For files that ship with your code, build the path from the script’s own
location, Path(__file__).resolve().parent / "data" / "marks.csv". For the user’s files, take the path as an
argument. Path.cwd() and Path.home() give the current folder and the user’s home folder, and resolve() turns a
path into an absolute one.
Key idea
A relative path is read from the current folder each time it is used: build paths to your own files from
Path(__file__), and take the user’s paths as arguments.
Checkpoint
Check: Where a relative path points
iterdir() lists one folder, glob() and walk() search a tree
Three methods look inside a folder, and walk() goes through a whole tree. This example builds a small project and
asks each of them what is in it:
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
project = Path(tmp) / "project"
for name in ["main.py", "README.md", "src/app.py", "src/util.py", "src/data/marks.csv", "tests/test_app.py"]:
file = project / name
file.parent.mkdir(parents=True, exist_ok=True) # create missing folders on the way
file.write_text(f"# {name}\n", encoding="utf-8")
def show(paths):
"""Paths relative to the project, sorted: iterdir() and glob() promise no order."""
return sorted(p.relative_to(project).as_posix() for p in paths)
print("iterdir:", show(project.iterdir())) # one level only
print("glob *.py:", show(project.glob("*.py"))) # matches in this folder
print("rglob *.py:", show(project.rglob("*.py"))) # matches at any depth
print("glob **/*.csv:", show(project.glob("**/*.csv")))
# Path.info (Python 3.14) caches what iterdir() learnt about each entry.
for entry in sorted(project.iterdir()):
kind = "folder" if entry.info.is_dir() else "file"
print(f" {entry.name:<10} {kind}")
# walk() (Python 3.12) visits every folder once, with its subfolders and files.
for folder, subfolders, files in project.walk():
subfolders.sort() # walk() visits subfolders in the order of this list
print(folder.relative_to(project).as_posix(), sorted(files)) Output
iterdir: ['README.md', 'main.py', 'src', 'tests'] glob *.py: ['main.py'] rglob *.py: ['main.py', 'src/app.py', 'src/util.py', 'tests/test_app.py'] glob **/*.csv: ['src/data/marks.csv'] README.md file main.py file src folder tests folder . ['README.md', 'main.py'] src ['app.py', 'util.py'] src/data ['marks.csv'] tests ['test_app.py']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 listing.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Press Edit, add "docs/guide.md" to the list of names and run it again: iterdir() and walk() find a new folder,
while rglob("*.py") finds the same four files.
The table sums up what each call found in that project:
| Call | What it found | How far it looks |
|---|---|---|
iterdir() |
README.md, main.py, src, tests |
the folder itself, files and folders alike |
glob("*.py") |
main.py |
names in the folder that match the pattern |
rglob("*.py") |
main.py, src/app.py, src/util.py, tests/test_app.py |
the pattern, at any depth |
glob("**/*.csv") |
src/data/marks.csv |
** stands for any number of folders |
walk() |
a triple for each of ., src, src/data and tests |
every folder of the tree, once |
glob(pattern) takes a glob pattern: A pattern for file names from the shell: * matches any run of characters within one name, ? one character, and in pathlib ** any number of folders, so **/*.csv finds CSV files at any depth.: * matches any run of characters within one
name, ? one character and ** any number of folders, so glob("**/*.csv") searches the whole tree.
rglob(pattern) is glob() with **/ put in front of the pattern: rglob("*.py") finds Python files at any depth.
walk() (Python 3.12 and later) visits each folder once and gives its path, the names of its subfolders and the
names of its files. Step through the four folders the example walked:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The folder itself first. walk() starts with the folder it is called on. Its first triple is the project folder, printed as ".", with its two files and its subfolders src and tests.
- Sorted on the spot. The loop sorts the subfolder list in place, and walk() goes into the folders in the order the list then has, so src comes before tests on every computer.
- Into src. Top-down, a folder comes before the folders inside it. src holds app.py and util.py, and one subfolder, data.
- One level deeper. Then src/data, with marks.csv and no subfolders, so that branch is finished.
- The last folder. Last comes tests, with test_app.py. Four folders gave four triples, and walk() visited each of them once.
Removing a name from the subfolder list stops walk() from going into that folder, which is how a search skips
folders such as .git or node_modules; change the list in place, as the example’s subfolders.sort() does. None
of these methods promise an order, so the example sorts every result before printing it; without sorting, the same
program can print a different order on another computer. Each entry that iterdir() returns also carries
Path.info, new in Python 3.14, which already knows from the folder listing whether the entry is a file or a folder
and caches the answer. The cache is never refreshed, so call is_dir() or is_file() on the path itself when the
folder may have changed since.
Expecting results in alphabetical order
iterdir(), glob() and walk() follow the file system, and their order can differ from one computer to the
next. Sort what they return whenever the order matters: sorted(folder.iterdir()).
The five largest files under a folder
rglob("*") and stat() are enough for a disk-space report. heapq.nlargest() keeps only the five biggest files
it has seen so far, so it does not sort a list of every file:
import heapq
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
downloads = Path(tmp) / "downloads"
sizes = {"setup.exe": 48_000, "photos/beach.jpg": 3_200, "photos/hill.jpg": 2_900, "notes.txt": 120,
"video/clip.mp4": 61_000, "video/old/trailer.mp4": 39_500, "music/song.mp3": 5_400}
for name, size in sizes.items():
file = downloads / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_bytes(b"\0" * size) # a file of exactly `size` bytes
files = (p for p in downloads.rglob("*") if p.is_file())
for file in heapq.nlargest(5, files, key=lambda p: p.stat().st_size):
print(f"{file.stat().st_size:>7,} bytes {file.relative_to(downloads).as_posix()}") Output
61,000 bytes video/clip.mp4 48,000 bytes setup.exe 39,500 bytes video/old/trailer.mp4 5,400 bytes music/song.mp3 3,200 bytes photos/beach.jpg
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest_files.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
stat() asks the operating system for a file’s details: st_size is its size in bytes and st_mtime the time it
was last changed. The is_file() filter leaves out folders, whose size would mean something else.
Checkpoint
Check: Listing and searching folders
Copying and moving overwrite, deleting cannot be undone
mkdir() creates a folder. With parents=True it also creates every missing folder above it, and exist_ok=True
makes it quiet when the folder is already there; the listing example uses both. write_text() and read_text()
open, write or read, and close a file in one call, and they take the same encoding argument as open(). Python
3.14 added copying and moving to Path:
import shutil
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
site = root / "site"
(site / "css").mkdir(parents=True)
(site / "index.html").write_text("<h1>Hi</h1>\n", encoding="utf-8")
(site / "css" / "main.css").write_text("h1 { color: teal }\n", encoding="utf-8")
# Python 3.14: copy() copies a file or a whole folder tree and returns the new path.
backup = site.copy(root / "site-backup")
print("copied:", sorted(p.relative_to(root).as_posix() for p in backup.rglob("*")))
(site / "index.html").copy_into(root) # copy_into() keeps the name
print("top level:", sorted(p.name for p in root.iterdir()))
# Deleting: unlink() for files, rmdir() for empty folders, shutil.rmtree() for trees.
(root / "index.html").unlink()
(root / "index.html").unlink(missing_ok=True) # already gone: no error
try:
backup.rmdir()
except OSError as err:
print("rmdir refused:", type(err).__name__)
shutil.rmtree(backup) # no confirmation and no undo
print("left:", sorted(p.name for p in root.iterdir())) Output
copied: ['site-backup/css', 'site-backup/css/main.css', 'site-backup/index.html'] top level: ['index.html', 'site', 'site-backup'] rmdir refused: OSError left: ['site']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copy_delete.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The example used five of the calls in this table. None of them asks before it acts:
| Call | What it does | A file already at the target |
|---|---|---|
copy(target) |
copies a file or a whole folder tree and returns the new path | is replaced |
copy_into(folder) |
copies into a folder and keeps the name | is replaced |
move(target) |
moves a file or a tree, with os.replace() on one file system |
is replaced |
move_into(folder) |
moves into a folder and keeps the name | is replaced |
unlink() |
deletes a file; missing_ok=True is quiet when it is already gone |
no target |
rmdir() |
deletes a folder, but only an empty one | no target |
shutil.rmtree(folder) |
deletes a folder and everything in it | no target |
Deleting a folder you did not check
shutil.rmtree() deletes whatever its path names, with no confirmation, and nothing goes to a recycle bin. An
empty or wrong value can name a parent folder: check that the path is the folder you mean, and print the plan
before you delete anything.
The example shows rmdir() refusing a folder that still held files, and shutil.rmtree() deleting the same folder
with everything in it a line later.
Version note
Path.copy(), copy_into(), move(), move_into() and Path.info are new in Python 3.14. On older versions use
shutil.copy2() for a file, shutil.copytree() for a folder tree and shutil.move() to move either.
Organising a downloads folder, with a dry run first
A program that moves or deletes many files should first work out what it would do, show the plan, and only then do it: a dry run: A run that only reports what a program would do, such as the list of files it would move, and changes nothing. The real run then carries out the same plan.. Here every file of a downloads folder goes into a subfolder chosen by its extension:
import tempfile
from pathlib import Path
RULES = {".pdf": "documents", ".docx": "documents", ".jpg": "images", ".png": "images", ".zip": "archives"}
def plan(folder):
"""The moves organising would make, as (file, subfolder) pairs, and the files it must skip."""
moves, skipped = [], []
for file in sorted(folder.iterdir()):
sub = RULES.get(file.suffix.lower()) # ".PDF" and ".pdf" are the same kind of file
if not file.is_file() or sub is None:
continue
if (folder / sub / file.name).exists():
skipped.append(f"{file.name} ({sub}/{file.name} exists)") # never overwrite
else:
moves.append((file, sub))
return moves, skipped
with tempfile.TemporaryDirectory() as tmp:
downloads = Path(tmp) / "downloads"
(downloads / "images").mkdir(parents=True)
for name in ["Report.PDF", "cv.docx", "beach.jpg", "logo.png", "backup.zip", "notes.txt", "images/logo.png"]:
(downloads / name).write_text("x", encoding="utf-8")
moves, skipped = plan(downloads)
print("dry run, nothing moved yet:")
for file, sub in moves:
print(f" {file.name} -> {sub}/")
print(" skipped:", skipped)
for file, sub in moves:
target = downloads / sub
target.mkdir(exist_ok=True)
file.move_into(target) # Python 3.14; shutil.move(file, target) on older versions
print("after:", sorted(p.relative_to(downloads).as_posix() for p in downloads.rglob("*") if p.is_file())) Output
dry run, nothing moved yet: Report.PDF -> documents/ backup.zip -> archives/ beach.jpg -> images/ cv.docx -> documents/ skipped: ['logo.png (images/logo.png exists)'] after: ['archives/backup.zip', 'documents/Report.PDF', 'documents/cv.docx', 'images/beach.jpg', 'images/logo.png', 'logo.png', 'notes.txt']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 organise.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
plan() changes nothing: it returns the moves and the files it must skip, so the same function serves the dry run
and the real one. Three decisions make it safe. The extension is compared in lower case, so Report.PDF counts as a
PDF. A file is skipped when its name already exists in the target folder, because move_into() would overwrite
images/logo.png without a word. And files with no rule, such as notes.txt, stay where they are.
Matching extensions with name.endswith(".pdf")
endswith(".pdf") misses Report.PDF. Compare path.suffix.lower() with the lower-case extension, as plan()
does.
Checkpoint
Check: Copying, moving and deleting
A name from outside can lead out of the folder
A program that serves reports/<name> to whoever asks, or extracts files from an archive, joins a name it did not
choose to a folder it trusts. That is a path traversal: A weakness in which a name from outside, such as ../../etc/passwd, makes a program read or write a file outside the folder it meant to use. Resolving the joined path and checking that it is still inside the folder prevents it. risk, and the plain /
operator does nothing about it:
import tempfile
from pathlib import Path
def safe_join(base, user_path):
"""The real location of user_path inside base, or ValueError if it is anywhere else."""
base = Path(base).resolve()
target = (base / user_path).resolve() # follows symbolic links and removes ".."
if target == base or not target.is_relative_to(base):
raise ValueError(f"{user_path!r} is not a file inside the folder")
return target
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp).resolve() / "reports"
(base / "q1").mkdir(parents=True)
(base / "q1" / "march.pdf").write_text("report", encoding="utf-8")
(Path(tmp) / "private").mkdir()
(base / "shortcut").symlink_to(Path(tmp) / "private") # a link inside base that points out of it
names = ["q1/march.pdf", "../../etc/passwd", "/etc/passwd", "q1/../../private/key", "shortcut/key"]
print("Plain join:")
for name in names:
joined = base / name
shown = joined.as_posix() if joined.is_absolute() and not joined.is_relative_to(base) else (
"reports/" + joined.relative_to(base).as_posix())
print(f" {name!r:24} {shown:30} looks inside: {joined.is_relative_to(base)}")
print("safe_join:")
for name in names:
try:
print(f" {name!r:24} ok: {safe_join(base, name).relative_to(base).as_posix()}")
except ValueError as err:
print(f" {name!r:24} refused: {err}") Output
Plain join: 'q1/march.pdf' reports/q1/march.pdf looks inside: True '../../etc/passwd' reports/../../etc/passwd looks inside: True '/etc/passwd' /etc/passwd looks inside: False 'q1/../../private/key' reports/q1/../../private/key looks inside: True 'shortcut/key' reports/shortcut/key looks inside: True safe_join: 'q1/march.pdf' ok: q1/march.pdf '../../etc/passwd' refused: '../../etc/passwd' is not a file inside the folder '/etc/passwd' refused: '/etc/passwd' is not a file inside the folder 'q1/../../private/key' refused: 'q1/../../private/key' is not a file inside the folder 'shortcut/key' refused: 'shortcut/key' is not a file inside the folder
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 safe_join.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The plain join failed in three ways. .. climbed out of the folder while the joined path still looked as if it
started with reports/. An absolute name, /etc/passwd, replaced the base folder completely, because joining with
an absolute path throws away everything before it. And shortcut/key looked harmless, but shortcut is a
symbolic link: A file that only points to another path. Opening it opens what it points to, so a link inside a folder can lead anywhere on the computer; Path.resolve() follows links to the real location. that leads out of the folder. is_relative_to() was fooled by the
first and the last: the pathlib documentation says it compares the parts of the paths as text and does not treat
.. specially.
safe_join() asks the file system where the joined path really leads. resolve() removes .. and follows symbolic
links, and only then does is_relative_to() compare the result with the resolved base folder. It also refuses the
base folder itself, which names no file inside it. Use it, or the same three lines, wherever a name comes from
outside: web requests, uploaded file names, archive members and configuration files. The diagram puts the steps in
their order:
How safe_join decides whether a name may be used
Text description of the diagram
The diagram is a flow from top to bottom.
- A name arrives from outside the program: a web request, a form, a member of an archive or a line of a configuration file.
- It is joined to the base folder with the / operator. If the name is an absolute path, it replaces the base folder completely.
- resolve() turns the result into the real location: it removes ".." parts and follows symbolic links.
- The decision: is the resolved path inside the resolved base folder, and not the base folder itself? If yes, the program may use the path. If no, safe_join raises ValueError and the name is refused.
When you know what to expect, check that as well: an allowlist of extensions such as {".pdf", ".csv"}, or a pattern
such as letters, digits and hyphens only, refuses many bad names before any path is built. The OWASP page on path
traversal, in the references, also lists percent-encoded and double-encoded forms of ../ and ..\, so check a name
after the last decoding your program applies to it, and never decode it again after the check.
Key idea
Resolve first, then compare: only resolve() finds out where .., an absolute name or a link really leads, so
only a check on its result keeps a name inside its folder.
Checkpoint
Check: Refusing paths from outside
TemporaryDirectory gives a script a folder that cleans itself up
Every example of this lesson works inside tempfile.TemporaryDirectory(). Used in a with statement, it creates a
new, empty folder with a name nobody else uses, in the system’s folder for temporary files, and deletes the folder and
everything in it when the block ends, also when the block fails:
import tempfile
from pathlib import Path
try:
with tempfile.TemporaryDirectory() as tmp:
scratch = Path(tmp) # a new, empty folder that nobody else uses
(scratch / "draft.txt").write_text("half done", encoding="utf-8")
print("inside the block:", [p.name for p in scratch.iterdir()])
raise RuntimeError("the job failed")
except RuntimeError as err:
print("caught:", err)
# The folder and everything in it were deleted when the block ended, although it failed.
print("folder still there?", scratch.exists()) Output
inside the block: ['draft.txt'] caught: the job failed folder still there? False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scratch.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The block wrote a draft and then failed, yet on the next line the folder was gone. Each call picks a fresh name, so
two runs of the same test at the same moment never see each other’s files. That makes it the right home for tests
that create files and for scripts that need a scratch area. tempfile.mkdtemp() creates such a folder without
deleting it later, when the result must outlive the block.
Checkpoint
Check: Scratch folders with tempfile
Practise: refuse unsafe paths, organise a folder
The exercise asks for two functions. safe_join() must refuse every way out of a folder, .., an absolute name, a
link that leads out and the folder itself, while accepting names inside it that do not exist yet. organise() must
plan moves by extension, never overwrite a file, and change nothing in a dry run. Its sample tests run in your
browser:
Exercise · Medium · Python
Refuse unsafe paths and organise a folder
Write two functions in safe_paths.py.
safe_join(base, user_path)returns the resolvedPaththatuser_pathnames inside the folderbase. It raisesValueErrorwhen the result would be anywhere else: a name that climbs out with.., an absolute path such as"/etc/passwd", a symbolic link insidebasethat leads out of it, and the folderbaseitself (""or"."), which names nothing inside it. The path does not have to exist yet.organise(folder, rules, dry_run=True)sorts the files directly insidefolderinto subfolders.rulesmaps a lower-case extension such as".pdf"to a subfolder name such as"documents", and a file's extension is compared in lower case, soReport.PDFfollows the".pdf"rule. The function returns the planned moves as a list of(file name, subfolder name)pairs, sorted by file name. Folders, and files whose extension has no rule, stay where they are and are not listed. A file whose name already exists in its target subfolder is skipped and not listed: the function never overwrites anything. Withdry_run=Truenothing changes on disk; withdry_run=Falsethe function also creates the subfolders it needs and moves the files.
Starter code · safe_paths.py
from pathlib import Path
def safe_join(base, user_path):
"""Return the resolved path that user_path names inside base, or raise ValueError."""
# Replace these lines with your code.
return Path(base) / user_path
def organise(folder, rules, dry_run=True):
"""Plan, and unless dry_run is true make, the moves of folder's files into subfolders."""
# Replace these lines with your code.
return [] The sample tests · test_safe_paths.py
import tempfile
from pathlib import Path
from safe_paths import organise, safe_join
RULES = {".pdf": "documents", ".jpg": "images", ".png": "images"}
def refused(base, name):
try:
safe_join(base, name)
except ValueError:
return True
return False
def make_files(folder, names):
for name in names:
file = folder / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(name, encoding="utf-8")
def tree(folder):
return sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())
def test_safe_join_inside():
"""returns the resolved path for names inside the folder, existing or not"""
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / "reports"
make_files(base, ["q1/march.pdf"])
assert safe_join(base, "q1/march.pdf") == base.resolve() / "q1" / "march.pdf"
assert safe_join(str(base), "q2/new.pdf") == base.resolve() / "q2" / "new.pdf"
assert safe_join(base, "q1/../q1/march.pdf") == base.resolve() / "q1" / "march.pdf"
def test_safe_join_refuses_escapes():
"""refuses .., absolute paths and the folder itself"""
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / "reports"
base.mkdir()
for name in ["../secret.txt", "../../etc/passwd", "/etc/passwd", "q1/../../outside.txt", "", "."]:
assert refused(base, name), f"safe_join(base, {name!r}) should raise ValueError"
def test_safe_join_refuses_links_out():
"""refuses a symbolic link inside the folder that points out of it"""
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp) / "reports"
base.mkdir()
(Path(tmp) / "private").mkdir()
(base / "shortcut").symlink_to(Path(tmp) / "private")
assert refused(base, "shortcut/key.txt"), "a link that leads out of base must be refused"
def test_organise_dry_run():
"""plans the moves, sorted by file name, and changes nothing"""
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
make_files(folder, ["b.jpg", "Report.PDF", "notes.txt", "a.png"])
before = tree(folder)
assert organise(folder, RULES) == [("Report.PDF", "documents"), ("a.png", "images"), ("b.jpg", "images")]
assert tree(folder) == before
def test_organise_moves_and_never_overwrites():
"""moves files into new or existing subfolders, skips names that exist and leaves other entries alone"""
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
make_files(folder, ["logo.png", "images/logo.png", "photo.JPG", "cv.pdf", "notes.txt", "music.pdf/readme.txt"])
assert organise(folder, RULES, dry_run=False) == [("cv.pdf", "documents"), ("photo.JPG", "images")]
assert tree(folder) == ["documents/cv.pdf", "images/logo.png", "images/photo.JPG", "logo.png", "music.pdf/readme.txt", "notes.txt"]
assert (folder / "images" / "logo.png").read_text(encoding="utf-8") == "images/logo.png" A hint
In safe_join, resolve both paths before you compare them: base = Path(base).resolve() and target = (base / user_path).resolve(). is_relative_to() only compares the parts of two paths as text, so it is reliable only after resolve() has removed .. and followed symbolic links; then also refuse target == base. In organise, loop over sorted(folder.iterdir()), keep entries for which is_file() is true and rules.get(p.suffix.lower()) gives a subfolder, and check (folder / sub / p.name).exists() before you plan a move.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The practice below covers building paths, relative paths, listing, file operations, names from outside and scratch folders. It opens with the idea you are least sure of, one question at a time, and each answer you pick is explained on the spot:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
What is the difference between Path.iterdir(), Path.glob() and Path.rglob()?
iterdir() lists the entries of one folder, files and subfolders alike, without looking inside the subfolders.
glob(pattern) returns the entries that match a shell-style pattern relative to the folder: "*.csv" matches CSV
files in the folder itself, and a ** part matches any number of folders, so "**/*.csv" searches the whole tree.
rglob(pattern) is glob() with **/ in front of the pattern, so rglob("*.csv") finds CSV files at any depth.
All three produce Path objects lazily and promise no order, so sort the results when order matters. To go through
a tree folder by folder, with the names of the files and subfolders of each, use walk(), added in Python 3.12.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A report-download endpoint returns the bytes of Path("/srv/reports") / name, where name comes from the URL. A tester downloads /etc/passwd twice, once with name "../../etc/passwd" and once with "/etc/passwd". Explain both, and describe the check you would add, including what it must do about symbolic links.
-
Mid to senior
A nightly clean-up job runs shutil.rmtree(Path(root) / cfg["build_dir"]). One night cfg["build_dir"] was an empty string, and the whole project folder was deleted. How do you make code that deletes files safe against bad configuration and mistakes?
Key takeaways
- Build paths with
Pathand/, and take them apart withname,stem,suffix,suffixesandparent; aPathnever changes, thewith_methods return new ones. - Relative paths start at the current working directory, not at the script’s folder, so build paths to your own
files from
Path(__file__).resolve().parent. iterdir()lists one folder,glob()andrglob()search with patterns,walk()visits a whole tree; none of them promise an order.copy()andmove()replace an existing file at the target, andunlink(),rmdir()andshutil.rmtree()delete with no undo: plan file operations, show the plan, then act.- For a name from outside, resolve the joined path and require
is_relative_to(base): plain joins accept.., absolute paths and links that lead out. tempfile.TemporaryDirectory()gives scripts and tests a private folder that deletes itself.
Terms in this lesson
- current working directory
- The folder that a program's relative paths start from: by default the folder the program was started from. Path.cwd() returns it, and os.chdir() changes it for the whole program.
- dry run
- A run that only reports what a program would do, such as the list of files it would move, and changes nothing. The real run then carries out the same plan.
- glob pattern
- A pattern for file names from the shell: * matches any run of characters within one name, ? one character, and in pathlib ** any number of folders, so **/*.csv finds CSV files at any depth.
- path traversal
- A weakness in which a name from outside, such as ../../etc/passwd, makes a program read or write a file outside the folder it meant to use. Resolving the joined path and checking that it is still inside the folder prevents it.
- relative path
- A path that does not start at a root, such as data/marks.csv. Python reads it from the current working directory each time it is used, not from the folder of the script that holds it.
- symbolic link
- A file that only points to another path. Opening it opens what it points to, so a link inside a folder can lead anywhere on the computer; Path.resolve() follows links to the real location.
References
- pathlib: object-oriented filesystem paths (Python Software Foundation)
- pathlib: copying, moving and deleting (Python Software Foundation)
- pathlib: Path.walk() (Python Software Foundation)
- pathlib: PurePath.is_relative_to() (Python Software Foundation)
- What's New In Python 3.14: pathlib (Python Software Foundation)
- shutil: high-level file operations (Python Software Foundation)
- tempfile: generate temporary files and directories (Python Software Foundation)
- heapq.nlargest() (Python Software Foundation)
- Path Traversal (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress