Lesson 3 of 6
Pick this one if you want the fastest payoff. Automation projects are small, and the first time a script does in two seconds what used to take you an hour, you're hooked.
The one-sentence version
A script that takes a folder full of inconsistently named files and turns it into something tidy — without ever losing a file.
What it must do
| Capability | Must-have | | --- | --- | | Find the files that match, ignore the ones that don't | yes | | Work out every rename before touching anything | yes | | A dry-run mode that prints the plan and changes nothing | yes | | Never overwrite an existing file | yes | | Write an undo log so the run can be reversed | yes | | Organise into subfolders by date | nice | | Find duplicate files by content | nice | | Run on a schedule | nice |
The rule that makes automation safe
An automation script runs over hundreds of files without asking. When it's wrong, it's wrong hundreds of times. So:
Plan first, print the plan, act last. Split the work into a function that decides and a function that does. The deciding function touches nothing, so you can run it as often as you like — and test it (lesson 9.9) without a filesystem full of junk.
Then default to dry run. The script prints what it would do; you pass --apply when
you've read it and agree.
Milestone 1: plan the renames
Notice plan_renames doesn't rename anything. It reads the folder and returns a list of pairs.
Two files in that folder slugify to the same name — IMG_20260316_ Sunset .jpg and
IMG_20260316_sunset.jpg. The taken set catches it and the second becomes
img-20260316-sunset-2.jpg. Without that, one photo would silently overwrite the other.
Real folders are always messier than you expect: trailing spaces, mixed case extensions, accidental copies. Handle it in the plan, where it's cheap.
Milestone 2: apply, with a dry run and an undo log
The if dst.exists(): raise line is not optional. On macOS and Linux, Path.rename silently
overwrites the destination. One name collision and a file is gone with no error and no
warning. Check first, every time.
The undo log is what turns this from "scary" into "fine". You ran it on the wrong folder? Undo. It's twelve lines and it's the difference between a tool you trust and one you don't.
Milestone 3: organise and de-duplicate
Same shape, more useful: sort files into YYYY-MM/ folders, and find duplicates by content
rather than by name.
Hashing is how you find duplicates properly: beach.jpg and beach_copy.jpg have different
names and identical bytes. Reading in chunks means a 4 GB video doesn't have to fit in memory —
iter(callable, sentinel) calls f.read over and over until it returns b"" (lesson 9.3).
rglob("*") walks subfolders too, which is how you check the result.
Making it a real command-line tool
Swap the hardcoded folder for arguments. argparse is standard library:
import argparse
parser = argparse.ArgumentParser(description="Tidy up a folder of photos.")
parser.add_argument("folder", help="folder to process")
parser.add_argument("--apply", action="store_true", help="actually rename (default: dry run)")
parser.add_argument("--undo", action="store_true", help="reverse the last run")
args = parser.parse_args()
if args.undo:
undo(args.folder)
else:
apply_renames(args.folder, plan_renames(args.folder), dry_run=not args.apply)
Now it's python tidy.py ~/Pictures to preview and python tidy.py ~/Pictures --apply to
commit. --help is generated for you.
Running it on a schedule
Your script is done; now make it run without you.
Linux / macOS — cron. crontab -e, then one line. Every day at 9am:
0 9 * * * /usr/bin/python3 /home/me/scripts/tidy.py /home/me/Pictures --apply >> /home/me/tidy.log 2>&1
Use the absolute path to both Python and your script — cron runs with almost no environment,
so relative paths and python (rather than python3) are the two things that always break.
Redirect output to a log; otherwise you'll never know why it stopped working.
Windows — Task Scheduler. Create Task → Trigger (daily) → Action: start
C:\Python312\python.exe with arguments C:\scripts\tidy.py C:\Users\me\Pictures --apply.
Inside Python — the schedule package (pip install schedule), for when the script is a
long-running process:
import schedule
import time
schedule.every().day.at("09:00").do(tidy_job)
while True:
schedule.run_pending()
time.sleep(60)
Cron is more reliable for anything that just needs to run daily — it survives reboots and doesn't hold a process open.
Sending yourself a report by email
Once it's scheduled you stop watching it, so have it tell you what it did. smtplib is standard
library:
import smtplib
import os
from email.message import EmailMessage
def send_report(subject, body, to_address):
message = EmailMessage()
message["Subject"] = subject
message["From"] = os.environ["SMTP_USER"]
message["To"] = to_address
message.set_content(body)
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
server.send_message(message)
Three things matter here:
- Credentials come from environment variables (lesson 8.3), never from the file. A password committed to git is a password you have to change.
- Gmail needs an "app password", not your account password — generate one in your Google account's security settings. Most providers work the same way.
- Wrap the send in
try/except. A failed email must not crash a job that already did its real work successfully.
Email needs a network connection and a real SMTP account, so it can't run in your browser — try this one on your own machine.
Definition of done
- [ ] Dry run is the default;
--applyis required to change anything - [ ] The script never overwrites an existing file
- [ ] Every run writes an undo log, and
--undoreverses it - [ ] Files that don't match are left completely alone
- [ ]
slugifyandplan_renameshave tests (they're pure functions — easy to test) - [ ] Running it twice in a row is safe and changes nothing the second time
That last one is idempotence, and it's what makes a script safe to schedule.
Stretch goals
- Read real photo dates from EXIF data with
pillow, instead of parsing the filename. - Archive old files into a dated
.zipwithshutil.make_archive. - Watch a folder and process new files as they appear (the
watchdogpackage). - A scrape-and-save job: fetch a page daily (Module 8), append the numbers you care about to a CSV, and email yourself when something crosses a threshold.
- Proper logging. Swap
printfor theloggingmodule so scheduled runs leave timestamped records.
Questions about this lesson
Log in to ask a question.