JavaLYG

Python copytree复制后旧文件还在:用隔离实验分清目录合并与镜像

用 Python 的 copytree 更新目录,新文件进去了,旧文件却还在;加了 dirs_exist_ok=True,也没把目标变成源目录的镜像。我用 Python 3.14.3 在 Linux 临时目录做了文件实验:同名普通文件会覆盖,目标独有文件会保留,复制报错后也可能已有文件落盘。下面把这几个现象拆开验证,不碰业务目录。

一、先分清“复制进去”和“完全一致”

假设源目录里有 main.txt 和 sub/new.txt,目标原来还有 extra.txt、sub/old.txt。更新后 main.txt 变新了,不代表后两项也该消失。它们在源目录里没有对应项,copytree 不会因此给它们安排删除操作。只检查一个新文件是否存在,很容易误以为整棵目录已经同步完成。

这里说的合并,是已有目录可以继续接收源中的内容;镜像则还需要处理目标多出来的内容,以及文件类型、链接和排除规则。两者对旧文件的要求不同。先问一句:目标独有的配置、上传文件和缓存,究竟要保留还是移走?这个答案比把参数改成 True 更早。

Python copytree允许已有目录合并,保留目标独有文件且失败不自动回滚
普通文件与目录的概念示意,箭头不是成功回执;覆盖仍可能受权限与类型冲突影响。dirs_exist_ok自Python3.8提供。本图经MiniMax H3 1K生成(原图1360×768),等比处理到1600×900,并精确纠正参数和底注文字;经技术与可读性复核,不代表真人签审。

二、dirs_exist_ok 放宽的是目录存在条件

官方文档明确:默认 dirs_exist_ok=False,目标目录已经存在就抛 FileExistsError;即使是空目录,也不能把“空”理解成“不存在”。设为 True 后,遇到已有目录继续复制,对应的目标文件会被源文件覆盖。这个参数从 Python 3.8 加入,并不是近期才有的目录同步功能。

本文把同名普通文件的覆盖与额外文件的保留分开检查,不把“继续”说成“跳过所有已有文件”。函数正常返回目标路径,也不是返回复制数量、差异列表或成功布尔值。拿到路径后仍要检查业务需要的文件集合和内容;路径本身不会告诉你旧文件是否符合预期。

三、完整实验只写临时目录

把下面完整代码保存为 copytree_lab.py,用 Python 3.8 或以上运行 python copytree_lab.py。本文实际执行的版本是 3.14.3,其他版本只是满足参数门槛,不是本次逐版本验收结论。程序没有第三方依赖,会逐项打印结果,末行输出 JSON,发生任何检查失败则抛异常退出。

脚本按标准约定使用 TemporaryDirectory,不写死你的临时路径。本次实际运行通过 TMPDIR 把文件限定在测试专用 scratch 目录内。所有源和目标都由脚本现场创建,退出上下文后自动清理。中文名称是合法的 Python 标识符;检查用显式 if/raise,没有把 assert 当作门禁,所以优化模式也会真正检查。

from pathlib import Path
import json
import shutil
import sys
import tempfile

结果 = []


def 检查(名称, 条件):
    if not 条件:
        raise RuntimeError("检查失败:" + 名称)
    结果.append(名称)
    print("通过:" + 名称)


def 写(目录, 名称, 内容):
    路径 = 目录 / 名称
    路径.parent.mkdir(parents=True, exist_ok=True)
    路径.write_text(内容, encoding="utf-8")


def 读(目录, 名称):
    return (目录 / 名称).read_text(encoding="utf-8")


def 快照(目录):
    return {str(路径.relative_to(目录)): 路径.read_bytes()
            for 路径 in 目录.rglob("*") if 路径.is_file()}


