Unit 5 Project: A Safe File Organizer
Put it together — argparse, pathlib, dry-run, and tests
In this lesson
In this project you’ll build a file organizer in Python: a command-line tool that sorts files into folders by type. The design splits cleanly into a pure planning core you can test and a thin CLI that runs it — with a --dry-run flag so nothing moves until you’ve seen the plan.
Explain it like I’m 5
This tool is a careful file helper: first it tells you exactly what it would do, and it only does it for real once you’re happy with the plan.
Plan the tool: separate deciding from doing
The key design move is to keep the decision (where should this file go?) separate from the action (actually move it). The decision is a pure helper with no side effects, so it’s trivial to test. target_folder returns a type folder, falling back to "other" for files with no extension.
from pathlib import Path
def target_folder(name):
"""Pure helper: where should this file go? Easy to test."""
ext = Path(name).suffix.lower().lstrip(".")
return ext or "other"
print(target_folder("Report.PDF"))
print(target_folder("README"))
pdf other
Build the dry-run plan
With the decision isolated, the plan builder just maps every file to a destination line. It returns data — a list of strings — rather than printing, so the caller decides what to do with it (show it, log it, count it).
from pathlib import Path
def target_folder(name):
return Path(name).suffix.lower().lstrip(".") or "other"
def plan_moves(files):
return [f"{name} -> {target_folder(name)}/{name}" for name in files]
for line in plan_moves(["a.txt", "pic.JPG", "notes"]):
print(line)
a.txt -> txt/a.txt pic.JPG -> jpg/pic.JPG notes -> other/notes
Wire up the CLI with --dry-run
The command-line layer is thin: parse a --dry-run flag, and either print the plan or carry out the moves. argparse turns --dry-run into args.dry_run (dashes become underscores).
Finish the tool: when --dry-run is passed, print each line of plan_moves(files). (We supply the args and file list for you.)
import argparse
from pathlib import Path
def target_folder(name):
return Path(name).suffix.lower().lstrip(".") or "other"
def plan_moves(files):
return [f"{name} -> {target_folder(name)}/{name}" for name in files]
files = ["a.txt", "b.jpg"]
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(["--dry-run"])
if args.dry_run:
pass # TODO: print each line of plan_moves(files)
else:
print(f"Would move {len(files)} files")
argparse stores --dry-run as args.dry_run. Loop for line in plan_moves(files): and print each one.
import argparse
from pathlib import Path
def target_folder(name):
return Path(name).suffix.lower().lstrip(".") or "other"
def plan_moves(files):
return [f"{name} -> {target_folder(name)}/{name}" for name in files]
files = ["a.txt", "b.jpg"]
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(["--dry-run"])
if args.dry_run:
for line in plan_moves(files):
print(line)
else:
print(f"Would move {len(files)} files")
a.txt -> txt/a.txt
b.jpg -> jpg/b.jpg
Common mistake: Moving files for real before the dry run looks right
The plan and the move are easy to wire up at once, so the safety preview gets skipped.
Default to dry-run output and require an explicit flag (or confirmation) to perform real moves. See the plan first, every time.
Common mistake: Mixing planning with the actual moving
Doing the move inside the loop that decides the destination feels shorter.
Keep target_folder/plan_moves pure and put the real file operations in a separate function. Only the pure part is easy to test.
Common mistake: Testing on real, important folders
The actual files are right there, so they get used as test data.
Test the pure helpers, or run against a folder of throwaway samples. Never let a test move files you care about.
Why separate plan_moves() from the code that actually moves files?
A pure planning function returns data you can test; the side-effecting move stays isolated.
What should --dry-run do?
Dry-run mode previews the plan so you can confirm it before any real change.
argparse turns the flag --dry-run into which attribute?
argparse replaces dashes with underscores, so --dry-run becomes args.dry_run.
Mini exercise (hard)
Build the planning core of a file organizer: target_folder(name) returns the lowercase extension (or "other" if none), and plan_moves(files) returns a list of "name -> folder/name" strings. Prove it on the given files.
Do it here. Finish the code below and press Run to try your answer right in the browser — no setup needed.
from pathlib import Path
def target_folder(name):
# TODO: return the lowercase extension without the dot, or "other" if none
return "other"
def plan_moves(files):
# TODO: return a "name -> folder/name" string for each file
return []
print(plan_moves(["song.MP3", "data.csv", "LICENSE"]))
target_folder: Path(name).suffix.lower().lstrip(".") or "other". plan_moves: a list comprehension building f"{name} -> {target_folder(name)}/{name}".
from pathlib import Path
def target_folder(name):
return Path(name).suffix.lower().lstrip(".") or "other"
def plan_moves(files):
return [f"{name} -> {target_folder(name)}/{name}" for name in files]
print(plan_moves(["song.MP3", "data.csv", "LICENSE"]))
['song.MP3 -> mp3/song.MP3', 'data.csv -> csv/data.csv', 'LICENSE -> other/LICENSE']
assert target_folder("a.TXT") == "txt", "extension should be lowercased without the dot"
assert target_folder("README") == "other", "files with no extension go to 'other'"
assert plan_moves(["a.txt"]) == ["a.txt -> txt/a.txt"], "each line should read 'name -> folder/name'"
assert plan_moves(["x.JPG", "y"]) == ["x.JPG -> jpg/x.JPG", "y -> other/y"], "keep the original name, file by lowercase extension"
print("✓ Organizer core works!")