Batch Rename Files with Python: Preview, Check Collisions, Then Apply
A download folder contains filenames with spaces, and you want underscores without replacing existing files. Calling rename() inside a loop can reveal a collision only after earlier files have already moved.
This tutorial builds a standard-library CLI that previews by default, checks the entire batch for collisions, and requires --apply to rename files. Its fixed rule replaces ordinary spaces in filenames with _, making the plan, validation, and execution steps easy to inspect.
1. Define the scope
Process regular files directly inside one directory, including hidden files. Skip subdirectories, symlinks, and special files. Contents stay unchanged, and extensions receive no separate treatment. Two spaces become two underscores; tabs and full-width spaces stay unchanged.
Use Linux and Python 3.10 or newer, with no third-party packages. The examples were tested on Linux with Python 3.11.4. Use a directory you control, and stop downloaders and other writers first. Preview and apply scan independently; a preview does not freeze the directory.
Back up important files and verify the ZIP backup. Avoid running directly on an active Hugo content directory: renaming can also affect page paths and references.
2. Save the complete script
Save the following as rename_spaces.py, outside the directory being processed.
"""Preview or apply space-to-underscore renames. Python 3.10+, Linux."""
import argparse
import json
from pathlib import Path
import stat
import sys
def occupied(path):
try:
path.lstat() # A dangling symlink also occupies a name.
except FileNotFoundError:
return False
return True
def make_plan(directory):
if not directory.is_dir():
raise ValueError(f"not a directory: {directory}")
plan = []
targets = set()
for source in sorted(directory.iterdir()):
if not stat.S_ISREG(source.lstat().st_mode):
continue
name = source.name.replace(" ", "_")
if name == source.name:
continue
target = source.with_name(name)
if target in targets or occupied(target):
raise ValueError(f"target collision: {target.name!r}")
targets.add(target)
plan.append((source, target))
return plan
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
parser.add_argument("--apply", action="store_true", help="perform the renames")
args = parser.parse_args()
completed = 0
try:
plan = make_plan(args.directory.resolve(strict=True))
for source, target in plan:
print(json.dumps({"from": source.name, "to": target.name}), flush=True)
print(f"PLAN: {len(plan)} file(s)", flush=True)
if not args.apply:
print("DRY RUN: no files renamed")
return 0
for source, target in plan:
if not stat.S_ISREG(source.lstat().st_mode) or occupied(target):
raise ValueError("directory changed; stop and inspect")
source.rename(target)
completed += 1
print(f"RENAMED: {json.dumps(target.name)}", flush=True)
except (OSError, ValueError) as exc:
print(f"ERROR after {completed} rename(s): {exc}", file=sys.stderr)
return 1
print(f"DONE: {completed} file(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
make_plan() finishes scanning before returning, so a planning collision stops the batch before any rename. A file, directory, or dangling symlink occupying a destination causes rejection, as do multiple sources mapping to one target. Operational errors return 1, argparse usage errors return 2, and successful preview or apply returns 0.
The official documentation explains that Path.lstat() inspects the link itself. The store_true action used for --apply defaults to false, keeping preview as the default behavior.
3. Method 1: Preview first
- Save the script in a fresh working directory, then run these commands individually. The demo directory must not exist yet. Do not repeat the file-creation commands over an existing demo.
python3 --version
python3 rename_spaces.py --help
mkdir rename-demo
printf 'alpha\n' > 'rename-demo/report one.txt'
printf 'beta\n' > 'rename-demo/report two.txt'
python3 rename_spaces.py rename-demo
- The last command should print the following, while both original filenames remain:
{"from": "report one.txt", "to": "report_one.txt"}
{"from": "report two.txt", "to": "report_two.txt"}
PLAN: 2 file(s)
DRY RUN: no files renamed
The plan displays filenames as JSON lines, escaping special characters so embedded newlines cannot split a name across log entries. Sorting makes display order stable for the same inputs.
4. Method 2: Apply and verify
- Review the plan, pass
--apply, then check filenames and contents:
python3 rename_spaces.py rename-demo --apply
python3 - <<'PYCODE'
from pathlib import Path
root = Path("rename-demo")
assert sorted(p.name for p in root.iterdir()) == ["report_one.txt", "report_two.txt"]
assert (root / "report_one.txt").read_text() == "alpha\n"
assert (root / "report_two.txt").read_text() == "beta\n"
print("OK: names changed; contents preserved")
PYCODE
python3 rename_spaces.py rename-demo --apply
- The first run prints two
RENAMEDlines andDONE: 2 file(s). The check printsOK: names changed; contents preserved. The final run should print:
PLAN: 0 file(s)
DONE: 0 file(s)
Repeating this rule makes no further changes. For larger collections, generate a SHA-256 manifest to check contents. That manifest records paths, so compare using the approved name mapping after a rename; a path difference alone does not imply content damage.
5. Verify that collisions stop the batch
Run these commands individually in the same working directory. This deliberately returns 1; do not put it in a script that exits immediately on errors.
mkdir rename-conflict
printf 'original\n' > 'rename-conflict/a b.txt'
printf 'keep\n' > 'rename-conflict/a_b.txt'
python3 rename_spaces.py rename-conflict --apply
printf 'exit=%s\n' "$?"
cat 'rename-conflict/a b.txt' 'rename-conflict/a_b.txt'
Expected error and contents follow. The error goes to stderr, so its position relative to stdout can vary in a terminal:
ERROR after 0 rename(s): target collision: 'a_b.txt'
exit=1
original
keep
The preflight rejects the occupied target and preserves both original files. A destination symlink pointing to a missing file must also cause a collision rather than count as an available name.
6. Troubleshooting and execution limits
| Symptom | Action |
|---|---|
target collision |
Inspect the destination name, choose another name for the conflict, and preview again. Do not simply delete it. |
Permission denied |
Inspect directory permissions with ls -ld rename-demo; run as a user with write and search permission on that directory. |
directory changed |
Stop competing writers, inspect the completed RENAMED entries, and preview the remaining files again. |
No such file or directory |
Check the path; use absolute paths in automation. |
| A file is missing from the plan | Check that it is a regular file whose name contains an ordinary space. |
The Path.rename() documentation explains that an existing destination file can be replaced on Unix. This script checks during planning and before each operation, but a time window remains between checking and renaming. It cannot guarantee protection against replacement when other processes write concurrently.
A batch is not a transaction. If the third rename fails, the first two remain renamed; there is no automatic rollback. Use the error count and RENAMED log to inspect progress. Stop writers, keep a backup, and test a small directory before applying the tool to real files.
For scheduled use, see fixing Python paths, working directories, and environment variables. Verify preview and apply with absolute paths in a terminal first, keep logs outside the processed directory, and preserve nonzero exit statuses.
7. Summary
Split batch renaming into planning, collision checks, and explicit execution, and verify the result in a demo directory first. Keep a backup and stop other writers before applying changes to real files. After a failure, inspect the completed rename log before deciding what to do with the remaining files.
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/en/python-batch-rename-dry-run.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
相关文章
- Python SIGTERM Shutdown: Finish the Current Record Before Stopping
- Fix Linux Script Startup Errors: Diagnose CRLF and BOM with Python
- Check TLS Certificate Expiry with Python: Verification, Warnings, and Local Tests
- Probe TCP Ports with Python: Distinguish Refusal, Timeout, and System Errors
- Check ZIP Backups with Python: CRC, Duplicate Members, and Missing Files