Papermill 二次开发完全指南:IO Handler、语言翻译器、执行引擎与 CLI 扩展实战

原创2026-10-07 10:14:571,691 阅读
文章标签:开发工具CLI数据工程

Papermill 二次开发完全指南:IO Handler、语言翻译器、执行引擎与 CLI 扩展实战

本文面向希望为 papermill 扩展新能力的开发者。papermill 是一个"参数化、执行、分析 Notebook"的流水线工具(项目描述见 pyproject.toml),其核心价值在于把 Jupyter Notebook 变成可批量、可参数化、可调度的作业。本文以仓库根目录的 DEVELOPMENT_GUIDE.md 为骨架,结合 iorw.py、translators.py、engines.py、execute.py 与 cli.py 等源码与测试,系统讲解四大扩展点——新增 IO 来源/目标、新增语言翻译器、新增执行引擎、新增 CLI/API 选项——以及发布新版本的完整流程。读完本文,你将能够像 papermill 核心开发者一样,为任意存储后端、任意内核语言、任意执行器扩展 papermill,并保证改动有测试覆盖、可随版本发布。

前置准备:先阅读贡献规范

DEVELOPMENT_GUIDE.md 开篇即强调:动手开发前务必先通读 CONTRIBUTING.md。贡献规范中约定了代码风格、提交信息格式、测试要求与协作流程,后续所有扩展点都遵循同一套工程约定:新增功能必须附带测试,且测试要模仿现有测试类的写法。

开发环境方面,按 DEVELOPMENT_GUIDE.md 的要求,发布前需安装开发依赖:

pip install -r requirements/dev.txt

其中 requirements/dev.txt 对应 pyproject.toml 中的 dev extra(含 pytest、bumpversion、black、twine、tox 等),详见 pyproject.toml。

一、扩展 IO 来源与目标(IO Sources / Sinks)

papermill 最灵活的扩展点之一就是 IO 层:它允许笔记本的输入、输出路径不仅仅是本地文件,还可以是 S3、GCS、HDFS、Azure Data Lake、Azure Blob、HTTP、GitHub 甚至标准输入/输出流。要接入一种新的存储,只需实现一个"Handler"并注册到全局注册表。

1.1 注册表机制:papermill_io 与 LIFO 前缀匹配

所有 IO 处理逻辑都集中在 iorw.py。模块根部有一个单例对象 papermill_io = PapermillIO()(iorw.py),它维护着一个LIFO(后进先出)的处理器队列 self._handlers。

register(scheme, handler) 通过 self._handlers.insert(0, (scheme, handler)) 把新处理器插入队首(iorw.py),因此后注册的处理器优先匹配。当 get_handler(path) 收到一个路径时,会从队首开始逐个检查 path.startswith(scheme),命中即返回对应 handler(iorw.py)。

当前仓库默认注册的 scheme 一览(iorw.py):

前缀 Handler 说明
local LocalHandler 本地文件系统,最低优先级兜底
s3:// S3Handler AWS S3(依赖 boto3)
adl:// ADLHandler Azure Data Lake
abs:// ABSHandler Azure Blob Store
http:// / https:// HttpHandler 通过 HTTP GET/PUT 读写
gs:// GCSHandler Google Cloud Storage(依赖 gcsfs,带限流重试)
hdfs:// HDFSHandler Hadoop 文件系统(依赖 pyarrow)
http://github.com/ 等 GithubHandler GitHub 仓库内容读取
- StreamHandler 标准输入/输出流

其中 local 是特例:它不靠前缀命中,而是在遍历完所有已注册 handler 后作为兜底返回(iorw.py)。若没有任何 handler 匹配且没有 local 兜底,会抛出 PapermillException: Could not find a registered schema handler for: {path}。

1.2 新增一个 Handler 的最小实现

按 DEVELOPMENT_GUIDE.md 的指引,新 Handler 需实现以下类方法:

  • read(path):读取路径内容,返回文本字符串;
  • listdir(path):列出目录内容(可选,execute_notebook 与 CLI 命令不会用到它);
  • write(buf, path):把内容写入目标路径;
  • pretty_path(path):返回用于日志展示的路径字符串。

