diff --git a/main.py b/main.py index 21996eb..3852962 100755 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ 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. @@ -18,25 +19,37 @@ def replace_content(path, old_string, new_string, preview, verbose): 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): + +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 directory tree and perform operations based on flags: + 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: match old_string as a relative path and move matching items to new_string path. - When rename_paths is set, for each matching line in Python files, show 3 lines of context before and after, - and prompt whether to apply slash-based or dot-based replacement, unless auto_path is True. + - 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. """ - # Full-path move logic if rename_paths: - # Move matching files and folders + # 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('.')] @@ -46,6 +59,29 @@ def process_directory(base_path, old_string, new_string, recursive, 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: @@ -53,7 +89,8 @@ def process_directory(base_path, old_string, new_string, recursive, os.rename(full_src, full_dst) if not recursive: break - # Line-by-line replacement in Python files + + # 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('.')] @@ -61,7 +98,7 @@ def process_directory(base_path, old_string, new_string, recursive, for f in files: if f.endswith('.py'): file_path = os.path.join(root, f) - print_verbose(f"Processing Python file for path replacement: {file_path}", verbose) + print_verbose(f"Processing Python file: {file_path}", verbose) try: with open(file_path, 'r', encoding='utf-8') as ff: lines = ff.readlines() @@ -73,14 +110,13 @@ def process_directory(base_path, old_string, new_string, recursive, changed = False for idx, line in enumerate(lines): if old_slash in line: - # Show context start = max(0, idx - 3) end = min(len(lines), idx + 4) - print(f"\nContext for replacement in {file_path}, line {idx+1}:") + 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()}") - # Determine replacement style + if auto_path: choice = '1' else: @@ -103,23 +139,22 @@ def process_directory(base_path, old_string, new_string, recursive, fw.writelines(lines) if not recursive: break - # Only return early when only path-mode is active + + # Exit early if only path-mode is requested if not (rename_files or replace_in_content): return - # Collect folder renames to apply after traversal + # 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('.')] - # Content replacement if replace_in_content: for f in files: replace_content(os.path.join(root, f), old_string, new_string, preview, verbose) - # File renaming if rename_files: for f in files: if old_string in f: @@ -129,7 +164,6 @@ def process_directory(base_path, old_string, new_string, recursive, if not preview: os.rename(src, dst) - # Gather folder renames if rename_folders: for d in dirs: if old_string in d: @@ -140,12 +174,12 @@ def process_directory(base_path, old_string, new_string, recursive, if not recursive: break - # Apply folder renames 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." @@ -165,9 +199,9 @@ def main(): 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="Match old_string as relative path and move to new_string path.") + help="Treat old_string as path and move matching items.") parser.add_argument('-y', '--yes', dest='auto_path', action='store_true', - help="Skip prompts in Python files and apply slash-based replacement by default.") + 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', @@ -175,7 +209,6 @@ def main(): args = parser.parse_args() - # Disallow using --path and --folders together if args.rename_paths and args.folders: parser.error("Cannot use --path and --folders together.") diff --git a/test.py b/test.py index 1216779..5a06737 100644 --- a/test.py +++ b/test.py @@ -22,6 +22,57 @@ class TestBulkStringReplacer(unittest.TestCase): f.write(content) return full + def test_test_prefix_extraction_prompt_no_auto(self): + # Setup a test file with test_ prefix + src = self.create_file('dir/test_module.py', 'print("hello")') + # Monkeypatch input to simulate 'y' + inputs = iter(['y']) + original_input = __builtins__['input'] + __builtins__['input'] = lambda _: next(inputs) + try: + process_directory( + base_path=self.base, + old_string='module', + new_string='module_new', + recursive=True, + rename_folders=False, + rename_files=False, + replace_in_content=False, + preview=False, + verbose=False, + include_hidden=True, + rename_paths=True, + auto_path=False + ) + finally: + __builtins__['input'] = original_input + + # After extraction, expect dir/module/test_module_new.py + dst = os.path.join(self.base, 'dir', 'module', 'test_module_new.py') + self.assertTrue(os.path.exists(dst)) + + def test_test_prefix_default_no_extraction_auto(self): + # Setup a test file with test_ prefix + src = self.create_file('dir/test_file.py', 'print("hello")') + process_directory( + base_path=self.base, + old_string='file', + new_string='file_new', + recursive=True, + rename_folders=False, + rename_files=False, + replace_in_content=False, + preview=False, + verbose=False, + include_hidden=True, + rename_paths=True, + auto_path=True + ) + # With auto_path, should do simple path replace: dir/test_file.py -> dir/test_file_new.py + dst = os.path.join(self.base, 'dir', 'test_file_new.py') + self.assertTrue(os.path.exists(dst)) + + def test_replace_content(self): f = self.create_file('foo.txt', 'hello OLD world') replace_content(f, 'OLD', 'NEW', preview=False, verbose=False)