🐍 Python 3.10+

Bulk File Renamer

Renames files using regex search/replace or sequential numbering, with a dry-run preview by default before anything is touched.

By WindowsScripting.com · 3.3 KB
bulk_file_renamer.py
#!/usr/bin/env python3
"""
bulk_file_renamer.py

Renames files in a folder using a search/replace pattern (regex supported)
or sequential numbering. Dry-run by default so you can preview changes
before anything is touched.

Usage:
    python bulk_file_renamer.py <folder> --pattern "IMG_(\\d+)" --replace "Photo_\\1"
    python bulk_file_renamer.py <folder> --sequence "vacation_{:03d}" --ext .jpg
    python bulk_file_renamer.py <folder> --pattern "IMG_(\\d+)" --replace "Photo_\\1" --apply

Without --apply, the script only prints what it WOULD rename (dry run).
"""

import argparse
import re
import sys
from pathlib import Path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Bulk rename files in a folder.")
    parser.add_argument("folder", type=Path, help="Folder containing files to rename")
    parser.add_argument("--pattern", help="Regex pattern to match against filenames (without extension)")
    parser.add_argument("--replace", help="Replacement string, may use \\1, \\2 backreferences")
    parser.add_argument("--sequence", help="Sequential name template, e.g. 'photo_{:03d}'")
    parser.add_argument("--ext", help="Only process files with this extension, e.g. .jpg")
    parser.add_argument("--apply", action="store_true", help="Actually perform the rename (default: dry run)")
    return parser.parse_args()


def collect_files(folder: Path, ext: str | None) -> list[Path]:
    if not folder.is_dir():
        sys.exit(f"Error: '{folder}' is not a valid folder.")

    files = sorted(p for p in folder.iterdir() if p.is_file())
    if ext:
        ext = ext if ext.startswith(".") else f".{ext}"
        files = [f for f in files if f.suffix.lower() == ext.lower()]
    return files


def rename_with_pattern(files: list[Path], pattern: str, replace: str, apply: bool) -> int:
    regex = re.compile(pattern)
    count = 0
    for f in files:
        new_stem = regex.sub(replace, f.stem)
        if new_stem == f.stem:
            continue
        new_path = f.with_name(new_stem + f.suffix)
        print(f"{'RENAME' if apply else 'WOULD RENAME'}: {f.name}  ->  {new_path.name}")
        if apply:
            f.rename(new_path)
        count += 1
    return count


def rename_with_sequence(files: list[Path], template: str, apply: bool) -> int:
    count = 0
    for i, f in enumerate(files, start=1):
        new_name = template.format(i) + f.suffix
        new_path = f.with_name(new_name)
        print(f"{'RENAME' if apply else 'WOULD RENAME'}: {f.name}  ->  {new_path.name}")
        if apply:
            f.rename(new_path)
        count += 1
    return count


def main() -> None:
    args = parse_args()
    files = collect_files(args.folder, args.ext)

    if not files:
        print("No matching files found.")
        return

    if args.sequence:
        count = rename_with_sequence(files, args.sequence, args.apply)
    elif args.pattern and args.replace is not None:
        count = rename_with_pattern(files, args.pattern, args.replace, args.apply)
    else:
        sys.exit("Error: specify either --sequence, or both --pattern and --replace.")

    print(f"\n{count} file(s) {'renamed' if args.apply else 'would be renamed'}.")
    if not args.apply:
        print("Run again with --apply to perform the rename.")


if __name__ == "__main__":
    main()