# 只在临时目录内写文件,退出上下文时自动清理。
with tempfile.TemporaryDirectory(prefix="copytree-lab-") as 临时目录:
    根 = Path(临时目录)
    源 = 根 / "src"
    写(源, "main.txt", "新版")
    写(源, "sub/new.txt", "新子文件")
    写(源, "skip.tmp", "不复制的新版")
    源原样 = 快照(源)

    新目标 = 根 / "new"
    返回值 = shutil.copytree(源, 新目标)
    检查("新目录复制内容", 快照(新目标) == 源原样)
    检查("新目录返回目标路径", 返回值 == 新目标)

    目标 = 根 / "dst"
    写(目标, "main.txt", "旧版")
    写(目标, "extra.txt", "目标独有")
    写(目标, "sub/old.txt", "嵌套旧文件")
    目标原样 = 快照(目标)
    try:
        shutil.copytree(源, 目标)
    except FileExistsError:
        检查("默认拒绝已有目录", True)
    else:
        raise RuntimeError("已有目录没有报错")
    检查("默认拒绝后目标内容不变", 快照(目标) == 目标原样)

    返回值 = shutil.copytree(源, 目标, dirs_exist_ok=True)
    检查("同名普通文件被覆盖", 读(目标, "main.txt") == "新版")
    检查("目标根目录额外文件保留", 读(目标, "extra.txt") == "目标独有")
    检查("目标嵌套额外文件保留", 读(目标, "sub/old.txt") == "嵌套旧文件")
    检查("已有子目录仍接收新文件", 读(目标, "sub/new.txt") == "新子文件")
    检查("合并返回目标路径", 返回值 == 目标)

    忽略目标 = 根 / "ignored"
    写(忽略目标, "skip.tmp", "目标旧缓存")
    规则 = shutil.ignore_patterns("*.tmp")
    shutil.copytree(源, 忽略目标, dirs_exist_ok=True, ignore=规则)
    检查("ignore不删除或更新目标旧文件", 读(忽略目标, "skip.tmp") == "目标旧缓存")
    空目标 = 根 / "ignored-new"
    shutil.copytree(源, 空目标, ignore=规则)
    检查("ignore不创建被忽略的文件", not (空目标 / "skip.tmp").exists())
    检查("ignore不妨碍其他文件", 读(空目标, "main.txt") == "新版")

    冲突目标 = 根 / "conflict"
    写(冲突目标, "sub", "我不是目录")
    try:
        shutil.copytree(源, 冲突目标, dirs_exist_ok=True)
    except shutil.Error as 异常:
        检查("源目录撞目标文件会汇总报错", any(
            Path(项[1]) == 冲突目标 / "sub" for 项 in 异常.args[0]))
    else:
        raise RuntimeError("目录与文件冲突没有报错")
    检查("类型冲突文件仍保持原内容", 读(冲突目标, "sub") == "我不是目录")

    失败源 = 根 / "failure-src"
    写(失败源, "a.txt", "甲")
    写(失败源, "b.txt", "乙")
    失败源原样 = 快照(失败源)
    失败目标 = 根 / "failure-dst"
    已写 = []

    def 注入失败(源路径, 目标路径):
        # 第一次调用先真实复制再报错,后续调用只报错,不靠遍历顺序。
        if not 已写:
            shutil.copy2(源路径, 目标路径)
            已写.append(Path(目标路径).name)
        raise OSError("演示故障:复制过程失败")

    try:
        shutil.copytree(失败源, 失败目标, copy_function=注入失败)
    except shutil.Error as 异常:
        检查("注入故障汇总两项原因", len(异常.args[0]) == 2)
    else:
        raise RuntimeError("注入失败没有向外报错")
    检查("报错后已复制文件没有回滚", len(已写) == 1 and
        读(失败目标, 已写[0]) == 读(失败源, 已写[0]))
    检查("报错后目标只留下部分文件", len(快照(失败目标)) == 1)
    检查("失败源文件内容保持不变", 快照(失败源) == 失败源原样)
    检查("普通源文件内容保持不变", 快照(源) == 源原样)

检查("临时目录已清理", not 根.exists())
print("结果JSON=" + json.dumps({
    "python": sys.version.split()[0], "optimize": sys.flags.optimize,
    "passed": len(结果), "checks": 结果,
    "temporary_directory_removed": not 根.exists()
}, ensure_ascii=False))

四、看结果时别把旧文件当复制失败

本次源码在默认、-O、-OO 三种模式下各跑了一遍,每遍通过 20 项检查,退出码均为 0,标准错误为空。重复运行没有增加独立检查数量。最终 HTML 中的代码已抽出与源码逐字节核对并重新执行,避免文件夹里的测试正确,读者拿到的却是另一版示例。

合并测试故意留下两处旧内容:根目录 extra.txt,以及 sub 下面的 old.txt。它们都保留,同时 main.txt 更新、sub/new.txt 新增。这组结果比只看“执行没报错”更能说明问题:copytree 确实完成了这次合并,但你期待的镜像验收并没有因此成立。

