Jupytext MyST 文本笔记本中的 Raw Cell 表示与无损往返:从"非字典 YAML 内容"样本看 `{raw-cell}` 指令的边界处理

原创2026-10-07 16:17:091,403 阅读
文章标签:开发工具

Jupytext MyST 文本笔记本中的 Raw Cell 表示与无损往返:从"非字典 YAML 内容"样本看 {raw-cell} 指令的边界处理

导读

本文围绕 Jupytext 仓库中的一份 round-trip 测试样本 raw_cell_with_non_dict_yaml_content.md 展开,深入讲解 MyST 文本笔记本格式(format_name 为 myst)如何表示 raw cell——尤其是当 raw cell 的内容本身以 --- 开头、看起来像 YAML 却并非一个有效字典(如本样本中的 ---\nContent.\n---)时,Jupytext 如何在读写两侧保证内容不被误解析、实现无损往返。读完本文,你将掌握 MyST 格式中 {code-cell} / {raw-cell} 指令的完整语法与解析规则、raw cell 与文档级 front matter 的区分机制,以及如何用源码验证 round-trip 行为。

一、样本全景:一份"长相可疑"的 raw cell 在 MyST 中的归宿

关联文档位于 tests/data/notebooks/outputs/ipynb_to_myst/ 目录,是 Jupytext 测试套件中"从 ipynb 转 MyST"的期望输出。全文如下:

---
kernelspec:
  display_name: Python 3
  language: python
  name: python3
---

```{raw-cell}

---
Content.
---
print("Hello, World!")

这份文件由三部分组成,恰好构成了一个最小 MyST 文本笔记本的完整骨架:

1. **顶层 YAML front matter**(`---` 包裹的 `kernelspec`):对应 ipynb 的 `metadata`,在 MyST 中位于文档最顶部;
2. **`{raw-cell}` 指令块**:对应输入 ipynb 的第一个单元格——一个 cell_type 为 `raw`、source 为 `"---\nContent.\n---"` 的单元格;
3. **`{code-cell}` 指令块**:对应第二个 code cell,内容为 `print("Hello, World!")`。

对应的输入文件 <a href="https://link.gitcode.com/i/4d4752e31de1e9c6e1dbad6056420df6" target="_blank">raw_cell_with_non_dict_yaml_content.ipynb</a> 中,raw cell 的 source 是:


Content.


这段文本的第一行和最后一行都是 `---`,外形与 YAML front matter 高度相似;但中间的 `Content.` 并不是一个键值对,因此用 `yaml.safe_load` 解析时得到的既不是字典也不是 `None`,而是一个字符串——这正是文件名中 "non_dict_yaml_content"(非字典 YAML 内容)的含义。这个样本要验证的核心问题是:**当 raw cell 内容与 YAML 语法"撞车"时,Jupytext 的 MyST 读写管线能否把它原样当作单元格内容保留,而不是误当成指令选项或文档元数据解析掉。**

## 二、MyST 格式的三类单元格指令:`{code-cell}`、`{raw-cell}` 与 `+++`

在 <a href="https://link.gitcode.com/i/6000e5ebf90183ce4ad9ebd1a1cf540b" target="_blank">src/jupytext/myst.py</a> 中,MyST 文本格式的单元格指令被定义为一组常量(见 <a href="https://link.gitcode.com/i/76a845a8ffd56e5b50dd3d72db902fa1" target="_blank">myst.py 第 26-28 行</a>):

```python
MYST_FORMAT_NAME = "myst"
CODE_DIRECTIVE = "{code-cell}"
RAW_DIRECTIVE = "{raw-cell}"

MyST 文本笔记本在 Jupytext 中有三种单元格载体:

单元格类型 MyST 文本表示 说明
markdown 普通 Markdown 文本 连续文本段落;需要给下一个单元格附加元数据时以 +++ {json} 分隔
code ```{code-cell} ... ``` 围栏(fence)内是代码,语言由围栏 info 字符串携带
raw ```{raw-cell} ... ``` 围栏内是原样内容,Jupytext 不做任何解析

该格式的扩展名由 myst_extensions() 管理:.myst、.mystnb、.mnb 是 MyST 专属扩展名,.md 也默认被接受;在 matches_mystnb()(myst.py 第 70-123 行)中,文件即使命名为 .md,只要内容含 front matter 且存在 {code-cell} 或 {raw-cell} 围栏,也会被识别为 MyST 格式。本样本文件正是"顶层 YAML front matter + 两个指令围栏"的典型形态,因此即便扩展名是 .md 也能被正确判定。

三、写出侧:notebook_to_myst 如何保护 raw cell 内容

