Last active
February 17, 2025 10:51
-
-
Save glowinthedark/0ee73566318c5a9c947d123f3307506d to your computer and use it in GitHub Desktop.
Find replace in files recursively with 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 | |
# | |
# Example Usage | |
# ------------------------------------------------------------------------------- | |
# modify dates from 30-10-2024 to 2024-10-30 | |
# find-replace-in-files25.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) | |
parser.add_argument("--silent", "-s", action="store_true", help="Keep silent and don't print anything. (%(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 colorize_matches(line, search_str, replace_str): | |
# """Highlight the matched text within a line.""" | |
# return line.replace(search_str, replace_str).strip() | |
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 | |
# pos = content.find(conf.search) | |
# | |
# while pos != -1: | |
# print(content[pos:pos + len(conf.search)]) | |
# pos = content.find(conf.search, pos + 1) | |
# | |
# Check for regex matches | |
matches = list(re.finditer(search_pattern, content)) | |
if not matches: | |
return | |
# Display matches | |
# if not conf.force: | |
if not conf.silent: | |
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 | |
# 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.""" | |
# return line.replace(search_str, replace_str).strip() | |
highlighted_line_before = line.replace(matched_search_text, rf'{Red}{matched_search_text}{Reset}').strip() | |
if not conf.silent: | |
print(f" {Magenta}BEFORE:{Reset}\n\t{highlighted_line_before}") | |
highlighted_line_after =search_pattern.sub(rf'{LightGreen}{args.replace}{Reset}', line).strip() | |
# highlighted_line_after = colorize_matches(line, args.search, rf'{LightGreen}{args.replace}{Reset}', args.literal, args.multiline) | |
if not conf.silent: | |
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 | |
if not conf.silent: | |
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') | |
if not conf.silent: | |
print('DBG: creating backup', str(backup_path.absolute())) | |
shutil.copyfile(file_path, backup_path) | |
file_path.write_text(new_content, encoding='utf-8') | |
else: | |
if not conf.silent: | |
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__": | |
"""Main function to handle argument parsing and execute the search-replace process.""" | |
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