以仓库内置的 LocalHandler 为例(iorw.py),它实现了全部四个方法,并且 read 有一个值得注意的细节:如果按文件打开失败(OSError),会尝试把传入的 path 当作内联 JSON 笔记本字符串直接返回(iorw.py),这正是"直接把 NotebookNode 序列化字符串当输入"能力的来源。另外 LocalHandler 还提供了 cwd(new_path) 方法用于配合 local_file_io_cwd 上下文管理器切换读写工作目录(iorw.py)。

再看只读型 Handler 的典型——GithubHandler(iorw.py):它按 https://github.com/{org}/{repo}/blob/{ref}/{sub_path} 的路径结构拆分出组织、仓库、分支与子路径,通过 PyGithub 读取文件内容;listdir 与 write 直接抛 PapermillException 表示不支持。

注册一行即可让 papermill 全链路感知新协议:

from papermill.iorw import papermill_io

class MyNewHandler:
    @classmethod
    def read(cls, path):
        ...
    @classmethod
    def listdir(cls, path):
        ...
    @classmethod
    def write(cls, buf, path):
        ...
    @classmethod
    def pretty_path(cls, path):
        return path

papermill_io.register("my-new-prefix://", MyNewHandler)

之后任何形如 my-new-prefix://... 的 input_path / output_path 都会被自动路由到该 Handler。

1.3 在外部模块注册:Entry Point 机制

DEVELOPMENT_GUIDE.md 特别强调:你也可以在自己的模块里扩展注册表,实现领域专属的 IO 扩展而无需向 papermill 提交代码。

注册表在初始化时会调用 register_entry_points()(iorw.py),通过 entrypoints.get_group_all("papermill.io") 加载其他包通过 Python Entry Point 声明的 handler。因此第三方库只需在自己的包元数据中声明:

[project.entry-points."papermill.io"]
my-storage = "my_package.my_handler:MyHandler"

即可被 papermill 自动发现并注册,无需任何代码改动。对应测试见 test_iorw.py 的 test_entrypoint_register。

1.4 测试与行为验证

DEVELOPMENT_GUIDE.md 提醒"记得为你的新 handler 添加测试"。现有测试提供了很好的模板:

  • 注册与匹配顺序测试 test_register_ordering:验证 LIFO 下后注册者优先匹配(test_iorw.py);
  • 无扩展名 / 非法扩展名会发出 UserWarning(test_iorw.py),扩展名校验逻辑在 get_handler 中通过 fnmatch 实现(iorw.py);
  • read 返回 bytes 时会自动 decode('utf-8')(iorw.py,对应测试 test_iorw.py);
  • 标准输入 - / 标准输出流读写(test_iorw.py)。

编写测试时可直接参考 TestPapermillIO 中 FakeHandler 的写法(test_iorw.py),仅实现四个方法即可完成一次完整的注册-读写-匹配链路验证。

二、扩展语言翻译器(Language Translations)

papermill 参数化的核心是把外部传入的 JSON/YAML 参数"翻译"成目标语言可执行的赋值代码,注入到笔记本的 parameters cell。每种内核/语言对应一个 Translator。

2.1 翻译器注册表:papermill_translators

与 IO 层对称,translators.py 模块根部有单例 papermill_translators = PapermillTranslators()(translators.py),以 {kernel/language 名: Translator 类} 的字典形式保存映射。

查询逻辑在 find_translator(kernel_name, language)(translators.py):优先按 kernel 名匹配,其次按语言名匹配,都找不到则抛 PapermillException。因此"翻译器名称必须与正在处理的 kernel 或语言名一致"——只要 notebook 的 kernelspec 名称或 language_info 名称命中注册表,该 notebook 执行时就会自动使用你的翻译。

当前仓库已注册的映射(translators.py):

