From 40ff300a928f4ef59a19dd987af9c48cf79a40cc Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 10 Jul 2025 19:57:11 +0200 Subject: [PATCH] Added question if replace should be dot or slash based --- main.py | 61 +++++++++++++++++++++++++++++++++++++++++--------- test.py | 69 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/main.py b/main.py index 0eab753..21996eb 100755 --- a/main.py +++ b/main.py @@ -24,17 +24,19 @@ def print_verbose(message, verbose): def process_directory(base_path, old_string, new_string, recursive, rename_folders, rename_files, replace_in_content, - preview, verbose, include_hidden, rename_paths): + preview, verbose, include_hidden, rename_paths, auto_path): """ Traverse 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. - Additionally, when rename_paths is set, update module paths in Python files by replacing path separators with dots. + 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. """ # Full-path move logic if rename_paths: + # Move matching files and folders for root, dirs, files in os.walk(base_path): if not include_hidden: dirs[:] = [d for d in dirs if not d.startswith('.')] @@ -51,20 +53,54 @@ def process_directory(base_path, old_string, new_string, recursive, os.rename(full_src, full_dst) if not recursive: break - # After moving, replace module paths in Python files + # Line-by-line 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'): - replace_content( - os.path.join(root, f), - old_string.replace('/', os.sep), - new_string.replace('/', '.'), - preview, - verbose - ) + file_path = os.path.join(root, f) + print_verbose(f"Processing Python file for path replacement: {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: + # Show context + start = max(0, idx - 3) + end = min(len(lines), idx + 4) + print(f"\nContext for replacement 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: + 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 # Only return early when only path-mode is active @@ -130,6 +166,8 @@ def main(): 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.") + parser.add_argument('-y', '--yes', dest='auto_path', action='store_true', + help="Skip prompts in Python files and apply slash-based replacement by default.") parser.add_argument('-v', '--verbose', action='store_true', help="Verbose mode.") parser.add_argument('-H', '--hidden', action='store_true', @@ -155,7 +193,8 @@ def main(): preview=args.preview, verbose=args.verbose, include_hidden=args.hidden, - rename_paths=args.rename_paths + rename_paths=args.rename_paths, + auto_path=args.auto_path ) if __name__ == '__main__': diff --git a/test.py b/test.py index 27b0e92..1216779 100644 --- a/test.py +++ b/test.py @@ -37,9 +37,8 @@ class TestBulkStringReplacer(unittest.TestCase): rename_folders=False, rename_files=True, replace_in_content=False, preview=False, verbose=False, - include_hidden=True, rename_paths=False + include_hidden=True, rename_paths=False, auto_path=True ) - # file moved self.assertTrue(os.path.exists(os.path.join(self.base, 'NEWfile.txt'))) self.assertFalse(os.path.exists(f)) @@ -52,16 +51,14 @@ class TestBulkStringReplacer(unittest.TestCase): rename_folders=True, rename_files=False, replace_in_content=False, preview=False, verbose=False, - include_hidden=True, rename_paths=False + include_hidden=True, rename_paths=False, auto_path=True ) self.assertTrue(os.path.isdir(os.path.join(self.base, 'NEWfolder'))) self.assertFalse(os.path.isdir(os.path.join(self.base, 'OLDfolder'))) def test_full_path_move(self): - # prepare vars/configuration.yml cfg = 'vars/configuration.yml' full = self.create_file(cfg, 'DATA') - # run with -P process_directory( base_path=self.base, old_string='vars/configuration.yml', @@ -70,28 +67,21 @@ class TestBulkStringReplacer(unittest.TestCase): rename_folders=False, rename_files=False, replace_in_content=False, preview=False, verbose=False, - include_hidden=True, rename_paths=True + include_hidden=True, rename_paths=True, auto_path=True ) - # original gone, new exists self.assertFalse(os.path.exists(full)) self.assertTrue(os.path.exists(os.path.join(self.base, 'config', 'main.yml'))) def test_path_and_folders_conflict(self): - # simulate CLI error when combining --path and --folders parser = argparse.ArgumentParser() - # replicate only the conflict check parser.add_argument('-P', dest='rename_paths', action='store_true') parser.add_argument('-F', dest='folders', action='store_true') args = parser.parse_args(['-P', '-F']) - # manual conflict - with self.assertRaises(SystemExit) as cm: - # mimic the parser.error behavior + with self.assertRaises(SystemExit): if args.rename_paths and args.folders: parser.error("Cannot use --path and --folders together.") - self.assertNotEqual(cm.exception.code, 0) def test_preview_does_nothing(self): - # create file and folder matching f = self.create_file('OLD.txt', 'OLD') os.makedirs(os.path.join(self.base, 'OLDdir')) process_directory( @@ -101,62 +91,71 @@ class TestBulkStringReplacer(unittest.TestCase): rename_folders=True, rename_files=True, replace_in_content=True, preview=True, verbose=False, - include_hidden=True, rename_paths=True + include_hidden=True, rename_paths=True, auto_path=True ) - # nothing changed self.assertTrue(os.path.exists(f)) self.assertTrue(os.path.isdir(os.path.join(self.base, 'OLDdir'))) with open(f, 'r', encoding='utf-8') as fp: self.assertIn('OLD', fp.read()) def test_module_path_replacement_in_python_files(self): - # Prepare a nested Python module under the old path content = 'from old/path import func' src = self.create_file('old/path/module.py', content) - - # Run with -P: should move the file and replace path separators in .py files process_directory( base_path=self.base, - old_string='old/path', - new_string='old.path', + old_string='old/path', new_string='old.path', recursive=True, rename_folders=False, rename_files=False, replace_in_content=False, preview=False, verbose=False, - include_hidden=True, rename_paths=True + include_hidden=True, rename_paths=True, auto_path=True ) - - # The file should have been moved new_path = os.path.join(self.base, 'old.path', 'module.py') self.assertTrue(os.path.exists(new_path)) - - # Its content should have been updated with open(new_path, 'r', encoding='utf-8') as fp: self.assertIn('from old.path import func', fp.read()) def test_non_python_files_are_not_content_updated(self): - # Prepare a non-Python file under the old path content = 'some reference to old/path in text' txt = self.create_file('old/path/readme.txt', content) - - # Run with -P: .txt files get moved but their content stays the same process_directory( base_path=self.base, - old_string='old/path', - new_string='old.path', + old_string='old/path', new_string='old.path', recursive=True, rename_folders=False, rename_files=False, replace_in_content=False, preview=False, verbose=False, - include_hidden=True, rename_paths=True + include_hidden=True, rename_paths=True, auto_path=True ) - - # The .txt should be moved but its content unchanged new_txt = os.path.join(self.base, 'old.path', 'readme.txt') self.assertTrue(os.path.exists(new_txt)) with open(new_txt, 'r', encoding='utf-8') as fp: self.assertIn('old/path', fp.read()) + def test_auto_path_line_level_replacement(self): + # create a Python file with two occurrences + lines = [ + 'import a\n', + 'path = "old/path/to/module"\n', + 'print("old/path/example")\n' + ] + py = self.create_file('old/path/test.py', ''.join(lines)) + process_directory( + base_path=self.base, + old_string='old/path', new_string='new/path', + 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 + ) + new_py = os.path.join(self.base, 'new', 'path', 'test.py') + self.assertTrue(os.path.exists(new_py)) + with open(new_py, 'r', encoding='utf-8') as fp: + content = fp.read() + # all replaced slash-based + self.assertIn('path = "new/path/to/module"', content) + self.assertIn('print("new/path/example")', content) if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file