Python project 20
Python Bulk File Renamer Project
Create a safety-focused bulk file renamer that sorts files predictably, previews every change, filters by extension, detects collisions, requires an explicit confirmation word, and uses temporary filenames to prevent overlapping-name failures.
Learn Python with mentor-guided projectsView all project ideas
What you will build
The tool creates numbered names such as photo_001.jpg. Its default mode is preview-only; files change only when the user adds --apply and then types RENAME after reviewing the plan.
Skills practised
- Pathlib file operations
- Dataclasses and type hints
- Command-line options
- Collision detection and rollback
Requirements
- Python 3.10 or later
- A test folder containing copied files
- No external packages
- About 40 minutes to build
Full Python code
Save this code as bulk_file_renamer.py, then follow the run instructions below.
"""Preview and safely rename files in one folder using numbered names."""
from __future__ import annotations
import argparse
import uuid
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Rename:
source: Path
destination: Path
def build_plan(
folder: Path,
prefix: str,
start: int = 1,
padding: int = 3,
extension: str | None = None,
) -> list[Rename]:
if not folder.is_dir():
raise ValueError(f"Folder does not exist: {folder}")
if not prefix or any(character in prefix for character in '<>:"/\\|?*'):
raise ValueError("Prefix must be non-empty and cannot contain filename separators.")
if start < 0 or padding < 1:
raise ValueError("Start must be zero or greater, and padding must be at least 1.")
normalized_extension = None
if extension:
normalized_extension = "." + extension.lower().lstrip(".")
files = sorted(
(
path
for path in folder.iterdir()
if path.is_file()
and (normalized_extension is None or path.suffix.lower() == normalized_extension)
),
key=lambda path: path.name.casefold(),
)
plan = [
Rename(source, folder / f"{prefix}_{number:0{padding}d}{source.suffix.lower()}")
for number, source in enumerate(files, start=start)
]
source_keys = {item.source.name.casefold() for item in plan}
destination_keys: set[str] = set()
for item in plan:
key = item.destination.name.casefold()
if key in destination_keys:
raise ValueError(f"Two files would receive the same name: {item.destination.name}")
destination_keys.add(key)
if item.destination.exists() and key not in source_keys:
raise ValueError(f"A different file already uses the target name: {item.destination.name}")
return plan
def apply_plan(plan: list[Rename]) -> None:
"""Use temporary names so swaps and overlapping target names remain safe."""
staged: list[tuple[Rename, Path]] = []
completed: list[Rename] = []
try:
for item in plan:
temporary = item.source.with_name(f".bulk-rename-{uuid.uuid4().hex}{item.source.suffix}")
item.source.rename(temporary)
staged.append((item, temporary))
for item, temporary in staged:
temporary.rename(item.destination)
completed.append(item)
except OSError:
for item in reversed(completed):
if item.destination.exists() and not item.source.exists():
item.destination.rename(item.source)
for item, temporary in reversed(staged[len(completed) :]):
if temporary.exists() and not item.source.exists():
temporary.rename(item.source)
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Preview and bulk-rename files in one folder.")
parser.add_argument("folder", type=Path)
parser.add_argument("--prefix", required=True)
parser.add_argument("--start", type=int, default=1)
parser.add_argument("--padding", type=int, default=3)
parser.add_argument("--extension", help="Only rename this extension, for example jpg")
parser.add_argument("--apply", action="store_true", help="Apply the previewed plan")
return parser.parse_args()
def main() -> None:
args = parse_args()
try:
plan = build_plan(args.folder, args.prefix, args.start, args.padding, args.extension)
except (ValueError, OSError) as error:
raise SystemExit(f"Cannot create rename plan: {error}") from error
if not plan:
print("No matching files found.")
return
print("Rename preview:")
for item in plan:
print(f"- {item.source.name} -> {item.destination.name}")
if not args.apply:
print("\nPreview only. Run again with --apply after checking every name.")
return
if input("Type RENAME to continue: ").strip() != "RENAME":
print("Cancelled. No files were renamed.")
return
try:
apply_plan(plan)
except OSError as error:
raise SystemExit(f"Rename failed; rollback was attempted: {error}") from error
print(f"Renamed {len(plan)} file(s).")
if __name__ == "__main__":
main()
How the project works
build_plan() sorts names without case sensitivity, optionally filters one extension, and computes every destination before changing anything. It rejects duplicate target names and a target already occupied by an unrelated file.
apply_plan() first moves every source to a unique temporary name. This two-stage process supports cases where a destination overlaps another original name. If an operating-system error occurs, the function attempts to move completed and staged files back to their original paths.
Run the project
- Create a separate practice folder containing copies of several files.
- Preview with
python bulk_file_renamer.py Practice --prefix photo --extension jpg. - Check every proposed source and destination.
- Add
--apply, review again, and typeRENAMEonly when correct.
Accuracy and safety notes
- Preview first: the safe default performs no rename.
- Scope: only files directly inside the selected folder are considered.
- Backups: test with copies before processing important files.
- Rollback: an attempted rollback cannot replace a real backup.
Ways to extend it
Add date-based names, recursive folders, a CSV mapping log, undo from the log, regular-expression replacements, or a graphical preview with checkboxes.
Continue learning Python
Build the next project, return to the Softenant project library, or explore the Python programming course in Vizag for structured lessons, mentor feedback, and portfolio practice.