開源程式碼審查Python測試CIGitHub軟體工程

24 天內併入六個組織的七筆 PR:維護者教我的七件事

· 17 分鐘閱讀
目錄
  1. 先看數字
  2. 一、CERT/CC SSVC #1225:`df == None` 在 DataFrame 上不是你以為的意思
  3. 二、hotosm drone-tm #882:具名參數綁走了值,`**kwargs` 是空的
  4. 三、NIST ai-metrology-submissions #10:只跑 Linux 的 CI 看不到 Windows 的 traceback
  5. 四、FINOS architecture-as-code #3009:`path.relative` 回傳的是主機的分隔符
  6. 五、UK AISI inspect_k8s_sandbox #236:Helm 模板把清單印成沒有逗號的字串
  7. 六、casact chainladder-python #1205:我說錯了,維護者當場查證
  8. 七、casact chainladder-python #1275:一個字元,775 列全錯
  9. 三個共同病灶
  10. 給想開始貢獻的人

2026 年 8 月 12 日到 9 月 5 日,24 天內,我開的七筆 PR 被六個組織的維護者合併:美國產險精算學會(casact)、人道 OpenStreetMap(hotosm)、美國國家標準暨技術研究院(NIST)、卡內基美隆大學的 CERT/CC、金融科技開源基金會(FINOS)、英國 AI 安全研究院(UK AISI),其中精算學會收了兩筆。全部由對方的維護者按下合併,沒有一筆是自己合併的。

這篇不是慶功文。我想做的是把七筆攤開來對:bug 到底在哪一行、既有測試為什麼沒抓到、審查過程中維護者糾正了我什麼。看完你會發現,六個互不相干的專案,病灶只有三種。

先看數字

組織 PR 開 PR 合併 改動 合併者
casact chainladder-python #1205 08-12 08-15 +100 / -8 henrydingliu
CERT/CC SSVC #1225 08-13 08-24 +78 / -2 ahouseholder
UK AISI inspect_k8s_sandbox #236 08-12 08-19 +27 / -1 art-dsit
FINOS architecture-as-code #3009 08-20 08-23 +90 / -4 LeighFinegold
NIST ai-metrology-submissions #10 08-23 08-26 +19 / -5 marionlb
hotosm drone-tm #882 09-01 09-02 +70 / -1 spwoodcock
casact chainladder-python #1275 09-03 09-05 +27 / -9 henrydingliu