名称 Translator 语言
python PythonTranslator Python
R RTranslator R
scala ScalaTranslator Scala
julia JuliaTranslator Julia
matlab MatlabTranslator MATLAB
.net-csharp CSharpTranslator C#
.net-fsharp FSharpTranslator F#
.net-powershell PowershellTranslator PowerShell
pysparkkernel PythonTranslator PySpark
sparkkernel ScalaTranslator Spark
sparkrkernel RTranslator SparkR
bash BashTranslator Bash

2.2 基类 Translator 的翻译骨架

所有 Translator 继承自 Translator 基类(translators.py),基类给出了 JSON 类型的默认翻译结构:

  • translate_none / translate_int / translate_float:默认直接原样输出(translate_raw_str);
  • translate_str:默认走 translate_escaped_str,对字符串做 unicode 转义并包裹双引号(translators.py);
  • translate_bool:默认输出 true / false;
  • translate_dict / translate_list:默认抛出 NotImplementedError,必须由子类实现;
  • translate(val):统一入口,按 None → str → bool → int → float → dict → list → 兜底 escaped_str 的顺序分派(translators.py)。注意 bool 判断在 int 之前,因为 Python 中 bool 是 int 的子类。

此外基类提供三个关键方法:

  • comment(cmt_str):把注释字符串转成目标语言注释,子类必须实现;
  • assign(name, str_val):默认生成 name = str_val 形式的赋值语句,语言有特殊赋值语法时可覆写;
  • codify(parameters, comment='Parameters'):把参数字典逐项组合为"注释头 + 若干赋值语句"的代码块(translators.py),这是注入 parameters cell 的最终产物。

2.3 新增一个语言翻译器的步骤

按 DEVELOPMENT_GUIDE.md 的指引:

  1. 继承 Translator,为每种 JSON 类型实现对应的 translate_* 类方法;
  2. 实现 comment,处理单行注释语法;
  3. 按需覆写 assign——例如 ScalaTranslator 覆写为 val name = str_val(translators.py),CSharpTranslator 覆写为 var name = str_val;(translators.py),RTranslator 则会剥离以 _ 开头的非法 R 变量名(translators.py);
  4. 注册:papermill_translators.register("my-lang", MyTranslator);
  5. 补测试:复制 test_translators.py 中少量语言专属的 pytest 用例,替换为新的翻译器名与期望值即可。

以仓库中几个有代表性的实现细节为例:

  • PythonTranslator 对非有限浮点数做了特殊处理:nan → float('nan')、-inf → float('-inf')、inf → float('inf')(translators.py),并且 codify 会尝试用 Black 格式化器美化生成的参数代码(未安装 Black 时静默跳过,translators.py);
  • RTranslator 的字典/列表翻译为 list(...),布尔值为 TRUE/FALSE,None → NULL(translators.py);
  • ScalaTranslator 的整型超出 32 位范围时自动追加 L 后缀(translators.py);
  • MatlabTranslator 覆写了 codify,每条赋值以分号结尾(translators.py);
  • BashTranslator 的字符串翻译使用 shlex.quote 做 shell 安全转义(translators.py)。

对应测试可参考 test_translators.py 中 test_translate_type_python 的参数化写法——它覆盖了字符串、字典、列表、整数、浮点、布尔、None 以及 nan/inf 等边界值,是最完整的翻译正确性验证模板。

2.4 参数内省(inspect)

