Added question if replace should be dot or slash based

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-10 19:57:11 +02:00
parent c54cb81812
commit 40ff300a92
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
2 changed files with 84 additions and 46 deletions

61
main.py
View File

@ -24,17 +24,19 @@ def print_verbose(message, verbose):
def process_directory(base_path, old_string, new_string, recursive, def process_directory(base_path, old_string, new_string, recursive,
rename_folders, rename_files, replace_in_content, 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: Traverse directory tree and perform operations based on flags:
- replace_in_content: replace inside file contents - replace_in_content: replace inside file contents
- rename_files: rename files whose names contain old_string - rename_files: rename files whose names contain old_string
- rename_folders: rename folders 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. - 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 # Full-path move logic
if rename_paths: if rename_paths:
# Move matching files and folders
for root, dirs, files in os.walk(base_path): for root, dirs, files in os.walk(base_path):
if not include_hidden: if not include_hidden:
dirs[:] = [d for d in dirs if not d.startswith('.')] 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) os.rename(full_src, full_dst)
if not recursive: if not recursive:
break 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): for root, dirs, files in os.walk(base_path):
if not include_hidden: if not include_hidden:
dirs[:] = [d for d in dirs if not d.startswith('.')] dirs[:] = [d for d in dirs if not d.startswith('.')]
files = [f for f in files if not f.startswith('.')] files = [f for f in files if not f.startswith('.')]
for f in files: for f in files:
if f.endswith('.py'): if f.endswith('.py'):
replace_content( file_path = os.path.join(root, f)
os.path.join(root, f), print_verbose(f"Processing Python file for path replacement: {file_path}", verbose)
old_string.replace('/', os.sep), try:
new_string.replace('/', '.'), with open(file_path, 'r', encoding='utf-8') as ff:
preview, lines = ff.readlines()
verbose 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: if not recursive:
break break
# Only return early when only path-mode is active # Only return early when only path-mode is active
@ -130,6 +166,8 @@ def main():
help="Preview only; no changes.") help="Preview only; no changes.")
parser.add_argument('-P', '--path', dest='rename_paths', action='store_true', 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="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', parser.add_argument('-v', '--verbose', action='store_true',
help="Verbose mode.") help="Verbose mode.")
parser.add_argument('-H', '--hidden', action='store_true', parser.add_argument('-H', '--hidden', action='store_true',
@ -155,7 +193,8 @@ def main():
preview=args.preview, preview=args.preview,
verbose=args.verbose, verbose=args.verbose,
include_hidden=args.hidden, include_hidden=args.hidden,
rename_paths=args.rename_paths rename_paths=args.rename_paths,
auto_path=args.auto_path
) )
if __name__ == '__main__': if __name__ == '__main__':

69
test.py
View File

