Python でファイルの差分を取得する(difflib)

ファイルの差分を確認する作業は、設定ファイルの変更確認やコードレビューなど様々な場面で必要になる。Python の標準ライブラリ difflib は、テキストの差分抽出から類似度計算まで幅広い比較機能を提供している。

基本的な差分表示:unified_diff

最もよく使われるのが unified_diff() だ。Git の diff 出力と似た形式で差分を表示してくれる。

import difflib

old = """\
host = localhost
port = 5432
debug = false
timeout = 30
""".splitlines(keepends=True)

new = """\
host = localhost
port = 3306
debug = true
timeout = 30
log_level = INFO
""".splitlines(keepends=True)

diff = difflib.unified_diff(old, new, fromfile="old.conf", tofile="new.conf")
print("".join(diff))

出力結果は次のようになる。- が削除行、+ が追加行を表す。

# --- old.conf
# +++ new.conf
# @@ -1,4 +1,5 @@
#  host = localhost
# -port = 5432
# -debug = false
# +port = 3306
# +debug = true
#  timeout = 30
# +log_level = INFO

splitlines(keepends=True) で改行を保持している点がポイントだ。改行なしで分割すると、出力のフォーマットが崩れてしまう。

ファイル同士の差分を取る

実際のファイル比較では、ファイルを読み込んでから unified_diff() に渡す。

import difflib

def file_diff(file1, file2):
    with open(file1, encoding="utf-8") as f:
        old_lines = f.readlines()
    with open(file2, encoding="utf-8") as f:
        new_lines = f.readlines()

    diff = difflib.unified_diff(
        old_lines, new_lines,
        fromfile=file1, tofile=file2
    )
    return "".join(diff)

result = file_diff("config_old.ini", "config_new.ini")
if result:
    print(result)
else:
    print("差分なし")

差分がない場合は unified_diff() が空のイテレータを返すため、結果が空文字列かどうかで判定できる。

context_diff:前後の文脈を表示する

context_diff() は、変更箇所の前後を明示的に区別する形式で差分を表示する。unified_diff() より古い形式だが、変更前と変更後がブロックごとに分かれているため、見やすいと感じる人もいる。

import difflib

old = ["alpha\n", "beta\n", "gamma\n", "delta\n"]
new = ["alpha\n", "BETA\n", "gamma\n", "epsilon\n"]

diff = difflib.context_diff(old, new, fromfile="old", tofile="new")
print("".join(diff))
unified_diff

変更箇所を 1 つのブロックにまとめて表示。Git や多くのツールで採用されている標準的な形式

context_diff

変更前と変更後を別々のブロックで表示。差分が大きい場合に変更の全体像を把握しやすい

ndiff:文字レベルの差分

ndiff() は行単位ではなく、行内の文字レベルでの変更も検出できる。

import difflib

old = ["red\n", "green\n", "blue\n"]
new = ["red\n", "GREEN\n", "blue\n", "yellow\n"]

diff = difflib.ndiff(old, new)
print("".join(diff))

出力には ? マーカーで文字レベルの変更箇所が示される。

#   red
# - green
# + GREEN
# ?  ^^^^
#   blue
# + yellow

? の行にある ^ 記号が、行内で変更された文字の位置を指し示している。細かいタイポの発見や、設定値の微妙な変更を見つけるのに有効だ。

HTML 形式の差分レポート

HtmlDiff クラスを使えば、ブラウザで閲覧できるリッチな差分レポートを生成できる。

import difflib

old = ["alpha", "beta", "gamma", "delta"]
new = ["alpha", "BETA", "gamma", "delta", "epsilon"]

html_diff = difflib.HtmlDiff()
result = html_diff.make_file(old, new,
                              fromdesc="旧バージョン",
                              todesc="新バージョン")

with open("diff_report.html", "w", encoding="utf-8") as f:
    f.write(result)

生成された HTML ファイルをブラウザで開くと、変更箇所がハイライトされた 2 カラムの比較表が表示される。非技術者への報告や、レビュー資料の作成に便利だ。

make_table() を使えば、HTML の table 要素だけを取得することもできる。既存の Web ページに差分表示を埋め込みたい場合に使うとよい。

類似度の計算:SequenceMatcher

difflib には差分表示だけでなく、2 つのシーケンスの類似度を数値で算出する SequenceMatcher クラスも含まれている。

import difflib

s = difflib.SequenceMatcher(None, "Python", "Pyhton")
print(s.ratio())  # 0.8333333333333334

s = difflib.SequenceMatcher(None, "apple", "apple")
print(s.ratio())  # 1.0

s = difflib.SequenceMatcher(None, "apple", "orange")
print(s.ratio())  # 0.36363636363636365

ratio() は 0.0(完全に異なる)から 1.0(完全一致)の範囲で類似度を返す。ファイル内容の同一性チェックよりも、ファジーマッチングや重複検出に向いている。

最も類似した文字列を見つける:get_close_matches

get_close_matches() は、候補リストの中から入力に最も近い文字列を返す関数だ。スペルミスの修正候補やあいまい検索に使える。

import difflib

commands = ["commit", "checkout", "cherry-pick", "clone",
            "config", "clean", "diff", "fetch"]

# "comit" に近いコマンドを検索
matches = difflib.get_close_matches("comit", commands)
print(matches)  # ['commit']

# "chek" に近いコマンドを検索
matches = difflib.get_close_matches("chek", commands, n=3, cutoff=0.5)
print(matches)  # ['checkout', 'cherry-pick', 'fetch']

n は返す候補の最大数、cutoff は類似度の閾値(0.0〜1.0)だ。cutoff を低くすると緩い一致でも候補に含まれるようになる。

ユーザーの入力を受け取る

get_close_matches() で候補を検索

「もしかして○○ですか?」と提案

CLI ツールで未知のサブコマンドが入力されたときに「Did you mean …?」と提案する機能を実装する際などに重宝する。

実践例:設定ファイルの変更監視

定期的にファイルの差分を監視して、変更があれば通知するスクリプトの例を示す。

import difflib
import time
from pathlib import Path

def watch_file(filepath, interval=5):
    path = Path(filepath)
    prev_content = path.read_text(encoding="utf-8").splitlines(keepends=True)

    print(f"監視開始: {filepath}")
    while True:
        time.sleep(interval)
        current = path.read_text(encoding="utf-8").splitlines(keepends=True)

        if current != prev_content:
            diff = difflib.unified_diff(
                prev_content, current,
                fromfile="before", tofile="after"
            )
            print(f"\n--- 変更検出 ({filepath}) ---")
            print("".join(diff))
            prev_content = current

watch_file("config.ini")

本格的なファイル監視には watchdog ライブラリのほうが適しているが、ちょっとした監視スクリプトなら difflib と time.sleep() の組み合わせで十分対応できる。difflib は標準ライブラリだけで完結するため、サーバー上で追加インストールなしに使えるのも大きなメリットだ。