Beginner · Lesson 48 of 94

Project: a file-organizing CLI

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

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.

Project: a file-organizing CLI: $ organize downloads/ --by type --dry-run → parse args → pick a Rule: ByType or ByDate → scan files, ask the rule for each destination → move (or only print, if dry-run) → append to organize.log
Project: a file-organizing CLI · Figure 1 · Open for full size

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.

Project: a file-organizing CLI: Diagram 2
Project: a file-organizing CLI · Figure 2 · Open for full size

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.

Project: a file-organizing CLI: Diagram 3
Project: a file-organizing CLI · Figure 3 · Open for full size

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()))
Output
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: 5

The 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}))
Output
[('old.jpg', '2025-12')]
MOVE  new.jpg          -> 2026-09/
MOVE  old.jpg          -> 2025-12/
2 moved
True

Try 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

What's next

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.

Course overview