Skip to content

Instantly share code, notes, and snippets.

@DrSkippy
Created December 9, 2025 19:51
Show Gist options
  • Select an option

  • Save DrSkippy/bca0f950fb1a816cd653c71756410a81 to your computer and use it in GitHub Desktop.

Select an option

Save DrSkippy/bca0f950fb1a816cd653c71756410a81 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
File Renamer Script
Walks directories and renames files matching the pattern:
FROM: <string>_<sequence>_<date>.jpg
TO: <date>_<sequence>_<string>.jpg
Creates backup files (.bak) before renaming.
"""
import argparse
import re
import shutil
from pathlib import Path
from typing import Optional, Tuple
class FileRenamer:
"""Handles file renaming operations with backup creation."""
# Pattern: <string>_<sequence>_<YYYYMMDD>.jpg
PATTERN = re.compile(r'^(.+?)_(\d+)_(\d{8})\.jpg$', re.IGNORECASE)
def __init__(self, dry_run: bool = False, verbose: bool = False):
self.dry_run = dry_run
self.verbose = verbose
self.processed_count = 0
self.skipped_count = 0
self.error_count = 0
def parse_filename(self, filename: str) -> Optional[Tuple[str, str, str, str]]:
"""
Parse filename to extract components.
Args:
filename: The filename to parse
Returns:
Tuple of (string_part, sequence, date, extension) or None if no match
"""
match = self.PATTERN.match(filename)
if not match:
return None
string_part, sequence, date = match.groups()
# Validate date format (basic check)
if not self._is_valid_date(date):
return None
return string_part, sequence, date, '.jpg'
def _is_valid_date(self, date_str: str) -> bool:
"""Validate YYYYMMDD date format."""
if len(date_str) != 8:
return False
try:
year = int(date_str[:4])
month = int(date_str[4:6])
day = int(date_str[6:8])
# Basic validation
if year < 1900 or year > 2100:
return False
if month < 1 or month > 12:
return False
if day < 1 or day > 31:
return False
return True
except ValueError:
return False
def create_new_filename(self, string_part: str, sequence: str, date: str, ext: str) -> str:
"""
Create new filename in format: <date>_<sequence>_<string>.jpg
Args:
string_part: The string portion of the filename
sequence: The sequence number
date: The date in YYYYMMDD format
ext: The file extension
Returns:
New filename
"""
return f"{date}_{sequence}_{string_part}{ext}"
def create_backup(self, file_path: Path) -> bool:
"""
Create a backup of the file.
Args:
file_path: Path to the file to backup
Returns:
True if backup successful, False otherwise
"""
backup_path = file_path.with_suffix(file_path.suffix + '.bak')
if self.dry_run:
print(f" [DRY RUN] Would create backup: {backup_path.name}")
return True
try:
shutil.copy2(file_path, backup_path)
if self.verbose:
print(f" Created backup: {backup_path.name}")
return True
except Exception as e:
print(f" ERROR creating backup: {e}")
return False
def rename_file(self, file_path: Path) -> bool:
"""
Process and rename a single file.
Args:
file_path: Path to the file to rename
Returns:
True if renamed successfully, False otherwise
"""
filename = file_path.name
parsed = self.parse_filename(filename)
if not parsed:
if self.verbose:
print(f"SKIP: {filename} (doesn't match pattern)")
self.skipped_count += 1
return False
string_part, sequence, date, ext = parsed
new_filename = self.create_new_filename(string_part, sequence, date, ext)
new_path = file_path.parent / new_filename
# Check if target already exists
if new_path.exists() and new_path != file_path:
print(f"SKIP: {filename} (target {new_filename} already exists)")
self.skipped_count += 1
return False
# Skip if filename is already in the correct format
if filename == new_filename:
if self.verbose:
print(f"SKIP: {filename} (already in correct format)")
self.skipped_count += 1
return False
print(f"\nProcessing: {filename}")
print(f" → {new_filename}")
# Create backup
if not self.create_backup(file_path):
self.error_count += 1
return False
# Rename file
if self.dry_run:
print(f" [DRY RUN] Would rename to: {new_filename}")
self.processed_count += 1
return True
try:
file_path.rename(new_path)
print(f" ✓ Renamed successfully")
self.processed_count += 1
return True
except Exception as e:
print(f" ERROR renaming file: {e}")
self.error_count += 1
return False
def walk_directory(self, directory: Path, recursive: bool = True) -> None:
"""
Walk through directory and process files.
Args:
directory: Directory to process
recursive: Whether to process subdirectories
"""
if not directory.exists():
print(f"ERROR: Directory does not exist: {directory}")
return
if not directory.is_dir():
print(f"ERROR: Not a directory: {directory}")
return
print(f"Scanning directory: {directory}")
print(f"Recursive: {recursive}")
print(f"Dry run: {self.dry_run}")
print("-" * 60)
# Get all .jpg files
if recursive:
files = list(directory.rglob('*.jpg')) + list(directory.rglob('*.JPG'))
else:
files = list(directory.glob('*.jpg')) + list(directory.glob('*.JPG'))
if not files:
print("No .jpg files found")
return
print(f"Found {len(files)} .jpg file(s)\n")
# Process each file
for file_path in sorted(files):
self.rename_file(file_path)
# Print summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"Files processed: {self.processed_count}")
print(f"Files skipped: {self.skipped_count}")
print(f"Errors: {self.error_count}")
if self.dry_run:
print("\n[DRY RUN MODE] No files were actually modified.")
print("Run without --dry-run to apply changes.")
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description='Rename files from <string>_<seq>_<date>.jpg to <date>_<seq>_<string>.jpg',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s /path/to/photos # Process directory
%(prog)s /path/to/photos --dry-run # Preview changes
%(prog)s /path/to/photos --no-recursive # Don't process subdirectories
%(prog)s . -v # Process current dir with verbose output
"""
)
parser.add_argument(
'directory',
type=Path,
nargs='?',
default=Path.cwd(),
help='Directory to process (default: current directory)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without actually renaming files'
)
parser.add_argument(
'--no-recursive',
action='store_true',
help='Do not process subdirectories'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Show verbose output including skipped files'
)
args = parser.parse_args()
renamer = FileRenamer(dry_run=args.dry_run, verbose=args.verbose)
renamer.walk_directory(args.directory, recursive=not args.no_recursive)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment