DeprecationWarning 默认不显示:过滤器链路、pytest 语义差异与 CI 里的 error 配置
现象:单文件脚本能看到,跑测试/跑服务看不到
把同一段触发弃用 API 的代码分别放进 python foo.py 和 pytest,结果常常相反:脚本在 stderr 上打印一行 DeprecationWarning,测试却一句不吭。原因是 Python 3.7 起(PEP 565)默认过滤器列表的第一条是 default::DeprecationWarning:__main__,只对 __main__ 里直接触发的警告生效。单文件脚本的模块名恰好是 __main__;而 zipapp 的 __main__.py、可执行包的 __main__ 子模块、console_scripts 生成的入口包装脚本,模块名都不是 __main__,因此照样被下面的 ignore::DeprecationWarning 吞掉。
默认过滤器只有五行
常规 release 构建的列表,按优先级:
default::DeprecationWarning:__main__
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::ImportWarning
ignore::ResourceWarning
default 表示每个位置(module + lineno)只打印第一次。3.7 之前没有第一条,DeprecationWarning 从 3.2 起就整体被忽略,副作用是开发者基本看不到自己发的弃用警告,PEP 565 只补了这一条。FutureWarning 面向应用最终用户,默认显示。debug 构建的默认过滤器列表为空——这点在后面会咬人。
七个 action 与四元组语义
过滤器条目是 (action, message, category, module, lineno),按顺序匹配,第一条命中的决定处置方式,全不命中走 default。action 有七个:default(每个位置一次)、error(抛异常)、ignore、always(all 是其别名)、module(每个模块一次,忽略行号)、once(全局一次)。
匹配语义在两个入口上不一样,这是最容易踩的:
message是正则,必须匹配警告消息的开头,大小写不敏感。在warnings.filterwarnings()里永远按正则处理;在-W/PYTHONWARNINGS里,只有写成/.../才是正则,否则是字面字符串前缀(忽略首尾空白)。module是正则,匹配完全限定模块名的开头,大小写敏感。在-W/PYTHONWARNINGS里,只有/.../才是正则,否则必须与完全限定模块名完全相等。所以-W ignore::DeprecationWarning:jsonschema匹配不到jsonschema.validators,要写-W "ignore::DeprecationWarning:/jsonschema/"。
插入顺序:filterwarnings 前插,catch_warnings 不重置
warnings.filterwarnings(...) 默认插到列表最前面(append=True 才追加到末尾),靠前的条目优先于靠后的。simplefilter 同理,只是不涉及正则。
catch_warnings() 的语义是保存并恢复当前 filter 列表,不是重置成默认列表。所以下面这种写法在 -Werror 下没用:
with warnings.catch_warnings():
warnings.simplefilter("ignore") # 只是临时覆盖
do_something() # 里面仍可能抛 DeprecationWarning
如果错误是在进入 with 之前就被 error 条目抛出的,或者警告来自被其他机制(例如 warnings.warn 的 stacklevel 归到别的模块)触发的代码,catch_warnings 也拦不住。可靠做法是按模块名精确 ignore,而不是临时塞一个 context manager。
-W 与 PYTHONWARNINGS
-W 只作用于当前进程,追加到 sys.warnoptions;PYTHONWARNINGS 通过环境变量生效,会被子进程继承。两者共同点是:只要其中之一设置了,运行时就不再套用默认过滤器。文档给测试跑者的推荐写法正是利用这一点,同时把值塞回环境变量以影响子进程:
import sys
if not sys.warnoptions:
import os, warnings
warnings.simplefilter("default")
os.environ["PYTHONWARNINGS"] = "default"
pytest 的两套语义
pytest 默认会显示用户代码和第三方库的 DeprecationWarning / PendingDeprecationWarning。但一旦测试里用了 pytest.warns()、pytest.deprecated_call() 或 recwarn fixture 捕获警告,输出就完全不显示;一旦解释器层已有 PYTHONWARNINGS 或 -W,pytest 也不再自行配置任何默认过滤器。
filterwarnings ini 选项的含义与 -W 接近但解析不同:ini 里的 message 是正则(匹配开头、大小写不敏感),-W 里默认是字面前缀。更要紧的是优先级方向相反:一个警告命中多个选项时,执行最后匹配的那个选项(因为每条都前插,后应用的最终排最前),而 warnings.filterwarnings() 是「最先匹配者生效」。
@pytest.mark.filterwarnings 优先于命令行和 ini。多个装饰器叠加时,装饰器逆序求值,先写的反而优先;单个装饰器传多个参数,后传的优先;用 pytestmark 赋列表则又回到「后者优先」。
还有一条容易忽略:pytest 3.8(提交 9965ed8)起,只有当 -W、filterwarnings ini、sys.warnoptions 三者都没配置时,pytest 才注入 always::DeprecationWarning 和 always::PendingDeprecationWarning。也就是说,你只要写了一条 filterwarnings,pytest 就不再帮你显示 DeprecationWarning 了——想保留显示,得显式加 always::DeprecationWarning。
CI 实操:三步收紧
**第一步,先观测,不阻断。**把警告数量降下来再谈 error:
PYTHONWARNINGS=default::DeprecationWarning python -m pytest -q
PYTHONWARNINGS 会继承给子进程,覆盖测试发现、导入阶段这些普通 runner 管不到的地方。想知道是哪条依赖在发,挂一个 showwarning:
import sys, traceback, warnings
def _show(message, category, filename, lineno, file=None, line=None):
if issubclass(category, DeprecationWarning):
sys.stderr.write(f"{filename}:{lineno}: {category.__name__}: {message}\n")
traceback.print_stack(limit=6)
warnings.showwarning = _show
warnings.simplefilter("always", DeprecationWarning)
放在 conftest.py 顶层或 sitecustomize.py 里,跑一遍全量测试,按 traceback 最上层 warnings.warn 调用帧的模块名归类。注意警告归属取决于 stacklevel,有时会被算到 builtins 或其他模块头上,按错误信息猜模块名经常猜错。
**第二步,逐条白名单收窄。**清掉自己代码里的警告后,对暂时修不了的第三方依赖按模块名放行:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:jsonschema.*",
]
这里 module 走 filterwarnings() 的正则语义,jsonschema.* 能匹配子模块名。
**第三步,全量启用。**白名单收敛到只剩确实不可修依赖后,把 error 提升为全类别,或在命令行加兜底:
python -W error::DeprecationWarning -m pytest
命令行 -W 的 module 是精确匹配,写空即匹配全部模块;需要按前缀时用 /regex/ 形式。若同时用了 ini 和 -W,注意 ini 通常后应用、优先级更高。
什么时候别急着升 error
- **警告来自不可修的第三方或 vendored 代码。**cpython 自己曾长期无法用
-Werror跑测试,原因就是 pip 里 vendored 模块发的弃用警告。这类情况只能按模块名ignore,不能指望上游立刻修。 - **
SyntaxWarning在编译期发出,运行时的过滤器可能抑制不住。**把它放进error会以不合预期的方式中断导入。 - **debug 构建的默认过滤器列表为空。**CI 镜像如果用的是 debug 版解释器,
default::DeprecationWarning:__main__和几条ignore都不存在,行为与 release 版不同。 - **测试发现/导入阶段与子进程。**unittest 默认 runner 只在测试执行期间覆盖过滤器,发现阶段和子进程里的 DeprecationWarning 仍看不到;要覆盖这些路径,用
PYTHONWARNINGS而不是-W。
参考资料
- Python 3.14
warnings文档 - PEP 565 – Show DeprecationWarning in
__main__ - pytest: How to capture warnings
- pytest 提交 9965ed8(PR 2908)
- StackOverflow: pytest: ignore third party library warnings with -Werror
- cpython issue #128770 / discuss.python.org 上关于「给测试套件开 warnings as errors」的讨论