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
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
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
| System | Tool | Example |
|---|---|---|
| macOS / Linux | cron | crontab -e |
| macOS | launchd | for anything that must survive sleep |
| Windows | Task Scheduler | the graphical wizard |
| Anywhere | GitHub Actions | if 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
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.
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 bytesSafe 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.
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.