登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python pathlib批量改名并保留冲突回滚点的脚本

来源:17golang原创

时间:2026-09-20 08:38:24 422浏览 收藏

整理一批导入文件时,直接在循环里调用 Path.rename() 看起来很快,但目标名一旦重复,脚本可能执行到一半才失败,留下“改了一部分、又不知道改了哪些”的现场。更稳妥的做法是把改名拆成计划、校验、执行和回滚四个阶段:先用 pathlib 生成确定性映射,冲突时整个计划不落地,成功项再写入可逆清单。

这个脚本只处理指定目录的直接文件,不递归进入子目录;它会先生成 rename-plan.json,发现目标已存在或目标重复就停止,执行成功后再生成 rename-rollback.json

先把源文件和目标文件变成一张计划表

Path 是面向当前操作系统的具体路径对象,适合同时完成目录遍历、名称拆分和重命名。计划表至少保留源路径、目标路径和是否已完成三个字段,后面检查冲突、写回滚清单都依赖它。

from pathlib import Path
import json

ROOT = Path("incoming")

def build_plan(root: Path) -> list[dict]:
    # 只处理当前目录的普通文件,避免一次误改名整个目录树。
    files = sorted((p for p in root.iterdir() if p.is_file()), key=lambda p: p.name)
    plan = []
    for index, source in enumerate(files, start=1):
        # 保留原后缀,只改变主文件名,目标顺序由排序后的源文件决定。
        target = source.with_name(f"asset-{index:03d}{source.suffix.lower()}")
        plan.append({
            "old": str(source),
            "new": str(target),
            "done": False,
        })
    return plan

plan = build_plan(ROOT)
Path("rename-plan.json").write_text(
    json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8"
)

这里先排序再编号,意味着同一批文件重复运行时计划是稳定的。suffix.lower() 只统一扩展名大小写;如果业务需要严格保留原始后缀,应改为直接使用 source.suffix,不要把“规范化”悄悄混进改名规则。

pathlib批量改名计划结构说明图,展示源文件、目标名称和计划清单的关系
图1:批量改名计划说明图,展示源文件如何映射到确定性目标名;这是静态结构图,不是运行截图。

执行前同时拦住已存在目标和计划内重复

只检查 target.exists() 还不够:两个源文件也可能因为业务规则生成同一个目标名。将所有目标转成字符串集合后,分别检查“计划内重复”和“目录中已有目标”,任何一项命中都返回报告,不执行任何改名。

def find_conflicts(plan: list[dict]) -> list[str]:
    # 先找多个源文件指向同一目标,再找目录中已有的目标文件。
    conflicts = []
    seen: dict[str, str] = {}
    for item in plan:
        old_path = Path(item["old"])
        new_path = Path(item["new"])
        key = str(new_path)
        if key in seen:
            conflicts.append(f"计划重复: {seen[key]} -> {key} 与 {old_path}")
        else:
            seen[key] = str(old_path)
        # 源文件本身就是计划的一部分,不把它误判成外部冲突。
        if new_path.exists() and new_path != old_path:
            conflicts.append(f"目标已存在: {new_path}")
    return conflicts

conflicts = find_conflicts(plan)
if conflicts:
    for message in conflicts:
        print(message)
    raise SystemExit("计划存在冲突,未执行改名")

这个判断把“冲突”定义得很保守:即使目标是一个可以覆盖的临时文件,也先停下来让人确认。批处理脚本最怕默认覆盖,因为覆盖成功后往往无法分辨原文件属于哪一轮任务。

按计划执行,并记录每一条成功映射

冲突检查通过后,按计划表调用 rename()。每完成一条就把 done 写回计划文件,这样进程中断时可以知道最后完成的位置;同时只把已完成项放进回滚清单,避免回滚不存在的目标。

PLAN_FILE = Path("rename-plan.json")
ROLLBACK_FILE = Path("rename-rollback.json")

def save_json(path: Path, data: object) -> None:
    # 统一 UTF-8 写入,便于人工复核中文文件名和恢复记录。
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

completed = []
for item in plan:
    old_path = Path(item["old"])
    new_path = Path(item["new"])
    try:
        # rename 保留同一文件系统内的原子改名语义,不复制文件内容。
        old_path.rename(new_path)
    except OSError as exc:
        # 已完成项仍然保留,下一次可先回滚或人工处理权限问题。
        save_json(ROLLBACK_FILE, list(reversed(completed)))
        raise RuntimeError(f"改名失败: {old_path} -> {new_path}: {exc}") from exc
    item["done"] = True
    completed.append({"old": str(old_path), "new": str(new_path)})
    save_json(PLAN_FILE, plan)

save_json(ROLLBACK_FILE, list(reversed(completed)))

清单使用“旧路径 → 新路径”的方向,回滚时则逆序执行“新路径 → 旧路径”。逆序的意义在于尽量先恢复后改名的文件,降低名称链条相互占用的概率;但它不是绕过外部文件占用的保证,回滚前仍要重新检查目标是否存在。

pathlib批量改名回滚结构说明图,展示成功映射、逆向清单和恢复边界
图2:回滚映射说明图,展示成功改名记录如何生成逆向恢复清单;这是静态结构图,不是运行截图。

回滚函数要拒绝覆盖新文件

回滚不是把清单无条件反向执行。恢复前要确认新路径仍存在、旧路径没有被别的文件占用,否则宁可报告失败,也不要用恢复动作覆盖用户后来创建的文件。

def rollback(records: list[dict]) -> None:
    for record in records:
        current = Path(record["new"])
        original = Path(record["old"])
        # 旧名重新出现时说明目录状态已变化,禁止覆盖。
        if not current.exists():
            raise FileNotFoundError(f"回滚源不存在: {current}")
        if original.exists():
            raise FileExistsError(f"回滚目标已被占用: {original}")
        current.rename(original)

# records = json.loads(ROLLBACK_FILE.read_text(encoding="utf-8"))
# rollback(records)  # 仅在确认目录状态后手动调用

几个容易被忽略的边界

场景处理建议
需要递归改名先明确是否允许跨目录移动,再用 rglob() 并重新设计排序和冲突策略。
符号链接默认不要跟随处理;把链接和真实文件分开列入计划,避免误改目标文件。
跨文件系统rename() 不等同于复制,跨设备时应改用复制、校验、删除的独立流程。
权限或文件被占用保留已完成清单,修复权限后先确认现场,再决定回滚还是从剩余计划继续。

最终核对不应只看文件数量,还要读取清单逐条确认旧路径已经不存在、新路径确实存在。只要清单和目录状态不一致,就先停在人工处理状态,不要再次运行整个批次。

相关问题

为什么不直接用字符串拼接文件路径?

Path 会按运行平台处理路径分隔符,并把名称、后缀、存在性和改名操作放在同一套对象接口中,减少手写分隔符和扩展名切分的错误。

目标文件已存在时能不能覆盖?

除非覆盖本身就是明确需求,并且有独立备份,否则不建议。对批量整理脚本来说,停止并输出冲突清单比静默覆盖更容易恢复。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>