Last active
November 13, 2024 15:40
-
-
Save glowinthedark/0ee73566318c5a9c947d123f3307506d to your computer and use it in GitHub Desktop.
Find replace in files recursively with regular expressions
This file contains 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-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