PythonkwargsS3minio開源測試人道科技

Python 的 **kwargs 接不到具名參數:人道 OpenStreetMap 無人機系統每張災區照片都存錯格式的原因

· 9 分鐘閱讀
目錄
  1. 函式長這樣
  2. Python 參數綁定的規則
  3. 修法
  4. 測試:mock 要綁真的簽章
  5. 我沒做到的事,也寫在 PR 裡
  6. 帶走的規則

人道 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/zipprojects/project_logic.py 處理使用者上傳時傳瀏覽器給的 file.content_type

put_object 那一行只傳了四個位置參數加 **kwargscontent_type 不在裡面。

Python 參數綁定的規則

呼叫 add_obj_to_bucket(bucket, obj, path, content_type="image/jpeg", metadata={...}) 時,Python 依序做這件事:

  1. 位置參數依序綁到 bucket_namefile_objs3_path
  2. 關鍵字參數逐一看:簽章裡這個名字的,綁到那個參數;簽章裡沒有的,才收進 **kwargs

content_type 在簽章裡,所以它被綁到 content_type 這個區域變數。metadata 不在簽章裡,所以它進了 kwargs。函式往下呼叫 put_object(..., **kwargs) 時,kwargs 裡只有 metadatacontent_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.s3minio,不需要資料庫、Redis 或 docker,在 CI 裡應該正常跑。

如果維護者偏好整合測試,等價的斷言是 client.stat_object(bucket, key).content_type,但我在這裡跑不了那個版本,所以我在 PR 裡明寫:我不會宣稱它驗證過。

這一段的價值跟修法一樣大。維護者要的是知道你驗證了什麼、沒驗證什麼,而不是一句「tests pass」。

帶走的規則

  1. 簽章裡的具名參數不會進 **kwargs 你宣告了它,就要自己往下傳。
  2. 同一個檔案裡的兄弟函式是最好的對照組。 add_file_to_bucket 寫對了,add_obj_to_bucket 沒有,diff 就是答案。
  3. mock 要綁真簽章。 inspect.signature(RealSDK.method).bind(...) 讓你斷言 SDK 真的收到什麼,而且在 SDK 改簽章時會出聲。
  4. 沒跑到的驗證要明寫。 「我不會宣稱它驗證過」這句話讓維護者知道該補哪裡,比假裝全跑過更快合併。

PR 原頁:hotosm/drone-tm#882,+70 / -1,其中正式程式碼改了 6 行,其餘是測試。這是24 天內七筆 PR裡的第二筆。

每週 AI 自動化實戰筆記

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

加入一人公司實驗室

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

需要技術協助?

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