Level 5 · In the Wild

Automating the Boring Things 🤖

This is the lesson that pays for the course. Everybody has a folder that is a disaster. Today you fix it in forty lines and get the afternoon back.

The golden rule of automation

🛑 Dry run first. Always. No exceptions.

A script that renames files is a script that can destroy files. Every tool in this lesson prints what it would do before it does anything, and takes an explicit flag to act for real. This is not caution for beginners; it is how professionals write destructive tools, and it will save you at least once.

Bulk renaming

from pathlib import Path


def bulk_rename(folder, old_text, new_text, dry_run=True):
    """Replace old_text with new_text in every filename. Previews by default."""
    changes = []
    for path in sorted(Path(folder).iterdir()):
        if not path.is_file() or old_text not in path.name:
            continue
        target = path.with_name(path.name.replace(old_text, new_text))
        changes.append((path, target))

    for source, target in changes:
        if dry_run:
            print(f"WOULD RENAME  {source.name}  ->  {target.name}")
        else:
            source.rename(target)
            print(f"renamed  {source.name}  ->  {target.name}")

    return len(changes)


# build a mess to clean up
holiday = Path("holiday")
holiday.mkdir(exist_ok=True)
for n in (1, 2, 3):
    (holiday / f"IMG_20260817_{n:04d}.jpg").write_text("x", encoding="utf-8")

print(f"{bulk_rename('holiday', 'IMG_', 'melee-island-')} files would change")
print("---")
bulk_rename("holiday", "IMG_", "melee-island-", dry_run=False)
WOULD RENAME  IMG_20260817_0001.jpg  ->  melee-island-20260817_0001.jpg
WOULD RENAME  IMG_20260817_0002.jpg  ->  melee-island-20260817_0002.jpg
WOULD RENAME  IMG_20260817_0003.jpg  ->  melee-island-20260817_0003.jpg
3 files would change
---
renamed  IMG_20260817_0001.jpg  ->  melee-island-20260817_0001.jpg
renamed  IMG_20260817_0002.jpg  ->  melee-island-20260817_0002.jpg
renamed  IMG_20260817_0003.jpg  ->  melee-island-20260817_0003.jpg

Sorting a downloads folder

from pathlib import Path

CATEGORIES = {
    "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp"},
    "Documents": {".pdf", ".docx", ".txt", ".md"},
    "Data": {".csv", ".json", ".xlsx"},
    "Archives": {".zip", ".tar", ".gz"},
}


def category_for(path):
    """Which folder should this file live in?"""
    for name, extensions in CATEGORIES.items():
        if path.suffix.lower() in extensions:
            return name
    return "Other"


def organise(folder, dry_run=True):
    """Move every file into a subfolder by type."""
    root = Path(folder)
    moved = {}
    for path in sorted(root.iterdir()):
        if not path.is_file():
            continue
        target_dir = root / category_for(path)
        moved.setdefault(target_dir.name, []).append(path.name)
        if not dry_run:
            target_dir.mkdir(exist_ok=True)
            path.rename(target_dir / path.name)
    return moved


downloads = Path("downloads")
downloads.mkdir(exist_ok=True)
for name in ["map.png", "contract.pdf", "sales.csv", "grog.mp3", "photo.jpg"]:
    (downloads / name).write_text("x", encoding="utf-8")

for category, files in sorted(organise("downloads").items()):
    print(f"{category:11} {', '.join(files)}")
Data        sales.csv
Documents   contract.pdf
Images      map.png, photo.jpg
Other       grog.mp3
PERCEPTION[Medium: Success]

Two things in that output are decisions rather than accidents. Archives does not appear at all, because no file matched it and the dictionary only gains a key when something lands in it. And grog.mp3 went to Other rather than being skipped.

A catch-all is safer than silence here: a file that matches nothing still gets moved somewhere you can find it, instead of quietly staying put while the report implies the folder was tidied.

The same thing without the helper function, using a Python oddity that fits this shape exactly:

from pathlib import Path

CATEGORIES = {"Images": {".png", ".jpg"}, "Data": {".csv"}}


def organise(folder):
    root = Path(folder)
    moved = {}
    for path in sorted(root.iterdir()):
        if not path.is_file():
            continue
        for name, extensions in CATEGORIES.items():
            if path.suffix.lower() in extensions:
                moved.setdefault(name, []).append(path.name)
                break
        else:
            moved.setdefault("Other", []).append(path.name)
    return moved


