設定ファイルを書き換えたあとで、前と何が変わったのかわからなくなったことはありませんか。
2つのファイルを左右に並べて、1行ずつ目で追う。数行ならそれで済みますが、100行を超えるとまず見落とします。
そんなときに役立つのが、標準ライブラリのdifflibです。2つの文章の違いを見つけて、Gitと同じ形式で表示してくれます。
しかもdifflibにできるのは差分表示だけではありません。文字列がどれくらい似ているかを数値にしたり、タイプミスに正しい候補を出したりもできます。今回は、この地味だけれど頼れるモジュールを順番に見ていきます。
difflibは、2つのものの違いを見つける道具¶
difflibという名前は、difference(差分)とlibrary(ライブラリ)を合わせたものです。
扱えるのは文字列だけではありません。リストのように順番のあるデータなら、何でも比べられます。
ファイルを比べるときは、ファイルを行のリストとして読み込んで渡すのが基本です。ファイルの読み込みに自信がない方は、先にこちらを読んでおくと安心です。【関連記事】Pythonのファイル読み書きとは?open()とwith文の基本を初心者向けに解説
標準ライブラリなので、pip installは要りません。import difflibと書けば、すぐに使えます。
まず覚えたい4つの道具¶
difflibにはいくつもの関数とクラスがありますが、最初に覚えるのは次の4つで十分です。
| 名前 | できること | 向いている場面 |
|---|---|---|
unified_diff |
Gitと同じ形式で差分を出す | ファイルの変更点を確認する |
ndiff |
1文字単位の違いまで印を付ける | 短い文章の細かい違いを探す |
SequenceMatcher |
似ている度合いを0から1の数値にする | 名寄せやデータのチェック |
get_close_matches |
候補の中から似ているものを選ぶ | タイプミスへの候補表示 |
上の2つは差分を見るための道具です。下の2つは似ているかどうかを測るための道具だと考えてください。
unified_diffで、Gitと同じ差分を出す¶
最初は、いちばん出番の多いunified_diffです。
例として、データベースの接続設定を書き換えた場面を考えます。変更前と変更後を行のリストで用意して、関数に渡します。
import sys
import difflib
old = ["host = localhost\n", "port = 5432\n", "user = admin\n", "timeout = 30\n"]
new = ["host = db.example.com\n", "port = 5432\n", "user = admin\n",
"timeout = 60\n", "retry = 3\n"]
diff = difflib.unified_diff(old, new,
fromfile="config_old.ini", tofile="config_new.ini")
sys.stdout.writelines(diff)
手元のPython 3.11で実行すると、次のように表示されました。
--- config_old.ini
+++ config_new.ini
@@ -1,4 +1,5 @@
-host = localhost
+host = db.example.com
port = 5432
user = admin
-timeout = 30
+timeout = 60
+retry = 3
Gitを使ったことがある方なら、見覚えのある形ではないでしょうか。
記号の読み方¶
この形式は、行の先頭に付いた記号で意味を表します。
-で始まる行は変更前にだけあった行、+で始まる行は変更後にだけある行です。空白で始まる行は、どちらにもある変わらなかった行です。
@@ -1,4 +1,5 @@は、変更前の1行目から4行分が、変更後の1行目から5行分になったという意味です。
-は消えた行、+は増えた行。この2つだけ覚えておけば、差分はほとんど読めます。
Gitの差分表示もこの形式なので、ここで慣れておくとgit diffも怖くなくなります。【関連記事】Python学習にGitは必要?初心者が最低限覚えたい使い方
引数と、戻り値の扱いに注意¶
unified_diffの戻り値は、結果の行を1行ずつ取り出せるジェネレーターです。そのままprintしても中身は表示されません。
そこで上の例ではsys.stdout.writelinesに渡しました。文字列にまとめたいときは、"".join(diff)とするのが定番です。
変わらなかった行を前後に何行表示するかは、引数nで決まります。初期値は3行です。
また、各行の末尾に改行が付いていることを前提にしています。splitlines()で改行を落としたリストを渡すときは、lineterm=""を指定すると表示が崩れません。
ndiffで、1文字の違いまで見つける¶
unified_diffは行単位で違いを教えてくれます。けれど、長い1行のどこが変わったのかまではわかりません。
そんなときはndiffを使います。似ている行どうしを見比べて、違う文字の位置に印を付けてくれます。
import sys
import difflib
before = ["apple\n", "banana\n"]
after = ["apple\n", "bananna\n"]
sys.stdout.writelines(difflib.ndiff(before, after))
出力はこうなりました。
apple
- banana
+ bananna
? +
?で始まる行が、ndiffならではの目印です。+の位置に、余計なnが1文字入ったことがわかります。
私は10年ほどエンジニアとして開発に関わってきましたが、見た目がそっくりな2行の違いを探す作業にはずいぶん時間を使ってきました。全角スペースが1つ混じっていただけ、ということもあります。人の目で探すより、道具に印を付けてもらうほうが確実です。
SequenceMatcherで、似ている度合いを数値にする¶
ここからは、差分ではなく似ているかどうかの話に移ります。
SequenceMatcherは、2つの文字列がどれくらい似ているかを0から1の数値で返してくれます。1なら完全に同じ、0ならまったく共通点がないという意味です。
from difflib import SequenceMatcher
pairs = [("python", "pyhton"), ("東京都", "東京"), ("abc", "xyz")]
for a, b in pairs:
ratio = SequenceMatcher(None, a, b).ratio()
print(a, b, round(ratio, 3))
実行結果は次のとおりです。
python pyhton 0.833
東京都 東京 0.8
abc xyz 0.0
最初の引数のNoneは、無視する文字を指定する場所です。特に無視したいものがなければNoneで構いません。
ratioの数値はどうやって決まるのか¶
公式ドキュメントによると、ratio()は2つの長さの合計をT、一致した文字の数をMとして、2.0×M÷Tで計算されます。
pythonとpyhtonなら、長さの合計は12です。一致したと数えられたのは5文字なので、10÷12で0.833になります。
注意したいのは、比べる順番を入れ替えると値が変わることがある点です。手元で試したところ、tideとdietの組み合わせは0.25になり、順番を逆にすると0.5になりました。
ratioは似ている度合いの目安であって、厳密な距離ではありません。しきい値を決めるときは、実際のデータで何件か試してから決めましょう。
get_close_matchesで、タイプミスに候補を出す¶
SequenceMatcherの仕組みを使って、候補の中から似ているものを選んでくれるのがget_close_matchesです。
コマンドの打ち間違いに、もしかしてと候補を出す場面を考えてみます。
import difflib
commands = ["start", "stop", "status", "restart", "install"]
def suggest(word):
if word in commands:
return f"{word} を実行します"
candidates = difflib.get_close_matches(word, commands, n=1)
if candidates:
return f"{word} というコマンドはありません。もしかして {candidates[0]} ですか?"
return f"{word} というコマンドはありません。"
print(suggest("strat"))
print(suggest("instal"))
print(suggest("xyz"))
実行すると、こう表示されました。
strat というコマンドはありません。もしかして start ですか?
instal というコマンドはありません。もしかして install ですか?
xyz というコマンドはありません。
わずか数行で、親切なエラーメッセージになりました。似ている候補がないときは空のリストが返るので、その場合の分岐も忘れずに書いておきます。
nとcutoffで、候補の出し方を調整する¶
get_close_matchesには、結果を調整する引数が2つあります。
nは返す候補の最大数で、初期値は3です。cutoffは候補として認める似ている度合いの下限で、初期値は0.6です。
たとえばstatをそのまま渡すと、start、status、restartの3つが似ている順に返ってきました。候補が多すぎるならnを減らし、的外れな候補が混じるならcutoffを上げます。
コマンドラインツールを自作している方は、argparseと組み合わせるとぐっと使いやすくなります。【関連記事】Pythonでコマンドラインツールを作るには?argparse入門
HtmlDiffで、左右に並べて見比べる¶
差分をプログラマー以外の人に見せたいときは、記号だらけの表示だと伝わりません。
HtmlDiffを使うと、変更前と変更後を左右に並べたHTMLの表を作れます。変わった部分には色が付くので、ブラウザで開くだけで見比べられます。
import difflib
from pathlib import Path
html = difflib.HtmlDiff().make_file(old, new, "変更前", "変更後")
Path("diff.html").write_text(html, encoding="utf-8")
ここでのoldとnewは、先ほどunified_diffで使った行のリストです。
私は以前、マスタデータの修正内容を運用担当の方に確認してもらう場面で、この方法を使いました。テキストの差分を送ったときは読んでもらえませんでしたが、色付きの表にしたら一目で伝わりました。
つまずきやすいところ¶
最後に、初心者がひっかかりやすい点を3つ挙げておきます。
ひとつめは、文字列をそのままunified_diffに渡してしまうことです。文字列は1文字ずつの並びとして扱われるので、行ではなく文字ごとの差分になります。ファイル全体を比べるときは、readlines()やsplitlines(keepends=True)で行のリストにしてから渡してください。
ふたつめは、大きなデータでの速さです。difflibは手軽ですが、何万行もあるファイルどうしの比較は時間がかかることがあります。大量のファイルを比べるなら、Gitやdiffコマンドのような専用の道具に任せるのも手です。
みっつめは、空白や大文字小文字の違いも差分として扱われることです。見た目の違いを無視したいときは、比べる前にstrip()やlower()で整えておきましょう。文字列の整え方はこちらにまとめています。【関連記事】Pythonの文字列メソッドとは?strip・split・replaceでデータを整える基本を初心者向けに解説
まとめと、次の一歩¶
difflibは、違いを見つけることと、似ているものを探すことの両方をこなせるモジュールです。
ファイルの変更点を知りたいならunified_diff、1文字の違いを探すならndiff。似ている度合いはSequenceMatcher、タイプミスへの候補はget_close_matchesです。
まずは、手元の設定ファイルを2つ用意してunified_diffで比べてみてください。目で探していた違いが一瞬で並ぶはずです。
目で見比べる作業は、疲れるうえに見落としが出ます。探すのは道具に任せて、あなたは違いの意味を考えることに時間を使いましょう。
ここまでお読みいただきありがとうございました。