You have a folder called textures containing files named rock albedo 2.png, Rock_Albedo_FINAL.png, and rockalbedo-old(1).png. By the end of this page you'll have a real command-line tool that fixes it - safely - and you'll have learned loops, functions, and paths through the fixing, not before it.
A working CLI tool (download download the finished script) with dry-run mode, collision detection, and an undo log - plus the three habits that separate pipeline scripts from pipeline accidents. If you've read Intro to Python, you're ready.
Because it's the truest first pipeline problem: messy inputs, a convention to enforce, files you must not destroy, and a payoff you can feel. Every concept it needs - walking folders, string surgery, dry runs - reappears in every tool you'll ever write. And when you're done, you'll actually use it, which beats any exercise about calculating fibonacci numbers.
Everything starts with pathlib. Make a scratch folder with some badly-named files (or download the finished script later and test on a copy), then:
from pathlib import Path folder = Path(r"D:/scratch/textures") # r"" so backslashes behave on Windows for f in folder.iterdir(): if f.is_file(): print(f.stem, "|", f.suffix) # name without extension | extension
Run it. That loop - for each file, look at it - is the skeleton of every batch tool in existence. f.stem and f.suffix doing the splitting for you is why we use pathlib and not string chopping.
Decide the target: T_Rock_Albedo_02.png - a T_ prefix, TitleCase words joined by underscores, numbers padded to two digits. Now write the function that converts one name. One name, not the folder - small functions you can test in isolation are the habit that keeps tools debuggable:
import re
def convention(name, prefix="T_"):
# split on spaces, dashes, underscores AND camelCase seams
words = re.split(r"[\s\-_]+", re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name))
words = [w for w in words if w]
if words and words[-1].isdigit(): # pad trailing numbers: 2 -> 02
words[-1] = f"{int(words[-1]):02d}"
new = "_".join(w if w.isdigit() else w.title() for w in words)
return new if new.startswith(prefix) else prefix + new
# test it immediately, in the same file:
for test in ["rock albedo 2", "Rock_Albedo_FINAL", "rockAlbedo-old"]:
print(test, "->", convention(test))The regex looks scarier than it is: one split pattern for separators, one lookahead to cut camelCase at the seam. Steal them; everyone does. The important move is the last three lines - testing the function on hostile examples before it ever meets a real file.
Here's the habit that will one day save your job: the script's default mode changes nothing. It builds a plan and shows you:
def plan_renames(folder, prefix="T_"):
pairs = []
for f in sorted(folder.iterdir()):
if not f.is_file():
continue
new_name = convention(f.stem, prefix) + f.suffix.lower()
if new_name != f.name: # skip already-correct files
pairs.append((f, f.with_name(new_name)))
return pairs
for old, new in plan_renames(folder):
print(f"{old.name:<40} -> {new.name}")Run it, read the plan, find the surprise. There's always a surprise - a file you forgot, a name the function mangles. Finding it in printout costs nothing. Finding it after renaming costs an afternoon and some trust.
Two rails turn this from homework into a pipeline tool. Collision detection: if rock albedo 2.png and Rock_Albedo_02.png both map to the same new name, one would silently vanish. Refuse loudly instead:
targets = [b.name for _, b in pairs]
dupes = {t for t in targets if targets.count(t) > 1}
if dupes:
raise SystemExit(f"COLLISION: multiple files map to {sorted(dupes)}")An undo log: when you do rename, write every old -> new pair to a text file first. It's four lines, and it converts "irreversible" into "annoying to reverse" - which, in pipeline work, is the whole religion:
with open(folder / "_rename_log.txt", "a", encoding="utf-8") as fh:
for old, new in pairs:
old.rename(new)
fh.write(f"{old.name} -> {new.name}\n")A script becomes a tool when someone else can run it without reading it. argparse gives you a folder argument, a --prefix option, an --apply flag (dry-run stays the default - the safe thing should always be the lazy thing), and free --help text. The finished script assembles all five steps - compare it against yours; where they differ, one of us learned something.
python ta_batch_rename.py D:/textures # shows the plan python ta_batch_rename.py D:/textures --apply # commits it python ta_batch_rename.py --help # someone else's first command
1 (warm-up): add --recursive so it walks subfolders too (rglob is your friend - mind the log file). 2 (real): add suffix detection - files containing "normal"/"nrm" get _N appended, "roughness" gets _R, so rock normal.png -> T_Rock_N.png. 3 (job-interview-grade): write ta_batch_rename_undo.py, which reads a rename log and reverses it. When all three work, you have a portfolio piece - write the breakdown.
Where next: run this same project inside a DCC with the Rosetta Stone, raid the snippet library for parts, or level the tool up with a watch folder (snippet library -> CLI & Automation).