By the end of this guide you will have a Python script that reads a folder of meaningless filenames — scan_0034.pdf, IMG_2291.png, document(3).pdf — looks inside each one, and proposes a name like 2026-03-11-acme-invoice.pdf filed under a category folder. It prints every planned move before it touches anything, and it writes an undo log so a bad batch is one command away from being reversed. Setup takes about twenty minutes; the script itself runs in seconds per file.
The reason this task is worth automating is that the information you need is already inside the file, just not in its name. A scanner names by counter, a browser names by URL fragment, a phone names by timestamp. A model that reads the first page can tell you it is an invoice from Acme dated 11 March, which is exactly the three things you would have typed by hand.
This guide belongs to Automating Repetitive Tasks with Python and AI, the section that covers turning a recurring chore into a script you can schedule. If you have already worked through Python Script to Automate Email Sorting, the classify-then-route pattern here will feel familiar — but the stakes are higher, because a wrong move on a filesystem is harder to spot than a misfiled email.
Prerequisites
You need Python 3.10 or newer and an activated virtual environment (an isolated folder of packages, so this project cannot break another one). If that phrase is new, Create a Python Virtual Environment for AI walks through it. Beyond the parent guide's setup you need one extra package, pypdf, to pull text out of PDFs:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "pypdf>=5.0" "python-dotenv>=1.0"
pip freeze > requirements.txt
Put your API key in a .env file beside the script:
OPENAI_API_KEY=sk-your-key-here
Add that file to .gitignore before you write another line, so the key never reaches a public repository:
echo ".env" >> .gitignore
Finally, make a copy of the folder you plan to sort and point the script at the copy for your first few runs. No amount of validation beats testing on data you can afford to lose.
Step 1 — Walk the folder and read a preview
pathlib is the standard-library module for working with paths. Its rglob("*") method yields every entry beneath a folder, including files in subfolders, so one loop covers a messy download pile. You filter out directories and dot-files, then read a short preview of each file rather than the whole thing — a page or two of text is plenty to identify a document, and sending less text keeps each request fast and cheap.
from pathlib import Path
from pypdf import PdfReader
PREVIEW_CHARS = 1500
TEXT_SUFFIXES = {".txt", ".md", ".csv", ".log"}
def read_preview(path: Path) -> str:
"""Return a short, whitespace-normalised text sample from one file."""
suffix = path.suffix.lower()
if suffix == ".pdf":
reader = PdfReader(path)
raw = "\n".join((page.extract_text() or "") for page in reader.pages[:2])
elif suffix in TEXT_SUFFIXES:
raw = path.read_text(encoding="utf-8", errors="ignore")
else:
raw = "" # images and unknown types: fall back to the filename alone
return " ".join(raw.split())[:PREVIEW_CHARS]
def candidates(source: Path) -> list[Path]:
"""Every real file under source, in a stable order."""
return sorted(
p for p in source.rglob("*")
if p.is_file() and not p.name.startswith(".")
)
if __name__ == "__main__":
folder = Path.home() / "Downloads" / "to-sort"
for path in candidates(folder):
print(path.name, "->", read_preview(path)[:80] or "(no text found)")
Two details matter. errors="ignore" stops one file with an odd byte from crashing the whole run. And " ".join(raw.split()) collapses the runs of newlines and spaces that PDF extraction produces, so your 1,500-character budget is spent on words instead of whitespace.
The full pipeline you are about to build has six stages, and every stage after the model exists to keep the model's answer out of direct contact with your disk.
Step 2 — Ask the model for a structured verdict
A free-text answer is useless here. You want four fields every time: a category from a fixed list, a slug (a short hyphenated label), a date, and a confidence score you can threshold on. The OpenAI API supports a strict JSON schema, which means the response is guaranteed to be valid JSON with exactly those keys — no code fences, no apology paragraph, no missing field.
Set temperature to 0 so the same file always produces the same name; temperature controls randomness, and randomness is the enemy of a repeatable filing system. What Is Temperature in an LLM API? explains the trade-off in more depth.
import json
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
CATEGORIES = ["invoice", "receipt", "contract", "statement", "letter", "other"]
VERDICT_SCHEMA = {
"name": "file_verdict",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": CATEGORIES},
"slug": {"type": "string"},
"date": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["category", "slug", "date", "confidence"],
"additionalProperties": False,
},
}
SYSTEM = (
"You name scanned documents. Reply with the category, a two-to-four word "
"lowercase hyphenated slug naming the sender and document type, the "
"document date as YYYY-MM-DD (use 0000-00-00 if none is visible), and a "
"confidence between 0 and 1. Never include a file extension or a slash."
)
def classify(filename: str, preview: str) -> dict:
"""Ask the model what this file is. Returns a dict, never a path."""
response = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
response_format={"type": "json_schema", "json_schema": VERDICT_SCHEMA},
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Filename: {filename}\n\nText:\n{preview}"},
],
)
return json.loads(response.choices[0].message.content)
For scan_0034.pdf containing an Acme invoice, you get back something like {"category": "invoice", "slug": "acme-invoice", "date": "2026-03-11", "confidence": 0.93}. Notice what the function returns: a plain dictionary. It does not return a path, and nothing in this file writes to disk. Keeping the model's job to "describe" rather than "decide where" is the single most important design choice on this page.
If you want the model to follow instructions like this more reliably, Write System Prompts that Control Output Format covers the wording patterns that hold up across a batch. And if you are pulling structured fields out of documents at a larger scale, Extract Invoice Data from PDFs with Python and AI goes further than naming.
Step 3 — Validate the JSON and sanitise the filename
Here is the rule the rest of this guide is built on: a model's output is untrusted text, and untrusted text must never become a filesystem path without being rewritten first. A slug of ../../.ssh/config is a plausible thing for a confused model to emit if a document happens to contain that string, and joining it onto a folder path would write outside your destination entirely. The defence is not to check for bad characters — it is to keep only the good ones.
Everything below either survives a strict allowlist or gets replaced. The category must be one of your six known values; the date must match the exact YYYY-MM-DD shape; the slug is rebuilt character by character from a-z, 0-9 and hyphen, which quietly destroys slashes, dots, backslashes, colons and null bytes as a side effect.
import re
import unicodedata
from datetime import date
from pathlib import Path
MAX_STEM = 80
SLUG_RE = re.compile(r"[^a-z0-9-]+")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
VALID_CATEGORIES = set(CATEGORIES)
def slugify(raw: str) -> str:
"""Rewrite arbitrary text as a safe lowercase filename stem."""
folded = unicodedata.normalize("NFKD", raw).encode("ascii", "ignore").decode()
cleaned = SLUG_RE.sub("-", folded.lower())
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
return cleaned[:MAX_STEM] or "untitled"
def target_path(dest_root: Path, verdict: dict, original: Path) -> Path:
"""Build the new path from a verdict. Every field is re-derived here."""
category = verdict.get("category")
if category not in VALID_CATEGORIES:
category = "other"
day = str(verdict.get("date", ""))
if not DATE_RE.match(day) or day.startswith("0000"):
day = date.fromtimestamp(original.stat().st_mtime).isoformat()
stem = f"{day}-{slugify(str(verdict.get('slug', '')))}"
suffix = original.suffix.lower() # extension comes from the file
folder = dest_root / category
candidate = folder / f"{stem}{suffix}"
counter = 2
while candidate.exists(): # never overwrite anything
candidate = folder / f"{stem}-{counter}{suffix}"
counter += 1
resolved = candidate.resolve()
if not resolved.is_relative_to(dest_root.resolve()):
raise ValueError(f"refusing to write outside {dest_root}: {resolved}")
return candidate
The final is_relative_to check is a belt-and-braces guard: even if a future edit weakens slugify, a path that escapes the destination folder raises instead of running. The extension is taken from the original file, never from the model, because a .pdf renamed to .exe is a genuinely dangerous outcome.
Each incoming verdict runs the same gauntlet, and every branch has a defined answer — there is no path where a surprising value reaches Path.rename.
Step 4 — Print a dry run before anything moves
A dry run is a pass that computes the entire plan and prints it without changing a single byte. It costs you the API calls but nothing else, and it is the moment you catch a model that has decided every file is a receipt. Build the plan as data first, print it, and only then decide whether to act on it.
import argparse
from pathlib import Path
CONFIDENCE_FLOOR = 0.55
def build_plan(source: Path, dest_root: Path) -> list[tuple[Path, Path]]:
"""Return a list of (old_path, new_path) pairs. Touches nothing."""
planned: list[tuple[Path, Path]] = []
for path in candidates(source):
try:
verdict = classify(path.name, read_preview(path))
except Exception as exc: # one bad file must not stop the run
print(f"SKIP {path.name}: {type(exc).__name__}: {exc}")
continue
if float(verdict.get("confidence", 0)) < CONFIDENCE_FLOOR:
print(f"SKIP {path.name}: low confidence {verdict.get('confidence')}")
continue
planned.append((path, target_path(dest_root, verdict, path)))
return planned
def show(plan: list[tuple[Path, Path]]) -> None:
for old, new in plan:
print(f"MOVE {old.name}\n -> {new.relative_to(new.parents[1])}")
print(f"\n{len(plan)} file(s) planned. Nothing has been changed.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Rename and sort files with AI.")
parser.add_argument("source", type=Path)
parser.add_argument("dest", type=Path)
parser.add_argument("--go", action="store_true", help="actually move the files")
args = parser.parse_args()
plan = build_plan(args.source, args.dest)
show(plan)
Run it with python sortfiles.py ~/Downloads/to-sort ~/Documents/filed and you get a readable list: MOVE scan_0034.pdf -> invoice/2026-03-11-acme-invoice.pdf. The --go flag exists but does nothing yet, which is deliberate — the safe mode is the default, and the destructive mode needs a conscious extra word on the command line.
Two guards are worth calling out. The broad except Exception around the API call means a corrupt PDF or a network hiccup skips one file instead of ending the batch; if a traceback still reaches you, Read a Python Traceback in Five Minutes will get you to the cause quickly. The confidence floor means the model can say "I am not sure" and have that respected, leaving genuinely ambiguous files where they are for you to handle by hand.
Step 5 — Execute the moves and write an undo log
Now the --go branch earns its place. Each move is written to an append-only log file as one JSON object per line — a format called JSON Lines, chosen because a crash halfway through leaves every completed line intact and readable. Write the log entry after the rename succeeds, so the log only ever describes moves that really happened.
import json
from datetime import datetime
from pathlib import Path
LOG_PATH = Path("undo.jsonl") # keep this OUTSIDE the folder you are sorting
def apply_plan(plan: list[tuple[Path, Path]], log_path: Path = LOG_PATH) -> int:
moved = 0
with log_path.open("a", encoding="utf-8") as log:
for old, new in plan:
new.parent.mkdir(parents=True, exist_ok=True)
if new.exists(): # someone beat us to it
print(f"SKIP {old.name}: {new.name} appeared during the run")
continue
old.rename(new) # same drive; see troubleshooting
log.write(json.dumps({
"from": str(old.resolve()),
"to": str(new.resolve()),
"at": datetime.now().isoformat(timespec="seconds"),
}) + "\n")
log.flush()
moved += 1
return moved
def undo(log_path: Path = LOG_PATH) -> int:
"""Reverse every logged move, newest first."""
lines = log_path.read_text(encoding="utf-8").splitlines()
restored = 0
for line in reversed(lines):
row = json.loads(line)
new, old = Path(row["to"]), Path(row["from"])
if new.exists() and not old.exists():
old.parent.mkdir(parents=True, exist_ok=True)
new.rename(old)
restored += 1
return restored
Wire it into the entry point by replacing the last line of Step 4 with a branch on args.go: call apply_plan(plan) when the flag is present, show(plan) otherwise. Add a third flag, --undo, that calls undo() and prints the count. Reversing newest-first matters because a later move might have used a name that an earlier move freed up.
The three modes differ in exactly three ways, and knowing which is which before you press enter is most of the safety story.
Sanitising rules at a glance
These six rules are the whole contract between the model's suggestion and your disk. Each one turns a class of bad input into a boring, predictable output.
| Rule | What it prevents | Implementation |
|---|---|---|
| Fold to ASCII, lowercase | Unicode look-alikes and case-only clashes | unicodedata.normalize("NFKD", raw) |
Allowlist a-z0-9- | Slashes, dots and .. escaping the folder | re.sub(r"[^a-z0-9-]+", "-", s) |
| Collapse and trim hyphens | Names like --acme--invoice-- | re.sub(r"-{2,}", "-", s).strip("-") |
| Cap the stem at 80 chars | Path-length limits on Windows | cleaned[:MAX_STEM] |
| Extension from the file | A PDF renamed to an executable | original.suffix.lower() |
| Suffix on collision | Silently overwriting an existing file | while candidate.exists() |
Troubleshooting
OSError: [Errno 18] Invalid cross-device link — Path.rename cannot move a file between two drives or partitions, which is common when the source is an external disk. Swap the rename for shutil.move(old, new), which copies then deletes when it has to.
FileNotFoundError: [Errno 2] No such file or directory — the category subfolder does not exist yet. Make sure new.parent.mkdir(parents=True, exist_ok=True) runs before the rename, not after it.
PermissionError: [Errno 13] Permission denied — the file is open in another application, most often a PDF viewer on Windows. Close the viewer and rerun; because the undo log records only completed moves, a partial batch is safe to resume.
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the reply was not JSON, usually because the response_format argument was dropped or the model was swapped for one that does not support schemas. Fix JSONDecodeError with AI API Responses in Python covers the fallbacks.
openai.RateLimitError: Error code: 429 — you are sending files faster than your account allows. Add a short time.sleep between calls or follow the retry pattern in Fix the 429 Rate-Limit Error in Python.
When to use this vs. alternatives
- Use plain rules, not AI, when the filename already tells you enough. If every file from your accounting tool is called
INV-2026-0031.pdf, a regular expression is faster, free, and never wrong. Reach for a model only when the answer lives in the content rather than the name. - Use a desktop rules app when you want live, always-on filing and your criteria are simple. Tools that watch a folder and act on extension or sender handle that with no code. This script wins when the decision needs reading comprehension and when you want the plan reviewable before it runs.
- Use a document-processing pipeline when you need the data, not just the name. If you want the invoice total in a spreadsheet rather than a tidy filename, Classify Incoming Documents with Python and AI is the better starting point.
Once the dry run looks right on a copy of your folder, point it at the real one, keep the undo log somewhere you will find it, and consider running it on a timer — Schedule Python AI Jobs with GitHub Actions shows one way to do that without leaving a laptop on. The same shape of script — collect, classify, validate, act, log — is what powers Automate a Weekly Report with Python and AI too, so the effort you put in here transfers. Back to Automating Repetitive Tasks with Python and AI.
Related guides
- Automating Repetitive Tasks with Python and AI — the main guide for this section, with the collect-classify-act loop in full.
- Automate a Weekly Report with Python and AI — the same pattern applied to summarising a week of data.
- Write System Prompts that Control Output Format — keep the model's verdicts uniform across a large batch.
- Estimate OpenAI API Costs with Python — work out what sorting a thousand files will actually cost.
- Summarize Long PDFs with Python and Chunking — go beyond a preview when a document is too long to sample.