warnings.warn 的 stacklevel 到底填几:默认 1 指错行、skip_file_prefixes 与怎么写测试断言
warnings.warn 的完整签名见 Python 文档(https://docs.python.org/3/library/warnings.html):
warnings.warn(message, category=None, stacklevel=1, source=None, *, skip_file_prefixes=())
stacklevel 是按栈帧向上数的整数。默认 1 指到 warnings.warn 调用行。包装函数里通常希望警告指向包装函数的调用者,而不是包装函数自身,否则警告落在库源码里,用户看到的是库实现行。
def deprecated_api(message):
warnings.warn(message, DeprecationWarning, stacklevel=2)
这里 stacklevel=2 指向 deprecated_api 的调用者。
Pymotw(https://pymotw.com/2/warnings/)解释过:默认警告消息包含产生它的源码行,但这行代码本身意义不大。可以告诉 warn() 向上走多少层栈,找到“调用了包含警告的那个函数”的行。这样被弃函数的用户看到的是函数被调用的地方,而不是函数的实现。
固定整数的问题在于调用路径会变。StackOverflow(https://stackoverflow.com/questions/64793316)里有一个场景:模块的函数既被外部调用又被模块内部调用时,固定的 stacklevel 无法同时满足两种场景——内部调用 test.g() 时 stacklevel=2 指向了 testmodule 内部。解决办法是让包装函数把 stacklevel 作为参数透传(def f(slevel=2))。
skip_file_prefixes(Python 3.12+)
skip_file_prefixes 关键字参数(Python 3.12 新增)用于指示计数栈层时要忽略哪些栈帧。当你希望警告总是指向包外面的调用点时很有用。它必须是一个字符串元组。当提供了前缀时,stacklevel 会被隐式覆盖为 max(2, stacklevel)。
# example/lower.py
_warn_skips = (os.path.dirname(__file__),)
def one_way(...):
warnings.warn("Please migrate to ...", skip_file_prefixes=_warn_skips)
# example/higher.py
from . import lower
def another_way(**kw):
lower.one_way(**kw)
这会让警告只从 example 包外面的调用代码指向 example.lower.one_way() 与 example.higher.another_way() 两处调用点。
warnings.deprecated 与 PEP 702(Python 3.13+)
@warnings.deprecated(message, /, *, category=DeprecationWarning, stacklevel=1)
stacklevel 决定警告在哪发出:1(默认)指向被弃对象的直接调用者;更高则指向更上层。静态类型检查器不受 category/stacklevel 影响。弃用消息保存在被装饰对象的 __deprecated__ 属性上。如果作用于 overload,装饰器必须在 typing.overload 装饰器之后。
与 warnings filter 的 module 参数交互
警告过滤器与 stacklevel 的交互(https://stackoverflow.com/questions/35947161):
warnings.filterwarnings('ignore', category=DeprecationWarning, module='')
warnings.filterwarnings('default', category=DeprecationWarning, module=r'__main__|my_package\.')
过滤器匹配的是 stacklevel 指向的位置,而不是 warnings.warn 调用处的位置。所以只要被弃代码正确设置了 stacklevel,就能用 module 参数只显示与自己代码相关的 DeprecationWarning;如果被弃代码没设对 stacklevel,就没办法。
Ruff B028
Ruff 规则 B028 no-explicit-stacklevel(https://docs.astral.sh/ruff/rules/no-explicit-stacklevel/)检查没有显式 stacklevel 关键字的 warnings.warn 调用。默认 stacklevel=1,会输出调用 warn 的那一行;设为更大的数会输出更上层栈帧。建议用 2 或更高。Python 3.12+ 也可用 skip_file_prefixes 指定忽略哪些文件前缀,这会把 stacklevel 隐式覆盖为至少 2。该规则的 fix 被标记为 unsafe,因为会改变代码行为;fix 会赋 stacklevel=2,而用户可能想要更高的值。
怎么写测试断言
如果警告面向用户,它就是公共 API 的一部分,应在测试套件里检查其来源。grueter.dev 的文章(https://grueter.dev/blog/assert-warning-stacklevel/)给的方式是:用 pytest.warns 捕获警告,用 inspect.currentframe().f_lineno 做基准:
def parse_message(message: str):
if not message:
warnings.warn("message is empty", stacklevel=2)
def test_parse_message():
with pytest.warns(UserWarning, match="message is empty") as record:
parse_message("")
expected_lineno = inspect.currentframe().f_lineno - 1
assert record[0].filename == __file__
assert record[0].lineno == expected_lineno
assert len(record) == 1
行号减 1 是因为警告应指向前一行(函数被实际调用的那行)。scikit-image 内部提供了 assert_stacklevel(record) 工具函数来简化这段断言(BSD-3-Clause 许可,可复制)。skip_file_prefixes 比 stacklevel 更灵活,但仍建议测试结果行为。
CPython issue(https://github.com/python/cpython/issues/88462):有人提议把 warnings.warn 的 stacklevel 默认值从 1 改成 2,因为 stacklevel 是依赖一堆远离 warnings.warn 调用的调用代码的整数,本身不算好 API。默认值改成 2 会破坏哪些依赖默认 1 的代码?skip_file_prefixes 是否会让固定整数逐渐退场?