d = Path("dl2")
d.mkdir(exist_ok=True)
for name in ["map.png", "sales.csv", "grog.mp3"]:
    (d / name).write_text("x", encoding="utf-8")

for category, files in sorted(organise("dl2").items()):
    print(f"{category:9} {files}")
Data      ['sales.csv']
Images    ['map.png']
Other     ['grog.mp3']

That for ... else is a genuine Python oddity: the else runs only if the loop finished without hitting break, which is exactly "no category matched".

Finding duplicates by content

import hashlib
from pathlib import Path


def file_hash(path, chunk_size=8192):
    """SHA-256 of a file, read in chunks so size does not matter."""
    digest = hashlib.sha256()
    with open(path, "rb") as f:
        while chunk := f.read(chunk_size):
            digest.update(chunk)
    return digest.hexdigest()


def find_duplicates(folder):
    """Group files by content hash. Same content, different names."""
    by_hash = {}
    for path in sorted(Path(folder).rglob("*")):
        if path.is_file():
            by_hash.setdefault(file_hash(path), []).append(path.name)
    return {h: names for h, names in by_hash.items() if len(names) > 1}


dupes = Path("dupes")
dupes.mkdir(exist_ok=True)
(dupes / "map.txt").write_text("x marks the spot", encoding="utf-8")
(dupes / "map-copy.txt").write_text("x marks the spot", encoding="utf-8")
(dupes / "other.txt").write_text("something else", encoding="utf-8")

for digest, names in find_duplicates("dupes").items():
    print(f"{digest[:12]}...  {names}")
421822047831...  ['map-copy.txt', 'map.txt']

Comparing content rather than names is the right way to find duplicates: identical photos with different filenames are still duplicates. while chunk := f.read(...) uses the walrus operator to assign and test in one go, so the file is read in pieces and a 40GB video does not need 40GB of memory.

Backups

import shutil
from pathlib import Path


def backup(source, destination, stamp):
    """Zip a folder into a timestamped archive. Returns the archive path."""
    Path(destination).mkdir(parents=True, exist_ok=True)
    base = Path(destination) / f"{Path(source).name}-{stamp}"
    return shutil.make_archive(str(base), "zip", source)


work = Path("ship-logs")
work.mkdir(exist_ok=True)
(work / "day1.txt").write_text("became a mighty pirate\n", encoding="utf-8")
(work / "day2.txt").write_text("lost the ship\n", encoding="utf-8")

archive = backup("ship-logs", "backups", "20260817")
print(Path(archive).name)
print(Path(archive).exists())
ship-logs-20260817.zip
True

In real use the stamp would be datetime.now().strftime("%Y%m%d-%H%M"). It is passed in here so the example produces the same output every time, which is the same testability idea from Lesson 29: functions that take the clock as an argument can be tested, functions that call it cannot.

Making it run by itself

SystemToolExample
macOS / Linuxcroncrontab -e
macOSlaunchdfor anything that must survive sleep
WindowsTask Schedulerthe graphical wizard
AnywhereGitHub Actionsif it does not need your machine
# crontab -e, then a line like this
# minute hour day month weekday  command

0 3 * * *   /home/you/tools/.venv/bin/python /home/you/tools/backup.py
*/15 * * * * /home/you/tools/.venv/bin/python /home/you/tools/check.py

# every day at 3am, and every 15 minutes
🪤 The three things that always break scheduled scripts

1. Paths. cron runs from your home directory, not your project. Use absolute paths everywhere, or set the working directory in the script.

2. The wrong Python. cron has a minimal PATH. Give the full path to your virtual environment's interpreter, as above.

3. Silence. Output vanishes. Log to a file (Lesson 28) or you will never know it has been failing for six weeks.

A complete, defensive tool

#!/usr/bin/env python3
"""tidy: organise a folder by file type."""

import argparse
import logging
import sys
from pathlib import Path

CATEGORIES = {
    "Images": {".jpg", ".jpeg", ".png", ".gif"},
    "Documents": {".pdf", ".docx", ".txt", ".md"},
    "Data": {".csv", ".json", ".xlsx"},
}

log = logging.getLogger("tidy")


def category_for(path: Path) -> str:
    for name, extensions in CATEGORIES.items():
        if path.suffix.lower() in extensions:
            return name
    return "Other"