除翻译外,Translator 基类还定义了 inspect(parameters_cell) 方法(translators.py),用于从 tagged 为 parameters 的单元格中反向解析出 Parameter 列表(名称、推断类型、默认值、帮助信息)。PythonTranslator 通过正则 PARAMETER_PATTERN 实现(translators.py),并支持多行字典/列表定义的分组解析(translators.py。

三、扩展执行引擎(Engines)

papermill 默认用 nbclient 处理笔记本,但它被设计为即插即用(plug-n-play):任何"能处理笔记本并返回输出 nbformat 对象"的函数都可以注册成引擎。

3.1 引擎注册表与默认引擎

engines.py 模块根部的 papermill_engines = PapermillEngines() 以字典保存引擎(engines.py),默认注册:

  • None → NBClientEngine(默认兜底);
  • 'nbclient' → NBClientEngine。

同样支持 Entry Point:register_entry_points() 加载 papermill.engine 组的插件(engines.py),测试见 test_engines.py。

3.2 基类 Engine 与 NotebookExecutionManager

按 DEVELOPMENT_GUIDE.md 的说明,新引擎类须继承 Engine 并实现类方法 execute_managed_notebook。基类 Engine.execute_notebook 负责统一的工作流(engines.py):

  1. 用输入笔记本构造 NotebookExecutionManager(从输入笔记本拷贝而来,提供函数式执行隔离,避免污染原对象——对应测试 test_cell_callback_execute 中断言 nb 与 self.nb 不相等);
  2. 调用 nb_man.notebook_start() 初始化元数据并保存;
  3. 调用子类实现的 execute_managed_notebook(nb_man, kernel_name, **kwargs);
  4. finally 中调用 cleanup_pbar() 与 notebook_complete(),完成计时元数据与最终保存。

NotebookExecutionManager(engines.py)是引擎与笔记本状态的中介,开发者只需要知道它有一个 nb 属性,以及三个可选的回调:

回调 作用 是否触发保存
cell_start(cell) 设置单元格 start_time、status=RUNNING,并可注入 tqdm 进度条描述 是
cell_exception(cell) 标记单元格与笔记本级 exception=True、status=FAILED 否(失败后笔记本完成时会保存)
cell_complete(cell) 计算 end_time、duration,status=COMPLETED(FAILED 保持) 是

此外管理器还负责:笔记本级元数据 start_time/end_time/duration/exception(engines.py)、单元格执行计数的重置、输出的清理、以及 autosave_cell_every 长单元格执行的自动保存与指数退避(engines.py)。

关键点:手动保存笔记本对象是不必要的——基类包装器会在笔记本开始和完成时替你保存。若想禁用保存,覆写 wrap_and_execute_notebook 并阻止 output_path 传入基类方法即可。

3.3 参考实现 NBClientEngine

NBClientEngine(engines.py)是 Engine 的官方参考实现,其 execute_managed_notebook 展示了引擎如何消费并清理透传参数:

kwargs = remove_args(['input_path'], **kwargs)                          # 剔除下游不用的参数
safe_kwargs = remove_args(['timeout', 'startup_timeout'], **kwargs)     # 剔除命名不同的参数
final_kwargs = merge_kwargs(
    safe_kwargs,
    timeout=execution_timeout or kwargs.get('timeout'),
    startup_timeout=start_timeout,
    kernel_name=kernel_name,
    log=logger,
    log_output=log_output,
    stdout_file=stdout_file,
    stderr_file=stderr_file,
)
return PapermillNotebookClient(nb_man, **final_kwargs).execute()

这里用到的两个工具函数来自 utils.py:

  • merge_kwargs(caller_args, **callee_args):合并参数字典,冲突时后者覆盖前者并发出 PapermillParameterOverwriteWarning(utils.py);
  • remove_args(args, **kwargs):从 kwargs 中剔除指定参数名(utils.py)。

这正是 DEVELOPMENT_GUIDE.md 所说的"确保引擎正确处理关键字参数,用 merge_kwargs 与 remove_args 合并和清理参数"。底层实际执行由 PapermillNotebookClient(继承自 nbclient)完成,见 clientwrap.py。

3.4 新增引擎与测试

实现一个极简的自定义引擎只需要继承 Engine 并实现 execute_managed_notebook:

from papermill.engines import Engine, papermill_engines

class MyEngine(Engine):
    @classmethod
    def execute_managed_notebook(cls, nb_man, kernel_name, **kwargs):
        for cell in nb_man.nb.cells:
            nb_man.cell_start(cell)
            # ... 在这里执行单元格逻辑 ...
            nb_man.cell_complete(cell)

papermill_engines.register("my-engine", MyEngine)

之后即可通过 engine_name="my-engine"(Python API)或 --engine my-engine(CLI)选用。测试方面,DEVELOPMENT_GUIDE.md 建议在 test_engines.py 中复制 TestNBConvertEngine 类(实际为 TestNBClientEngine)并修改。注意 test_engines.py 中的 test_cell_callback_execute 与 test_no_cell_callback_execute 分别演示了"引擎调用了回调"与"引擎不调用任何回调"两种情形下 NotebookExecutionManager 的行为——后者会在 notebook_complete 阶段把仍处于 PENDING 的单元格统一置为 COMPLETED(engines.py),因此回调是可选的。

四、为 CLI / API 新增配置项

DEVELOPMENT_GUIDE.md 的"CLI / Execute"一节(DEVELOPMENT_GUIDE.md)给出了新增配置项的两步法:先在 cli.py 加 @click.option,再到 execute.py 的 execute_notebook 中落地。

4.1 CLI 层:极薄的参数包装

CLI 层被刻意设计为 execute_notebook 的极薄包装(thin wrapper),目的是遵循 DRY 原则,并保证"import 的 Python 模块与 CLI 具有同等能力"。

在 cli.py 中添加新选项需要做两件事:

  1. 在 papermill 方法上方添加 @click.option(...) 装饰器;
  2. 把选项名作为位置参数加入 papermill 方法的签名;
  3. 在方法体内把该参数透传给 execute_notebook(...) 调用(cli.py)。

现有选项清单可直接作为命名与默认值的参照(cli.py):

选项 简写 默认值 说明
--parameters -p - 键值对参数,自动类型解析
--parameters_raw -r - 键值对参数,按原始字符串处理
--parameters_file -f - 指向 YAML 参数文件的路径
--parameters_yaml -y - 内联 YAML 参数字符串
--parameters_base64 -b - Base64 编码的 YAML 参数字符串
--engine - - 引擎名
--request-save-on-cell-execute - True 每执行一个单元格后保存
--autosave-cell-every - 30 长单元格执行期间自动保存间隔(秒,0 禁用)
--prepare-only - False 只参数化不执行
--kernel -k - 覆盖 notebook 元数据中的内核名
--language -l - 覆盖 notebook 元数据中的语言
--cwd - - 执行工作目录
--progress-bar - 智能 开关进度条
--log-output - False 把笔记本输出写入日志
--stdout-file / --stderr-file - - 把 stdout/stderr 重定向到文件
--start-timeout - 60 等待内核启动的秒数
--execution-timeout - 永不 单单元格执行超时
--report-mode - False 隐藏输入代码
--version - - 打印版本

参数类型解析由 _resolve_type 完成(cli.py):"True"→True、"False"→False、"None"→None,可转 int 则转 int,可转 float 则转 float,否则保持字符串(--parameters_raw 则完全按原始字符串)。对应测试见 test_cli.py。

CLI 还有两个值得注意的行为:输出路径为 - 时禁用逐单元格保存并自动降低日志级别(避免污染 stdout,cli.py);内核死亡(DeadKernelError)时以特殊退出码 138 退出(cli.py)。

4.2 Execute 层:参数落地

接下来在 execute.py 的 execute_notebook 中(execute.py):

  1. 添加参数并给出合理默认值;
  2. 同时更新 docstring(该 docstring 是 help(papermill.execute_notebook) 的来源);
  3. 在函数体内使用该参数达成预期效果——通常透传给 _execute_parameterized_notebook(即参数化与执行流程中的核心路径)。

execute_notebook 的整体执行流水线(execute.py):

  1. 路径参数化:add_builtin_parameters + parameterize_path 支持 {param} 占位符(execute.py);
  2. 加载笔记本:load_notebook_node(读取、升级到 v4、补齐 papermill 元数据,见 iorw.py);
  3. 参数化:parameterize_notebook 把参数翻译并注入 parameters cell,同时对未在 notebook 中预定义的参数发出 warning(execute.py);
  4. 元数据准备:prepare_notebook_metadata 写入 input_path/output_path,report_mode 下隐藏代码(execute.py);
  5. 清除上次运行的错误标记单元格(remove_error_markers,execute.py);
  6. 执行:经 papermill_engines.nb_kernel_name 确认内核名后,在指定 cwd 下调用引擎执行(execute.py);
  7. 错误处理:raise_for_execution_errors 扫描错误输出,发现异常时在笔记本顶部插入带红色样式的错误提示 markdown 单元格并抛出 PapermillExecutionError(execute.py);SystemExit 且退出码为 0 被视为正常退出;
  8. 最终写入:write_ipynb(nb, output_path)(execute.py)。

4.3 测试要求

DEVELOPMENT_GUIDE.md 要求 test_cli.py 与 test_execute.py 同时覆盖新选项,且 CLI 测试只需验证参数正确透传给 execute_notebook——这再次印证了 CLI 层"薄包装"的设计哲学。现有测试模板:

  • test_cli.py 通过 click.testing.CliRunner 驱动 papermill 命令,并直接单测 _resolve_type / _is_int / _is_float 等纯函数;
  • test_execute.py 直接调用 execute_notebook 验证执行行为。

五、版本发布流程(Releasing)

DEVELOPMENT_GUIDE.md 的"Releasing"一节(DEVELOPMENT_GUIDE.md)描述了完整的发布流水线,分为前置检查、推送 GitHub、推送 PyPI 三步。

5.1 前置检查

  • 确认 CHANGELOG.md 已为下一个版本号更新完毕;
  • 安装开发依赖:pip install -r requirements/dev.txt。

5.2 推送 GitHub

根据改动幅度选择从 patch 升级到 minor 或 major,然后使用 bumpversion 自动完成版本号递增、提交与打 tag:

bumpversion patch
git push upstream && git push upstream --tags

bumpversion 的配置位于 pyproject.toml:current_version = "2.6.0"、commit = true、tag = true,并指定版本号文件为 papermill/version.py,即"单一事实来源"(pyproject.toml 通过 version = { attr = "papermill.version.version" } 动态读取)。

5.3 推送 PyPI

清空旧的构建产物后,用 python -m build 构建 wheel 与 sdist,再用 twine 上传:

rm -rf dist/*
rm -rf build/*
python -m build
twine upload dist/*

注意:twine、bumpversion、build 等发布工具均已包含在 dev/test 依赖中(pyproject.toml)。tox 环境中的 dist testenv 还会在发布前自动验证构建产物可安装(pyproject.toml),可作为发布前的一次本地演练。

六、扩展点全景与工程实践小结

扩展点 注册表单例 注册方法 必须实现 可选/可覆写 测试文件
IO Handler papermill_io(iorw.py) register(scheme, handler) read、write、pretty_path listdir、cwd test_iorw.py
语言翻译器 papermill_translators(translators.py) register(language, translator) comment + 各 translate_* assign、codify、inspect test_translators.py
执行引擎 papermill_engines(engines.py) register(name, engine) execute_managed_notebook 三个回调(cell_start/cell_exception/cell_complete) test_engines.py
CLI / API 选项 - @click.option + execute_notebook 参数 - - test_cli.py、test_execute.py

从源码结构可以归纳出 papermill 插件化设计的三条核心原则:

  1. 统一注册表 + Entry Point 双通道:所有扩展点(io、engine)都支持外部包通过 Python Entry Point 注入,见 pyproject.toml 的依赖清单与 iorw.py、engines.py 的 register_entry_points;
  2. 薄 CLI、厚 API:CLI 只负责解析与透传,全部能力集中在 execute_notebook(execute.py),保证 Python API 与命令行能力完全一致;
  3. 回调驱动、自动持久化:NotebookExecutionManager 统一管理元数据、计时与保存,引擎开发者只需关注单元格逻辑。

遵循 DEVELOPMENT_GUIDE.md 的指引与上述测试模板,你可以在不改动 papermill 核心的前提下,为自己的存储后端、内核语言与执行器建立完整的扩展实现,并通过 test_engines.py、test_iorw.py、test_translators.py 等测试文件中的既有用例快速验证正确性,最终按第五节的流程随版本发布,惠及所有 papermill 使用者。

登录后查看全文
papermill