- 开发工具
【免费下载链接】jupytext
Jupyter Notebooks as Markdown Documents, Julia, Python or R scripts
本篇文章以 Jupytext 仓库中一份专门的 round-trip 镜像测试样本 tests/data/notebooks/outputs/ipynb_to_md/Notebook with many hash signs.md 为骨架,深入讲解 Jupytext 如何把包含"大量井号(#)"文本的 Jupyter 笔记本转换为 Markdown 文档,并确保信息无损、格式稳定、且不会被误判为 Sphinx Gallery 脚本。读完本文,你将理解 Jupytext Markdown 格式的 YAML 头、代码围栏与单元格切分规则,掌握同一边界场景在 percent、myst、Rmd 等文本格式下的差异表达,并能通过源码与测试复现、验证这类转换行为。
一份"井号密集"测试样本的定位:round-trip 镜像输出
在 Jupytext 的测试体系中,tests/data/notebooks/outputs/目录存放着各类"镜像输出"(mirror file):它们由tests/functional/round_trip/test_mirror.py中的镜像测试生成并比对。其中:
def test_ipynb_to_md(ipynb_file, no_jupytext_version_number): assert_conversion_same_as_mirror(ipynb_file, "md", "ipynb_to_md")(见 tests/functional/round_trip/test_mirror.py)
这条测试把 tests/data/notebooks/inputs/ipynb_py/Notebook with many hash signs.ipynb 转换为 Markdown,再与ipynb_to_md/下的镜像文件逐字节比对,保证"新版本发布时表示形式最小化变化"。我们讨论的这份 md 文件,正是该测试针对"含大量井号"笔记本生成的官方镜像,它同时是一份完全合法、可被jupytext.reads读回为 ipynb 的 Markdown 笔记本。
输入侧:三个单元格的原始结构
先看输入 ipynb(nbformat 4),它包含三个单元格:
- Markdown 单元格:以整行井号分隔线开合、内含说明文字的文本块;
- 代码单元格:
some = 1、code = 2、some+code,其后紧跟着两段被整行井号线包裹的普通#注释; - Markdown 单元格:与第一个单元格内容相同的文本块。
输出侧:md 镜像的完整内容
转换得到的 Markdown 镜像全文如下(即本篇文章的主题文档):
--- jupyter: kernelspec: display_name: Python 3 language: python name: python3 --- ################################################################## This is a notebook that contains many hash signs. Hopefully its python representation is not recognized as a Sphinx Gallery script... ################################################################## ```python some = 1 code = 2 some+code ################################################################## # A comment ################################################################## # Another comment################################################################## This is a notebook that contains many hash signs. Hopefully its python representation is not recognized as a Sphinx Gallery script... ##################################################################
这段文本包含了 Jupytext Markdown 格式(`md`)的全部核心语法要素,下面逐一拆解。 ## Markdown 格式的三大语法要素:YAML 头、代码围栏与连续 Markdown 段落 ### 1. YAML 元数据头 文件开头是由 `---` 包裹的 YAML 块,保存了笔记本级元数据。本例保存的是 `jupyter.kernelspec`(`display_name: Python 3`、`language: python`、`name: python3`)。这正是输入 ipynb 中 `metadata.kernelspec` 的原样保留——Jupytext 默认会把 `kernelspec` 等元数据写入文本表示,可用 `notebook_metadata_filter` 配置调整保留范围(见 [src/jupytext/config.py](https://link.gitcode.com/i/4f5c32c4f5ef7fdd48bb6c6dd5035fe0))。 ### 2. Markdown 单元格:直接书写,不做注释化转义 两个 Markdown 单元格在 md 表示中是"裸文本"——**没有添加任何 `# ` 前缀**。这一点与 percent / light 等 Python 脚本格式截然不同(脚本格式中 Markdown 内容必须逐行加注释前缀)。这正是 Markdown 格式的核心设计:Markdown 单元格本身就是 Markdown,直接落地即可。 这意味着包含大量井号的行(如 `##################################################################`)在 md 文件中就是普通文本行,与 Sphinx Gallery 脚本的分隔符语法天然不冲突(详见下文第三节)。 ### 3. 代码单元格:围栏式代码块 代码单元格被包裹在 ```python 围栏中,围栏语言标记来自输入 ipynb 中该单元格的语言(此处为 Python)。单元格内部内容(含 `#` 注释)**原样保留、不做任何转义**:注释行 `# A comment` 依然是注释,井号分隔行依然是代码注释,语义与 ipynb 完全一致。 ## 为什么"大量井号"是必须专门测试的边界场景? 这份测试样本的命名和内容直指一个真实风险:**文本表示不能与 Sphinx Gallery 脚本混淆**。镜像文件中那句说明文字写得很直白: > Hopefully its python representation is not recognized as a Sphinx Gallery script... ### Sphinx Gallery 的分隔规则:20 个井号即触发 Markdown 单元格 Jupytext 原生支持把 Sphinx Gallery 脚本(`py:sphinx` 格式)读作笔记本。在 [SphinxGalleryScriptCellReader](https://link.gitcode.com/i/5b3e73d00cc3910329b7d13586c22b24) 中,判断"新 Markdown 单元格开始"的关键正则如下: ```python twenty_hash = re.compile(r"^#( |)#{19,}\s*$") default_markdown_cell_marker = "#" * 79(见 src/jupytext/cell_reader.py)
也就是说:一行以#开头、其后跟随至少 19 个#(合计 ≥20 个井号)的整行注释,会被视为 Markdown 单元格的分隔符;而当导出回 Sphinx 格式时,SphinxGalleryCellExporter 的默认单元格标记是 79 个井号(default_cell_marker = "#" * 79),并会把源码中的分隔行规范化为该默认标记。
风险推演:如果 md 被当成 sphinx 脚本解析
本测试样本中的井号分隔行包含数十个#(远超 20 个的识别阈值)。设想一个相反的解析场景:若这份 md 文档被误以py:sphinx格式读取,##################################################################这类行就会被twenty_hash正则命中,从而被当作"新 Markdown 单元格开始"的标记,单元格切分将完全错乱——文本段落会被割裂成多个单元格,代码注释也可能被误提升为 Markdown 文本。Jupytext 之所以为这种场景专门建立镜像测试,就是为了把"Markdown 单元格直接书写、井号行原样保留"的行为固化下来,防止未来某个版本把 Markdown 内容改成注释化表达(像脚本格式那样加#前缀),从而在两类格式之间埋下歧义。
配置侧的相关防线
仓库在 src/jupytext/config.py 提供了preferred_jupytext_formats_read,其帮助文本明确写道:
Use
"py:sphinx"if you want to read all python scripts as Sphinx gallery scripts.
即"所有 Python 脚本都按 Sphinx 脚本读"属于显式声明的行为;另一个选项sphinx_convert_rst2md(src/jupytext/config.py)控制读取 Sphinx 脚本时是否把 reStructuredText 转换为 Markdown,其底层调用sphinx_gallery.notebook.rst2md的导入在 src/jupytext/cell_reader.py 被标记为可选依赖(ImportError时置为None)。默认情况下 md 与 sphinx 是两套独立格式,互不干扰——这正是本测试样本所保障的。
源码视角:MarkdownCellReader 如何读回这份文档
这份 md 镜像不仅是输出,也是可回读的输入。Jupytext 读取 Markdown 文档时使用 MarkdownCellReader,其核心机制如下:
- 代码围栏识别:
start_code_re正则^```()(\s)(<语言>)(\s.*)?$匹配以围栏 + Jupyter 语言名开头的行,从而把 ```` ```python ```` 识别为代码单元格起点;对应的end_code_re`(```)匹配围栏闭合行(src/jupytext/cell_reader.py)。 - Markdown 单元格切分:在没有显式元数据时,以"连续两个空行"作为单元格结束标志(
find_cell_end中prev_blank >= 2即返回,见 src/jupytext/cell_reader.py),并妥善跳过显式代码围栏与缩进代码块内部的内容,避免围栏内的空行被误当作单元格边界。 - 内容原样保留:Markdown 单元格的
extract_content不做任何去注释化处理,因此##################################################################分隔线连同其间的文本被整体作为一个 Markdown 单元格读入。
对照本测试样本:两个 Markdown 单元格各以整行井号线开头结尾、内部无空行,读取时自然成为两个独立 Markdown 单元格;some = 1等代码被围栏包裹,完整还原为代码单元格,内部注释行保持注释身份。由此完成md → ipynb的信息无损回读,与ipynb → md形成闭环(这也是 tests/functional/round_trip/test_mirror.py 中 Part II"text → ipynb → text"所验证的)。
同一边界场景在不同文本格式下的表达差异
同样的输入 ipynb,Jupytext 会针对不同目标格式生成差异化的文本表示。仓库镜像目录中保留了多种输出,对比如下:
| 目标格式 | 镜像文件 | Markdown 单元格表达 | 代码单元格表达 |
|---|---|---|---|
md | ipynb_to_md/Notebook with many hash signs.md | 裸文本,井号线原样 | ```python围栏,注释原样 |
md:myst | ipynb_to_myst/Notebook with many hash signs.md | 裸文本,井号线原样 | ```{code-cell} ipython3围栏 |
py:percent | ipynb_to_percent/Notebook with many hash signs.py | 逐行加#前缀 +# %% [markdown]标记 | # %%标记,井号线注释原样 |
Rmd | ipynb_to_Rmd/Notebook with many hash signs.Rmd | 裸文本 | ```{python}围栏 |
py:hydrogen | ipynb_to_hydrogen/Notebook with many hash signs.py | 注释化 +# %% [markdown] | # %%标记 |
py:marimo | ipynb_to_marimo/Notebook with many hash signs.py | 三引号字符串包裹(缩进) | 普通代码 |
以 percent 输出为例(ipynb_to_percent 镜像):
# %% [markdown] # ################################################################## # This is a notebook that contains many hash signs. # Hopefully its python representation is not recognized as a Sphinx Gallery script... # ##################################################################可以看到:脚本类格式必须给 Markdown 内容逐行添加#前缀(因为脚本中没有"裸 Markdown"这一层),而井号分隔线此时就变成了# ##################################################################这样的注释行。一旦解析器错误地把这类注释行按 Sphinx 规则解读(≥20 个井号的整行注释 = Markdown 单元格分隔符),单元格结构就会崩溃——这正是测试样本文字所担忧的情形。Jupytext 的解决方案是:sphinx 作为独立格式(py:sphinx,注册于 src/jupytext/formats.py,并列入FORMATS_WITH_NO_CELL_METADATA)严格按自身语法解析,与 percent / light / md 等格式互不误判,测试样本 tests/functional/simple_notebooks/test_read_simple_sphinx.py 则专门验证了 sphinx 格式下井号分隔、三引号、空单元格等解析规则的确定性。
实操:本地复现这份镜像转换
要在本地复现本文讨论的转换,只需两步:
1. 命令行转换:在仓库根目录执行 Jupytext 的--to选项,把输入 ipynb 转为 Markdown:
jupytext --to md "tests/data/notebooks/inputs/ipynb_py/Notebook with many hash signs.ipynb"输出的 md 文件应与本文展示的镜像内容一致(生成的默认镜像不含jupytext元数据版本号,与测试所用no_jupytext_version_numberfixture 行为一致)。
2. 运行镜像测试:仓库测试套件可直接验证稳定性:
pytest tests/functional/round_trip/test_mirror.py -k "ipynb_to_md"该测试会断言"ipynb → md → ipynb"与"md → ipynb → md"两条往返路径均保持内容等价(compare.py 中的compare_notebooks负责归一化比对)。
小结:井号是文本笔记本的"分水岭"
一份看似简单的"many hash signs"测试样本,揭示了 Jupytext 文本笔记本体系中一个极易踩坑的边界:整行大量井号在 Markdown 格式下是普通文本,在脚本格式下是注释行,在 Sphinx 格式下则是单元格分隔标记。Jupytext 通过"md 直接书写 + 各格式独立解析器 + round-trip 镜像测试固化"三层设计,保证了同一份笔记本在不同文本表示之间往返转换时内容与语义不丢失。对于在自己的文档中大量使用井号分隔线的用户,本测试样本与镜像文件可作为转换正确性的直接参照;而 test_read_simple_sphinx.py、test_mirror.py 等测试则为后续深入探索 Jupytext 的格式解析实现提供了入口。
- 开发工具
【免费下载链接】jupytext
Jupyter Notebooks as Markdown Documents, Julia, Python or R scripts
相关推荐
Jupytext 中 Notebook 的 Markdown 表示:以 PowerShell 笔记本的 ipynb→md 转换为例
Jupytext 中 Notebook 的 Markdown 表示:以 PowerShell 笔记本的 ipynb→md 转换为例 Jupytext 的核心能力
开发工具Jupytext 实战:将 SAS 笔记本转换为 Markdown 文档(ipynb → md 完整解析)
Jupytext 实战:将 SAS 笔记本转换为 Markdown 文档(ipynb → md 完整解析) 本文围绕 jupytext 仓库中一份真实的 SAS
开发工具Jupytext 实战:将 Lua 笔记本转换为 Markdown 文档(ipynb → md 全流程解析)
Jupytext 实战:将 Lua 笔记本转换为 Markdown 文档(ipynb → md 全流程解析) 本篇技术指南以 Jupytext 仓库中的一份真实
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考