PYTHON / CAPSTONE PROJECTS
Project: a file-organising automation script
Build a Python file organiser that plans every move first, then applies them, sorting by extension or date without ever overwriting a file.
What you will learn
- Split the script into a pure plan() step and a side-effecting move step
- Resolve name collisions with a counted suffix instead of letting rename overwrite
- Bucket files by suffix.lower() or by st_mtime formatted into a folder name
- Snapshot the directory listing before moving anything out of it
Understanding Project: a file-organising automation script
A file organiser is really two different programs glued together: one that decides where each file belongs, and one that changes the disk. Keeping them apart matters because the first is easy to test and print, and the second is the only part that can destroy data. In this project plan() returns a list of (source, destination) pairs and touches nothing; run_moves() consumes that list and is the single place where shutil.move is called. That split is what makes a --dry-run flag a two-line feature rather than a rewrite.
The second thing to internalise is that the filesystem is shared mutable state, and your script is not the only thing looking at it. Path.exists() followed by shutil.move is a check-then-act pair: on POSIX, shutil.move ends up calling os.rename, which silently replaces an existing file of the same name, so a wrong destination means a file is gone with no error and no traceback. Generating a fresh name like report (2).pdf when the target is taken turns a destructive rename into an additive one, which is the behaviour you actually want from an organiser.
Finally, be careful about scanning and mutating the same directory. Path.iterdir() is a generator reading the OS directory stream lazily, so moving files while you iterate can cause entries to be visited twice or skipped entirely; wrapping it in sorted() or list() forces the whole listing to be read before the first move happens. The same reasoning applies to rglob: if your category folders live inside the tree you are walking, you will start finding files you have already filed and move them again.
import shutil
from pathlib import Path
from tempfile import mkdtemp
CATEGORIES = {
'.jpg': 'images', '.png': 'images',
'.csv': 'data', '.json': 'data',
'.pdf': 'docs', '.txt': 'docs',
}
def plan(folder):
moves = []
for entry in sorted(folder.iterdir()):
if entry.is_file():
category = CATEGORIES.get(entry.suffix.lower(), 'other')
moves.append((entry, folder / category / entry.name))
return moves
def unique_name(target):
candidate, n = target, 2
while candidate.exists():
candidate = target.with_name(target.stem + ' (' + str(n) + ')' + target.suffix)
n += 1
return candidate
def run_moves(moves, dry_run):
verb = 'would move' if dry_run else 'moved'
for src, dst in moves:
final = unique_name(dst)
print(verb, src.name, '->', final.parent.name + '/' + final.name)
if not dry_run:
final.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(final))
sandbox = Path(mkdtemp())
for name in ['archive.zip', 'holiday.JPG', 'notes.txt', 'notes.txt.bak', 'sales.csv']:
(sandbox / name).write_text('x')
moves = plan(sandbox)
run_moves(moves, dry_run=True)
print('---')
run_moves(moves, dry_run=False)
print('---')
for path in sorted(sandbox.rglob('*')):
print(path.relative_to(sandbox))
shutil.rmtree(sandbox)A file organiser is safe only when choosing each file's destination is a separate, printable step from actually moving it.
Worked examples
Collisions get a new name, not a new owner
Three different files called report.pdf end up in one folder with all three contents intact.
import shutil
from pathlib import Path
from tempfile import mkdtemp
def unique_name(target):
candidate, n = target, 2
while candidate.exists():
candidate = target.with_name(target.stem + ' (' + str(n) + ')' + target.suffix)
n += 1
return candidate
root = Path(mkdtemp())
(root / 'docs').mkdir()
(root / 'docs' / 'report.pdf').write_text('already here')
for sub in ('jan', 'feb'):
(root / sub).mkdir()
(root / sub / 'report.pdf').write_text('from ' + sub)
for sub in ('jan', 'feb'):
dst = unique_name(root / 'docs' / 'report.pdf')
shutil.move(str(root / sub / 'report.pdf'), str(dst))
print(sub + '/report.pdf', '->', 'docs/' + dst.name)
for path in sorted((root / 'docs').iterdir()):
print(path.name, '=', path.read_text())
shutil.rmtree(root)Example explained
Line 1unique_name loops rather than trying a single suffix, so the second collision becomes (3) instead of clobbering (2).
Line 2target.with_name rebuilds the filename from stem and suffix, which keeps the extension last where the OS and other tools expect it.
Line 3Passing str(dst) as an explicit filename, not the docs directory, is what lets the renamed target take effect.
Line 4Reading each file back proves nothing was overwritten: three distinct payloads, three names.
Bucketing by modification date
Groups log files into YYYY-MM folders taken from st_mtime instead of from the filename.
import os, shutil
from datetime import datetime, timezone
from pathlib import Path
from tempfile import mkdtemp
root = Path(mkdtemp())
stamps = {'a.log': '2024-03-14', 'b.log': '2024-03-30', 'c.log': '2025-01-02'}
for name, day in stamps.items():
f = root / name
f.write_text(name)
ts = datetime.fromisoformat(day + 'T12:00:00+00:00').timestamp()
os.utime(f, (ts, ts))
for f in sorted(root.glob('*.log')):
when = datetime.fromtimestamp(f.stat().st_mtime, timezone.utc)
bucket = root / when.strftime('%Y-%m')
bucket.mkdir(exist_ok=True)
shutil.move(str(f), str(bucket / f.name))
print(f.name, '->', bucket.name)
print(sorted(p.name for p in root.iterdir()))
shutil.rmtree(root)Example explained
Line 1os.utime sets the modification time deliberately, so the example produces the same buckets on any machine.
Line 2st_mtime is a POSIX timestamp with no timezone; passing timezone.utc makes the folder name independent of the machine's local time.
Line 3sorted(root.glob('*.log')) drains the generator before the first move, so creating bucket folders mid-loop cannot disturb the scan.
Line 4mkdir(exist_ok=True) lets the second March file reuse the folder the first one created instead of raising FileExistsError.
Important notes
shutil.move behaves differently depending on the destination: if it names an existing directory the file is moved into it, otherwise the file is renamed to that exact path. Always build the full target filename yourself.
A move across filesystems (external drive, mounted network share) is a copy followed by a delete, so it is not atomic and an interruption can leave a partial file behind.
Common mistakes
Calling shutil.move onto a path that already exists: os.rename replaces the target silently, so the older file is destroyed with no error message.
Using entry.suffix without .lower(), so holiday.JPG falls through to 'other' while holiday.jpg lands in 'images' and the same category ends up split across two folders.
Moving files while iterating the same directory with iterdir() or rglob(), which skips entries or revisits already-filed files because the OS directory stream changes underneath the generator.
Assuming suffix captures the whole extension: for archive.tar.gz suffix is '.gz', so a tar rule keyed on '.tar' never fires.
Try it yourself
Change, predict, then run
Create a temp folder with photo.jpeg, photo.JPEG and budget.xlsx, then write plan() plus unique_name() so both photos land in images/ under different names and the spreadsheet goes to other/. Print the plan before applying it and confirm the dry run leaves the folder unchanged.
Open the Python workspaceCheck your understanding
Your organiser walks Downloads with iterdir() and moves each file into Downloads/images, Downloads/docs and so on as it goes. Most files are filed, but a few are left behind every run. What is the most likely cause?
- The directory listing is being consumed lazily while the same directory is modified, so some entries are never yielded
- shutil.move runs in a background thread, so later files are processed before earlier moves finish
- mkdir() invalidates the Path objects created before it, so their moves fail silently
- iterdir() returns files in arbitrary order and needs sorted() to return all of them
Show answer
iterdir() reads the OS directory stream lazily; renaming entries out of that directory during iteration can cause remaining entries to be skipped, which is why materialising the listing with sorted() or list() first fixes it. The sorting option is tempting because sorted() does fix the bug, but not because ordering was wrong: sorted() works only as a side effect of reading the entire listing up front, and a random order would still visit every file.