@ -37,9 +37,8 @@ class TestBulkStringReplacer(unittest.TestCase):
rename_folders=False, rename_files=True, rename_folders=False, rename_files=True,
replace_in_content=False, replace_in_content=False,
preview=False, verbose=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.assertTrue(os.path.exists(os.path.join(self.base, 'NEWfile.txt')))
self.assertFalse(os.path.exists(f)) self.assertFalse(os.path.exists(f))
@ -52,16 +51,14 @@ class TestBulkStringReplacer(unittest.TestCase):
rename_folders=True, rename_files=False, rename_folders=True, rename_files=False,
replace_in_content=False, replace_in_content=False,
preview=False, verbose=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.assertTrue(os.path.isdir(os.path.join(self.base, 'NEWfolder')))
self.assertFalse(os.path.isdir(os.path.join(self.base, 'OLDfolder'))) self.assertFalse(os.path.isdir(os.path.join(self.base, 'OLDfolder')))
def test_full_path_move(self): def test_full_path_move(self):
# prepare vars/configuration.yml
cfg = 'vars/configuration.yml' cfg = 'vars/configuration.yml'
full = self.create_file(cfg, 'DATA') full = self.create_file(cfg, 'DATA')
# run with -P
process_directory( process_directory(
base_path=self.base, base_path=self.base,
old_string='vars/configuration.yml', old_string='vars/configuration.yml',
@ -70,28 +67,21 @@ class TestBulkStringReplacer(unittest.TestCase):
rename_folders=False, rename_files=False, rename_folders=False, rename_files=False,
replace_in_content=False, replace_in_content=False,
preview=False, verbose=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.assertFalse(os.path.exists(full))
self.assertTrue(os.path.exists(os.path.join(self.base, 'config', 'main.yml'))) self.assertTrue(os.path.exists(os.path.join(self.base, 'config', 'main.yml')))
def test_path_and_folders_conflict(self): def test_path_and_folders_conflict(self):
# simulate CLI error when combining --path and --folders
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
# replicate only the conflict check
parser.add_argument('-P', dest='rename_paths', action='store_true') parser.add_argument('-P', dest='rename_paths', action='store_true')
parser.add_argument('-F', dest='folders', action='store_true') parser.add_argument('-F', dest='folders', action='store_true')
args = parser.parse_args(['-P', '-F']) args = parser.parse_args(['-P', '-F'])
# manual conflict with self.assertRaises(SystemExit):
with self.assertRaises(SystemExit) as cm:
# mimic the parser.error behavior
if args.rename_paths and args.folders: if args.rename_paths and args.folders:
parser.error("Cannot use --path and --folders together.") parser.error("Cannot use --path and --folders together.")
self.assertNotEqual(cm.exception.code, 0)
def test_preview_does_nothing(self): def test_preview_does_nothing(self):
# create file and folder matching
f = self.create_file('OLD.txt', 'OLD') f = self.create_file('OLD.txt', 'OLD')
os.makedirs(os.path.join(self.base, 'OLDdir')) os.makedirs(os.path.join(self.base, 'OLDdir'))
process_directory( process_directory(
@ -101,62 +91,71 @@ class TestBulkStringReplacer(unittest.TestCase):
rename_folders=True, rename_files=True, rename_folders=True, rename_files=True,
replace_in_content=True, replace_in_content=True,
preview=True, verbose=False, 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.exists(f))
self.assertTrue(os.path.isdir(os.path.join(self.base, 'OLDdir'))) self.assertTrue(os.path.isdir(os.path.join(self.base, 'OLDdir')))
with open(f, 'r', encoding='utf-8') as fp: with open(f, 'r', encoding='utf-8') as fp:
self.assertIn('OLD', fp.read()) self.assertIn('OLD', fp.read())
def test_module_path_replacement_in_python_files(self): def test_module_path_replacement_in_python_files(self):
# Prepare a nested Python module under the old path
content = 'from old/path import func' content = 'from old/path import func'
src = self.create_file('old/path/module.py', content) 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( process_directory(
base_path=self.base, base_path=self.base,
old_string='old/path', old_string='old/path', new_string='old.path',
new_string='old.path',
recursive=True, recursive=True,
rename_folders=False, rename_files=False, rename_folders=False, rename_files=False,
replace_in_content=False, replace_in_content=False,
preview=False, verbose=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') new_path = os.path.join(self.base, 'old.path', 'module.py')
self.assertTrue(os.path.exists(new_path)) self.assertTrue(os.path.exists(new_path))
# Its content should have been updated
with open(new_path, 'r', encoding='utf-8') as fp: with open(new_path, 'r', encoding='utf-8') as fp:
self.assertIn('from old.path import func', fp.read()) self.assertIn('from old.path import func', fp.read())
def test_non_python_files_are_not_content_updated(self): 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' content = 'some reference to old/path in text'
txt = self.create_file('old/path/readme.txt', content) txt = self.create_file('old/path/readme.txt', content)
# Run with -P: .txt files get moved but their content stays the same
process_directory( process_directory(
base_path=self.base, base_path=self.base,
old_string='old/path', old_string='old/path', new_string='old.path',
new_string='old.path',
recursive=True, recursive=True,
rename_folders=False, rename_files=False, rename_folders=False, rename_files=False,
replace_in_content=False, replace_in_content=False,
preview=False, verbose=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') new_txt = os.path.join(self.base, 'old.path', 'readme.txt')
self.assertTrue(os.path.exists(new_txt)) self.assertTrue(os.path.exists(new_txt))
with open(new_txt, 'r', encoding='utf-8') as fp: with open(new_txt, 'r', encoding='utf-8') as fp:
self.assertIn('old/path', fp.read()) 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__': if __name__ == '__main__':
unittest.main() unittest.main()