用 Python 批量重命名文件:默认预览、冲突检查与显式执行
下载了一批文件,文件名里全是空格,想统一改成下划线,又担心覆盖已有文件。把 rename() 直接放进循环,可能改到一半才发现重名。
本文写一个只用 Python 标准库的命令行工具:默认只打印计划,先检查整批冲突,加上 --apply 才执行。规则固定为把文件名中的普通空格替换成 _,方便先学会预览、校验、执行的完整流程。
1. 确定处理范围
只处理指定目录第一层的普通文件,包括隐藏文件;跳过子目录、符号链接和其他特殊文件。文件内容不变,扩展名也不会被单独修改;连续两个空格会变成两个下划线,制表符和全角空格保持原样。
使用 Linux、Python 3.10 或更新版本,无第三方依赖。示例已在 Linux、Python 3.11.4 下验证。目录必须由你控制,运行期间先停止下载器和其他写入程序;预览与执行是两次扫描,不是冻结的快照。
重要文件先备份,并参考检查 ZIP 备份是否完整验证副本。不要对正在使用的 Hugo 内容目录直接运行;改名还可能影响页面路径和其他引用。
2. 保存完整脚本
把下面代码保存为 rename_spaces.py,放在待处理目录之外。
"""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() 完成全部扫描后才返回,因此发现冲突时还没有执行任何重命名。目标名称被文件、目录或失效符号链接占用都会拒绝;多个源文件映射到同一目标也会拒绝。错误统一返回退出码 1,参数用法错误由 argparse 返回 2,成功预览或执行返回 0。
Python 官方文档说明,Path.lstat()检查链接本身,而 --apply 使用的 store_true在没有传入开关时默认为假。这样可以把“只看计划”作为默认行为。
3. 方法一:先预览
- 在一个新的工作目录保存脚本,再逐条执行以下命令。演示目录必须尚不存在;不要重复执行创建文件的命令,以免覆盖演示数据。
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
- 最后一条命令的预期输出如下,两个原始文件名都还在:
{"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
计划使用 JSON 行展示文件名,特殊字符会被转义,避免文件名中的换行混进输出。目录排序保证同一批输入的显示顺序稳定。
4. 方法二:确认后执行并验证
- 核对计划后,传入
--apply,再检查文件名和内容:
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
- 第一次执行会输出两条
RENAMED和DONE: 2 file(s);验证代码应输出OK: names changed; contents preserved。最后一次执行应输出:
PLAN: 0 file(s)
DONE: 0 file(s)
这条规则再次运行不会继续改名。想核对大量文件的内容,可参考生成 SHA-256 清单;清单按路径记录,重命名后需要按已确认的名称映射对照,不能直接把路径差异当作内容损坏。
5. 验证重名时整批停止
在同一个工作目录中逐条运行。这里故意触发退出码 1,不要放进遇错立即退出的脚本。
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'
预期错误和内容如下;错误写到 stderr,终端上与 stdout 的交错顺序可能不同:
ERROR after 0 rename(s): target collision: 'a_b.txt'
exit=1
original
keep
这证明预检拒绝了目标重名,原来的两个文件仍在。若目标是一个指向不存在文件的符号链接,也应报告冲突,而不会把它当成空闲名称。
6. 排错与执行边界
| 现象 | 具体处理 |
|---|---|
target collision |
检查输出的目标名;为冲突文件另选名字,重新预览。不要直接删除它。 |
Permission denied |
用 ls -ld rename-demo 检查目录权限,确认以拥有目录写入和搜索权限的用户运行。 |
directory changed |
停止其他写入程序,检查已输出的 RENAMED,重新预览剩余文件。 |
No such file or directory |
核对路径;自动化中使用绝对路径。 |
| 文件没有出现在计划里 | 检查是否为普通文件,以及文件名是否真的包含普通空格。 |
Python 的 Path.rename() 文档说明,在 Unix 上目标文件已存在时可能被覆盖。脚本会在规划阶段和每次操作前检查目标,但检查与改名之间仍有时间窗口,不能保证并发写入场景下绝不覆盖。
整批改名也不是事务。 如果第三个文件操作失败,前两个已经改好,不会自动回滚;错误中的计数和 RENAMED 日志可用于核对。暂停写入、先备份、在小目录测试,是执行这类批处理的前提。
需要定时执行时,参考固定 Python 路径、工作目录和环境变量。先在终端验证绝对路径下的预览与执行,日志放在处理目录外,并保留非零退出状态。
7. 总结
把批量改名拆成生成计划、检查冲突和显式执行,先用演示目录验证结果。正式执行前保存副本并暂停其他写入程序;执行失败时先检查已完成的日志,再决定如何处理剩余文件。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-batch-rename-dry-run.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。