快轉到主要內容
  1. 教學文章/

Python difflib 實戰:文字差異比對、相似度比較與 patch 輸出完全攻略

·10 分鐘· loading · loading · ·
Python Difflib Text-Processing Developer-Tools Cli
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 49: 本文

featured

一. 前言:很多工具真正核心,其實只是「比對差異」
#

你有沒有遇過這些場景?設定檔改了一點點,但看半天不知道差在哪;使用者打錯指令,你想幫他猜最接近的正確名稱;兩份文字內容很像,但你想知道到底是新增、刪除還是替換;做了一個文件同步工具,想輸出像 Git 一樣的 diff 給人看。這些問題表面上看起來很多種,本質上其實都跟「比較兩段序列有多像、哪裡不同」有關。

而 Python 標準函式庫裡,就有一個很安靜但很能打的模組:difflib。它最常被拿來做幾件事:

  • 比較兩段文字或兩個列表
  • 產生 ndiffcontext diffunified diff
  • 根據相似度找出最接近的候選字串
  • 輸出 HTML 版差異報表

如果你前面看過拍拍君寫的 Python argparse 實戰Python pathlib 實戰Python prompt_toolkit 實戰,今天這篇可以把它們串起來看:argparse 幫你收參數,pathlib 幫你處理檔案路徑,difflib 幫你看懂差異,而 prompt_toolkit 可以在互動式介面裡把比對結果包得更漂亮。

這篇文章會一路帶你做五件事:理解 SequenceMatcher、學會 ndiff()unified_diff()、用 get_close_matches() 做 typo 建議、用 HtmlDiff 生報表,最後做一個小型 CLI,比對兩個文字檔。difflib 不會讓你變成 Git,但很多「需要比較差異」的日常工具,它其實已經很夠用了。

二. 安裝:好消息,這次幾乎是零安裝
#

difflib 是 Python 標準函式庫的一部分,所以多數情況下你不用額外安裝任何套件,只要這樣就能開始:

import difflib

如果你想在乾淨環境裡試玩,可以用 uv 快速開一個小專案:

uv init difflib-demo
cd difflib-demo
uv venv
source .venv/bin/activate
python - <<'PY'
import difflib
print('difflib ready')
PY

拍拍君先提醒兩件事。第一,difflib 比較的是「序列」,不只是字串;也就是說,不只 str 可以比,list[str]list[int] 甚至你自己切好的 token list 都能比。第二,difflib 的設計重點比較偏向「人類可讀的差異呈現」,不是所有場景都追求最數學上的最佳解。所以如果你要的是超大型文本的極致效能、嚴格的編輯距離演算法,或語意層級的相似度,那就要看別的工具;但如果你的需求是 CLI 顯示差異、文件版本比較、使用者輸入提示,或快速做出 patch-like 輸出,那 difflib 真的很好用。

三. 先從 SequenceMatcher 開始:理解它怎麼判斷相似
#

difflib 的核心角色之一是 SequenceMatcher。它的工作可以粗略理解成:找出兩個序列裡「最長、最像、可以對上的區塊」,再據此推算整體相似度與差異位置。

先看一個簡單例子:

from difflib import SequenceMatcher

old = ["python", "diff", "tool"]
new = ["python", "text", "diff", "tool"]

matcher = SequenceMatcher(a=old, b=new)
print(matcher.ratio())
for opcode in matcher.get_opcodes():
    print(opcode)

你會看到類似這樣的結果:

0.8571428571428571
('equal', 0, 1, 0, 1)
('insert', 1, 1, 1, 2)
('equal', 1, 3, 2, 4)

這表示第一個元素 python 一樣,new 中間多插入了一個 text,後面的 difftool 又重新對上了。

ratio() 是什麼意思?
#

ratio() 會回傳 0 到 1 之間的浮點數。越接近 1 代表越像,越接近 0 代表越不像。它很適合拿來做模糊建議、排序候選結果,或判斷某次修改是不是小改動。不過它不是語意分數。例如 delete userremove account 語意可能很近,但字面上不一定很像,所以你要記得,difflib 看的是「序列結構」,不是語意理解。

get_opcodes() 超實用
#