默认拒绝已有目标后,脚本还检查了目标文件内容不变。这个结论只对应本例在入口遇到已有目标而失败,不应扩大成“copytree 任何异常都不会改文件”。下一组故障注入就是反例。另外,源文件快照核对的是名称和内容,不是证明访问时间等全部元数据毫无变化。

五、ignore 是不复制,不是清理目标

ignore_patterns('*.tmp') 会让匹配的源项不参与复制。若目标里已经有同名 skip.tmp,规则不会把它删除,也不会用被忽略的源内容更新它;若目标里没有,这次就不会新建。两种初始状态必须分开看,否则容易把“没复制新缓存”误读成“已经清掉旧缓存”。

文件与目录同名也要单独排查。实验中源的 sub 是目录,目标的 sub 却是普通文件,继续合并仍然报 shutil.Error。dirs_exist_ok 并不负责把文件转换成目录。不要把这类冲突概括成所有方向表现相同:默认 copy2 接收目录作为目标时还有自己的规则,复杂目录树应先列出类型差异再操作。

六、报错并不会把已经复制的文件收回

为了不依赖目录枚举顺序,故障源只放两个普通文件,自定义 copy_function 在第一次被调用时先真实复制,再主动抛 OSError;后续调用只抛错。无论系统先枚举到哪个文件,目标最终都恰好留下一个文件,且它的内容与源对应项一致,另一个没有复制成功。

这段实验不规定 a.txt 或 b.txt 谁先留下,只规定真实写入一次后故障仍然向外传播。测试若依赖某个文件先被枚举,就可能在换文件系统后失效;用第一次调用控制注入点,才把故障条件与目录排序分开。

在本次版本中,这些文件级 OSError 被汇总成 shutil.Error,记录两项原因。自定义函数写完再报错是人为故障注入,不是声称复现了某块磁盘损坏。它验证的边界很明确:异常已经传出来,目标却已经发生修改。程序不能把捕获异常后的目录直接当成“什么都没发生”。

copytree 没有提供整棵目录的一次性原子切换或失败自动回滚。若你的业务必须让读者只看到完整版本,应另行设计新版本目录、内容校验、受控切换和恢复流程。不要把“先复制到另一个地方”单独当成原子部署保证;并发读写、跨文件系统与切换方式还需要专门测试。

七、按症状查,不要先删生产目录

我不建议为了消除旧文件就直接递归删除现有业务目录。里面可能混着上传文件、运行时生成内容,甚至另一个任务正在写。先保存差异清单和必要备份,再决定删除边界;如果需求本来就是保留目标独有项,根本不该为追求“看起来干净”而删除它们。

现象先确认什么处理方向
默认复制报 FileExistsError目标是否已经存在,包括空目录确认需求后选新目标或允许合并
同名普通文件内容变了是否启用了 dirs_exist_ok=True按覆盖策略检查源与目标内容
根目录或子目录旧文件还在是否属于目标独有项把清理作为独立需求评审
ignore 后目标缓存仍在是否复制前就存在不要把忽略规则当删除规则
文件与目录同名报错冲突两边的实际类型先处理类型差异,不盲目重试
报错后目标已有新文件错误列表及已写入内容隔离失败结果,重新核验完整性

八、把验收写成可勾选的清单

实际接入脚本时,建议把“合并成功”拆成下面几项,而不只记一行复制成功日志。尤其是重试路径:第一次可能已经覆盖了一部分,第二次面对的就不是最初的目录。源目录如果还在变化,本实验对固定输入的结论也不能代替你的快照或一致性方案。

  • 确认源、目标的真实路径与所有权,实验不套用到生产路径。
  • 说明需求是合并还是镜像,明确目标独有项的处理规则。
  • 核对同名文件覆盖、嵌套残留、ignore 和类型冲突。
  • 记录异常原因,并检查失败后已写入哪些内容。
  • 返回路径之外,再验文件清单、内容和业务所需元数据。
  • 确认临时资源清理完成,生产备份与恢复另行验收。

本次只验了 Linux 上真实临时目录里的普通文件复制,没有做生产部署、Windows、符号链接、权限故障、并发写入或断电恢复测试。读完若只记一件事:dirs_exist_ok=True 允许已有目录参与复制,不负责让目标与源完全一致。要不要清旧文件,需要你明确决定,不能让参数名替你决定。

参考资料:Python 3.14 官方 copytree 文档;CPython v3.14.3 的 shutil.py 源码。文档用于核对参数、版本和返回值,固定标签源码用于核对逐项复制及异常汇总路径,两者与上面的隔离实验相互对照。

🔕 评论已关闭