一. 前言:警告不是「還沒壞,所以不用管」 #
嗨,我是拍拍君!你有沒有遇過這種升級流程?
測試全綠,終端機卻印出一排棄用警告。大家先加上 ignore,三個月後換新版套件,程式突然不能跑了。
問題不一定是套件偷偷改規則,而是遷移通知早就送到,只是沒有人收信。
這次不教你把黃字全部藏起來。我們做一個小型 API 遷移實驗:舊函式仍能用,新函式保持安靜,未預期的舊用法則讓 CI 失敗。
之前的 pytest 入門在講怎麼組織測試;logging 教學在講事件輸出。本文處理另一個契約:程式能繼續,但使用方式需要改變。
二. 安裝:標準庫不用裝,測試工具才需要 #
準備 Python 3.14 和一個空目錄:
mkdir warnings-lab
cd warnings-lab
uv venv --python 3.14
uv pip install pytest
以下指令用 uv run --no-project 執行目前目錄的 .venv;也可以啟用環境後改用 python。
我們只建三個檔案:pypy_api.py、app.py、test_api.py。warnings 是 Python 內建模組,不要再去 pip 安裝同名套件。
三. 先分清楚:Exception、Warning、Log #
拍拍君的判斷順序很簡單:
| 狀況 | 選擇 | 範例 |
|---|---|---|
| 無法履行函式契約 | Exception | 必填名稱是空字串 |
| 還能執行,但呼叫者需要採取行動 | Warning | 舊 API 將在某版移除 |
| 記錄程式發生的事件 | Log | 某批資料已處理完成 |
不要用 warning 代替必要的輸入驗證;也不要以為 logger.warning() 就等於 Python warning。
後者是日誌等級,前者有自己的分類、篩選與重複抑制機制。
棄用分類則先看「通知誰」:PEP 565說明了開發者與應用使用者的不同需求。
DeprecationWarning:主要提醒呼叫 API 的 Python 開發者。FutureWarning:主要提醒應用的終端使用者,未來行為會改變。- 自訂子類別:讓使用者能只處理你的套件,不波及其他依賴。
不要只根據名字猜測「Future 比 Deprecation 更嚴重」。它們不是日誌等級。
四. 建立有遷移出口的舊 API #
把以下內容存成 pypy_api.py:
# file: pypy_api.py
import warnings
class PypyDeprecationWarning(DeprecationWarning):
"""拍拍君套件的 API 棄用通知。"""
def greeting(name: str) -> str:
if not name.strip():
raise ValueError("name must not be empty")
return f"你好,{name}!"
def old_greeting(name: str) -> str:
warnings.warn(
"old_greeting() is deprecated; use greeting(); removal in 2.0",
PypyDeprecationWarning,
stacklevel=2,
)
return greeting(name)
這段通知提供三件事:哪個入口要退場、替代方案是什麼、預計何時移除。
2.0 是這個示範套件的政策,不是 Python 的版本。正式專案必須讓訊息、文件與實際發版計畫一致。
warning 也不是保證會繼續執行:當使用者設定 error,warn() 就會拋出該 warning 類別的例外,後面的 return 不會執行。
五. stacklevel:把責任指到呼叫端
#
接著建立 app.py:
# file: app.py
from pypy_api import old_greeting
def main():
print(old_greeting("拍拍醬"))
if __name__ == "__main__":
main()
uv run --no-project python -W default app.py
你會看到指向 app.py 呼叫位置的棄用通知,以及正常問候。
stacklevel=1 指向發出通知的 warn();2 指向它的直接呼叫端。因此讀者知道自己要改哪一行,而不是被帶到套件內部。
但 stacklevel 不是越大越好。 如果又包了一層 wrapper,就要重新驗證位置;多個入口深度不同時,硬寫同一個數字可能誤報。
另一個陷阱是「同一警告,有時看得到、有時看不到」。一般 release build 預設會忽略多數 imported code 的 DeprecationWarning,但歸因到 __main__ 的情況不同;pytest 也有自己的顯示政策。
所以不要用「我在筆電沒看到」當測試。開發時明確使用 -W default,測試時明確斷言警告。
六. Filter 有順序,而且 module 會跟著歸因走
#
可以把篩選想成一串規則:由前往後找,第一個符合的決定結果。
simplefilter() 用分類等條件篩選;filterwarnings() 還能限定訊息與模組。它們預設都把新規則插在前面。
下面是獨立可執行的例子:
import warnings
from pypy_api import old_greeting, PypyDeprecationWarning
with warnings.catch_warnings(record=True) as seen:
warnings.simplefilter("error")
# 後加入、較精準的例外規則,會排在前面。
warnings.filterwarnings(
"always",
message=r"^old_greeting\(\) is deprecated;",
category=PypyDeprecationWarning,
)
result = old_greeting("拍拍君")
assert result == "你好,拍拍君!"
assert len(seen) == 1
這裡特別沒有加 module=r"^pypy_api$"。
因為 stacklevel=2 把通知歸因到呼叫端,篩選所見的模組可能是 app、測試模組或 __main__,不是定義 warning 類別的 pypy_api。
Category 的定義位置,不等於 warning 的歸因模組。 想只處理自己的套件,自訂分類通常比猜模組更直接。
還有一個小細節:Python API 的 message 使用正規表示式。本文鎖定的 Python 3.14,命令列 -W 的 message 則是字面前綴;不要直接互抄含跳脫字元的規則。
七. 為什麼迴圈十次,只看到一次? #
預設的重複抑制不是拿來做事件計數。
default會考慮訊息、分類、模組與行號。module不再區分同模組的不同行號。once的判斷不再區分位置。always則每次都處理。
想測「每次呼叫都有通知」,就明確使用 always:
import warnings
from pypy_api import old_greeting, PypyDeprecationWarning
with warnings.catch_warnings(record=True) as seen:
warnings.simplefilter("always", PypyDeprecationWarning)
for _ in range(3):
old_greeting("拍拍醬")
assert len(seen) == 3
assert all(w.category is PypyDeprecationWarning for w in seen)
不要只加 record=True 就期待所有警告出現:記錄之前仍會經過篩選。
也不要為了測試直接清空全域 warning registry。測試應該表達政策,而不是依賴前一個測試留下的狀態。
八. 把「通知正確」寫成測試契約 #
建立 test_api.py,一次驗證舊 API、新 API、輸入錯誤與呼叫位置:
# file: test_api.py
import inspect
from pathlib import Path
import warnings
import pytest
from pypy_api import (
greeting, old_greeting, PypyDeprecationWarning,
)
def test_old_api_keeps_result_and_points_to_caller():
with pytest.warns(PypyDeprecationWarning, match="use greeting") as seen:
call_line = inspect.currentframe().f_lineno + 1
result = old_greeting("拍拍醬")
assert result == greeting("拍拍醬")
assert len(seen) == 1
assert Path(seen[0].filename).resolve() == Path(__file__).resolve()
assert seen[0].lineno == call_line
def test_new_api_is_warning_free():
with warnings.catch_warnings():
warnings.simplefilter("error")
assert greeting("拍拍君") == "你好,拍拍君!"
def test_invalid_input_is_an_error_not_a_warning():
with warnings.catch_warnings():
warnings.simplefilter("error")
with pytest.raises(ValueError, match="empty"):
greeting(" ")
def test_legacy_use_can_be_blocked():
with warnings.catch_warnings():
warnings.simplefilter("error", PypyDeprecationWarning)
with pytest.raises(PypyDeprecationWarning):
old_greeting("拍拍君")
uv run --no-project python -m pytest -q
這裡 pytest.warns() 是「預期會發生」的局部契約,不是無差別忽略。
尤其是行號測試:如果有人重構 wrapper,卻忘記調整 stacklevel,CI 就能抓到不再有用的通知位置。
九. CI 採用「預設嚴格,例外有期限」 #
這個小專案可以新增 pyproject.toml:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]
再跑一次同樣的測試。四個測試仍應通過:預期的警告已被局部捕捉,其他未處理的警告才會變成錯誤。
要驗證 CI 真的會擋,另外建立臨時的 test_unexpected.py:
# file: test_unexpected.py
from pypy_api import old_greeting
def test_unmigrated_caller():
old_greeting("拍拍君")
單跑這個檔案應失敗,錯誤類別是 PypyDeprecationWarning。驗證後移走它,不要把故意失敗的探針留在正常測試集。
大型專案如果既有警告很多,可以先把自己的自訂分類升級為 error,其他分類保留可見;每次升級依賴再縮減清單。
確實暫時無法修的第三方警告,例外要限定「訊息前綴+分類+經過實測的歸因模組」,附上 issue、負責人與回看期限。
注意 pytest 設定列表是後面的符合規則優先,不要和 Python 執行時「由目前 filter 串列前端找起」混為一談。詳見 pytest 警告政策文件。
--disable-warnings 只是收起摘要,不是修好棄用用法,也不等同建立 ignore 規則。綠色畫面不該靠藏收據換來。
十. 只需要標記整個函式?用 @deprecated
#
Python 3.13 起,可以使用 PEP 702對應的標準庫裝飾器:
from warnings import deprecated
from pypy_api import greeting, PypyDeprecationWarning
@deprecated("Use greeting(); removal in 2.0", category=PypyDeprecationWarning)
def legacy_hello(name: str) -> str:
return greeting(name)
它同時提供執行期通知與支援此規格的型別檢查器可讀取的棄用資訊。是否顯示、如何診斷,仍取決於工具與設定。
如果只有某個舊參數需要退場,函式本身仍有效,就在該分支手動 warn(),不要把整個函式都標成棄用。
手動警告和裝飾器也不要重疊使用,否則一次呼叫可能收到兩份通知。
十一. 接上 Logging,但不要改掉使用者政策 #
應用程式入口若已有集中式日誌,可以選擇轉送:
import logging
import warnings
from pypy_api import old_greeting, PypyDeprecationWarning
logging.basicConfig(level=logging.WARNING)
logging.captureWarnings(True)
try:
with warnings.catch_warnings():
warnings.simplefilter("always", PypyDeprecationWarning)
old_greeting("拍拍醬")
finally:
logging.captureWarnings(False)
通過 warning filter 的通知會送到 py.warnings logger,以 WARNING 等級記錄;被忽略的通知不會因此復活,error 仍然是例外。
這個選擇應放在應用入口,不要讓可重用套件一 import 就更改全域 filter 或開啟日誌轉送。Logging 官方說明有完整邊界。
十二. 平行程式的補充:with 不保證執行緒隔離
#
前面的測試是循序執行,不會同時改 filter。要移到 thread 或 async task 裡,不能只看見 context manager 就認定安全。
Python 3.14 引入 sys.flags.context_aware_warnings:啟用時,catch_warnings() 的篩選狀態能放進 context-local 儲存;未啟用時仍涉及全域狀態。不同建置的預設值也不一樣。
不要在每個 worker 裡競相切換全域忽略規則;先確認執行環境,或在工作啟動前統一設定政策。執行緒的 context 繼承又是另一個需要確認的選項。
相關語義請依實際 Python 版本閱讀 warnings 官方文件,不要把單執行緒測試通過當作平行安全證明。
結語:讓 Warning 成為可以處理的工作項目 #
拍拍君會把每次依賴升級拆成三個問題:誰需要改、改成什麼、哪個測試防止又走回舊路?
通知要附上替代入口,位置要指向呼叫端,測試要分清楚預期與意外,暫時例外則留下到期日。
這樣 warning 就不是惱人的黃字,而是一份可以驗證的遷移待辦。下一次升級,讓 CI 提早提醒你,不要等正式環境替你按鈴!