Last active
November 28, 2025 20:52
-
-
Save glowinthedark/b0186679a2086675c6a099e4cdd6a0d1 to your computer and use it in GitHub Desktop.
replace files using regular expressions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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