pytest 8.0.2 版本解析:一个补丁修复如何让 Cython 模块的异常回溯不再显示 "???"
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
导读
本文基于 pytest 官方发布的 8.0.2 补丁版本公告,深入解析该版本的核心修复:当代码以"相对于项目根目录而非当前工作目录"的路径编译(典型如 Cython 编译模块)且 pytest 从子目录运行时,异常回溯不再显示无意义的???,而是正确还原源码行。你将了解到该补丁的升级方式、底层findsource()源码查找机制、sys.path兜底策略以及对应的回归测试,可直接在当前仓库中逐行对照验证。
一、发布背景:一个 "drop-in replacement" 的补丁版本
在 doc/en/announce/release-8.0.2.rst 公告中,pytest 官方明确了 8.0.2 的定位:
This is a bug-fix release, being a drop-in replacement.
也就是说,8.0.2 是 8.0 系列的一个纯缺陷修复版本,不包含破坏性变更,也不改变既有 API 与行为,因此可以在任何 8.0.x 环境上无痛替换。该版本由贡献者 Ran Benita 完成,已发布至 PyPI。
升级方式
公告给出的升级命令非常直接:
pip install --upgrade pytest对于使用隔离环境(如 venv/conda)或依赖锁定工具(pip-tools、poetry、uv 等)的项目,同样只需将版本约束更新到pytest>=8.0.2,<8.1范围即可。由于是 drop-in replacement,升级后无需修改任何测试代码或配置文件(如 pyproject.toml 中的[tool.pytest.ini_options]、根目录的 tox.ini 等)。
二、核心修复:回溯中的 "???" 从何而来
本次发布的唯一实质变更记录在 changelog/1139.bugfix.rst:
Fixed tracebacks showing
???instead of source lines for code compiled with a filename relative to a directory other than the current working directory -- e.g. compiled Cython modules, when running pytest from a subdirectory of the project root. The source lookup now falls back to searchingsys.pathfor such filenames, as the standard :mod:tracebackmodule does.
问题场景
要理解这个问题,需要先认识 Python 代码对象(code object)中的co_filename字段。它记录了代码来源文件的路径,但这个路径是编译时确定的,并不保证是绝对路径。典型的触发场景包括:
- Cython 编译模块:Cython 在编译
.pyx/.py时,可能将co_filename记录为相对于项目根目录的相对路径; - 任何以相对路径
compile()出的代码对象(如动态生成代码、某些打包工具产物)。
当 pytest 恰好从项目根目录的子目录启动时,当前工作目录(cwd)与co_filename所相对的那个目录不再一致。此时若断言失败或抛出异常,pytest 需要把源码行渲染到回溯信息里,却按 cwd 找不到对应文件,最终只能退化为显示???——用户无法从回溯中看到出错的代码内容,排查成本大增。
标准库的解法
Python 标准库traceback模块面对同样的情形时,并不直接以 cwd 为基准查找文件,而是借助linecache:linecache.getlines(filename)在本地文件查找失败后,会遍历sys.path,对每个路径项尝试拼接裸文件名进行匹配。也就是说,标准库早已具备"在sys.path中兜底搜索"的能力,而 pytest 此前的源码查找逻辑没有对齐这一行为。
三、源码级解析:findsource()的兜底链
修复的落点位于 src/_pytest/_code/source.py 中的findsource()函数。该函数是 pytest 从代码对象还原源码文件的核心入口,其修复后逻辑如下:
def findsource(obj) -> tuple[Source | None, int]: try: sourcelines, lineno = inspect.findsource(obj) except Exception: # inspect.findsource() fails for code objects whose co_filename is # relative to a directory other than the cwd, e.g. compiled Cython # modules when running pytest from a subdirectory (#1139). # Fall back to linecache, which also searches sys.path for bare # filenames, as the standard traceback module does. code = ( obj if isinstance(obj, types.CodeType) else getattr(obj, "__code__", None) ) if code is None: return None, -1 sourcelines = linecache.getlines(code.co_filename) if not sourcelines: return None, -1 lineno = code.co_firstlineno - 1 source = Source() source.lines = [line.rstrip() for line in sourcelines] source.raw_lines = sourcelines return source, lineno三段式工作流
- 主路径:优先调用
inspect.findsource(obj),它在绝大多数常规情况下(源码文件路径可从 cwd 解析)直接命中; - 异常兜底:一旦抛出异常(正是
co_filename相对目录与 cwd 不一致的场景),不再放弃,而是取出代码对象(obj自身若是CodeType则直接用,否则取__code__属性),改调linecache.getlines(code.co_filename)。linecache内部会走sys.path搜索裸文件名,与标准库traceback行为一致; - 失败判定:若仍取不到任何行(
not sourcelines),返回(None, -1),由上层决定如何渲染。
值得注意的是,第 139-141 行的防御处理:当对象拿不到__code__(例如纯内置对象)时返回(None, -1),避免让异常进一步扩散到回溯渲染流程中。
在回溯渲染链路中的位置
findsource()并非孤立函数,它被 src/_pytest/_code/code.py 中的多个关键路径依赖:
Code.fullsource属性(code.py)直接调用findsource(self.raw),为ExceptionInfo/TracebackEntry提供整份源码文件;getfslineno()(code.py)在计算"文件 + 行号"定位信息时同样调用findsource求行号。
因此,只要findsource()能正确解析源码,最终--tb=long/--tb=short等回溯模式就能展示真实的源码行与^定位符,而不再退化为???。可以说,这一个函数是"回溯信息可读性"的命脉。
四、回归测试:如何验证这个修复
仓库在 testing/code/test_source.py 中为该修复新增了专项回归测试test_findsource_filename_relative_to_syspath_entry:
def test_findsource_filename_relative_to_syspath_entry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """findsource() falls back to searching sys.path for code objects whose co_filename is relative to a directory other than the cwd, like the standard traceback module does (#1139). This happens e.g. with compiled Cython modules, whose code objects carry paths relative to the project root, when pytest is run from a subdirectory. """ from _pytest._code.source import findsource filename = "findsource_syspath_demo.py" lines = ["def f():\n", " return 1\n"] (tmp_path / filename).write_text("".join(lines), encoding="utf-8") co = compile("".join(lines), filename, "exec") d: dict[str, Any] = {} eval(co, d) empty = tmp_path / "empty" empty.mkdir() monkeypatch.chdir(empty) monkeypatch.syspath_prepend(str(tmp_path)) src, lineno = findsource(d["f"].__code__) assert src is not None assert lineno == 0 assert src[lineno] == "def f():"该测试精准复现了缺陷条件:
- 用
compile()以纯文件名("findsource_syspath_demo.py",即相对路径)编译一段代码,模拟 Cython 编译产物的co_filename形态; - 通过
monkeypatch.chdir切换到另一个空目录,模拟"从项目子目录运行 pytest"; - 通过
monkeypatch.syspath_prepend把源文件所在目录塞入sys.path; - 最终断言
findsource()能通过sys.path兜底找到源码,且行号与内容精确匹配。
同文件中的 test_findsource_fallback 与 test_findsource 则覆盖了普通函数对象与通过linecache.cache预置内存源码的路径,共同构成对该查找逻辑的完整测试矩阵。
五、升级影响与验证建议
由于 8.0.2 是 bug-fix 版,升级后的行为差异几乎只体现在"曾经显示???的场景现在能正常显示源码",因此验证手段也很直接:
- 准备一个 Cython 模块(或任意
co_filename为相对路径的代码对象); - 在其中故意触发异常或失败的断言;
- 分别在项目根目录与根目录的某个子目录下运行 pytest(如
python -m pytest或pytest); - 观察失败回溯:8.0.2 之前子目录运行会显示
???,升级后应显示真实源码行与行号。
完整的版本变更可查阅仓库根目录 CHANGELOG.rst,该文件聚合了每次发布的全部变更条目,8.0.2 对应的条目即由 changelog/1139.bugfix.rst 生成。
结语
pytest 8.0.2 虽是一个仅有单条修复的小版本,却体现了测试框架在"异常可读性"上的较真:让回溯信息在冷门的相对路径编译场景下也能忠实还原代码现场。透过 src/_pytest/_code/source.py 中findsource()的三段式兜底设计与 testing/code/test_source.py 中的精确回归测试,我们可以看到一条成熟的修复路径——先对齐标准库行为,再用最小化的测试复现缺陷条件,最终在不破坏任何既有 API 的前提下以 drop-in replacement 的形式发布。这正是补丁版本应有的姿态:小、稳、可预期。
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考