Last active
November 4, 2024 13:50
-
-
Save glowinthedark/04f78bde56f5e914f1c9acddf51bc013 to your computer and use it in GitHub Desktop.
Recursively find and replace text in files under a specific folder with colorized preview of changed data in dry-run mode
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-files.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="Replace text in files. (%(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 | |
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] | |
matched_search_text = match.group(0) | |
"""Highlight matched text""" | |
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() | |
# highlighted_line_after = colorize_matches(line, args.search, rf'{LightGreen}{args.replace}{Reset}', args.literal, args.multiline) | |
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') | |
print('DBG: creating backup', str(backup_path.absolute())) | |
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__": | |
"""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