Added path renaming to bsr

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-09 01:00:55 +02:00
parent 792732b44b
commit 5ffb317dbf
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
6 changed files with 250 additions and 121 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*__pycache__

4
Makefile Normal file
View File

@ -0,0 +1,4 @@
.PHONY: test
test:
python3 -m unittest test.py

View File

@ -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.
---

0
__init__.py Normal file
View File

190
main.py
View File

@ -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()

115
test.py Normal file
View File

@ -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()