首页
/ ruff 的 Notebook 单元格边界解析:逐 Cell 独立解析、合并 lint 与 mdtest 测试设计

ruff 的 Notebook 单元格边界解析:逐 Cell 独立解析、合并 lint 与 mdtest 测试设计

2026-09-07 16:30:21作者:冯梦姬Eddie

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 的运行实现 可以看到具体流程:

  1. 解析 markdown 套件后,在内存文件系统里创建项目根目录 /src
  2. 把内嵌的 pypyiipynbtoml 代码块按各自文件名写入该目录(lang == "ignore" 的块会被跳过);
  3. 用文档中 [TOML] 配置块构造 Configuration 并解析为 linter 设置;
  4. 对每个写入的文件调用 test_contents(定义于 ruff_linter/src/test.rs)执行 lint,得到诊断列表;
  5. 用内联断言(# snapshot)与 ```snapshot 块比对诊断输出,不一致即测试失败。

这正是为什么文档中的快照路径写作 src/syntax-error.ipynb:cell 1:2:6 —— 内嵌文件被放在内存根 /src 下。诊断的渲染、# snapshot 断言的匹配以及失败时的 diff 输出,由底层 mdtest 库validate_inline_snapshotmatch_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_kindSourceKind::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/pythonpyiipynbtomlignore,见 ruff_mdtest/src/lib.rsassert_matches!),harness 会将其纳入同一套“写入内存 /src → lint → 断言匹配”的流程。

小结

cell-boundaries.md 用三个紧凑的 fixture 精确刻画了 ruff notebook 解析的三条不变量:错误不越过 cell 边界、无 Python 单元格可安全解析、边界 token 不影响空行/缩进/抑制类规则的判断。这些不变量最终都收敛到 linter.rs 中的 parse_cells_unchecked 分派 这一处实现:逐 cell 的词法/语法解析保证错误定位与缩进收束的局部性,合并模块保证语义规则的跨 cell 全局性。文档既是行为规格,也是回归测试本身——任何破坏这三条不变量的改动,都会让对应的 mdtest 用例直接失败。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388