Python filecmp 比较文件内容:避开浅比较与缓存误判
备份文件和原文件的大小、修改时间完全相同,Python 却把内容不同的两个文件判成了一样。原因可能不是复制失败,而是比较函数默认只做了元数据快捷判断。
本文面向初级到中级读者,复现 filecmp.cmp() 的浅比较和缓存陷阱,再写一个只读的文件内容比较命令。它区分相同、不同和检查失败,适合接在本地备份或自动化任务后面,不删除或覆盖被检查的文件。
1. 方法:先区分元数据与内容
Python 官方 filecmp 文档说明,默认 shallow=True:文件类型、大小和修改时间一致时,可以直接判为相同。显式传入 shallow=False 才跳过这条“仅凭元数据判相同”的路径;大小不同时仍可直接判不同。
本教程只比较可信本地目录里、已经停止写入的普通文件:
- 比较字节,不解码文本,不忽略空格、行尾或 BOM;不比较文件权限、所有者和时间戳。
- 使用
shallow=False,并在每轮比较前清除该进程的filecmp缓存。 - 退出码约定为 0=内容相同,1=内容不同,2=参数或检查失败。
- 路径可以指向普通文件的符号链接;不把它当成防越界或对抗恶意路径的工具。
如果需要看清配置中改了哪几行,用文本配置差异工具。这里回答的是“两个文件的字节是否相同”,不是文本是否等价。
2. 准备:建立演示目录
使用已有的 Python 3.9 或更新版本,无需安装第三方包。示例在 Linux、Python 3.11.4 下验证,shell 命令适用于 bash、zsh。重复实验时换一个新的目录名。
python3 --version
mkdir filecmp-demo
cd filecmp-demo
将下一节代码保存为 demo_filecmp.py。它只创建并改写当前演示目录下的 left.bin、right.bin,不要在真实文件旁直接运行。
3. 复现:相同时间戳不等于相同内容
import filecmp
import os
from pathlib import Path
left = Path("left.bin")
right = Path("right.bin")
stamp = 1_700_000_000_000_000_000
def write_at_fixed_time(path, data):
path.write_bytes(data)
os.utime(path, ns=(stamp, stamp))
write_at_fixed_time(left, b"alpha\n")
write_at_fixed_time(right, b"omega\n")
filecmp.clear_cache()
print("default:", filecmp.cmp(left, right))
print("deep:", filecmp.cmp(left, right, shallow=False))
write_at_fixed_time(right, b"alpha\n")
filecmp.clear_cache()
assert filecmp.cmp(left, right, shallow=False) is True
write_at_fixed_time(right, b"omega\n")
print("cached deep:", filecmp.cmp(left, right, shallow=False))
filecmp.clear_cache()
print("cleared deep:", filecmp.cmp(left, right, shallow=False))
运行:
python3 demo_filecmp.py
预期输出:
default: True
deep: False
cached deep: True
cleared deep: False
这里故意用 os.utime(..., ns=...)固定时间戳,确保两份等长文件具有相同签名。真实环境中,保留修改时间的复制操作也可能遇到这种情况,不要依赖“再等一秒”来修复比较逻辑。
cached deep: True 展示另一个问题:这个进程先比较相同内容,再把右侧改成同大小、同修改时间的不同内容;旧的深比较结果仍可能被复用。官方的 clear_cache() 说明提供了清缓存入口。因此,shallow=False 不等于完全不使用缓存。
4. 实现:只读比较命令
保存为 compare_files.py:
"""Compare two stable local regular files by content. Python 3.9+."""
import argparse
import filecmp
from pathlib import Path
import sys
def same_content(left, right):
for path in (left, right):
if not path.is_file():
raise ValueError(f"not a regular file: {path}")
# Disable metadata-only equality and discard earlier comparison results.
filecmp.clear_cache()
return filecmp.cmp(left, right, shallow=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("left", type=Path)
parser.add_argument("right", type=Path)
args = parser.parse_args()
try:
equal = same_content(args.left, args.right)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print("SAME" if equal else "DIFFERENT")
return 0 if equal else 1
if __name__ == "__main__":
raise SystemExit(main())
same_content() 中真正改变判断方式的是 shallow=False;clear_cache() 避免复用此前的比较结果。清缓存影响整个进程的 filecmp 缓存,因此这个小工具适合独立运行,不建议把它当成多线程共享缓存管理方案。
普通文件检查会跟随符号链接。它能挡住常见的目录或缺失文件输入,但不是并发路径替换防护;请勿对不可信、不断变化的目录运行。
5. 使用与验证:检查输出,也检查退出码
在同一个演示目录执行。cp 只用于把演示右文件改成相同内容;比较工具本身不会修改两份文件。
python3 compare_files.py --help
python3 compare_files.py left.bin right.bin; printf 'exit=%s\n' "$?"
cp left.bin right.bin
python3 compare_files.py left.bin right.bin; printf 'exit=%s\n' "$?"
python3 compare_files.py left.bin missing.bin; printf 'exit=%s\n' "$?"
除 --help 外,依次得到以下结果;错误提示写到标准错误:
DIFFERENT
exit=1
SAME
exit=0
ERROR: not a regular file: missing.bin
exit=2
以上演示预期会返回 1 和 2,不要原样放进启用了 set -e 的脚本。接入自动化时应立即保存退出码,区分“不同”与“检查失败”:
status=0
python3 compare_files.py left.bin right.bin || status=$?
case "$status" in
0) printf 'verified same content\n' ;;
1) printf 'content differs; review before continuing\n' ;;
*) printf 'comparison failed; stop the job\n' >&2 ;;
esac
这是演示分支处理的片段,不会自行终止外层任务;生产脚本应在失败分支明确 exit 或返回错误。若需要持续检查目录中新增、缺失和改变的文件,可进一步使用 SHA-256 数据清单。
再把下面保存为 check_compare.py,执行 python3 check_compare.py。它使用自动清理的临时目录,检查空文件、长度不同、同内容、同路径、非 UTF-8 字节、超过 1 MiB 文件的末尾差异、缺失文件和目录输入:
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
script = Path("compare_files.py").resolve()
def check(left, right, status, stdout):
result = subprocess.run(
[sys.executable, str(script), str(left), str(right)],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == status, result
assert result.stdout == stdout, result
if status == 2:
assert result.stderr.startswith("ERROR:"), result
else:
assert result.stderr == "", result
with TemporaryDirectory(prefix="filecmp-check-") as name:
root = Path(name)
a, b = root / "a.bin", root / "b.bin"
a.write_bytes(b"")
b.write_bytes(b"")
check(a, b, 0, "SAME\n")
a.write_bytes(b"A")
check(a, b, 1, "DIFFERENT\n")
b.write_bytes(b"A")
check(a, b, 0, "SAME\n")
check(a, a, 0, "SAME\n")
a.write_bytes(b"\x00\xff")
b.write_bytes(b"\x00\xfe")
check(a, b, 1, "DIFFERENT\n")
a.write_bytes(b"x" * 1_048_576 + b"A")
b.write_bytes(b"x" * 1_048_576 + b"B")
check(a, b, 1, "DIFFERENT\n")
b.write_bytes(a.read_bytes())
check(a, b, 0, "SAME\n")
check(a, root / "missing.bin", 2, "")
check(a, root, 2, "")
print("OK: 9 CLI checks")
预期输出 OK: 9 CLI checks。第三节另外验证同大小同时间戳和同进程缓存场景;不要仅凭普通同内容用例认为已经覆盖这两类问题。
6. 排错:结果与预期不一致怎么办
| 现象 | 直接处理 |
|---|---|
默认比较返回 True,但内容不同 |
显式使用 shallow=False,不要用修改时间代替内容检查。 |
| 深比较仍返回旧结果 | 在下一轮比较前执行 filecmp.clear_cache();确认写入进程已经停止。 |
not a regular file |
检查命令的工作目录和路径;本例不接受目录、断开的符号链接或缺失文件。 |
PermissionError 等读取错误 |
使用有权限的账户和正确路径,不要通过屏蔽异常把错误变成“相同”。 |
肉眼看相同的文本返回 DIFFERENT |
字节比较会保留 CRLF、BOM 和末尾换行差异;需要文本规范化时改用第一节的文本工具。 |
| 文件还在写,结果时好时坏 | 先停止写入,或比较已完成的独立副本。清缓存不提供锁,也不生成一致性快照。 |
对于大目录中的候选重复项,可用只读重复文件报告先筛选,而不是手动对每两个路径运行本命令。读取内容可能更新访问时间;“只读”指不改写文件内容,并不承诺所有元数据不变。
7. 总结
比较文件内容时,先明确是否允许元数据快捷判断和缓存复用。本例用 shallow=False 加每轮清缓存修正这两种行为,再用 0、1、2 区分相同、不同与失败。比较前让文件停止变化;这个命令不替代并发控制、备份完整性方案或配置语义验证。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-filecmp-shallow-byte-comparison.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。