Python 的 **kwargs 接不到具名參數:人道 OpenStreetMap 無人機系統每張災區照片都存錯格式的原因
人道 OpenStreetMap 團隊(HOT)的 drone-tm 是災後空拍影像的任務管理系統:規劃飛行任務、收回無人機拍的照片、拼成可用的地圖。2026 年 9 月 2 日,維護者合併了我開的 PR #882。bug 是:每一張上傳的照片都被存成 application/octet-stream,瀏覽器打開不會顯示,只會下載。
修法是一個參數。有趣的是為什麼會漏,因為那個參數明明就在函式簽章裡。
函式長這樣
def add_obj_to_bucket(
bucket_name: str,
file_obj: BytesIO,
s3_path: str,
content_type: str = "application/octet-stream",
**kwargs,
):
...
result = client.put_object(
bucket_name, s3_path, file_obj, file_obj.getbuffer().nbytes, **kwargs
)
簽章宣告了 content_type,docstring 也寫了。呼叫端也真的有傳:arq/tasks.py 上傳 QField 匯出檔時傳 application/zip,projects/project_logic.py 處理使用者上傳時傳瀏覽器給的 file.content_type。
但 put_object 那一行只傳了四個位置參數加 **kwargs。content_type 不在裡面。
Python 參數綁定的規則
呼叫 add_obj_to_bucket(bucket, obj, path, content_type="image/jpeg", metadata={...}) 時,Python 依序做這件事:
- 位置參數依序綁到
bucket_name、file_obj、s3_path。 - 關鍵字參數逐一看:簽章裡有這個名字的,綁到那個參數;簽章裡沒有的,才收進
**kwargs。
content_type 在簽章裡,所以它被綁到 content_type 這個區域變數。metadata 不在簽章裡,所以它進了 kwargs。函式往下呼叫 put_object(..., **kwargs) 時,kwargs 裡只有 metadata,content_type 靜靜躺在區域變數裡,沒有人把它往下傳。
這就是為什麼同一個函式 metadata 一直是好的,content_type 一直是壞的。具名參數是一道閘門:宣告了它,就等於承諾要親手把它傳下去。 **kwargs 只接簽章沒提到的東西。
而且十二行上面的兄弟函式 add_file_to_bucket 早就寫對了:fput_object(..., content_type=content_type)。同一個檔案,兩個函式,一個記得一個忘了。
維護者 spwoodcock 審查時問的正是這個問題:這個參數不會被 kwargs 接住嗎?不會。我把上面的規則寫在回覆裡,他核准。
修法
result = client.put_object(
bucket_name,
s3_path,
file_obj,
file_obj.getbuffer().nbytes,
content_type=content_type,
**kwargs,
)
一個參數。minio 的 SDK 收到 content_type 就會把它放到物件的 Content-Type 標頭,瀏覽器就會用對的方式顯示。
測試:mock 要綁真的簽章
這種 bug 最容易寫出「為了錯的理由通過」的測試。常見寫法是 mock 一個 put_object,斷言它有被呼叫,或者斷言呼叫時的 kwargs 裡有 content_type。第一種永遠綠;第二種在 minio 改簽章的時候會靜默失效。
我用的寫法是把呼叫端的參數綁到真正的 SDK 簽章上:
import inspect
from minio import Minio
class _RecordingClient:
def __init__(self):
self.calls = []
def put_object(self, *args, **kwargs):
bound = inspect.signature(Minio.put_object).bind(self, *args, **kwargs)
bound.apply_defaults()
self.calls.append(bound.arguments)
return _Result()
inspect.signature(Minio.put_object).bind(...) 會用 minio 自己的簽章去解析這次呼叫,位置參數、關鍵字參數、預設值全部按 SDK 的規則落位。斷言的對象是 bound.arguments["content_type"],也就是 SDK 真的會放上線的值,不是包裝函式自以為送出的值。如果 minio 哪天改了參數順序或名字,bind 會直接拋 TypeError,測試會紅,不會靜默過。
第二個測試釘住 metadata 仍然會到,因為 QField 那個呼叫端兩個都傳。修 content_type 不能把 metadata 擠掉。
兩個測試在修正前失敗、修正後通過,順序是先跑未修的版本。
我沒做到的事,也寫在 PR 裡
我的機器裝不起這個專案的完整後端,GDAL 和 Scrapy 在這裡編不過。所以測試是用 --noconftest 跑的,這兩個測試只碰 app.s3 和 minio,不需要資料庫、Redis 或 docker,在 CI 裡應該正常跑。
如果維護者偏好整合測試,等價的斷言是 client.stat_object(bucket, key).content_type,但我在這裡跑不了那個版本,所以我在 PR 裡明寫:我不會宣稱它驗證過。
這一段的價值跟修法一樣大。維護者要的是知道你驗證了什麼、沒驗證什麼,而不是一句「tests pass」。
帶走的規則
- 簽章裡的具名參數不會進
**kwargs。 你宣告了它,就要自己往下傳。 - 同一個檔案裡的兄弟函式是最好的對照組。
add_file_to_bucket寫對了,add_obj_to_bucket沒有,diff 就是答案。 - mock 要綁真簽章。
inspect.signature(RealSDK.method).bind(...)讓你斷言 SDK 真的收到什麼,而且在 SDK 改簽章時會出聲。 - 沒跑到的驗證要明寫。 「我不會宣稱它驗證過」這句話讓維護者知道該補哪裡,比假裝全跑過更快合併。
PR 原頁:hotosm/drone-tm#882,+70 / -1,其中正式程式碼改了 6 行,其餘是測試。這是24 天內七筆 PR裡的第二筆。