get_opcodes() 會把差異拆成一段一段操作,操作類型通常有:equalreplaceinsertdelete。這在做自訂 diff 顯示時非常方便,例如你可能想把替換部分標紅、新增部分標綠、刪除部分加刪除線,那就可以自己遍歷 opcode。

很多時候我們不是要比一整個長字串,而是比「每一行」或「每個詞」。這時你可以先 splitlines()split() 再交給 SequenceMatcher。如果你比較的是自然語言句子,拍拍君很推薦你依需求決定粒度:想看大方向變動就逐行、想看單字替換就逐詞、想看極細節拼字變化才逐字元。這樣做出來的 diff,通常比硬上逐字元更可讀。

一句話總結:SequenceMatcher 像是 difflib 的引擎,負責理解「哪些片段其實對得上」。理解它之後,後面幾個 API 會順很多。

四. ndiff()unified_diff():開始輸出人類看得懂的差異
#

如果你不是想自己解 opcode,而是想直接拿到常見 diff 格式,那就可以交給 ndiff()unified_diff()。這兩個函式都很常用,但用途有點不同。

1. ndiff():教學友善、逐行可讀
#

先看例子:

import difflib

old = ["apple\n", "banana\n", "cat\n"]
new = ["apple\n", "banana split\n", "cat\n", "dog\n"]

for line in difflib.ndiff(old, new):
    print(line, end='')

它會輸出帶前綴的結果: 代表沒變、- 代表舊版本有新版本沒有、+ 代表新版本新增、? 代表輔助提示,指出細節差異位置。ndiff() 很適合教學展示、給人肉眼看差異,或在 terminal 直接輸出簡單比較結果,但它不一定最適合拿去做 patch 檔。

2. unified_diff():更像 Git、patch 工具常見格式
#

如果你想要的是更標準、更接近 Git diff 的輸出,unified_diff() 通常更合適。

import difflib

old = ["apple\n", "banana\n", "cat\n"]
new = ["apple\n", "banana split\n", "cat\n", "dog\n"]

for line in difflib.unified_diff(
    old,
    new,
    fromfile='before.txt',
    tofile='after.txt',
    lineterm='\n',
):
    print(line, end='')

你會看到像這樣的格式:

--- before.txt
+++ after.txt
@@ -1,3 +1,4 @@
 apple
-banana
+banana split
 cat
+dog

這種格式的優點非常明確:大家熟悉、好讀、可以保存成 .diff.patch,也很適合版本比較、審查工具、CLI 輸出。如果你要自己做一個「比較兩個設定檔差異」的小工具,拍拍君大多數時候會先選 unified_diff()

context_diff() 呢?
#

difflib 也有 context_diff()。它輸出的也是經典格式,只是現在大家更常看到 unified diff,所以使用頻率通常沒那麼高。你可以先記住一個實務選擇:想教學、想看得更細用 ndiff();想跟 Git 世界接軌用 unified_diff();需要舊式上下文格式才用 context_diff()

splitlines(keepends=True) 是常見小細節
#

如果你拿的是整段文字,而不是本來就帶換行的 list,記得可以這樣切:

old_lines = old_text.splitlines(keepends=True)
new_lines = new_text.splitlines(keepends=True)

為什麼要 keepends=True?因為 diff 工具很多時候希望保留每行結尾,這樣輸出格式會更穩定。如果你把換行全部吃掉,有時印出來的結果會黏在一起,看起來就很醜。

實務上常見的坑
#

  1. 逐字元 diff 太吵:用在長文件通常可讀性很差,先改成逐行或逐詞。
  2. 直接比 JSON 字串,結果很亂:先 pretty print,再逐行 diff。
  3. 沒有正規化就直接比較:空白、排序、大小寫都可能造成假差異,有些資料先 normalize 再比會更準。

例如你在比較設定檔前,可以先做:

normalized = text.strip().replace('\r\n', '\n')

小小的正規化,常常可以少掉一堆根本不重要的 diff 噪音。

五. get_close_matches():拿來做拼字修正和命令建議超方便
#

很多 CLI 做得貼心的地方,不是功能多,而是使用者打錯時它還願意幫忙。像是使用者輸入 pritnpubishstatuz,你如果只回一句「未知指令」,體驗就比較硬;但 difflib.get_close_matches() 可以讓你輕鬆補上一句:「你是不是想輸入 publish?」

先看一個簡單例子:

from difflib import get_close_matches

commands = ['build', 'deploy', 'diff', 'doctor', 'publish']
word = 'pubish'
print(get_close_matches(word, commands, n=3, cutoff=0.5))

輸出通常會是:

['publish']

get_close_matches(word, possibilities, n=3, cutoff=0.6) 最常調的就是兩個:n 代表最多回傳幾個候選,cutoff 代表最低相似度門檻。門檻越高,候選更保守、不容易亂猜,但可能什麼都猜不到;門檻越低,則比較容易給建議,但有時會出現有點勉強的結果。拍拍君常見的經驗值大概是:CLI 指令用 0.5 ~ 0.7、選單項目用 0.6 ~ 0.8,而人名或自然語言則不一定適合直接用。

做成友善錯誤訊息也很簡單:

from difflib import get_close_matches

commands = ['build', 'deploy', 'diff', 'doctor', 'publish']
user_input = 'pubish'

if user_input not in commands:
    suggestions = get_close_matches(user_input, commands, n=1, cutoff=0.5)
    if suggestions:
        print(f"未知指令:{user_input},你是不是想輸入 {suggestions[0]!r}?")
    else:
        print(f"未知指令:{user_input}")

這種細節很小,但整個工具的「體感品質」會差很多。它不只適合命令列,也很適合拿來做 CSV 欄位名稱比對、設定鍵名修正、API 欄位 typo 警告,或 FAQ 搜尋候選關鍵字。例如你預期欄位叫 username,結果資料裡出現 usernmae,這時就可以提示使用者。這種地方不用上大模型,difflib 就已經很好用了。

六. HtmlDiff:想做可分享的差異報表,就讓瀏覽器來看
#

有些差異很適合在 terminal 看,有些則不然。例如兩份文件都很長、非工程同事也要一起看、想把差異報告存成 HTML 附件,或想放進內部工具頁面。這時候 HtmlDiff 會很好用。

它可以把兩份逐行內容轉成 HTML 表格,左右對照,而且會標出差異。基本範例如下:

from difflib import HtmlDiff
from pathlib import Path

old_lines = Path('before.txt').read_text().splitlines()
new_lines = Path('after.txt').read_text().splitlines()

html = HtmlDiff().make_file(
    old_lines,
    new_lines,
    fromdesc='before.txt',
    todesc='after.txt',
)

Path('report.html').write_text(html, encoding='utf-8')

跑完後,直接打開 report.html 就能看結果。拍拍君覺得 HtmlDiff 最適合三種場景:內容審稿、設定檔變更審查,以及資料清理前後比對。如果原始內容很雜,先做基本清理再輸出 HTML,可讀性通常會好很多,例如把 \r\n 統一成 \n、去掉行尾多餘空白、決定要不要保留空行。否則 HTML 雖然漂亮,但整頁都在標一些沒意義的 whitespace 差異,看了也很累。

另外要注意,HtmlDiff 吐出來的是完整 HTML。如果你要把它塞進現有網頁,可以包進 iframe,或抽取中間內容再整合版型;同時也要注意 escaping 與安全性,尤其是當原始文字來自使用者輸入時,最好先確認整體處理流程是不是安全,不要直接把任何可疑內容原封不動丟進頁面。

七. 實戰:做一個比對兩個文字檔的 CLI
#

前面分開看 API 很清楚,但真正落地通常會長成一個工具。這一節我們來做一個小小的 textdiff.py:接收兩個檔案路徑、可以輸出 unifiedndiff、也可以選擇另外寫出 HTML 報表。這種工具很適合配合 Python argparse 實戰Python pathlib 實戰 一起複習。

先看完整程式:

from __future__ import annotations

import argparse
import difflib
from pathlib import Path


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description='Compare two text files with difflib.',
    )
    parser.add_argument('old_file', help='path to the old file')
    parser.add_argument('new_file', help='path to the new file')
    parser.add_argument(
        '--format',
        choices=['unified', 'ndiff'],
        default='unified',
        help='diff output format',
    )
    parser.add_argument('--html', help='optional output path for HTML report')
    return parser


def read_lines(path: Path) -> list[str]:
    text = path.read_text(encoding='utf-8')
    normalized = text.replace('\r\n', '\n')
    return normalized.splitlines(keepends=True)


def make_diff(
    old_lines: list[str],
    new_lines: list[str],
    fmt: str,
    old_name: str,
    new_name: str,
) -> str:
    if fmt == 'ndiff':
        lines = difflib.ndiff(old_lines, new_lines)
    else:
        lines = difflib.unified_diff(
            old_lines,
            new_lines,
            fromfile=old_name,
            tofile=new_name,
            lineterm='\n',
        )
    return ''.join(lines)


def write_html_report(
    old_lines: list[str],
    new_lines: list[str],
    old_name: str,
    new_name: str,
    output_path: Path,
) -> None:
    html = difflib.HtmlDiff().make_file(
        old_lines,
        new_lines,
        fromdesc=old_name,
        todesc=new_name,
    )
    output_path.write_text(html, encoding='utf-8')


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    old_path = Path(args.old_file)
    new_path = Path(args.new_file)

    old_lines = read_lines(old_path)
    new_lines = read_lines(new_path)

    diff_text = make_diff(
        old_lines,
        new_lines,
        args.format,
        old_path.name,
        new_path.name,
    )
    print(diff_text, end='')

    if args.html:
        write_html_report(
            old_lines,
            new_lines,
            old_path.name,
            new_path.name,
            Path(args.html),
        )
        print(f'\nHTML report written to {args.html}')


if __name__ == '__main__':
    main()

這個版本故意做得很樸素,但已經很實用:

  • argparse 負責 CLI 介面
  • Path.read_text() 負責讀檔
  • splitlines(keepends=True) 保留換行資訊
  • make_diff() 集中處理輸出格式
  • write_html_report() 負責產生 HTML 報表

假設有兩個檔案:before.txtafter.txt,可以這樣跑:

python textdiff.py before.txt after.txt

如果想看 ndiff

python textdiff.py before.txt after.txt --format ndiff

如果想額外輸出 HTML:

python textdiff.py before.txt after.txt --html report.html

再往前走一步,你還可以加上 --ignore-case、用 pathlib 遞迴比較整個資料夾、用 rich 把新增刪除做彩色輸出、用 watchdog 監看檔案自動重跑 diff,或用 prompt_toolkit 做互動式檔案選擇器。這些主角都不同,但底層那顆「比較差異」的小引擎,很多時候還是 difflib

拍拍君也順便潑個冷水:如果你要處理超大檔案、binary file、語意層級的等價判斷,或嚴格 patch 套用流程,那就不要硬把所有事都丟給 difflib。工具不是越萬能越好,而是知道它擅長哪一段。對 difflib 來說,它最擅長的就是:用很低的成本,幫你把文字差異整理成對人有用的資訊。

結語:別小看標準函式庫裡這些「安靜但很能打」的模組
#

difflib 很少像 FastAPIPydanticTextual 那樣一登場就很華麗。它比較像工具箱裡那把你平常沒特別炫耀,但每次拿出來都會想說「啊,還好 Python 內建有這個」的工具。

今天我們一路看了幾件事:

  • SequenceMatcher 怎麼理解相似片段
  • ndiff()unified_diff() 怎麼輸出差異
  • get_close_matches() 怎麼拿來做 typo 建議
  • HtmlDiff 怎麼變成可分享的報表
  • 最後怎麼把它們包成一個小 CLI

如果你正在做文件工具、設定檔管理工具、CLI 體驗優化,或自動化比對流程,那拍拍君真的很推薦把 difflib 放進你的腦中備用清單。它不一定是最強、最快、最潮的方案,但在很多「我只是想把差異看懂」的時刻,它會非常省時間。

下次你想寫 typo suggestion、config diff、patch 輸出,或版本比較報告時,先別急著找第三方套件,標準函式庫可能已經幫你準備好了。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 49: 本文

相關文章

Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略
·10 分鐘· loading · loading
Python Prompt_toolkit Cli REPL Developer-Tools
Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具