编写 nbdev 插件

如何定制 nbdev 处理器以实现您的需求

本文将介绍什么?

借助 nbdev,可以通过一个深思熟虑且可扩展的框架进一步定制和扩展其标准功能。您的特定库或需求是否要求您在某些单元格中注入自定义的 Quarto 添加项?或者如果您想做一些更简单的事情,比如寻找快捷方式更容易地替换复杂的 Quarto 指令(例如将 ::: {.column-margin} 替换为 #| margin)呢?

使用 nbdev 编写自定义插件是实现此目标的最简单方法,通过本教程,我们将帮助您快速掌握如何利用它创建自己的插件,以扩展和简化您使用 nbdev 和 Quarto 进行文学编程的体验。

具体来说,我们将构建一个 processor(处理 notebook 单元格的东西),它将使我们能够快速写出任何 Quarto 特定的头部(如 ::: {.some_annotation}),并将其替换为一个 div 快捷方式。这当然是一个非常特定于 Quarto 的示例,发生在构建文档时,但此技术也可以用于在库导出期间实现自定义行为。

注意:这里我们使用 div 是因为它更像相关的 Quarto 指令的行为,它们在 HTML 代码中类似于 <div> 标签

本教程不会涵盖 nbdev 的一些基础知识,而是假设您了解如何使用 nbdev(例如什么是指令export 等)。

入门:nbdev 如何使其变得容易?

首先,让我们可视化一下我们要实现的目标。

与其执行以下代码(它将把 "Some text" 添加到侧边栏,如当前侧边所示)

一些文本

::: {.column-margin}
Some text
:::

我们将创建一个更短的方式来写出它,利用 nbdev 和 Quarto 编写其指令的方式

在本教程结束时,我们将创建一个看起来像下面的东西

#| div column-margin

Some text

这还将包括 div 应该跨越多个单元格的情况,通过指定 startend

注意:请查阅 文章布局 Quarto 文档,以找到此自定义指令的最佳用例示例,包括刚刚展示的 column-margin

这可以在不到 50 行代码内实现!

nbdev 让我们创建所谓的 processors(例如,#| export 就是通过这种方式将代码放入模块的)。这些处理器作用于 notebook 的每个单元格,并可以修改其内容。然后可以将它们封装到一个模块中,就像 nbdev 处理 nbdev_exportnbdev_docs 一样。由于编写自定义 nbdev 扩展的强大功能,无需深入了解框架的内部工作原理!

引入所需的组件

我们实际需要从 nbdev 导入的并不多!我们只需要两个:- extract_directives,用于读取编写的 #| 列表 - Processor 类,它将实际对 notebook 单元格执行我们想要的操作。

其余的导入是为了让我们的工作更轻松,稍后会解释

from nbdev.process import extract_directives
from nbdev.processors import Processor

from fastcore.basics import listify

from string import Template

最后,出于测试目的,我们将利用 nbdevmk_cell 函数和 NBProcessor 类,这将使我们能够在“真实”的 notebook 上模拟运行我们的处理器!

from nbdev.processors import mk_cell, NBProcessor

编写转换器

第一步是创建一种快速简便的方法,将我们想要使用的 nbdev 指令(例如 #| div column-margin)快速转换为 Quarto 可以读取的内容(例如 ::: {.column-margin})。

我们可以创建一个字符串 Template 来为我们执行此操作

_LAYOUT_STR = Template("::: {.$layout}\n${content}\n")
提示

这不必是字符串模板,我只是发现它最容易使用!

_LAYOUT_STR.substitute(
    layout="column-margin",
    content="Some text to go on the sidebar"
)
'::: {.column-margin}\nSome text to go on the sidebar\n'

接下来,我们需要编写一个在单元格级别操作的简单转换器

def convert_layout(
    cell:dict, # A single cell from a Jupyter Notebook
    is_multicell=False # Whether the div should be wrapped around multiple cells
):
    "Takes a code cell that contains `div` in the directives and modifies the contents to the proper Quarto format"
    content = cell.source
    code = cell.source.splitlines(True)
    div_ = cell.directives_["div"]
    # We check if end is in the first line of the cell source
    if "end" in div_:
        # If it is, just fill the text with `:::` if no code exists there
        cell.source = ":::" if len(code) == 1 else f'{code.source}:::'
    else:
        # Actually modify the code
        cell.source = _LAYOUT_STR.substitute(layout=" ".join(div_), content=content)
        if not is_multicell: cell.source += ":::"

让我们详细了解这里发生了什么。

    content = cell.source

notebook 单元格中存在的任何内容的源文本都将位于 .source 中。

    code = cell.source.splitlines(True)

然后我想提取单元格的内容并将其分成多行,用换行符分隔。这使我们可以检查单元格是否仅包含 #| div end,这意味着之前开始的 div 应该停止。

    div_ = cell.directives_["div"]

任何指令(任何用 #| 标记的单元格中的注释)将作为字典存在于 directives_ 属性中。对于我们的特定处理器,我们只关心 div 指令

    if "end" in div_:
        # If it is, just fill the text with `:::` if no code exists there
        cell.source = ":::" if len(code) == 1 else f'{code.source}:::'
    else:
        # Actually modify the code
        cell.source = _LAYOUT_STR.substitute(layout=" ".join(div_), content=content)
        if not is_multicell: cell.source += ":::"

从那里,这最后一部分检查是向单元格添加结束的 ::: 块,还是使用 _LAYOUT_STR 并为 Quarto 注入样板 div CSS 代码。

让我们看看它的实际效果

cell = mk_cell(
    """#| div margin-column
Here is something for the sidebar!""",
    cell_type="markdown"
)

nbdev 将提取这些指令并使用 extract_directives 函数将它们存储在单元格的 directives_ 属性中

cell.directives_ = extract_directives(cell, "#")
cell.directives_
{'div': ['margin-column']}

现在我们可以测试一下我们的 convert_layout 函数是否有效!

convert_layout(cell)
print(cell.source)
::: {.margin-column}
Here is something for the sidebar!
:::

注意:我在这里打印 cell.source 是为了让其文本看起来更清晰,就像我们在 Markdown 单元格中看到的那样

看起来完全符合我们之前的要求!太棒了!

我们如何告诉 nbdev 使用它并创建前面提到的 Processor 类?

编写 Processor

这里的倒数第二步是创建自定义的 Processor,nbdev 利用它来应用 procs(修改单元格内容的东西)。对它们的简单理解是:您应该创建一个类,让它继承 Processor,并且所有应该进行的修改都必须在一个 cell 函数中定义,该函数接受一个 cell 并就地修改它。

class LayoutProc(Processor):
    "A processor that will turn `div` based tags into proper quarto ones"
    has_multiple_cells = False
    def cell(self, cell):
        if cell.cell_type == "markdown" and "div" in cell.directives_:
            div_ = cell.directives_["div"]
            if self.has_multiple_cells and "end" in div_:
                convert_layout(cell)
            else:
                is_start = div_[-1] == "start"
                if is_start:
                    self.has_multiple_cells = True
                    div_.remove("start")
                convert_layout(cell, is_start)

我们如何测试它是否会工作?

一个最小的 Jupyter Notebook 只是一个字典,其中的单元格位于 cells 键中,而单元格本身是一个遵循特殊格式的 notebook 单元格列表。我们在上面已经创建了一个这样的示例。nbdev 有一个 dict2nb 函数,可以让我们快速将这个最小的 Jupyter Notebook 概念转换为真实的对象。

之后,我们可以通过 NBProcessor 类(nbdev 用于应用这些处理器)将处理器应用于这些单元格

from nbdev.process import NBProcessor, dict2nb
nb = {
    "cells":[
    mk_cell("""#| div column-margin
A test""", "markdown"),
    mk_cell("""#| div column-margin start
A test""", "markdown"),
    mk_cell("""#| div end""", "markdown"),
]}

mk_cell 函数将根据一些 content 和一个 cell type 创建一个单元格。我们构建的特定扩展适用于 Markdown 单元格,所以我们将类型设置为 markdown

NBProcessor 接受一个应该应用的 procs(处理器)列表和一个打开的 Jupyter Notebook

processor = NBProcessor(procs=LayoutProc, nb=dict2nb(nb))

应用这些处理器的操作是通过调用 .process(): 函数完成的

processor.process()

现在我们可以看到那些代码单元格已被更改

for i in range(3):
    print(f"Before:\n{nb['cells'][i].source}\n")
    print(f"After:\n{processor.nb.cells[i].source}\n")
Before:
#| div column-margin
A test

After:
::: {.column-margin}
A test
:::

Before:
#| div column-margin start
A test

After:
::: {.column-margin}
A test


Before:
#| div end

After:
:::

太棒了!我们成功创建了一个 nbdev 插件,可以让我们轻松地偷懒编写 Markdown Quarto 指令。我们如何在项目中实际使用它呢?

如何在您的项目中启用插件

这需要在您的 settings.ini 中进行两处更改。

首先,如果这段代码存在于 nbdev 中,我们可以添加一个特殊的 procs 键并指定处理器来自哪里

procs = 
    nbdev.extensions:LayoutProc

它遵循 library.module:processor_name 的格式

如果这是从外部库使用(例如此处理器基于 nbdev-extensions 中的处理器),您应该将其添加到您的项目需求中

requirements = nbdev-extensions

这样就完成了!现在当调用 nbdev_docsnbdev_preview 时,我们刚刚创建的处理器将自动应用于您的 notebooks 并执行此转换!

总结、nbdev-extensions 以及关于我的一些介绍!

基本上,如果单元格的任何部分以及它在导出模块、构建文档或创建自己的特殊命令执行后处理时的外观,都可以利用 nbdev 提供的这个 Processor 类快速高效地完成!

(Zachary Mueller 撰写)如果您有兴趣查看更多 nbdev-extensions 的示例以及它能做什么,我编写了一个专门的库,名为 nbdev-extensions,任何可能对我处理 nbdev 有益的想法,我都会将其转化为一个扩展供大家使用。

感谢阅读!