raw cell 的写出逻辑位于 notebook_to_myst()。代码中 cell.cell_type in <a href="https://link.gitcode.com/i/c0b39f8d3f88bf45380508ab239d0c1d" target="_blank">"code", "raw"] 共用同一条写出分支,核心片段([myst.py 第 394-411 行)如下:

elif cell.cell_type in ["code", "raw"]:
    cell_delimiter = three_backticks_or_more(cell.source.splitlines())
    string += "\n{}{}".format(
        cell_delimiter,
        code_directive if cell.cell_type == "code" else raw_directive,
    )
    if pygments_lexer and cell.cell_type == "code":
        string += f" {pygments_lexer}"
    string += "\n"
    metadata = cell.metadata
    if metadata:
        string += dump_yaml_blocks(metadata)
    elif cell.source.startswith("---") or cell.source.startswith(":"):
        string += "\n"
    string += cell.source
    if not cell.source.endswith("\n"):
        string += "\n"
    string += cell_delimiter + "\n"

这段代码透露了几个与本文样本直接相关的关键设计:

  1. 围栏定界符自适应:cell_delimiter 由 three_backticks_or_more(cell.source.splitlines()) 决定——如果 raw cell 内容本身含有三连反引号,写出时会自动升级为四个或更多反引号作为围栏,避免与内容冲突。这是保证"任意内容都能放回围栏"的第一道防线。

  2. --- / : 开头的空行保护:当单元格没有元数据、且 source 以 --- 或 : 开头时,指令行后额外写入一个空行再输出内容(myst.py 第 406-407 行)。这正是本样本输出中出现 ```{raw-cell} 后紧跟空行、然后才是 ---\nContent.\n--- 的原因。这个空行让"指令选项区"与"单元格内容区"在视觉和解析上明确隔离。

  3. 内容零改写:string += cell.source 把 raw cell 的 source 逐字节原样拼接,不缩进、不转义、不尝试解析。raw cell 在 MyST 文本中就是一个"受围栏保护的透明容器"。

对于本样本,raw cell 没有 metadata、source 以 --- 开头,于是命中 elif 分支写入一个空行,最终产出与期望输出完全一致的 ```{raw-cell}\n\n---\nContent.\n---\n```。

四、读取侧:myst_to_notebook 与 parse_directive_options 的容错路径

读取方向由 myst_to_notebook() 负责。它在遍历 markdown-it 解析出的 token 时,对 fence 类型 token 按 info 前缀分流(myst.py 第 306-330 行):

elif token.type == "fence" and token.info.startswith(raw_directive):
    _flush_markdown(md_start_line, token, md_metadata)
    options, body_lines = read_fenced_cell(token, len(notebook.cells), "Raw")
    meta = nbf.from_dict(options)
    source_map.append(token.map[0] + 1)
    notebook.cells.append(nbf_version.new_raw_cell(source="\n".join(body_lines), metadata=meta))

raw 指令围栏内的内容先交给 read_fenced_cell(),后者调用 parse_directive_options() 尝试剥离"指令选项",剩余部分全部作为单元格正文。parse_directive_options 只识别两种可选的选项语法:

  • --- 包裹的 YAML 块:要求主体以 --- 开头,用 yaml.safe_load 解析;
  • 冒号风格:主体以 : 开头的行序列,同样交给 yaml.safe_load。

除此之外的一切内容都原样进入 body_lines。对本样本而言,围栏内紧跟在 {raw-cell} 之后的是一行空行,因此主体并不以 --- 开头,不会进入 YAML 选项解析分支,---\nContent.\n--- 被整体保留为 raw cell 的 source——"非字典 YAML 内容"因此毫发无损地穿过了读取管线。这也解释了为什么写出侧要在 --- 开头的内容前补一个空行:它确保读取侧永远能先看到空行而不是 ---,从而把"长得像选项"的内容稳定地归入正文。

需要补充的是,即便主体真的以 --- 开头进入了 YAML 分支,parse_directive_options 对解析失败(yaml.parser.ParserError / yaml.scanner.ScannerError)也会抛出 MystMetadataParsingError 之外的行为——从源码看,解析结果会通过 yaml.safe_load(yaml_block) or {} 兜底(myst.py 第 210 行),但该分支主要用于承载真实的单元格元数据,而非字典 YAML 内容的标准写法就是本文样本所展示的"先空行、再内容",这一点可以结合下一节的对照样本进一步印证。

五、对照样本:复杂多行 YAML 风格内容同样原样保留

仓库中还有一份孪生样本 raw_cell_with_complex_yaml_like_content.md,其输入 raw_cell_with_complex_yaml_like_content.ipynb 中的 raw cell 内容是:

---

This is a complex paragraph
that is split over multiple lines.

It also includes blank lines.


---

这份内容的首行同样紧贴 ---,且中间含空行、多行段落、首尾双 ---,是更"复杂"的 YAML 风格文本。其 MyST 输出为:

```{raw-cell}

---

This is a complex paragraph
that is split over multiple lines.

It also includes blank lines.


---

注意两个细节:其一,`{raw-cell}` 与内容之间依然保留了空行隔离;其二,围栏内的空行、多行折行结构被原样保留,没有经过任何 YAML 归一化或重新序列化。这两份样本共同构成了 Jupytext 对 raw cell 的承诺:**raw cell 的内容在 MyST 文本中永远以"围栏 + 原样内容"的形式存在,既不参与单元格元数据解析,也不参与文档 front matter 解析。**

## 六、Raw cell 与顶层 front matter 的边界:`root_level_metadata_as_raw_cell`

理解了 `{raw-cell}` 之后,还需要厘清它与"文档顶层 YAML front matter"的关系,否则很容易把样本开头的 `kernelspec` 块与 raw cell 混为一谈。

在 MyST 文档中,位于文件最顶部的 `--- ... ---` 是**文档级元数据**,由 `myst_to_notebook` 在 `front_matter` token 中读取并写入 `notebook.metadata`(<a href="https://link.gitcode.com/i/de96d6b277c95c8a9e45befd12d5dbaa" target="_blank">myst.py 第 272-283 行</a>)。而 raw cell 是**单元格**,两者层级完全不同。

但二者存在一个可配置的交汇点:`root_level_metadata_as_raw_cell`。该选项在 <a href="https://link.gitcode.com/i/567f2cdd31db61dc9ced7aa5ac214e44" target="_blank">src/jupytext/config.py 第 94-99 行</a> 中定义为:

```python
root_level_metadata_as_raw_cell = Bool(
    True,
    help="Should the root level metadata of text documents (like the fields 'title' or 'author' in "
    "R Markdown document) appear as a raw cell in the notebook (True), or go to the notebook"
    "metadata?",
    config=True,
)

当其为 True(默认)时,文本文档的根级元数据会被打包成"形如 YAML front matter 的 raw cell"放进 ipynb 的第一格;反向转换时,src/jupytext/header.py 的 metadata_and_cell_to_metadata() 会检查首个单元格是否为 raw cell,若是则尝试 yaml.safe_load_all 其内容,只有在解析结果确实是字典时才把它提升回根级元数据;解析失败或结果非字典时,仅记录 warning("<a href="https://link.gitcode.com/i/6d3fd63b64c3031b3784ee6c21b9dae0" target="_blank">jupytext] failed to parse YAML in raw cell" / "[jupytext] YAML header in raw cell is not a dictionary"),单元格保持原样([header.py 第 323-336 行)。

这正好呼应了本文样本的主题:Content. 解析出来不是字典,因此即便有人把这段内容误放在首格,Jupytext 也不会把它误吞为元数据,而是原样保留为 raw cell。同理,R Markdown 中 title: / author: 这类真正的根级元数据,在 MyST 与 Rmd 之间转换时正是通过这一机制在"raw cell"与"文档 front matter"之间迁移,相关行为在 tests/functional/simple_notebooks/test_read_simple_rmd.py 中通过参数化测试 test_root_level_metadata_as_raw_cell(True/False) 做了双向验证。

七、跨格式视角:不同文本格式对 raw cell 的承载方式

raw cell 并非 MyST 专属概念,它在 Jupytext 支持的每种文本格式中都有对应语法,理解这一点有助于判断 MyST 方案的取舍:

文本格式 raw cell 表示 仓库中的测试证据
MyST(本文) ```{raw-cell} 围栏 本样本及 raw_cell_with_complex_yaml_like_content.md
Markdown(md) <!-- #raw --> 与 <!-- #endraw --> HTML 注释包裹 tests/functional/simple_notebooks/test_read_simple_markdown.py 中 test_raw_cell_with_metadata 等用例
百分号脚本(py:percent) # %% <a href="https://link.gitcode.com/i/7855adae4d2b0d931580a7897ef01279" target="_blank">raw] 单元格标记 [tests/functional/simple_notebooks/test_read_simple_percent.py
Hydrogen(py:hydrogen) # %% <a href="https://link.gitcode.com/i/70ddf4788817c7edc96b12ee531a8cdd" target="_blank">raw] 标记 [tests/functional/simple_notebooks/test_read_simple_hydrogen.py
Python 脚本(py) # + <a href="https://link.gitcode.com/i/65b3b74b88f36c859e72e9d6a7c4ed0a" target="_blank">raw] 注释标记 [tests/functional/simple_notebooks/test_read_simple_python.py 中 test_raw_with_metadata_2

MyST 采用"围栏"承载 raw cell,与脚本格式的"注释标记"相比,优势在于对内容本身几乎零约束:注释标记方案要求 raw cell 的每一行都必须能被注释符前缀处理,而围栏方案中只有围栏定界符本身需要避开内容。three_backticks_or_more 的自适应升级机制(见第三节)进一步消除了"内容里恰好有三连反引号"的唯一冲突点。

八、实操:用 CLI 复现样本的 ipynb → MyST 转换

要亲手复现本样本,只需对输入 ipynb 执行 MyST 格式的转换命令。仓库的 CLI 入口为 src/jupytext/cli.py,格式名 myst 定义于 src/jupytext/myst.py 第 26 行。在已安装 Jupytext 的环境中:

jupytext "tests/data/notebooks/inputs/ipynb_py/raw_cell_with_non_dict_yaml_content.ipynb" --to myst

也可以显式指定 --to md:myst 或 --to mystnb。转换产生的 .md 文件内容应与 raw_cell_with_non_dict_yaml_content.md 一致。反向验证则执行:

jupytext "tests/data/notebooks/outputs/ipynb_to_myst/raw_cell_with_non_dict_yaml_content.md" --to ipynb

此时 raw cell 应还原为 source 为 "---\nContent.\n---" 的原始单元格。需要注意的是,MyST 格式依赖 markdown-it-py 及其 MyST 插件,myst.py 中的 raise_if_myst_is_not_available()(myst.py 第 39-41 行)会在缺少依赖时抛出 ImportError 提示安装。

九、测试体系:这份样本如何被自动化验证

在测试层面,tests/conftest.py 第 314-316 行 定义了 ipynb_to_myst fixture,它遍历 ipynb_all 目录下的所有输入笔记本,与 tests/data/notebooks/outputs/ipynb_to_myst/ 下的期望输出一一对比。因此 raw_cell_with_non_dict_yaml_content.md 并不仅仅是一份"示例文件",而是被 round-trip 测试直接引用的基线:

  • 每个输入 ipynb 经 jupytext.writes(nb, "myst") 生成的文本,必须逐字节等于该期望文件;
  • 反向 jupytext.reads(md, "myst") 得到的笔记本,必须与输入 ipynb 的单元格结构一致(对 raw cell 的 source 与 cell_type 做严格比对)。

此外,tests/functional/round_trip/test_myst_header.py 专门覆盖 MyST 头部元数据与 raw cell 的边界行为,其中通过 new_raw_cell(dump_yaml_blocks(frontmatter, compact=False).strip()) 构造"元数据形态的 raw cell"并验证其读写一致,与本文样本形成互补:前者验证"元数据可以被包成 raw cell",后者验证"看起来像元数据的普通内容不会被吞成元数据"。

十、边界与注意事项

  1. {raw-cell} 指令必须位于顶层:myst_to_notebook 在 docstring 中明确假设所有指令都出现在文档顶层(myst.py 第 260-261 行),嵌套在列表或其他 directive 中的围栏不会被当作单元格(解析循环中 nesting_level != 0 的 token 会被跳过,myst.py 第 299-304 行)。

  2. 空行隔离是稳定往返的前提:手工编写 {raw-cell} 时,建议像样本一样在指令行后保留一个空行,再书写以 --- 或 : 开头的内容,从而规避读取侧的选项解析分支。

  3. 文档级 front matter 必须是合法 YAML 字典:顶层 --- 块会被 yaml.safe_load 解析为笔记本元数据(myst.py 第 272-278 行),解析失败会抛出 MystMetadataParsingError。与 raw cell 不同,front matter 没有"非字典内容"的容错——因此"像 YAML 但不是字典"的内容应当放进 {raw-cell},而不是文档顶部。

  4. 元数据过滤与 raw cell 相互独立:root_level_metadata_as_raw_cell 只影响文档根级元数据的存放位置,不影响 {raw-cell} 内已由用户写定的内容;单元格自身的元数据在 MyST 中通过指令选项区(--- YAML 块或 :key: 冒号行)表达,与正文分离。

结语

通过 raw_cell_with_non_dict_yaml_content.md 这一最小样本,可以看到 Jupytext 的 MyST 格式在"内容保护"上做了三重设计:写出时用 three_backticks_or_more 自适应围栏并针对 ---/: 开头补空行,读取时用 parse_directive_options 把选项解析限定在严格的两类语法之内,最后再用 metadata_and_cell_to_metadata 对"元数据形态的 raw cell"做字典校验兜底。三者的合力使得任何 raw 内容——包括外形酷似 YAML front matter 的非字典文本——都能在 ipynb 与 MyST 文本之间无损往返,这正是 Jupytext 将 Notebook 文本化的可靠性根基之一。若要进一步深入,可继续阅读 src/jupytext/myst.py 的完整读写实现、src/jupytext/header.py 的元数据迁移逻辑,以及 tests/functional/round_trip/test_myst_header.py 的相关测试用例。

登录后查看全文
jupytext