前言
批量改名听起来像"循环里调一下os.rename",真写起来会遇到三个立刻见血的问题:改成什么规则(加前缀、去空格、正则替换、统一编号)、改的时候会不会撞名、以及改了一半失败了怎么办。第三个问题最要命——改名是不可逆操作,脚本跑到第 50 个文件崩了,前 49 个已经改了,剩下的一片混乱,很难手工还原。
本文按"先想清楚命名规则 → 用 dry-run 预演 → 处理撞名 → 两阶段提交"的顺序,给出一份能安全跑在真实目录上的批量改名方案。全部基于标准库的pathlib与os。
先说一个 Windows 上特别容易中招的点:文件系统大小写不敏感。想把photo.jpg改成Photo.jpg,某些情况下这个操作会被当成"没有变化"而失败或静默无效。稳妥做法是两阶段改名:先改成一个临时名,再改成目标名。
一、确定改名规则
命名规则通常落在这几类:统一加前缀/后缀、去掉文件名里的空格或特殊字符、按正则替换片段、统一编号(img_001.jpg)。抽取"主名 + 扩展名"是关键一步,务必用Path.stem和Path.suffix,而不是自己split(".")——因为archive.tar.gz的主名是archive.tar,suffix是.gz,手工切很容易切错。
# 适用于 Python 3.8+
from pathlib import Path
p = Path("archive.tar.gz")
print(p.stem) # archive.tar
print(p.suffix) # .gz
print(p.suffixes) # ['.tar', '.gz']改名时推荐用path.with_name(新文件名)来构造目标路径,它会保留父目录、只替换最后一段名字,比字符串拼接安全。也可以用with_stem()/with_suffix()单独换主名或后缀(这两个方法在Python 3.9+才提供)。
二、撞名检测与两阶段提交
改名最大的风险是"多个旧名映射到同一个新名"。比如把空格替换成下划线时,a b.txt和a_b.txt会变成同一个目标。检测方式是先把整批映射算出来,检查目标名是否重复、是否已存在,再动手。
# 适用于 Python 3.8+
from pathlib import Path
def build_plan(files, rule):
"""rule: 接收 Path,返回新文件名(不含目录)。返回 (src, dst) 列表。"""
plan = []
seen = {}
for f in files:
new_name = rule(f)
dst = f.with_name(new_name)
if new_name in seen:
raise ValueError(f"目标名冲突: {f.name} 与 {seen[new_name]} 都想叫 {new_name}")
if dst.exists() and dst.name != f.name:
raise ValueError(f"目标已存在: {dst}")
seen[new_name] = f.name
plan.append((f, dst))
return plan真正的"提交"分两步走:第一轮把每个源文件改成一个唯一的临时名,第二轮再把临时名改成目标名。这样即使两批名字互相交叉(比如要把a.txt改成b.txt、b.txt改成a.txt),也不会中途撞车。
# 适用于 Python 3.8+
import os
def apply_plan(plan):
"""两阶段改名,先统一移到临时名,再统一落到目标名。"""
staged = []
for src, dst in plan:
tmp = src.with_name(f"__tmp__{os.getpid()}_{src.name}")
os.rename(src, tmp)
staged.append((tmp, dst))
for tmp, dst in staged:
os.rename(tmp, dst)用os.getpid()拼进临时名,避免同一目录下并行跑两个实例时临时名互相覆盖。
三、完整实例:四类常见规则
# 适用于 Python 3.8+
import argparse
import os
import re
from pathlib import Path
def rule_prefix(p):
return "2024_" + p.name
def rule_strip_spaces(p):
return re.sub(r"\s+", "_", p.stem) + p.suffix
def rule_normalize(p):
"""把主名里的非字母数字统一替换为下划线。"""
stem = re.sub(r"[^0-9A-Za-z一-鿿]+", "_", p.stem)
return stem.strip("_") + p.suffix.lower()
def make_number_rule():
"""返回一个有状态的编号规则:每次调用递增,从 001 开始。"""
state = {"n": 0}
def rule(p):
state["n"] += 1
return f"item_{state['n']:03d}{p.suffix.lower()}"
return rule
def build_rules():
return {
"prefix": rule_prefix,
"spaces": rule_strip_spaces,
"normalize": rule_normalize,
"number": make_number_rule(),
}
def build_plan(files, rule):
plan, seen = [], {}
for f in files:
new_name = rule(f)
dst = f.with_name(new_name)
if new_name in seen:
raise ValueError(f"目标名冲突: {f.name} 与 {seen[new_name]} -> {new_name}")
if dst.exists() and dst.name != f.name:
raise ValueError(f"目标已存在: {dst}")
seen[new_name] = f.name
plan.append((f, dst))
return plan
def apply_plan(plan):
staged = []
for src, dst in plan:
tmp = src.with_name(f"__tmp__{os.getpid()}_{src.name}")
os.rename(src, tmp)
staged.append((tmp, dst))
for tmp, dst in staged:
os.rename(tmp, dst)
def main():
rules = build_rules()
ap = argparse.ArgumentParser(description="批量重命名文件")
ap.add_argument("root")
ap.add_argument("--rule", choices=sorted(rules), required=True)
ap.add_argument("--apply", action="store_true", help="真正执行,默认只预演")
args = ap.parse_args()
root = Path(args.root)
files = [f for f in root.iterdir() if f.is_file() and not f.name.startswith(".")]
files.sort(key=lambda p: p.name)
plan = build_plan(files, rules[args.rule])
for src, dst in plan:
print(f"{src.name} -> {dst.name}")
if args.apply:
apply_plan(plan)
print(f"已重命名 {len(plan)} 个文件")
else:
print("(预演模式,加 --apply 才会真正执行)")
if __name__ == "__main__":
main()files的顺序依赖iterdir()的返回顺序。如果规则里用了编号,最好先files.sort(key=lambda p: p.name)固定顺序,否则"第几个文件变成 item_001"每次可能不一样。
| 需求 | 写法 | 注意 |
|---|
| 加前缀 | "2024_" + p.name | 直接拼name,别丢后缀 |
| 替换空格 | re.sub(r"\s+", "_", p.stem) | 只处理主名,后缀单独接回 |
| 统一小写后缀 | p.suffix.lower() | 后缀含点,别漏 |
| 按序号命名 | f"item_{i:03d}{p.suffix}" | 先排序保证顺序稳定 |
| 只改后缀 | p.with_suffix(".txt") | 3.9+ 有with_stem |
常见坑点
- ❌ 用
name.split(".")[0]取主名
✅a.tar.gz会被切成a,后缀全丢;用Path.stem/Path.suffix
- ❌ 边遍历边改名,导致重复处理或漏处理
✅ 先list(root.iterdir())固化成列表,算出完整映射再统一执行
- ❌ 直接
os.rename而不管目标是否已存在
✅ 目标存在时 POSIX 上可能静默覆盖、Windows 上可能抛错,行为随平台漂移;提前做存在性检查
- ❌ 一批文件的新名字互相交叉(a→b、b→a)时直接一对一对改
✅ 用两阶段改名:先全部改成唯一临时名,再改成目标名
- ❌ 想把
photo.jpg改成Photo.jpg却直接os.rename
✅ 大小写不敏感的文件系统上可能被当成无变化;先改临时名再改回来
- ❌ 没有预演,第一次就在真实目录上执行
✅ 默认 dry-run 打印映射表,确认后加--apply;重要目录先备份
- ❌ 正则替换只替换第一处,或把后缀也一起改了
✅re.sub默认替换全部;但要用stem参与匹配,最后再把suffix接回去
- ❌ 忘了跳过隐藏文件和临时文件
✅ 过滤name.startswith("."),并排除自己生成的__tmp__前缀,避免二次运行把临时文件当成目标
总结
| 阶段 | 做什么 | 关键点 |
|---|
| 规划 | 算新名字 | 用stem/suffix,别手工切点 |
| 校验 | 查冲突与存在 | 一处冲突就中止,不部分执行 |
| 执行 | 两阶段改名 | 临时名过渡,跨平台安全 |
| 兜底 | dry-run | 默认不落盘,确认再动手 |
批量改名的核心不是正则写得多花,而是"先算清楚再动手、冲突就整体中止、用两阶段避免交叉撞车"。把这三条落实,--apply这个开关按下去的时候才不会心慌。