ruff 的 Notebook 单元格边界解析:逐 Cell 独立解析、合并 lint 与 mdtest 测试设计
ruff 在 lint Jupyter Notebook(.ipynb)时面临一个结构性难题:单元格的源码片段单独看可能不完整,拼接起来又会产生跨单元格的“伪语法”(例如末尾的装饰器会“吃掉”下一个单元格的定义)。本文围绕仓库中的 mdtest 测试文档 cell-boundaries.md 展开,说明 ruff “每个 cell 作为独立模块解析、同时保留一个合并模块用于 lint”的设计如何落地的,并完整解析文档中三个测试场景(跨 cell 语法错误定位、无 Python 单元格、单元格边界 token)的预期行为与验证方式,读完后你可以掌握 ruff notebook 解析的边界处理原理,以及如何用 mdtest 复现和扩展这些测试。
测试载体:mdtest 框架如何驱动这份文档
cell-boundaries.md 并不是普通说明文档,而是一个可执行的测试套件(mdtest fixture)。仓库通过 mdtest 测试入口 用 datatest_stable::harness! 以 root = "../ruff_linter/resources/mdtest"、pattern = r"\.md$" 扫描该目录下所有 .md 文件,逐个作为测试执行;每个 .md 内部的 fenced code block 会被当作内嵌文件处理。
从 ruff_mdtest 的运行实现 可以看到具体流程:
- 解析 markdown 套件后,在内存文件系统里创建项目根目录
/src; - 把内嵌的
py、pyi、ipynb、toml代码块按各自文件名写入该目录(lang == "ignore"的块会被跳过); - 用文档中
[TOML]配置块构造Configuration并解析为 linter 设置; - 对每个写入的文件调用
test_contents(定义于 ruff_linter/src/test.rs)执行 lint,得到诊断列表; - 用内联断言(
# snapshot)与```snapshot块比对诊断输出,不一致即测试失败。
这正是为什么文档中的快照路径写作 src/syntax-error.ipynb:cell 1:2:6 —— 内嵌文件被放在内存根 /src 下。诊断的渲染、# snapshot 断言的匹配以及失败时的 diff 输出,由底层 mdtest 库 的 validate_inline_snapshot 与 match_file 完成。
核心设计:每个 cell 独立解析,同时保留合并模块
文档开篇给出的设计陈述是整篇文章的主线:
Ruff parses every notebook cell as its own module while retaining a single combined module for linting.
(ruff 将每个 notebook cell 作为独立模块解析,同时保留一个合并模块用于 lint。)
在源码中可以印证这一设计:linter.rs 中的解析分派 显示,当 source_kind 是 SourceKind::ipy_notebook 时,ruff 不会走普通的 parse_unchecked,而是调用 ruff_python_parser::parse_cells_unchecked,传入 notebook.cell_offsets().content_ranges()(各单元格的源码区间)作为参数:
match source_kind.as_ipy_notebook() {
Some(notebook) => ruff_python_parser::parse_cells_unchecked(
source_kind.source_code(),
notebook.cell_offsets().content_ranges(),
&options,
),
None => ruff_python_parser::parse_unchecked(source_kind.source_code(), options)
parse_cells_unchecked 的语义正是文档所描述的“逐 cell 模块 + 合并 lint”:每个单元格的词法/语法分析在 cell 边界处收口,从而避免一个 cell 中未闭合的结构“越界”解释到下一个 cell;而各 cell 的 AST 会合并为一个统一的模块视图,使语义类规则(未定义名、未使用导入等)仍能看到跨 cell 的完整信息。单元格的元数据(索引、起止偏移)由 ruff_notebook 的 cell 模型 提供。下面三个测试场景分别验证这一设计的三类边界行为。
场景一:语法错误止步于 cell 边界
文档给出的反例非常直观:如果把所有 cell 的源码直接拼接,第一个 cell 末尾的 @deco 会“合法地”装饰第二个 cell 的 def f(): pass,语法错误凭空消失。独立解析每个 cell 后,这个装饰器在自己的 cell 内找不到被装饰对象,错误必须定位到装饰器所在的 cell:
syntax-error.ipynb(原文档 中的完整内嵌文件):
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["# snapshot\n", "@deco"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["def f(): pass"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}
第一个 cell 中的 # snapshot 是 mdtest 的内联断言标记(对应 mdtest 库中对 # snapshot 的处理),其后的 ```snapshot 块给出期望的诊断输出:
error[invalid-syntax]: Expected class, function definition or async function definition after decorator
--> src/syntax-error.ipynb:cell 1:2:6
|
2 | @deco
| ^
三个细节值得注意:
- 诊断位置是
cell 1:2:6,即第一个 cell 内部的第 2 行,而不是合并模块的某个偏移——这验证了错误归属到装饰器所在的 cell; - 错误类型是
invalid-syntax(解析器级语法错误),说明该场景在解析阶段就被独立 cell 的解析捕获; - 该 fixture 与 linter.rs 测试模块 中针对
resources/test/fixtures/syntax_errors/*.ipynb的 notebook 语法错误测试属于同一类验证目标,mdtest 版本则把内嵌 fixture 与断言写进了同一个文档。
场景二:不含任何 Python 单元格的 Notebook
文档中第二个场景只有一条断言式的陈述:
A notebook containing no Python code cells still parses successfully.
(不包含任何 Python 代码单元格的 notebook 也能成功解析。)
对应 fixture no-code-cells.ipynb:
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": ["# Nothing to check"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}
该 fixture 没有任何代码块级断言,测试通过的判据就是 lint 全流程不产生诊断、不 panic。它守护的是一个真实的边界条件:当 cell_offsets().content_ranges() 为空(没有可解析的 Python 区间)时,parse_cells_unchecked 的“逐 cell 解析 + 合并”路径仍要正常完成,而不是在空列表上出错或对纯 markdown notebook 报告无关诊断。
场景三:单元格边界 token 与跨 cell 的语义规则
文档的第三个场景是技术含量最高的一组断言。它的前提是:
Per-cell parsing inserts tokens at each boundary before merging the cells into one module.
(逐 cell 解析会在每个边界处插入 token,之后再把各 cell 合并为一个模块。)
这里的“边界 token”指解析一个 cell 时为了正确收束缩进语法而补齐的词法单元(如 Dedent):Python 的缩进状态是跨行的,独立解析一个 cell 必须在结束前把缩进栈压平。这个场景同时压测三类跨 cell 交互,并断言所有选中的规则都不应报告任何诊断:
- 结尾 Dedent:cell 以缩进块结束时,边界处补齐的
Dedent不应触发空行类规则; - 跨 cell 的定义引用:
compute在 cell 1 定义、cell 3 使用,合并模块必须让语义分析(F821未定义名)看到这一引用; - 跨 cell 的 range 抑制:
# ruff: disable[F401]指令在 cell 2、被抑制的import json在 cell 3,抑制范围跨越 cell 边界依然生效。
选中规则列表(文档中的 [TOML] 配置块,完整继承):
[lint]
select = [
"E301",
"E302",
"E303",
"E305",
"E306",
"F401",
"F821",
"W291",
"W293",
"W391",
]
规则覆盖面经过精心设计:E301/E302/E303/E305/E306 是 pycodestyle 的空行/缩进类规则(验证边界 token 不产生伪空行或伪缩进问题),W291/W293 检查行尾空白(验证 cell 拼接处不引入尾随空白),W391 检查文件末尾多余空行(验证合并模块的末尾 token 序列干净),F401/F821 验证跨 cell 语义。
fixture boundary-tokens.ipynb(完整内嵌文件):
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["def compute():\n", " return 1"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["# ruff: disable[F401]"]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": ["import json\n", "print(compute())"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 4
}
逐 cell 对照三类压测点:cell 1 的 def compute(): 块在边界处以补齐的 Dedent 收束;cell 2 只有抑制指令 # ruff: disable[F401];cell 3 中 import json 应被 cell 2 的抑制覆盖(否则报 F401),print(compute()) 引用 cell 1 的定义(否则报 F821)。该 fixture 没有任何 # snapshot 断言,按 mdtest 的匹配逻辑,“零诊断”本身就是断言:只要边界 token、跨 cell 语义或 range 抑制任何一处实现回退,选中规则就会报错并使测试失败。
如何运行与扩展这些测试
这份文档的测试由 ruff_mdtest crate 的 harness 自动发现,无需手工注册。在仓库根目录可以按测试名过滤运行:
# 运行 cell-boundaries.md 这一个 mdtest fixture
cargo test -p ruff_mdtest --test mdtest -- cell-boundaries
mdtest 框架同时提供两个环境变量(定义于 mdtest 库):MDTEST_TEST_FILTER 用于按名称过滤要执行的测试,MDTEST_UPDATE_SNAPSHOTS(设为非 0 值)用于自动回写 ```snapshot 内联快照。若要为 notebook 解析新增边界用例,只需在 crates/ruff_linter/resources/mdtest/notebook/ 下新增一个 .md,按现有格式写明 [TOML] 配置块与 ```ipynb 内嵌文件(harness 支持的块语言为 py/python、pyi、ipynb、toml 与 ignore,见 ruff_mdtest/src/lib.rs 的 assert_matches!),harness 会将其纳入同一套“写入内存 /src → lint → 断言匹配”的流程。
小结
cell-boundaries.md 用三个紧凑的 fixture 精确刻画了 ruff notebook 解析的三条不变量:错误不越过 cell 边界、无 Python 单元格可安全解析、边界 token 不影响空行/缩进/抑制类规则的判断。这些不变量最终都收敛到 linter.rs 中的 parse_cells_unchecked 分派 这一处实现:逐 cell 的词法/语法解析保证错误定位与缩进收束的局部性,合并模块保证语义规则的跨 cell 全局性。文档既是行为规格,也是回归测试本身——任何破坏这三条不变量的改动,都会让对应的 mdtest 用例直接失败。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00