def tidy(folder: Path, dry_run: bool = True) -> int:
    moved = 0
    for path in sorted(folder.iterdir()):
        if not path.is_file() or path.name.startswith("."):
            continue
        target = folder / category_for(path) / path.name
        if target.exists():
            log.warning("skipping %s: %s already exists", path.name, target)
            continue
        if dry_run:
            log.info("would move %s -> %s", path.name, target.parent.name)
        else:
            target.parent.mkdir(exist_ok=True)
            path.rename(target)
            log.info("moved %s -> %s", path.name, target.parent.name)
        moved += 1
    return moved


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="Organise a folder by file type.")
    parser.add_argument("folder", type=Path)
    parser.add_argument("--go", action="store_true", help="actually move files")
    parser.add_argument("-v", "--verbose", action="store_true")
    args = parser.parse_args(argv)

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(levelname)-8s %(message)s",
    )

    if not args.folder.is_dir():
        log.error("not a folder: %s", args.folder)
        return 1

    count = tidy(args.folder, dry_run=not args.go)
    if not args.go:
        log.info("dry run: %d files would move. Pass --go to do it.", count)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Note every defensive choice: it previews unless told otherwise, it refuses to overwrite, it skips hidden files, it validates the folder before starting, it logs rather than prints, and it returns a proper exit code. That is the difference between a script and a tool you trust with your own files.

Exercise 1

Extension report

Walk a folder tree and report how many files of each extension there are, and how much space each type uses, biggest first.

Reveal solution
from pathlib import Path
from collections import Counter

root = Path("project")
(root / "src").mkdir(parents=True, exist_ok=True)
(root / "docs").mkdir(exist_ok=True)
for name, size in [("src/main.py", 400), ("src/utils.py", 250),
                   ("docs/readme.md", 120), ("notes.txt", 60)]:
    (root / name).write_text("x" * size, encoding="utf-8")

counts = Counter()
sizes = Counter()

for path in root.rglob("*"):
    if path.is_file():
        counts[path.suffix] += 1
        sizes[path.suffix] += path.stat().st_size

for suffix, total in sizes.most_common():
    print(f"{suffix:6} {counts[suffix]:3} files  {total:6,} bytes")
.py      2 files     650 bytes
.md      1 files     120 bytes
.txt     1 files      60 bytes
Exercise 2

Safe cleanup with a preview

Write a function that deletes files over a certain age. It must preview by default and refuse to touch anything outside the folder it was given.

Reveal solution
from pathlib import Path


def clean_old(folder, keep_names, dry_run=True):
    """Delete files not in keep_names. Previews unless dry_run is False."""
    root = Path(folder).resolve()
    removed = []

    for path in sorted(root.iterdir()):
        if not path.is_file() or path.name in keep_names:
            continue
        # refuse anything that escaped the folder, for example via a symlink
        if root not in path.resolve().parents:
            print(f"REFUSING {path}: outside {root.name}")
            continue
        removed.append(path.name)
        if dry_run:
            print(f"WOULD DELETE {path.name}")
        else:
            path.unlink()
            print(f"deleted {path.name}")

    return removed


tmp = Path("cache")
tmp.mkdir(exist_ok=True)
for name in ["keep.txt", "old1.tmp", "old2.tmp"]:
    (tmp / name).write_text("x", encoding="utf-8")

print(f"{len(clean_old('cache', {'keep.txt'}))} files would go")
print("---")
clean_old("cache", {"keep.txt"}, dry_run=False)
print(sorted(p.name for p in Path("cache").iterdir()))
WOULD DELETE old1.tmp
WOULD DELETE old2.tmp
2 files would go
---
deleted old1.tmp
deleted old2.tmp
['keep.txt']

The resolve() check matters: a symlink inside the folder can point anywhere on your disk, and a delete script that follows one is a very bad afternoon. Refusing to act outside a known root is standard practice for anything destructive.

Exercise 3

Design before you code

You want a script that watches a folder and converts any new CSV into JSON. List everything that could go wrong before writing a line.

Reveal solution
  • The file is still being written when you see it. Wait until the size stops changing, or watch for a rename.
  • The CSV is malformed, or has a different set of columns than expected.
  • The output already exists. Overwrite, skip, or version it?
  • The file is enormous and does not fit in memory. Stream it.
  • Two copies of the script run at once and fight over the same file.
  • The script crashes halfway and leaves a truncated JSON file. Write to a temporary name and rename at the end, since rename is atomic.
  • The encoding is not UTF-8, because someone exported it from Excel.
  • Nobody notices it has been failing for a month, because there is no logging or alert.

That list is the actual work. The conversion itself is Lesson 23 and takes six lines. Thinking about failure before you type is what separates a script that works once from a tool that runs unattended for a year.

+100 XP