Skip to content

Instantly share code, notes, and snippets.

@glowinthedark
Last active November 13, 2024 15:40
Show Gist options
  • Save glowinthedark/0ee73566318c5a9c947d123f3307506d to your computer and use it in GitHub Desktop.
Save glowinthedark/0ee73566318c5a9c947d123f3307506d to your computer and use it in GitHub Desktop.
Find replace in files recursively with regular expressions
#!/usr/bin/env python3
#
# Example Usage:
# -------------------------------------------------------------------------------
# modify dates from 30-10-2024 to 2024-10-30
# find-replace-in-files-regex.py -g "*.html" "(\d\d)-(\d\d)-(\d\d\d\d)" "\3-\2-\1"
# -------------------------------------------------------------------------------
import argparse
import re
import shutil
import sys
from pathlib import Path
Default = "\033[39m"
Black = "\033[30m"
Red = "\033[31m"
Green = "\033[32m"
Yellow = "\033[33m"
Blue = "\033[34m"
Magenta = "\033[35m"
Cyan = "\033[36m"
LightGray = "\033[37m"
DarkGray = "\033[90m"
LightRed = "\033[91m"
LightGreen = "\033[92m"
LightYellow = "\033[93m"
LightBlue = "\033[94m"
LightMagenta = "\033[95m"
LightCyan = "\033[96m"
White = "\033[97m"
Reset = "\033[0m"
def parse_args():
parser = argparse.ArgumentParser(description="Recursively search and replace text in files.")
parser.add_argument("search", help="The search regex pattern. use -m to enable multiline patterns.")
parser.add_argument("replace", help="The replacement regex pattern.")
parser.add_argument("--path", "-p", default=".", help="Folder path to start the search (default=%(default)s).")
parser.add_argument("--glob", "-g", default="*.*", help="Glob pattern for files to search (default=%(default)s).")
parser.add_argument("--force", "-f", action="store_true", help="Replace text in files. (%(default)s)", default=False)
exclusive_group = parser.add_mutually_exclusive_group()
exclusive_group.add_argument("--literal", "-l", action="store_true", help="Search/replace are literal strings, not regexes (%(default)s)", default=False)
exclusive_group.add_argument("--multiline", "-m", action="store_true", help="Enable multiline text matching. (%(default)s)", default=False)
parser.add_argument('--backup', '-b',
action='store_true',
help='Create backup files',
default=False)
return parser.parse_args()
def process_file(
conf,
file_path: Path,
search_pattern: re.Pattern,
search_pattern_colorized: re.Pattern
):
try:
content: str = file_path.read_text(encoding="utf-8")
if conf.literal:
if conf.search not in content:
return
matches = list(re.finditer(search_pattern, content))
if not matches:
return
# Display matches
print(f"{'-' * 60}\n💡{file_path}\n{'-' * 60}")
for match in matches:
# Get the entire line where the match occurred
start_of_line = content.rfind('\n', 0, match.start()) + 1
end_of_line = content.find('\n', match.end())
end_of_line = end_of_line if end_of_line != -1 else len(content)
line = content[start_of_line:end_of_line]
# Highlight the match within the line
# note: group() and group(0) are equivalent. They both return the entire matched string
matched_search_text = match.group(0)
# Highlight the matched text within a line.
highlighted_line_before = line.replace(matched_search_text, rf'{Red}{matched_search_text}{Reset}').strip()
print(f" {Magenta}BEFORE:{Reset}\n\t{highlighted_line_before}")
highlighted_line_after =search_pattern.sub(rf'{LightGreen}{args.replace}{Reset}', line).strip()
print(f" {Cyan}AFTER:{Reset}\n\t{highlighted_line_after}")
if conf.force:
new_content = search_pattern.sub(conf.replace, content)
if new_content != content: # Only write if there's a change
print(f"Writing changes: {file_path}")
if conf.backup:
backup_path: Path = file_path.with_suffix(f'{file_path.suffix}.bak')
while backup_path.exists():
backup_path = backup_path.with_suffix(f'{backup_path.suffix}.bak')
shutil.copyfile(file_path, backup_path)
file_path.write_text(new_content, encoding='utf-8')
else:
print(f"No changes applied to: {file_path}")
except Exception as e:
print(f"Error processing {file_path}: {e}", file=sys.stderr)
if __name__ == "__main__":
args = parse_args()
print(args)
# multiline is mutually exclusive with literal
search_patt: re.Pattern
search_patt_colorized: re.Pattern
if args.multiline:
search_patt = re.compile(args.search, flags=re.MULTILINE | re.DOTALL)
search_patt_colorized = re.compile("({args.search})", flags=re.MULTILINE | re.DOTALL)
else:
if args.literal:
search_patt = re.compile(re.escape(args.search))
search_patt_colorized = re.compile(re.escape("({args.search})"))
else:
search_patt = re.compile(args.search)
search_patt_colorized = re.compile("({args.search})")
root_dir = Path(args.path)
if not args.force:
print('\n\nDry run mode! No files will be changed. Use -f to force modifications.')
for path in root_dir.rglob(args.glob):
if path.is_file():
process_file(args, path, search_patt, search_patt_colorized)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment