用 Python 对比配置文件:difflib 统一差异、行尾与退出码
自动化任务把 workers = 2 改成了 workers = 4,文件能打开,下一步却不该直接继续。发布配置、调整 Hugo 参数或检查批处理输入之前,先看清具体改了哪几行,比只比较修改时间可靠。
本文面向初学者到中级读者,用 Python 标准库写一个只读的配置对比工具:输出统一差异,区分“相同”“不同”和“检查失败”,不安装依赖,也不修改原文件。它比较文本,不判断 TOML、YAML 或应用配置是否有效。
1. 方法:先确定比较规则
- 只处理可信本地目录中、已停止写入的小型普通 UTF-8 文件;每个文件最多 256 KiB、2000 行。
- 将 CRLF 和单独的 CR 统一为 LF,再逐行比较;保留空格、注释、行序和末尾换行差异。
- 相同不输出内容;不同只向标准输出写差异;读取或参数错误写标准错误。
- 只审阅,不覆盖配置,也不自动应用差异。
difflib.unified_diff() 官方文档介绍了带上下文的统一差异,n 控制上下文行数。这里用固定标签 before 和 after,避免把完整路径写入报告。
**退出 0 只表示按上述规则文本相同,不表示字节完全一致。**如果关心脚本的原始行尾,另看 CRLF 与 BOM 排查。注释或空白变动仍会报告,两个语义等价的配置也可能不同。
2. 准备:建立独立目录
需要 Python 3.9 或更新版本,无第三方包。示例在 Linux、Python 3.11.4 上测试;shell 命令适用于 bash、zsh。
python3 --version
mkdir config-diff-demo
cd config-diff-demo
把下一节代码保存为当前目录的 compare_config.py,也可以下载同一份 完整脚本。重复实验请换一个新目录;下面的 printf > 仅用于创建演示文件,会覆盖同名文件。
3. 实现:先读完并检查,再生成差异
"""Compare small UTF-8 config snapshots without modifying them. Python 3.9+."""
import argparse
import difflib
import io
from pathlib import Path
import sys
MAX_BYTES = 256 * 1024
MAX_LINES = 2000
def context_lines(value):
number = int(value)
if not 0 <= number <= 20:
raise argparse.ArgumentTypeError("context must be between 0 and 20")
return number
def read_lines(path):
if not path.is_file():
raise ValueError(f"not a regular file: {path}")
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError(f"file exceeds {MAX_BYTES} bytes: {path}")
text = raw.decode("utf-8", errors="strict")
if text.startswith("\ufeff"):
raise ValueError(f"UTF-8 BOM found: {path}")
if "\x00" in text:
raise ValueError(f"NUL byte found: {path}")
text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = io.StringIO(text).readlines()
if len(lines) > MAX_LINES:
raise ValueError(f"file exceeds {MAX_LINES} lines: {path}")
return lines
def render_diff(before, after, context):
parts = []
for line in difflib.unified_diff(
before, after, fromfile="before", tofile="after", n=context,
):
parts.append(line)
if not line.endswith("\n"):
parts.append("\n\\ No newline at end of file\n")
return "".join(parts)
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("before", type=Path)
parser.add_argument("after", type=Path)
parser.add_argument("--context", type=context_lines, default=3)
args = parser.parse_args()
try:
before = read_lines(args.before)
after = read_lines(args.after)
if before == after:
return 0
report = render_diff(before, after, args.context)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
sys.stdout.write(report)
return 1
if __name__ == "__main__":
raise SystemExit(main())
open() 文档区分二进制和文本模式。这里先按字节限制读取,再严格解码 UTF-8;不使用 errors="ignore" 丢弃错误数据。脚本拒绝开头的 UTF-8 BOM 和 NUL 字节,不猜测编码,也不自动修复输入。
\ No newline at end of file 是脚本为缺少末尾换行的差异行补上的提示,不属于配置内容。文件尺寸和行数上限只是控制示例规模,不是运行时间保证;大量重复行仍可能使比较变慢。符号链接会跟随目标,示例不防御恶意路径、并发替换或网络文件系统挂起。
4. 运行:看懂差异和退出码
- 查看帮助,创建两份小配置,再比较:
python3 compare_config.py --help
printf 'workers = 2\n' > before.toml
printf 'workers = 4\n' > after.toml
if python3 compare_config.py before.toml after.toml; then
rc=0
else
rc=$?
fi
printf 'exit=%s\n' "$rc"
输出如下。用 if 保存退出码,可以在启用 set -e 的 shell 中保留“发现不同”这一正常分支:
--- before
+++ after
@@ -1 +1 @@
-workers = 2
+workers = 4
exit=1
--- 和 +++ 是前后文件标签;@@ 是行号范围;- 表示旧内容,+ 表示新内容。最后的 exit=1 来自 shell,不属于差异。
| 退出码 | 含义 | 标准输出 |
|---|---|---|
| 0 | 规范化行尾后相同 | 空 |
| 1 | 文本不同,需要审阅 | 统一差异 |
| 2 | 读取、编码、尺寸或参数错误 | 读取检查失败时为空 |
--help 会输出帮助并退出 0。无效参数由 argparse报告并退出 2;allow_abbrev=False 拒绝 --cont 之类的缩写,避免拼错选项被意外接受。
- 在现有流水线中区分“不同”与“出错”:
if python3 compare_config.py before.toml after.toml; then
printf 'UNCHANGED: next step is allowed\n'
else
rc=$?
case "$rc" in
1) printf 'CHANGED: review the diff first\n' >&2 ;;
*) printf 'ERROR: comparison failed (exit=%s)\n' "$rc" >&2 ;;
esac
fi
这只是交互式分支演示,不是完整的 CI 门禁;要让脚本阻断流水线,需在失败分支显式 exit "$rc"。不要用 || true 淹没错误。配置有效性是另一步,例如 tomllib 配置校验;目录同步则可接着使用 rsync 先预览再执行。
5. 验证:行尾、编码与原文件保护
在同一目录运行以下自检。它只创建自己的临时目录,允许被测进程返回非零,并检查两份输入的 SHA-256 未变化:
python3 - <<'PY_CHECK'
import hashlib
from pathlib import Path
import subprocess
import sys
import tempfile
with tempfile.TemporaryDirectory(prefix="diff-checks-", dir=".") as folder:
root = Path(folder)
cases = [
("same", b"x = 1\n", b"x = 1\n", 0),
("changed", b"x = 1\n", b"x = 2\n", 1),
("crlf", b"x = 1\r\n", b"x = 1\n", 0),
("no-final-newline", b"x = 1", b"x = 1\n", 1),
("bad-utf8", b"\xff", b"x = 1\n", 2),
("bom", b"\xef\xbb\xbfx = 1\n", b"x = 1\n", 2),
("too-large", b"x" * (256 * 1024 + 1), b"x = 1\n", 2),
]
for label, left, right, expected in cases:
before, after = root / "before.toml", root / "after.toml"
before.write_bytes(left)
after.write_bytes(right)
original = [hashlib.sha256(p.read_bytes()).digest()
for p in (before, after)]
result = subprocess.run(
[sys.executable, "compare_config.py", str(before), str(after)],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == expected, (label, result)
assert original == [hashlib.sha256(p.read_bytes()).digest()
for p in (before, after)]
if expected == 0:
assert result.stdout == result.stderr == ""
elif expected == 1:
assert result.stdout.startswith("--- before\n+++ after\n")
assert result.stderr == ""
else:
assert result.stdout == ""
assert result.stderr.startswith("ERROR:")
if label == "no-final-newline":
assert "\\ No newline at end of file" in result.stdout
print(f"{label}: exit={result.returncode}")
print("PASS: 7 cases; inputs unchanged")
PY_CHECK
预期输出:
same: exit=0
changed: exit=1
crlf: exit=0
no-final-newline: exit=1
bad-utf8: exit=2
bom: exit=2
too-large: exit=2
PASS: 7 cases; inputs unchanged
自检中的 timeout=10 只限制测试子进程,不是对比工具的内置超时。扩展到真实配置前,先用脱敏副本验证所需的比较规则。
6. 排障:先区分输入问题和真实差异
| 现象 | 原因与处理 |
|---|---|
UnicodeDecodeError 信息出现在 ERROR: 后 |
输入不是有效 UTF-8;先确认来源编码并转换副本,不要忽略错误字节。 |
UTF-8 BOM found |
输入有 BOM;先在副本上确认并去掉 BOM,再比较。 |
not a regular file |
文件缺失或传入了目录等非普通文件;检查当前目录和输入路径。 |
file exceeds ... |
超过示例的字节或行数上限;缩小到相关配置片段,不要直接拿海量日志比较。 |
| 肉眼相同却退出 1 | 检查空格、空行、注释和最后一行的换行;工具不会忽略这些差异。 |
| Windows 与 Linux 版本退出 0 | 可能只有 CRLF/LF 差异,已按约定规范化;这不是字节校验。 |
差异会包含配置中的真实值,不要将密钥、令牌或私密地址写入公开日志。本工具面向审阅,不把输出当作原始字节的可还原补丁;也不要直接将未知配置应用到服务器。
7. 总结
先约定行尾与编码规则,再用统一差异定位配置变化。将退出码 1 视为“需要审阅”,将退出码 2 视为“检查失败”,就能避免自动化流程把两者混在一起。保留原文件,并在文本审阅之后单独执行配置解析与应用检查。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-difflib-config-diff.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。