七筆加起來 441 行變動,落在正式程式碼的不到 100 行(其中 51 行是 #1205 一筆),其餘全是測試與設定。這個比例本身就是第一件事:維護者合併的不是修正,是「證明修正是對的」那段。

一、CERT/CC SSVC #1225:`df == None` 在 DataFrame 上不是你以為的意思

SSVC 是 CERT/CC 的漏洞分級決策工具。ascii_tree(dt, df=None) 的第二個參數只要傳值就一定拋 ValueError,因為程式用 == 拿 DataFrame 跟 None 比:

if df == None:
    df = decision_table_to_longform_df(dt)

在 pandas 裡 == 是逐元素運算,回傳的是同形狀的布林表,if 再對整張表問真假,pandas 從 __bool__ 拋例外,這是它的設計。預設路徑活著只因為 None == None 是一個純量 True。整個 repo 加兩個 README 範例沒有一處傳過 df,所以沒人踩到。

修法一行:is None。但我另外改了一處不在 issue 裡的東西:函式原本用 inplace=Truerow 欄位從傳進來的表上刪掉。參數不能用的時候這無所謂,表永遠是函式自己造的;參數一能用,呼叫端自己的物件就會被偷偷改。我把「這一項我最想要第二雙眼睛」寫在 PR 裡,附上改動前後呼叫端欄位的實測。維護者 sei-vsarvepalli 核准時只寫了一句「Thanks for this patch」。

教訓:哨兵值的比較要用 is,這不是風格問題,是 pandas 會拋例外的問題。 而修一個從沒被用過的參數,等於把後面所有沒被用過的副作用一起打開,要一起看。

二、hotosm drone-tm #882:具名參數綁走了值,`**kwargs` 是空的

人道 OpenStreetMap 的無人機任務管理系統,災後空拍影像靠它收集。上傳函式簽章裡有 content_type,文件也寫了,但呼叫 minio 的 put_object 時只傳了四個位置參數加 **kwargscontent_type 是簽章裡的具名參數,Python 會把呼叫端的值綁在它身上,kwargs 裡沒有它,所以它永遠到不了 SDK。每一張災區空拍照都被存成 application/octet-stream,瀏覽器不顯示,只會下載。

同一個函式的 metadata 沒事,因為它不在簽章裡,真的走 **kwargs。十二行上面的兄弟函式 add_file_to_bucket 早就正確地寫了 content_type=content_type

維護者 spwoodcock 問了一個好問題:這個參數不是會被 kwargs 接住嗎?不會。這是 Python 參數綁定的規則,不是這個專案的 bug。我把這件事寫成獨立一篇:Python 的 **kwargs 接不到具名參數

測試的寫法值得說。我沒有 mock 一個假的 put_object 然後斷言「有被呼叫」,而是用 inspect.signature(Minio.put_object).bind(...) 把呼叫端的參數綁到真正的 SDK 簽章上,斷言的是 SDK 真的會送上線的東西,不是包裝函式自以為送出的東西。

教訓:mock 要綁真簽章。 「函式有被呼叫」和「函式收到了對的參數」是兩件事,前者的測試永遠是綠的。

三、NIST ai-metrology-submissions #10:只跑 Linux 的 CI 看不到 Windows 的 traceback

NIST 這個 repo 收 AI 量測方法的投稿,README 要求投稿者在開 PR 前先在本機跑驗證腳本。腳本 finish() 把報告印到標準輸出,每一份報告都帶 emoji 狀態標記。在編碼不是 UTF-8 的主控台,也就是 Windows 的預設,印那個字元會拋 UnicodeEncodeError,traceback 蓋掉報告。通過的路徑也會炸:一個什麼都做對的投稿者看到的是 traceback 和 exit 1,不是 Passed。

CI 只有 ubuntu-latest,所以上游看不到。我沒有寫「在 Windows 上會壞」就交差,而是在測試套件加了一個案例,用 PYTHONIOENCODING=cp1252 跑驗證器,讓這個失敗在任何平台都能重現。維護者 marionlb 的回覆我留著:

Reproducing a Windows-only failure on any platform with PYTHONIOENCODING is the part I appreciate most. It turned something we had no way to test into something we can now check on every push.

審查中她指出我對子行程編碼的說明有一處說反了。我回:你是對的,第二點我不是不精確,是整個弄反了。改掉,rebase,合併。

教訓:一個只在別的平台出現的 bug,你的工作不是報告它,是把它變成每次 push 都會跑的測試。

四、FINOS architecture-as-code #3009:`path.relative` 回傳的是主機的分隔符

FINOS 是金融科技開源基金會,architecture-as-code 是它旗下的架構文件工具。path.relative 回傳主機的路徑分隔符,所以在 Windows 產生的文件會把路徑寫成反斜線,POSIX 讀者把整串當成一個檔名,什麼都指不到。三個地方會把路徑寫進產出物:front matter、時間軸文件、bundle 清單。

維護者 LeighFinegold 看完說:我們在 #1357 和 #1358 踩過同一個坑,CI 只跑 Linux,這類 bug 從來不會在建置裡出現。然後問我要不要順手修 timeline.ts:80。我掃了一輪,多找到一處:bundle.ts:184 寫進清單的路徑會被 resolveFilePath 讀回來,同一個「這裡寫、那裡讀」的形狀。

兩個測試上的坑:既有的 bundle.spec.ts 有兩個斷言本來就期望正斜線,在 Windows 上一直是壞的;timeline.spec.tspath.join 算期望值,等於把主機分隔符釘進期望值,所以在只有 Linux 的 CI 上永遠是綠的。新測試把 path mock 成 win32,讓 Windows 行為在任何主機上都跑得到。「輸出裡沒有反斜線」這種斷言在 Linux 上不管有沒有修都會過,證明不了任何事。

還有一件事跟程式碼無關。LeighFinegold 提到上次辦公時間貢獻者討論過要減少不必要的 AI 冗詞,請我把 commit 訊息和 PR 描述縮短。我重寫了,兩則 commit 訊息砍短,行內註解各留一行。這件事我另外記成了紀律:對外的文字砍到三分之一,證據留給自己,結論給維護者。

五、UK AISI inspect_k8s_sandbox #236:Helm 模板把清單印成沒有逗號的字串

英國 AI 安全研究院的 inspect 評測框架,k8s sandbox 這個 chart 的 templates/services.yaml 寫著 args: {{ $service.args }}。Go 的預設字串化把清單印成 [setarch -R /bin/echo hello],YAML 的流式序列靠逗號分隔,這串沒有逗號,解析回來是一個元素:一整個用空白接起來的字串。容器執行的是一個 argv token,不是呼叫端寫的四個。

影響面經過 compose/_converter.py,它把 compose 的 command: 對到 Helm 的 args,所以任何用清單型 command: 的 compose 檔都中。entrypoint: 沒事,因為它對到的 command 三行上面早就用了 toYaml

修法就是補 toYaml。我把整個 chart 掃了一遍:其他裸插值全是純量,這是唯一一個沒經過 toYaml 的清單欄位,一處就是全部。測試斷言的是解析後的清單,不是子字串:"setarch" in str(args) 在修前修後都成立,這種斷言什麼都證明不了。我先對未修改的模板跑一次確認它會失敗,再對修好的跑一次確認它會過,順序不能反。

教訓:斷言要對「解析後的結構」,不要對「字串裡有沒有某個字」。 後者的測試會為了錯的理由通過。

六、casact chainladder-python #1205:我說錯了,維護者當場查證

美國產險精算學會的損失準備金函式庫。ParallelogramOLF 在四種原始期間粒度中有兩種一用就崩:季度和半年。兩個獨立的成因,一個是把已經字串化的標籤拿回來解析閏年旗標,格式只認年和月,2016Q1 解析不了;另一個是 pandas 沒有 to_period("S")S 會被讀成秒。

這筆值得寫的不是修法,是我在 PR 描述裡對 to_period("2Q") 的解釋是錯的。我寫「pandas 會丟掉倍數」,維護者 henrydingliu 自己跑了一遍,指出倍數其實有被尊重,一個 2Q 期間確實是六個月長;真正的問題是每個期間錨定在觀測值所在的季度,不是固定的半年邊界,所以連續的視窗會重疊,兩年給出八個重疊視窗而不是四個半年。

我回覆的第一句是:你是對的,我的解釋是錯的,謝謝你去查證而不是相信我的說法。然後把 PR 描述改成正確的版本,數值用他自己在 #524 提出的倒數均值檢查驗證。另一位維護者 kennethshsu 正式核准,提出糾正的 henrydingliu 親自合併。

教訓:被糾正的時候,最有價值的回覆是承認得具體。 不是「感謝指正」,是把哪一句錯、正確的是什麼,寫回 PR 裡讓下一個讀的人不會再被我誤導。

七、casact chainladder-python #1275:一個字元,775 列全錯

三個星期後回到同一個 repo。CapeCod.predict() 決定要不要把預測資料聚合回模型擬合時的粒度,判斷方式是數 sample_weightapriori_ 多出幾層索引。條件寫的是「多於一層」,所以最常見的「剛好多一層」落到沒有聚合的分支,用預測資料重算了 apriori,而不是用擬合好的那個。

clrd 樣本的 comauto 業務線為例,擬合出的 apriori 是 0.5689995797,修正前 predict() 回傳 1.2516635774,775 列全部跟自己業務線擬合值不同。修法是把 > 1 改成 > 0,一個字元。

既有的 test_capecod_predict2 明明覆蓋這條路徑,為什麼沒抓到?因為它用 prism 資料集,那個三角形比擬合模型多五層索引,永遠走在聚合的分支上。測試覆蓋了分支,沒有覆蓋邊界。 新測試用 clrd,剛好多一層。這一筆的完整拆解在另一篇

這筆 PR 依 casact 的 AI 使用政策做了揭露:Claude Code 重現了 bug、跑了前後比較、起草了回歸測試;那一個字元的修法是我和維護者在 issue 裡先談定的,在任何程式碼寫出來之前。我自己審過 diff 和測試,套件在我的機器上跑。這段揭露寫在 PR 裡,維護者合併時沒有任何意見。

三個共同病灶

把七筆疊起來看:

  1. 只跑 Linux 的 CI:NIST、FINOS 兩筆都是 Windows 上必炸、上游永遠看不到。解法不是要求對方加 Windows CI,是把平台差異變成參數(PYTHONIOENCODING、mock pathwin32),讓它在 Linux 上也能重現。
  2. 為了錯的理由通過的測試:casact #1275 的測試走錯分支、inspect_k8s 若用子字串斷言會永遠綠、FINOS 用 path.join 算期望值等於把 bug 釘進期望。每一筆我都先對未修改的程式碼跑新測試,確認它會紅,再對修好的跑,確認它會綠。
  3. 被語言語意騙過的哨兵與綁定:CERT/CC 的 == None、hotosm 的具名參數不進 kwargs、Helm 的清單字串化。這三個都不是專案的錯,是語言或工具的規則跟直覺不一致,而直覺會贏。

給想開始貢獻的人

七筆裡面沒有一筆是「新功能」。全部是:讀 issue,重現,找到那一行,證明修正是對的,把不確定的地方寫在 PR 裡請人看。維護者合併的速度跟 PR 大小成反比,跟「我能不能一眼看出你證明了什麼」成正比。

如果你想從哪裡開始:挑一個你真的在用的函式庫,找一個有重現步驟的 issue,先讓它在你機器上壞掉。壞不掉就不要開 PR。

每一筆 PR 的完整紀錄都在 Ultra Lab 首頁的開源紀錄,點進去是 GitHub 原頁。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

需要技術協助?

免費諮詢,24 小時內回覆。