From 5ffb317dbf2b653c3b165e8ede45f769d4d08383 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 9 Jul 2025 01:00:55 +0200 Subject: [PATCH] Added path renaming to bsr --- .gitignore | 1 + Makefile | 4 ++ README.md | 61 ++++++++++------- __init__.py | 0 main.py | 190 +++++++++++++++++++++++++--------------------------- test.py | 115 +++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 121 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 __init__.py create mode 100644 test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..264daca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*__pycache__ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..85c716f --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test + +test: + python3 -m unittest test.py \ No newline at end of file diff --git a/README.md b/README.md index 10a1dd5..e5dc122 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bulk String Replacer CLI (bsr) πŸ”„ -[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) +[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html) [![GitHub stars](https://img.shields.io/github/stars/kevinveenbirkenbach/bulk-string-replacer.svg?style=social)](https://github.com/kevinveenbirkenbach/bulk-string-replacer/stargazers) @@ -10,11 +10,12 @@ Bulk String Replacer CLI (bsr) is a powerful Python-based command-line tool that ## πŸ›  Features -- **Comprehensive Replacement:** Replace strings in folder names, file names, and inside file contents. -- **Recursive Processing:** Traverse directories recursively to update all matching files. -- **Hidden Files Support:** Option to include hidden files and directories. -- **Preview Mode:** Preview changes without modifying any files. -- **Verbose Output:** Display detailed logs of the operations performed. +* **Comprehensive Replacement:** Replace strings in folder names, file names, and inside file contents. +* **Recursive Processing:** Traverse directories recursively to update all matching files. +* **Hidden Files Support:** Option to include hidden files and directories. +* **Preview Mode:** Preview changes without modifying any files. +* **Verbose Output:** Display detailed logs of the operations performed. +* **Full-Path Moves:** Match an `old_string` as a relative path (including `/`) and move matching subtrees to a new location. --- @@ -36,36 +37,50 @@ This command makes the tool globally available as `bsr` in your terminal. πŸš€ Once installed, run Bulk String Replacer CLI using the alias: ```bash -bsr old_string --new-string "replacement_value" [options] [paths...] +bsr old_string -n "replacement_value" [options] [paths...] ``` ### Options -- **`old_string`**: The string to search for and replace. -- **`--new-string`**: The string that will replace `old_string` (default is an empty string). -- **`--recursive`**: Process all subdirectories and files recursively. -- **`--folder`**: Replace occurrences within folder names. -- **`--files`**: Replace occurrences within file names. -- **`--content`**: Replace occurrences inside file contents. -- **`--preview`**: Preview changes without applying them. -- **`--verbose`**: Display detailed logs during execution. -- **`--hidden`**: Include hidden files and directories in the operation. +* **`old_string`**: The string or relative path to search for. +* **`-n, --new`**: The replacement string or new relative path (default is empty string). +* **`-r, --recursive`**: Recurse into all subdirectories and files. +* **`-F, --folders`**: Replace occurrences within folder names. +* **`-f, --files`**: Replace occurrences within file names. +* **`-c, --content`**: Replace occurrences inside file contents. +* **`-P, --path`**: Match `old_string` as a relative path (e.g. `vars/config.yml`) and move matching subtree to `new` relative path. +* **`-p, --preview`**: Preview changes without applying them. +* **`-v, --verbose`**: Display detailed logs during execution. +* **`-H, --hidden`**: Include hidden files and directories in the operation. -### Example Command +### Examples + +Replace text within filenames, folder names, and file contents: ```bash -bsr "old_value" --new-string "new_value" --recursive --verbose /path/to/first/directory /path/to/second/directory +bsr "old_value" -n "new_value" -r -F -f -c /path/to/dir ``` -Replace `/path/to/first/directory` and `/path/to/second/directory` with the paths you wish to process. +Move every `vars/configuration.yml` to `config/main.yml` in each parent directory: + +```bash +bsr "vars/configuration.yml" -n "config/main.yml" -r -P ./ +``` + +Preview a full-path move without changes: + +```bash +bsr "vars/configuration.yml" -n "config/main.yml" -r -P -p ./ +``` --- ## πŸ§‘β€πŸ’» Author -Developed by **Kevin Veen-Birkenbach** -- πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) -- 🌐 [https://www.veen.world/](https://www.veen.world/) +Developed by **Kevin Veen-Birkenbach** + +* πŸ“§ [kevin@veen.world](mailto:kevin@veen.world) +* 🌐 [https://www.veen.world/](https://www.veen.world/) Learn more about the development of this tool in the [original ChatGPT conversation](https://chat.openai.com/share/cfdbc008-8374-47f8-8853-2e00ee27c959). @@ -73,7 +88,7 @@ Learn more about the development of this tool in the [original ChatGPT conversat ## πŸ“œ License -This project is licensed under the **GNU Affero General Public License, Version 3, 19 November 2007**. +This project is licensed under the **GNU Affero General Public License, Version 3, 19 November 2007**. For more details, see the [LICENSE](./LICENSE) file. --- diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 7d402c8..0784908 100755 --- a/main.py +++ b/main.py @@ -3,136 +3,130 @@ 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 f: - f.write(new_content) + 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) - except UnicodeDecodeError as e: - print_verbose(f"Warning: Unicode decode error encountered in file {path}. Skipping file.", verbose) -def print_verbose(content,verbose): +def print_verbose(message, verbose): if verbose: - print(content) - + print(message) -def process_directory(base_path, old_string, new_string, recursive, folder, files, content, preview, verbose, hidden): - # Eine Liste, um die Pfade der umzubenennenden Ordner zu speichern - directories_to_rename = [] - for root, dirs, filenames in os.walk(base_path): - # Wenn "hidden" nicht gesetzt ist, versteckte Dateien/Ordner aus der Liste entfernen - if not hidden: - dirs[:] = [d for d in dirs if not d.startswith(".")] - filenames = [f for f in filenames if not f.startswith(".")] +def process_directory(base_path, old_string, new_string, recursive, + rename_folders, rename_files, replace_in_content, + preview, verbose, include_hidden, rename_paths): + """ + Traverse directory tree at base_path and perform operations based on flags: + - replace_in_content: replace 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 to new_string + """ + # Full-path move logic + if rename_paths: + 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) + 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 + return - if content: - for f in filenames: + 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 files: - for f in filenames: + if rename_files: + for f in files: if old_string in f: - old_path = os.path.join(root, f) - new_path = os.path.join(root, f.replace(old_string, new_string)) - print_verbose(f"Renaming file from: {old_path} to: {new_path}",verbose) + 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(old_path, new_path) + os.rename(src, dst) - # Pfade von zu Γ€ndernden Ordnern speichern - if folder: + if rename_folders: for d in dirs: if old_string in d: - old_path = os.path.join(root, d) - new_path = os.path.join(root, d.replace(old_string, new_string)) - directories_to_rename.append((old_path, new_path)) + 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 - # Ordnernamen Γ€ndern nach dem os.walk() Durchlauf - for old_path, new_path in directories_to_rename: - print_verbose(f"Renaming directory from: {old_path} to: {new_path}",verbose) + for src, dst in folders_to_rename: + print_verbose(f"Renaming directory: {src} β†’ {dst}", verbose) if not preview: - os.rename(old_path, new_path) + os.rename(src, dst) + def main(): parser = argparse.ArgumentParser( - description="Replace strings in directories and files." + 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.") - # positional args - parser.add_argument( - 'paths', - nargs='+', - help="Paths in which replacements should be made." - ) - parser.add_argument( - 'old_string', - help="The string to be replaced." - ) - - # options with short and long flags - parser.add_argument( - '-n', '--new-string', - dest='new_string', - default="", - help="The string to replace with. Default is empty string." - ) - parser.add_argument( - '-r', '--recursive', - action='store_true', - help="Replace in all subdirectories and files." - ) - parser.add_argument( - '-F', '--folder', - action='store_true', - help="Replace in folder names." - ) - parser.add_argument( - '-f', '--files', - action='store_true', - help="Replace in 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 changes without replacing." - ) - parser.add_argument( - '-v', '--verbose', - action='store_true', - help="Verbose mode." - ) - parser.add_argument( - '-H', '--hidden', - action='store_true', - help="Apply to hidden files and folders." - ) + 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="Match old_string as relative path and move to new_string path.") + 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() + 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 + ) - # Use os.path.expanduser to expand the tilde to the home directory - expanded_paths = [os.path.expanduser(path) for path in args.paths] - - for path in expanded_paths: - print_verbose(f"Replacing in path: {path}",args.verbose) - process_directory(path, args.old_string, args.new_string, args.recursive, args.folder, args.files, args.content, args.preview, args.verbose, args.hidden) - -if __name__ == "__main__": +if __name__ == '__main__': + main() main() - diff --git a/test.py b/test.py new file mode 100644 index 0000000..a79cb84 --- /dev/null +++ b/test.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import os +import shutil +import tempfile +import unittest + +from main import process_directory, replace_content + +class TestBulkStringReplacer(unittest.TestCase): + def setUp(self): + # Create an isolated temporary directory for each test + self.base = tempfile.mkdtemp() + + def tearDown(self): + # Clean up when done + shutil.rmtree(self.base) + + def create_file(self, relpath, content=''): + """Helper: make a file (and any parent dirs) under self.base.""" + full = os.path.join(self.base, relpath) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, 'w', encoding='utf-8') as f: + f.write(content) + return full + + def test_replace_content(self): + f = self.create_file('foo.txt', 'hello old world') + replace_content(f, 'old', 'new', preview=False, verbose=False) + with open(f, 'r', encoding='utf-8') as fp: + self.assertIn('hello new world', fp.read()) + + def test_rename_file(self): + f = self.create_file('oldfile.txt', '') + process_directory( + base_path=self.base, + old_string='old', + new_string='new', + recursive=False, + rename_folders=False, + rename_files=True, + replace_in_content=False, + preview=False, + verbose=False, + include_hidden=True, + rename_paths=False + ) + self.assertTrue(os.path.exists(os.path.join(self.base, 'newfile.txt'))) + self.assertFalse(os.path.exists(f)) + + def test_rename_folder(self): + os.makedirs(os.path.join(self.base, 'oldfolder', 'inner')) + process_directory( + base_path=self.base, + old_string='old', + new_string='new', + recursive=False, + rename_folders=True, + rename_files=False, + replace_in_content=False, + preview=False, + verbose=False, + include_hidden=True, + rename_paths=False + ) + 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): + # Create nested path vars/configuration.yml + cfg = 'vars/configuration.yml' + full_cfg = self.create_file(cfg, 'x') + # Now move vars/configuration.yml -> config/main.yml + process_directory( + base_path=self.base, + old_string='vars/configuration.yml', + new_string='config/main.yml', + recursive=True, + rename_folders=False, + rename_files=False, + replace_in_content=False, + preview=False, + verbose=False, + include_hidden=True, + rename_paths=True + ) + # Original should be gone + self.assertFalse(os.path.exists(full_cfg)) + # New location should exist + self.assertTrue(os.path.exists(os.path.join(self.base, 'config', 'main.yml'))) + + def test_preview_mode(self): + # Create file and folder that would match + f = self.create_file('oldfile.txt', 'old') + os.makedirs(os.path.join(self.base, 'oldfolder')) + process_directory( + base_path=self.base, + old_string='old', + new_string='new', + recursive=True, + rename_folders=True, + rename_files=True, + replace_in_content=True, + preview=True, + verbose=False, + include_hidden=True, + rename_paths=True + ) + # Nothing changed + self.assertTrue(os.path.exists(f)) + self.assertTrue(os.path.isdir(os.path.join(self.base, 'oldfolder'))) + with open(f, 'r', encoding='utf-8') as fp: + self.assertIn('old', fp.read()) + +if __name__ == '__main__': + unittest.main()