What you'll learn
- Combine argparse, pathlib, shutil, classes, and exceptions in one tool you can actually use
- Model the sorting rules as small classes with a shared method, so new rules are new classes
- Add a
--dry-runflag that shows the plan without touching anything - Write a log of what was moved, so every run is accountable
Concept
The Beginner level gave you collections, files, exceptions, modules, classes, and the standard library's everyday tools. The project uses all of them for a job everyone recognizes: a folder full of downloads that needs sorting into subfolders by type or by date. It is small enough to finish in one sitting, useful enough to keep, and shaped like real tools: a command with flags, rules that can grow, a safety switch, and a record of what happened.

The design decision that matters is how to represent "sort by type" and "sort by date". Each is a rule that answers one question: for this file, which subfolder? That is a method, so each rule is a class with a destination(path) method, and the organizer holds whichever rule the user chose. Adding "sort by size" later means one new class and one new entry in the parser's choices, with the organizer untouched. This is polymorphism doing exactly the job the OOP module promised.
| Rule class | destination(path) returns |
Example |
|---|---|---|
ByType |
a folder named after the extension | report.pdf → pdf/ |
ByDate |
a folder named after the modified month | photo.jpg → 2026-09/ |
Safety first: --dry-run runs the whole plan and prints every move without performing one. Tools that move files must offer this, because the cost of a wrong rule on a real folder is high and the cost of a preview is nothing. The same plan-then-apply structure also makes testing easy: the plan is a list of pairs you can inspect, and applying it is a separate step.

Every run appends to a log file: a timestamp, the source, and the destination of each move. Logs turn "what did the script do last Tuesday" from a mystery into a lookup, and the logging lesson in the Advanced level will replace the hand-written version here with the standard module. Errors are handled where they happen: a file that cannot be moved is reported and skipped, and the run continues, because one locked file should not abandon the other hundred.

Code
The whole tool. Read it as four parts: the rule classes, the Organizer that plans and applies, the parser, and main. A demo script then drives it from Python against a scratch folder so the output is real.
# organize.py
import argparse
import shutil
import sys
from datetime import datetime
from pathlib import Path
class ByType:
name = "type"
def destination(self, path):
return path.suffix.lstrip(".").lower() or "no_extension"
class ByDate:
name = "date"
def destination(self, path):
modified = datetime.fromtimestamp(path.stat().st_mtime)
return modified.strftime("%Y-%m")
RULES = {rule.name: rule for rule in (ByType, ByDate)}
class Organizer:
def __init__(self, folder, rule, log_path):
self.folder = Path(folder)
self.rule = rule
self.log_path = Path(log_path)
def plan(self):
moves = []
for path in sorted(self.folder.iterdir()):
if path.is_file() and path != self.log_path:
target = self.folder / self.rule.destination(path) / path.name
moves.append((path, target))
return moves
def apply(self, moves, dry_run):
moved = 0
with open(self.log_path, "a", encoding="utf-8") as log:
for source, target in moves:
label = "DRY" if dry_run else "MOVE"
print(f"{label} {source.name:<16} -> {target.parent.name}/")
if dry_run:
continue
try:
target.parent.mkdir(exist_ok=True)
shutil.move(str(source), str(target))
except OSError as err:
print(f"SKIP {source.name}: {err}")
continue
log.write(f"{datetime.now().isoformat(timespec='seconds')} {source} -> {target}\n")
moved += 1
return moved
def build_parser():
parser = argparse.ArgumentParser(prog="organize", description="Sort a folder's files into subfolders.")
parser.add_argument("folder")
parser.add_argument("--by", choices=sorted(RULES), default="type")
parser.add_argument("--dry-run", action="store_true")
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
folder = Path(args.folder)
if not folder.is_dir():
build_parser().error(f"{folder} is not a folder")
organizer = Organizer(folder, RULES[args.by](), folder / "organize.log")
moves = organizer.plan()
moved = organizer.apply(moves, args.dry_run)
print(f"{len(moves)} files planned, {moved} moved")
return 0
if __name__ == "__main__":
sys.exit(main())
A scratch folder with a few files, a dry run, then the real run by type. Nothing moves during the dry run; everything moves after it, and the log records each move.
# demo.py
import os
import shutil
import time
from pathlib import Path
from organize import main
folder = Path("downloads")
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir()
for name in ["report.pdf", "photo.jpg", "notes.txt", "archive.zip", "README"]:
(folder / name).write_text("x")
old = time.mktime((2025, 12, 15, 12, 0, 0, 0, 0, -1))
os.utime(folder / "archive.zip", (old, old))
main(["downloads", "--dry-run"])
print("after dry run:", sorted(p.name for p in folder.iterdir()))
main(["downloads", "--by", "type"])
print("after run:", sorted(p.name for p in folder.iterdir()))
print("log lines:", len((folder / "organize.log").read_text(encoding="utf-8").splitlines()))
DRY README -> no_extension/
DRY archive.zip -> zip/
DRY notes.txt -> txt/
DRY photo.jpg -> jpg/
DRY report.pdf -> pdf/
5 files planned, 0 moved
after dry run: ['README', 'archive.zip', 'notes.txt', 'organize.log', 'photo.jpg', 'report.pdf']
MOVE README -> no_extension/
MOVE archive.zip -> zip/
MOVE notes.txt -> txt/
MOVE photo.jpg -> jpg/
MOVE report.pdf -> pdf/
5 files planned, 5 moved
after run: ['jpg', 'no_extension', 'organize.log', 'pdf', 'txt', 'zip']
log lines: 5The other rule on a fresh folder, with one file backdated to show the date grouping. The organizer code did not change; only the rule object did.
# demo_by_date.py
import os
import shutil
import time
from pathlib import Path
from organize import ByDate, Organizer
folder = Path("photos")
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir()
(folder / "new.jpg").write_text("x")
(folder / "old.jpg").write_text("x")
old = time.mktime((2025, 12, 15, 12, 0, 0, 0, 0, -1))
os.utime(folder / "old.jpg", (old, old))
organizer = Organizer(folder, ByDate(), folder / "organize.log")
plan = organizer.plan()
print([(src.name, dst.parent.name) for src, dst in plan if src.name == "old.jpg"])
print(organizer.apply(plan, dry_run=False), "moved")
print(sorted(p.name for p in folder.iterdir() if p.is_dir()) == sorted({dst.parent.name for _, dst in plan}))
[('old.jpg', '2025-12')]
MOVE new.jpg -> 2026-09/
MOVE old.jpg -> 2025-12/
2 moved
TrueTry it yourself
Extend organize.py in three steps. Add a BySize rule that returns small, medium, or large from the file size, and register it in RULES. Add a --exclude option (repeatable) so names like organize.log or .DS_Store are skipped. Then make the organizer refuse to overwrite: if the target already exists, append a counter to the filename instead. Run each change with --dry-run first.
Common mistakes
- Moving before previewing. Always run
--dry-runon a real folder first; the preview is free and the alternative is not. - Rules as
if/elifinside the organizer. Each new rule then edits the core; a class per rule keeps the core stable. - A log that is inside the folder being sorted and not excluded. The tool would try to move its own log; the organizer skips it explicitly.
What's next
- A useful tool is a parser, a small core, and a set of interchangeable rule objects; new behavior arrives as a new class.
- Plan first, then apply; a dry-run flag costs nothing and prevents disasters.
- Log every change and handle per-file errors where they happen so one failure does not stop the run.
Your Beginner project is done: you can organize code, data, and files into working tools. One lesson remains in this level, the idioms you will see in every real codebase, so that other people's Python stops slowing you down. After that, you enter Advanced.