Skip to content

Instantly share code, notes, and snippets.

@glowinthedark
Last active November 28, 2025 20:52
Show Gist options
  • Select an option

  • Save glowinthedark/b0186679a2086675c6a099e4cdd6a0d1 to your computer and use it in GitHub Desktop.

Select an option

Save glowinthedark/b0186679a2086675c6a099e4cdd6a0d1 to your computer and use it in GitHub Desktop.
replace files using regular expressions
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# NOTES
# - to search recursively in subfolders pass `--glob "**/*.*"`
import argparse
import os
import re
import sys
from pathlib import Path
# ---- Color helpers ---------------------------------------------------------
class Color:
RED = "\033[31m"
GREEN = "\033[32m"
RESET = "\033[0m"
def __init__(self, enabled: bool = True):
self.enabled = enabled
def red(self, s: str) -> str:
return f"{self.RED}{s}{self.RESET}" if self.enabled else s
def green(self, s: str) -> str:
return f"{self.GREEN}{s}{self.RESET}" if self.enabled else s
# ---- Core ------------------------------------------------------------------
def build_pattern(search: str | None, literal: bool, ignore_case: bool) -> re.Pattern | None:
if not search:
return None
flags = re.IGNORECASE if ignore_case else 0
pat = re.escape(search) if literal else search
try:
return re.compile(pat, flags)
except re.error as e:
raise SystemExit(f"Invalid regular expression: {e}") from e
def color_matches(pat: re.Pattern, text: str, color: Color) -> str:
"""Color the matched parts (preview only)."""
return pat.sub(lambda m: color.red(m.group(0)), text)
def color_replacements(pat: re.Pattern, text: str, repl: str, color: Color) -> str:
"""Show the new text with replaced parts highlighted."""
return pat.sub(lambda m: color.green(m.expand(repl)), text)
def is_case_only_change(a: str, b: str) -> bool:
return a.lower() == b.lower() and a != b
def rename_path(src: Path, dst: Path, overwrite: bool, case_only: bool) -> None:
"""
- case-only rename: two-step to bypass case-insensitive FS quirks
- overwrite: use os.replace for atomic overwrite
"""
if src == dst:
return
if case_only:
tmp = src.with_name(f".__tmp_rename_{os.getpid()}__")
# ensure unique temp name
i = 0
while tmp.exists():
i += 1
tmp = src.with_name(f".__tmp_rename_{os.getpid()}__{i}")
os.replace(src, tmp) # replace is fine: tmp should not exist
try:
if overwrite:
os.replace(tmp, dst)
else:
os.rename(tmp, dst) # will fail if dst exists
except Exception as e:
print(str(e))
try:
os.replace(tmp, src)
except Exception as e:
print(str(e))
raise
else:
if overwrite:
os.replace(src, dst)
else:
os.rename(src, dst)
def iter_paths(root: Path, glob: str, recursive: bool):
return (root.rglob(glob) if recursive else root.glob(glob))
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(description="Batch rename with regex/literal support (dry-run by default).")
p.add_argument("root", nargs="?", default=".", help="Root folder (default: .)")
p.add_argument("-g", "--glob", default="*", help='Glob to filter (default: "%(default)s")')
p.add_argument("-R", "--recursive", action="store_true", help="Recurse into subdirectories")
p.add_argument("-s", "--search", help="Search string (regex unless -l)")
p.add_argument("-r", "--replace", help="Replacement string (use \\1, \\g<name> for backrefs)")
p.add_argument("-l", "--literal", action="store_true", help="Treat search as literal text")
p.add_argument("-i", "--ignore-case", action="store_true", help="Case-insensitive matching")
p.add_argument("-k", "--lower", action="store_true", help="Convert final name to lower-case")
p.add_argument("-K", "--upper", action="store_true", help="Convert final name to UPPER-CASE")
p.add_argument("-w", "--write", action="store_true", help="Apply changes (default: dry-run)")
p.add_argument("-y", "--overwrite", action="store_true", help="Overwrite existing targets")
p.add_argument("--no-color", action="store_true", help="Disable colored preview")
p.add_argument("-v", "--show-non-matching", action="store_true", help="List files that yield no change")
args = p.parse_args(argv)
# ---- argument validation ------------------------------------------------
if args.lower and args.upper:
p.error("Choose at most one of --lower or --upper.")
if (args.search is None) != (args.replace is None):
p.error("Provide both --search and --replace, or neither.")
if not args.search and not args.lower and not args.upper:
p.error("Nothing to do: set --search/--replace and/or --lower/--upper.")
color = Color(enabled=sys.stdout.isatty() and (not args.no_color))
pattern = build_pattern(args.search, args.literal, args.ignore_case) if args.search else None
root = Path(args.root)
if not root.exists():
print(f"Root does not exist: {root}", file=sys.stderr)
return 2
scanned = changed = skipped = errors = 0
for path in iter_paths(root, args.glob, args.recursive):
# We allow both files and dirs by default (intuitive). Skip roots like '.' from globbing.
if path.name in (".", ".."):
continue
scanned += 1
old_name = path.name
new_name = old_name
# Apply search/replace if any
matched = False
if pattern:
if pattern.search(old_name):
matched = True
new_name = pattern.sub(args.replace, old_name)
# Apply case conversion last
if args.lower:
new_name = new_name.lower()
elif args.upper:
new_name = new_name.upper()
if new_name == old_name:
if args.show_non_matching:
if pattern:
# preview where it would have matched (if it did)
preview = color_matches(pattern, old_name, color) if color.enabled and pattern else old_name
print(f"= {preview} (no change)")
else:
print(f"= {old_name} (no change)")
continue
dst = path.with_name(new_name)
# Build preview safely (never uppercase/lower escape codes)
if pattern and matched:
left = color_matches(pattern, old_name, color)
# show replacements in green on the old text *where they land*
mid = color_replacements(pattern, old_name, args.replace, color)
else:
left = old_name
mid = new_name
final_preview = new_name # final (after case conversion)
right = color.green(final_preview) if color.enabled else final_preview
arrow = "→"
# If we did both replace and case-conversion, show both steps
if pattern and (args.lower or args.upper) and matched and mid != final_preview:
print(f"{left} {arrow} {mid} {arrow} {right}")
else:
print(f"{left} {arrow} {right}")
if not args.write:
continue
# Safety checks and rename
try:
if dst.exists() and dst != path and not args.overwrite:
print(f"WARNING: target exists, skipping: {dst}")
skipped += 1
continue
case_only = is_case_only_change(old_name, new_name)
rename_path(path, dst, overwrite=args.overwrite, case_only=case_only)
changed += 1
except Exception as e:
print(f"ERROR: {e} ({path} -> {dst})", file=sys.stderr)
errors += 1
print(f"\nScanned: {scanned} | Renamed: {changed} | Skipped: {skipped} | Errors: {errors}")
return 0 if errors == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment