拆分 Python 模組:pytest 的特徵測試、同名套件與重新匯出、自動登記的分派表、避免循環匯入
這篇整理在 Python 裡拆分一個模組、而對外行為不變的寫法,範圍從職責邊界已經決定好那一刻開始。是否要拆、依什麼切,以及拆分時與語言無關的三件事——特徵測試鎖住行為、搬移與修改分開提交、拆出模組之間的依賴只朝一個方向——寫在從修改紀錄畫模組邊界:那一篇用 git 歷史統計每個檔案被幾個不同的需求修改,再在〈從修改紀錄找出職責邊界〉逐個 commit 把需求歸到職責。本篇沿用那一篇的範例:一支 commit 守衛 guard.py,多個 AI 代理人各自在自己的工作目錄同時開發時,代理人工具在執行每一條 shell 指令之前把整條指令字串交給它判斷,它擋下把 commit 寫進其他代理人工作目錄的寫法。修改紀錄歸類出三種職責:命令解析、狀態判斷、訊息產生。
同在 Python 重構這一章的重構的動機與策略用認知負擔判斷何時該重構、怎麼分階段進行,程式碼壞味道偵測從程式碼本身的寫法辨認壞味道;本篇處理的是決定拆之後怎麼拆。
用 pytest 寫特徵測試鎖住拆分前的行為
特徵測試(characterization test)的預期值取自拆分前程式當下的實際輸出,所以寫測試時先對拆分前的程式跑一次,把輸出填成預期值;測試表要含應該擋下的反例,理由在從修改紀錄畫模組邊界的〈拆分時與語言無關的做法〉。用 pytest 寫成:
1import pytest
2
3import guard
4from guard import evaluate # 拆分前後都從這裡匯入
5
6@pytest.fixture(autouse=True)
7def fixed_parallel_state(monkeypatch):
8 # 固定並行狀態:只有 /other 是其他代理人正在使用的工作目錄
9 monkeypatch.setattr(guard, "is_parallel_period", lambda target_dir: target_dir == "/other")
10
11@pytest.mark.parametrize("command, expected", [
12 ("git commit -m 'msg'", "allow"),
13 ("git -C /other commit -m 'msg'", "block"), # 拆分前的輸出是 block:-C 把 commit 指到別的工作目錄
14 ("echo 'git commit'", "allow"), # 字串中出現的 git
15])
16def test_evaluate_behavior_is_unchanged(command, expected):
17 assert evaluate(command) == expected
18
19def test_block_message_is_unchanged(capsys):
20 # 訊息產生也是要拆的一類,所以印出的內容也要斷言;
21 # 把 <拆分前印出的訊息> 換成拆分前的程式實際印出的那一行
22 evaluate("git -C /other commit -m 'msg'")
23 assert capsys.readouterr().out == "<拆分前印出的訊息>\n"is_parallel_period 的結果取決於執行當下有哪些代理人在工作,所以 fixture 先把它固定住,測試在任何機器上都得到同一個結果。evaluate 每次執行時都在 guard 模組的命名空間裡查 is_parallel_period 這個名字,fixture 用 monkeypatch 替換的正是那個名字。〈依職責萃取模組:改成同名套件並重新匯出,保留原本的匯入路徑〉會把 guard.py 改成同名套件、evaluate 留在 guard/__init__.py,evaluate 查名字的位置仍是 guard 這個命名空間,所以拆分前後同一個 fixture 都生效;evaluate 之後若搬進 guard/core.py 這類子模組,它查名字的位置變成 guard.core,monkeypatch.setattr 的第一個引數要跟著改成 guard.core。
測試表裡的 git -C /other commit 是應該擋下的寫法。這個範例只示範結構;實際的 commit 守衛還要處理 --git-dir、--work-tree、GIT_DIR 環境變數與先 cd 再 commit 這些同樣會把 commit 寫到別處的寫法。
依職責萃取模組:改成同名套件並重新匯出,保留原本的匯入路徑
呼叫端寫的是 from guard import evaluate,所以拆分之後 guard 這個名字要繼續提供 evaluate。做法是刪掉 guard.py,改成同名的套件目錄 guard/,把每一類職責的函式移到套件裡的新模組,guard/__init__.py 從新模組匯入對外名稱,再以套件的名義重新匯出(re-export):
1guard.py ← 原本 1200 行,三種職責混在一起;拆分時刪除
2
3guard/
4├── __init__.py ← 保留 evaluate 等對外名稱,呼叫端不用改
5├── command_parse.py ← 命令解析
6├── state.py ← 狀態判斷
7└── messages.py ← 訊息產生 1# guard/__init__.py
2from .command_parse import parse_command
3from .messages import build_block_message
4from .state import is_parallel_period
5
6def evaluate(command: str) -> str:
7 parsed = parse_command(command)
8 if parsed.is_commit and is_parallel_period(parsed.target_dir):
9 print(build_block_message(parsed))
10 return "block"
11 return "allow"guard.py 與 guard/ 同時存在時,Python 的匯入系統在 import guard 時取到的是套件,留下來的 guard.py 沒有人載入,之後改它不會有任何效果,所以要刪掉。git 依內容相似度判定改名,改名與搬函式混在同一個 commit 時,__init__.py 只剩下原檔的一小部分,git 認不出它是 guard.py 改名而來,git log --follow guard/__init__.py 就追不到改名之前的歷史。所以刪的方式是把它改名成套件的 __init__.py,單獨提交一個只改名的 commit,再往外搬函式:
1mkdir guard # git mv 不會自動建立目標目錄
2git mv guard.py guard/__init__.py
3git commit -m "guard.py 改成套件 guard/(只改名)"這個 commit 只影響 git log 的追蹤;等待中的需求的分支 rebase 到拆分之後的版本時,git 的改名偵測比對的是拆分前後的整體差異、不看中間的 commit,所以它不決定哪一份新檔被當成改名目標(衝突的兩種形態見從修改紀錄畫模組邊界的〈拆分的時機與等待中的需求〉)。
原本寫 from guard import evaluate 的呼叫端不用改。__init__.py 從新模組匯入的 parse_command、build_block_message、is_parallel_period 三個名字都被 evaluate 用到,所以要留著;is_parallel_period 另外還是〈用 pytest 寫特徵測試鎖住拆分前的行為〉那個 fixture 替換的名字。舊的呼叫端若也直接寫 from guard import parse_command,parse_command 這個名字同時負責相容,等那些呼叫端改成從 guard.command_parse 匯入之後,相容的用途才結束。__init__.py 在套件匯入時扮演的角色與 from package import name 的解析方式,見 Python 官方教學的 Packages 一節。
拆分時常會發現同一種判斷在好幾個檔案各寫了一份,例如 commit 守衛和另外兩支擋 push、擋 rebase 的守衛,各自實作「從命令中取出 commit 的目標工作目錄」。三份跟著同一條規則改,是同一份知識(判斷依據見 DRY 卡片),規則一改三份都要改,這是散彈式修改(Shotgun Surgery);收斂成共用模組的做法與遷移時的陷阱在DRY 原則與共用程式庫。
把巨型分支改成自動登記的分派表
一個函式的主體是一長串 if/elif、每個分支處理一種事件類型時,每新增一種事件類型都要在這個函式加一個分支。這個函式是寫成程式碼的集中登記檔(每加一個功能就要在這裡登記一筆的檔案),它所在的檔案一直被不同的需求修改,留在熱點排行(依被幾個不同需求修改排序的檔案清單)的前段;兩個詞的完整說明在從修改紀錄畫模組邊界的〈用 git 歷史量出熱點〉與〈熱點的分類與處理方式〉。改成分派表(dispatch table)之後,新增一種事件類型只需要加一個處理函式並登記,這是把「新增事件類型」這一類變更做成擴充點,對應的原則是 Open-Closed Principle(開放封閉原則):
1# 改之前:每個新需求都要改這個函式
2def handle(event):
3 if event.kind == "commit":
4 ...
5 elif event.kind == "merge":
6 ...
7 elif event.kind == "rebase":
8 ...
9
10# 改之後:新增事件類型只需要加一個處理函式並登記
11HANDLERS = {}
12
13def register(kind):
14 def decorator(func):
15 if kind in HANDLERS:
16 raise ValueError(f"事件類型 {kind!r} 已經登記過") # 兩個模組登記同一種類型時直接報錯
17 HANDLERS[kind] = func
18 return func
19 return decorator
20
21@register("commit")
22def handle_commit(event): ...
23
24@register("merge")
25def handle_merge(event): ...
26
27@register("rebase")
28def handle_rebase(event): ...
29
30def handle(event):
31 handler = HANDLERS.get(event.kind)
32 if handler is None:
33 return None # 與改之前相同:未知的事件類型不處理
34 return handler(event)register 遇到重複的事件類型就報錯:改之前的 if/elif 裡,同一種類型寫了兩個分支時第一個生效、而且兩個分支在同一個函式裡看得到;改成自動登記之後,兩個模組登記同一種類型時,後被匯入的那一個會靜默蓋掉前一個,而兩個需求同時加同一種事件類型正是並行開發會遇到的情形。
分派表要讓檔案離開熱點排行,處理函式還得放進各自的模組:處理函式都寫在同一個檔案裡時,每個新需求仍然改這個檔,而且都在檔尾追加,兩個同時進行的需求照樣衝突。處理函式各放一個模組之後,手動列出 import 的那份清單(一個模組加一行 import)又是一份集中登記檔,所以登記改成由載入端掃描目錄。HANDLERS 與 register 放進獨立的 registry.py,處理函式的模組從那裡匯入。理由是依賴方向:registry.py 在最下層,只提供登記的機制,不依賴任何處理函式。把它們寫在 handlers/__init__.py 裡時,能否載入取決於定義寫在掃描迴圈的前面還是後面——寫在後面,處理函式的模組在掃描時被匯入、去 handlers 套件找 register,而那時 handlers 套件還沒執行到定義 register 的那一行,匯入就失敗。handle 留在原本的模組、不放進 registry.py 也是同一個理由:registry.py 若為了觸發登記去 import handlers,最下層就依賴了上層,形成循環匯入,能否載入又變成取決於那行 import 擺在檔案的哪裡。三個位置的配置是:
1registry.py ← HANDLERS 與 register,不匯入任何處理函式
2handlers/
3├── __init__.py ← 掃描同目錄,逐一匯入(見下一個區塊)
4├── commit.py ← from registry import register;@register("commit") def handle_commit
5└── merge.py ← 同上,一種事件類型一個檔
6dispatch.py ← handle() 原本所在的模組:import handlers 觸發登記,handle() 用 HANDLERS.get 分派1# handlers/__init__.py:匯入同目錄底下的每一個模組,模組裡的 @register 在匯入時執行登記
2import importlib
3import pkgutil
4
5for module in pkgutil.iter_modules(__path__):
6 importlib.import_module(f"{__name__}.{module.name}")新增一種事件類型只需要在 handlers/ 底下加一個檔案;handle 所在的模組要先 import handlers,登記才會發生。處理函式分散在不同套件時,改用套件的 entry points:每個套件在自己的 pyproject.toml 宣告登記點,安裝之後由 importlib.metadata.entry_points() 列舉出來。這兩種自動登記的共同代價(靜態分析看不到登記關係、半成品檔也會被登記)見從修改紀錄畫模組邊界的〈熱點的分類與處理方式〉。登記式的外掛(plugin)架構在插件系統設計,用 metaclass 與裝飾器自動註冊的寫法在案例:自動註冊機制。
拆出模組之間的循環匯入:Python 的症狀與解法
拆出來的模組之間依賴要只朝一個方向,判斷上下層的方法與互相呼叫時是否併回同一個模組,寫在從修改紀錄畫模組邊界的〈拆分時與語言無關的做法〉。Python 違反這一條時,症狀依匯入的寫法而定。兩個模組用 from .x import y 互相匯入時,症狀是匯入失敗:例如 messages.py 先被匯入,執行到第一行 from .state import ... 時開始執行 state.py,state.py 第一行的 from .messages import build_block_message 要取的名字還不存在(messages.py 還停在它的第一行),Python 拋出 ImportError: cannot import name 'build_block_message' from partially initialized module '...messages' (most likely due to a circular import)——traceback 指向後被匯入的 state.py,訊息裡寫的是先被匯入的 messages。改用 from . import state 這種只匯入模組、呼叫時才取屬性的寫法,或把 import 寫在被取用的定義之後,循環照樣載入、執行正確,依賴方向卻仍然違反;所以沒有 ImportError 不代表依賴是單向的,確認要讀 import 的關係圖。
這個例子裡 command_parse.py 只把指令字串解析成結構,在最下層;state.py 與 messages.py 使用解析結果;__init__.py 組合三者,在最上層。兩個模組都用到的定義(例如 state.py 與 messages.py 都用到的「並行狀態」資料型別)往下抽成一個 models.py,兩者都從 models.py 匯入、彼此不互相匯入,循環就斷開。models.py 之後被每個需求都改到時的判讀,在從修改紀錄畫模組邊界的〈拆分之後重跑熱點統計〉。
實作練習:反例測試
為一個要拆的模組補一組特徵測試,至少包含一筆「應被擋下或應回報錯誤」的反例,再思考:拆分時如果拆錯了——例如把 -C 參數的解析搬進 command_parse.py 時漏掉一個分支——這組測試抓得到嗎?
是否要拆、依什麼切,以及拆分時與語言無關的特徵測試、搬移與修改分開提交、單向依賴:從修改紀錄畫模組邊界(用 git 歷史量出被多個需求修改的檔案,逐個 commit 歸類職責,拆分的時機與 rebase 時的衝突形態)。
上一章:作用域迴歸案例研究