mirror of
https://github.com/kevinveenbirkenbach/bulk-string-replacer.git
synced 2025-08-01 01:51:08 +02:00
235 lines
10 KiB
Python
Executable File
235 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import argparse
|
|
|
|
|
|
def replace_content(path, old_string, new_string, preview, verbose):
|
|
"""
|
|
Replace occurrences of old_string with new_string inside the file at path.
|
|
"""
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
if old_string in content:
|
|
new_content = content.replace(old_string, new_string)
|
|
print_verbose(f"Replacing content in: {path}", verbose)
|
|
if not preview:
|
|
with open(path, 'w', encoding='utf-8') as fw:
|
|
fw.write(new_content)
|
|
except UnicodeDecodeError:
|
|
print_verbose(f"Warning: Unicode decode error in file {path}. Skipping.", verbose)
|
|
|
|
|
|
def print_verbose(message, verbose):
|
|
if verbose:
|
|
print(message)
|
|
|
|
|
|
def process_directory(
|
|
base_path,
|
|
old_string,
|
|
new_string,
|
|
recursive,
|
|
rename_folders,
|
|
rename_files,
|
|
replace_in_content,
|
|
preview,
|
|
verbose,
|
|
include_hidden,
|
|
rename_paths,
|
|
auto_path
|
|
):
|
|
"""
|
|
Traverse the directory tree and perform operations based on flags:
|
|
- replace_in_content: replace inside file contents
|
|
- rename_files: rename files whose names contain old_string
|
|
- rename_folders: rename folders whose names contain old_string
|
|
- rename_paths: treat old_string as a relative path and move matching items to new_string path.
|
|
When rename_paths is set, prompts occur for Python files unless auto_path is True.
|
|
Additionally, for test_*.py files when auto_path is False, prompts to extract 'test_' prefix into a subdirectory.
|
|
"""
|
|
if rename_paths:
|
|
# Full-path moves and optional test_ prefix extraction
|
|
for root, dirs, files in os.walk(base_path):
|
|
if not include_hidden:
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
files = [f for f in files if not f.startswith('.')]
|
|
for name in files + dirs:
|
|
full_src = os.path.join(root, name)
|
|
rel = os.path.relpath(full_src, base_path)
|
|
if old_string in rel:
|
|
new_rel = rel.replace(old_string, new_string)
|
|
|
|
# Special handling for test_*.py when auto_path is False
|
|
if (
|
|
os.path.isfile(full_src)
|
|
and name.startswith('test_')
|
|
and name.endswith('.py')
|
|
and not auto_path
|
|
):
|
|
choice = None
|
|
while choice not in ('y', 'n'):
|
|
choice = input(
|
|
f"File {full_src} is a test module. "
|
|
"Extract 'test_' prefix into subdirectory? [y/N]: "
|
|
).strip().lower() or 'n'
|
|
if choice == 'y':
|
|
rel_dir = os.path.dirname(rel)
|
|
orig_basename = os.path.basename(rel)
|
|
base_noext = orig_basename[:-3] # remove .py
|
|
subdir_name = base_noext[len('test_'):]
|
|
new_name_part = os.path.basename(new_string)
|
|
new_filename = f"test_{new_name_part}.py"
|
|
new_rel = os.path.join(rel_dir, subdir_name, new_filename)
|
|
|
|
full_dst = os.path.join(base_path, new_rel)
|
|
print_verbose(f"Moving {full_src} → {full_dst}", verbose)
|
|
if not preview:
|
|
os.makedirs(os.path.dirname(full_dst), exist_ok=True)
|
|
os.rename(full_src, full_dst)
|
|
if not recursive:
|
|
break
|
|
|
|
# Line-by-line content replacement in Python files
|
|
for root, dirs, files in os.walk(base_path):
|
|
if not include_hidden:
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
files = [f for f in files if not f.startswith('.')]
|
|
for f in files:
|
|
if f.endswith('.py'):
|
|
file_path = os.path.join(root, f)
|
|
print_verbose(f"Processing Python file: {file_path}", verbose)
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8') as ff:
|
|
lines = ff.readlines()
|
|
except UnicodeDecodeError:
|
|
print_verbose(f"Warning: Unicode decode error in file {file_path}. Skipping.", verbose)
|
|
continue
|
|
old_slash = old_string.replace('/', os.sep)
|
|
new_dot = new_string.replace('/', '.')
|
|
changed = False
|
|
for idx, line in enumerate(lines):
|
|
if old_slash in line:
|
|
start = max(0, idx - 3)
|
|
end = min(len(lines), idx + 4)
|
|
print(f"\nContext in {file_path}, line {idx+1}:")
|
|
for i in range(start, end):
|
|
prefix = '>' if i == idx else ' '
|
|
print(f"{prefix} {i+1}: {lines[i].rstrip()}")
|
|
|
|
if auto_path:
|
|
choice = '1'
|
|
else:
|
|
choice = None
|
|
while choice not in ('1', '2'):
|
|
choice = input(
|
|
f"Replace this line:\n"
|
|
f" 1) slash-based: '{old_slash}' → '{new_string}'\n"
|
|
f" 2) dot-based: '{old_slash}' → '{new_dot}'\n"
|
|
f"Choose [1/2]: "
|
|
).strip()
|
|
if choice == '1':
|
|
lines[idx] = line.replace(old_slash, new_string)
|
|
else:
|
|
lines[idx] = line.replace(old_slash, new_dot)
|
|
changed = True
|
|
print_verbose(f"Replaced line {idx+1} in {file_path}", verbose)
|
|
if changed and not preview:
|
|
with open(file_path, 'w', encoding='utf-8') as fw:
|
|
fw.writelines(lines)
|
|
if not recursive:
|
|
break
|
|
|
|
# Exit early if only path-mode is requested
|
|
if not (rename_files or replace_in_content):
|
|
return
|
|
|
|
# Fallback operations: replace content, rename files, rename folders
|
|
folders_to_rename = []
|
|
for root, dirs, files in os.walk(base_path):
|
|
if not include_hidden:
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
files = [f for f in files if not f.startswith('.')]
|
|
|
|
if replace_in_content:
|
|
for f in files:
|
|
replace_content(os.path.join(root, f), old_string, new_string, preview, verbose)
|
|
|
|
if rename_files:
|
|
for f in files:
|
|
if old_string in f:
|
|
src = os.path.join(root, f)
|
|
dst = os.path.join(root, f.replace(old_string, new_string))
|
|
print_verbose(f"Renaming file: {src} → {dst}", verbose)
|
|
if not preview:
|
|
os.rename(src, dst)
|
|
|
|
if rename_folders:
|
|
for d in dirs:
|
|
if old_string in d:
|
|
src = os.path.join(root, d)
|
|
dst = os.path.join(root, d.replace(old_string, new_string))
|
|
folders_to_rename.append((src, dst))
|
|
|
|
if not recursive:
|
|
break
|
|
|
|
for src, dst in folders_to_rename:
|
|
print_verbose(f"Renaming directory: {src} → {dst}", verbose)
|
|
if not preview:
|
|
os.rename(src, dst)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Bulk string replacer with optional full-path moves."
|
|
)
|
|
parser.add_argument('paths', nargs='+', help="Base directories to process.")
|
|
parser.add_argument('old_string', help="String or relative path to replace.")
|
|
parser.add_argument('-n', '--new', dest='new_string', default='',
|
|
help="Replacement string or new relative path.")
|
|
parser.add_argument('-r', '--recursive', action='store_true',
|
|
help="Recurse into subdirectories.")
|
|
parser.add_argument('-F', '--folders', action='store_true',
|
|
help="Rename folder names.")
|
|
parser.add_argument('-f', '--files', action='store_true',
|
|
help="Rename file names.")
|
|
parser.add_argument('-c', '--content', action='store_true',
|
|
help="Replace inside file contents.")
|
|
parser.add_argument('-p', '--preview', action='store_true',
|
|
help="Preview only; no changes.")
|
|
parser.add_argument('-P', '--path', dest='rename_paths', action='store_true',
|
|
help="Treat old_string as path and move matching items.")
|
|
parser.add_argument('-y', '--yes', dest='auto_path', action='store_true',
|
|
help="Skip prompts and apply default replacements.")
|
|
parser.add_argument('-v', '--verbose', action='store_true',
|
|
help="Verbose mode.")
|
|
parser.add_argument('-H', '--hidden', action='store_true',
|
|
help="Include hidden files and folders.")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.rename_paths and args.folders:
|
|
parser.error("Cannot use --path and --folders together.")
|
|
|
|
base_paths = [os.path.expanduser(p) for p in args.paths]
|
|
for base in base_paths:
|
|
print_verbose(f"Processing: {base}", args.verbose)
|
|
process_directory(
|
|
base_path=base,
|
|
old_string=args.old_string,
|
|
new_string=args.new_string,
|
|
recursive=args.recursive,
|
|
rename_folders=args.folders,
|
|
rename_files=args.files,
|
|
replace_in_content=args.content,
|
|
preview=args.preview,
|
|
verbose=args.verbose,
|
|
include_hidden=args.hidden,
|
|
rename_paths=args.rename_paths,
|
|
auto_path=args.auto_path
|
|
)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|