dlt 与 marimo:dlt.helpers.marimo 交互小组件的开发、注册与测试完整指南
【免费下载链接】dltdata load tool (dlt) is an open source Python library that makes data loading easy 🛠️项目地址: https://gitcode.com/GitHub_Trending/dl/dlt
本文以仓库中的 marimo 小组件开发指南 为主体,系统讲解 dlt 是如何把 marimo notebook 封装成可复用的交互式小组件(widget)的:从创建 widget notebook、编写 setup cell 约定,到在 dlt/helpers/marimo/init.py 中注册、用render()渲染,再到按单元格(cell)粒度写单元测试和端到端集成测试。读完本文,你将能够完整走通“为 dlt 新增一个 marimo 小组件”的全流程,并理解当前仓库中已内置的三个 widget——load_package_viewer、schema_viewer与pipeline_selector——的实际实现方式。
一、什么是 marimo widget:marimo notebook 就是 widget
dlt 的 marimo 集成模块位于 dlt/helpers/marimo/。该模块的核心设计前提是:一个 marimo widget 本质上就是一个 marimo notebook,官方文档称之为widget notebook。当前仓库中该模块的实际构成如下:
- init.py:模块入口,负责依赖检查、导入各 widget 的
app对象、提供render()统一渲染入口; - _load_package_viewer.py:文件浏览 widget,用于加载并查看 load package 中的各类数据文件;
- _schema_viewer.py:schema 查看 widget,展示 pipeline 详情、表格清单与多种格式(dict/JSON/YAML/DBML/DOT)的 schema 导出;
- _pipeline_selector.py:pipeline 选择 widget,列出本地 pipeline 并通过下拉框选择,对外暴露
pipeline_name/pipeline_path等变量; - utils.py:widget 共用的文件加载工具函数。
__init__.py开头对依赖做了显式检查,未安装时会抛出MissingDependencyException:
try: import marimo import mowidgets except ModuleNotFoundError: raise MissingDependencyException( "dlt.helpers.marimo", ["marimo", "mowidgets"], )从 pyproject.toml 的依赖声明看,该模块要求marimo>=0.14.5,且mowidgets>=0.2.1仅在 Python 3.11 及以上版本可用(mowidgets>=0.2.1 ; python_version >= '3.11')。这一点在使用前需要确认运行环境满足版本约束。
下面以指南中假想的pipeline_browserwidget 为例,按 README 的六个步骤完整走一遍开发流程。
二、步骤 1:在 dlt/helpers 下创建 widget notebook
使用 marimo CLI 创建 notebook:
marimo edit dlt/helpers/_pipeline_browser.py约定要点:widget notebook 的文件名以_前缀开头。这样做有两个目的——一是遵循模块私有文件的命名惯例,二是避免与 dlt 其他模块产生命名冲突。仓库中现有的三个 widget notebook(_load_package_viewer.py、_schema_viewer.py、_pipeline_selector.py)均遵循此约定。
一个典型的 widget notebook 骨架(以_load_package_viewer.py为例):
import marimo __generated_with = "0.19.2" app = marimo.App(width="medium") with app.setup: import marimo as mo from dlt.helpers.marimo.utils import _load_file pipeline_path = None @app.cell def pipeline_browser(): mo.stop(pipeline_path is None) ... if __name__ == "__main__": app.run()三、步骤 2:编写 widget notebook 的三条核心约定
README 给出了编辑 widget notebook 时的三条关键技巧,这三条约定直接决定了 widget 能否被注册和测试:
1. 使用 setup cell 承载 import 与常量
所有import语句和输入常量都放在with app.setup:块中。三个实际 widget 均如此,例如_schema_viewer.py的 setup cell:
with app.setup: from typing import Any, cast from itertools import chain import marimo as mo import dlt from dlt.common.utils import without_none pipeline_name = None2. 给 cell 起函数名(而非匿名的_)
marimo 默认生成的单元格函数名是匿名_。如果要让单元测试直接按名字调用某个 cell,就必须给 cell 函数起一个明确的名称。例如_pipeline_selector.py中的三个 cell 分别命名为pipeline_locations、pipeline_selector、outputs,测试代码因此可以写成_pipeline_selector.pipeline_locations.run()(见 tests/helpers/marimo_tests/test_pipeline_selector.py)。
3. 可选输入参数:setup 中置 None,下游 cell 用mo.stop()隐藏
如果 widget 需要接收输入参数,就在 setup cell 中定义对应变量并初始化为None;下游依赖它的 cell 第一行调用mo.stop(VAR is None),当参数缺失时该 cell(及其下游)会被跳过而不报错。README 给出的完整示例:
# dlt/helpers/marimo/_pipeline_browser.py with app.setup: from typing import Any, cast from itertools import chain import marimo as mo import dlt from dlt.common.utils import without_none pipeline_name = None @app.cell def _(): mo.stop(pipeline_name is None) pipeline = dlt.attach(pipeline_name) return (pipeline,) # ...仓库中的_schema_viewer.py正是这个模式的实现:pipeline_name = None定义在 setup cell,紧接的 cell 以mo.stop(pipeline_name is None)开头,随后通过dlt.attach(pipeline_name)恢复出 pipeline 对象。_load_package_viewer.py则用同样的模式接收pipeline_path,并在参数就绪后渲染mo.ui.file_browser。
四、步骤 3:在 dlt/helpers/marimo/init.py 中注册 widget
要让 widget 通过dlt公开可用,需要修改 dlt/helpers/marimo/init.py,README 明确要求四个动作:
- 从 widget notebook 的
.py文件导入app变量并起一个别名; - 把这个别名加入
__all__(注意加的是别名的app变量,而不是新写的_widget函数); - 编写一个以
app为第一个参数、并带 widget 输入参数的工厂函数; - 在
render()的if/else分支中加入对app is xxx的判定并渲染该 widget。
以注册pipeline_browser为例,README 给出的代码形态:
import marimo import mowidgets from dlt.helpers.marimo._pipeline_browser import app as pipeline_browser # pre-existing function def render(app: marimo.App, *args, **kwargs): if not isinstance(app, marimo.App): raise ValueError("app must be an instance of marimo.App") if ...: ... # add new condition elif app is pipeline_browser: return pipeline_browser_widget(app, *args, **kwargs) else: raise ValueError("app must be either load_package_viewer or schema_viewer") # ... def pipeline_browser_widget(app, pipeline_name, *args, **kwargs): return mowidgets.widgetize( app, data_access=True, # this must match the input name in the notebook inputs={"pipeline_name": pipeline_name} ) __all__ = ( "render", ..., # add the aliased `app` variable; not the newly created `_widget` function "pipeline_browser", )对照仓库当前的实际实现,render()的完整结构为:
def render(app: marimo.App, *args: Any, **kwargs: Any) -> mowidgets.MoWidget: if not isinstance(app, marimo.App): raise ValueError("app must be an instance of marimo.App") if app is load_package_viewer: return load_package_widget(app, *args, **kwargs) elif app is schema_viewer: return schema_viewer_widget(app, *args, **kwargs) elif app is pipeline_selector: return pipeline_selector_widget(app, *args, **kwargs) else: raise ValueError( "Unknown app. Must be one of: load_package_viewer, schema_viewer, pipeline_selector" )两个细节值得注意:
- 每个
_widget()工厂函数内部都调用mowidgets.widgetize(app, data_access=True, ...),并用inputs={...}把参数注入 notebook。inputs的键名必须与 notebook setup cell 中的变量名完全一致(如{"pipeline_name": pipeline_name}对应 setup 里的pipeline_name = None),这正是 README 中注释# this must match the input name in the notebook强调的点; pipeline_selector_widget没有显式inputs,而是通过public_variables=["pipeline_path", "pipeline_name", "pipeline_locations"]声明对外暴露的变量——这对应_pipeline_selector.py中outputscell 返回的pipeline_name与pipeline_path,供宿主 notebook 读取选择结果;render()对非marimo.App实例抛出ValueError,对未注册的 app 也抛出带明确提示的ValueError,测试 tests/helpers/marimo_tests/test_widgets.py 中的test_render_unknown_app_raises与test_render_non_app_raises分别验证了这两种异常路径。
五、步骤 4:在 marimo notebook 中试用 widget
试用方式是新建一个 marimo notebook(marimo edit dev.py),从dlt.helpers.marimo导入render和 app 对象:render(app)返回一个 widget 对象,对它执行await才会真正渲染。README 给出的示例片段(此处按仓库实际存在的pipeline_selector对应关系理解,render的用法完全一致):
# dev.py @app.cell def _(): import marimo as mo from dlt.helpers.marimo import render, pipeline_selector return pipeline_selector, render @app.cell async def _(pipeline_selector, render): # to display directly await render(pipeline_selector) return # or assign a variable to display elsewhere and access the widget's data @app.cell async def _(pipeline_selector, render): w = render(pipeline_selector) await w return (w,) @app.cell def _(w): w.data return两种用法对应两种需求:await render(app)直接渲染展示;把返回值赋给变量w再await w,则可同时在其他位置引用w,并通过w.data访问 widget 内部数据——这依赖widgetize时的data_access=True参数。
另一条绕过render()的捷径:直接调用 app 对象对应的_widget()工厂函数(注意工厂函数当前是模块私有实现,公开入口是render()):
@app.cell async def _(pipeline_selector, pipeline_selector_widget): # to display directly await pipeline_selector_widget(pipeline_selector) return六、步骤 5:单元测试——按 cell 粒度调用
README 建议:对包含复杂逻辑的 cell,直接把该 cell 当作独立函数导入测试。因为 cell 被赋予了函数名(约定 2),测试可以按名导入并给它传输入:
# tests/helpers/marimo_tests/test_pipeline_browser.py # import the named cell directly from the widget notebook from dlt.helpers.marimo._pipeline_browser import selector def test_cell_selector(): base_path = ... # outputs is the HTML to be displayed # definitions is a dictionary of values returned outputs, definitions = selector.run(base_path=base_path) assert definitions["select_pipeline"] == ...cell.run(...)返回(outputs, definitions)二元组:outputs是用于展示的 HTML,definitions是 cell 返回值构成的字典。仓库现有的 tests/helpers/marimo_tests/test_pipeline_selector.py 展示了完整的落地形态:
def test_cell_pipeline_locations(): _, defs = _pipeline_selector.pipeline_locations.run() assert "pipelines_locations" in defs assert isinstance(defs["pipelines_locations"], dict) def test_cell_pipeline_selector(): _, defs = _pipeline_selector.pipeline_selector.run() assert "pipeline_selector" in defs assert isinstance(defs["pipeline_selector"], marimo.ui.dropdown)此外 tests/helpers/marimo_tests/test_widgets.py 还维护了一条契约测试:遍历dlt.helpers.marimo.__all__中的每个公开名,断言它们都是marimo.App实例——这保证了“__all__里只放 app 别名、不放_widget函数”这一注册约定不会被破坏。
七、步骤 6:集成测试——整本 notebook 端到端运行
对整本 widget notebook 调用app.run(defs=...)即可端到端执行 DAG;传入的defs可以覆盖 DAG 中某些节点的取值,从而模拟应用的不同状态:
# tests/helpers/marimo_tests/test_pipeline_browser.py import pytest # import the aliased app from dlt.helpers.marimo import pipeline_browser @pytest.mark.parametrize("input1", [...]) @pytest.mark.parametrize("input2", [...]) def test_pipeline_browser(input1, input2) -> None: input_definitions = { "input1": input1, "input2": input2, } outputs, definitions = pipeline_browser.run(defs=input_definitions) assert ...仓库中对应的真实用例是test_app_variables():整本运行_pipeline_selector.app,断言pipeline_selector、pipelines_locations、pipeline_name、pipeline_path四个变量都出现在 definitions 中——正好覆盖了该 widget 对外暴露的全部变量。
八、参考实现:三个内置 widget 与共用工具函数
理解开发流程后,回看仓库中的实际实现可以快速校准每个约定的用途。
_load_package_viewer.py(文件浏览 + 内容查看):pipeline_browsercell 在pipeline_path就绪后渲染mo.ui.file_browser(..., selection_mode="file", restrict_navigation=True)限定用户在指定目录内选文件;file_viewercell 取选中文件路径调用共用的_load_file()展示内容。而 utils.py 的_load_file()是一个按扩展名分发的加载器:.pickle走 pickle、.json/.jsonl走 dlt 内置 JSON 库、.parquet/.csv走 pyarrow、.insert_values.gzip通过 sqlglot 解析 INSERT 语句再用 DuckDB 内存库还原成 Arrow Table、普通 gzip 走FileStorage.open_zipsafe_ro,任何加载异常最终回退为原始字节。这正是“load package 里能看什么文件”的权威依据。
_schema_viewer.py(schema 多维展示):除dlt.attach(pipeline_name)恢复 pipeline 外,还有若干值得借鉴的 marimo 用法:
@app.function定义的pipeline_details()汇总 pipeline 名称、destination、credentials(解析失败时降级为"Could not resolve credentials")、dataset/schema 名称与 working dir;create_table_list()用@mo.cache缓存,从pipeline.default_schema.tables提取name/parent/resource/write_disposition/description五个字段,可切换是否显示子表(show_child_tables)与_dlt前缀的内部表(show_internals);- 用
mo.mermaid(dlt.Schema.from_dict(schema_dict).to_mermaid())渲染 schema 关系图(该 mermaid 能力对应仓库 dlt/helpers/mermaid.py); - 用
mo.ui.tabs+mo.lazy把 schema 的 dict/JSON/YAML/DBML/DOT 五种导出(to_dict、to_pretty_json、to_pretty_yaml、to_dbml、to_dot)组织成懒加载标签页。
_pipeline_selector.py(无输入参数的 widget 形态):它演示了另一种 widget 形态——不需要inputs注入,而是利用 setup 中导入的 dlt._workspace.cli.utils 的list_local_pipelines()自动枚举本地 pipeline 目录,mo.ui.dropdown默认选中第一个 pipeline,outputscell 把选择结果固化为pipeline_name/pipeline_path两个字符串变量,供宿主 notebook 通过public_variables消费。这对应pipeline_selector_widget()中public_variables=["pipeline_path", "pipeline_name", "pipeline_locations"]的声明。
九、小结:新增一个 dlt marimo widget 的完整检查单
综合 README 指南与仓库实现,新增一个 widget 的落地清单是:
marimo edit dlt/helpers/_your_widget.py,文件名带_前缀;- setup cell 写 import 与输入变量(初始化为
None);需要复用的数据查询逻辑用@app.function或具名 cell 承载; - 所有依赖可选输入的下游 cell 以
mo.stop(VAR is None)开头; - 在init.py 中:
from dlt.helpers.marimo._your_widget import app as your_widget→ 写your_widget_widget()工厂(mowidgets.widgetize(app, data_access=True, inputs={...}),键名必须与 setup 变量一致)→ 在render()中加app is your_widget分支 → 把your_widget加入__all__; - 按 cell 名写单元测试(
your_module.cell_name.run(...)断言definitions),并对整本 app 写app.run(defs=...)集成测试,测试文件放入 tests/helpers/marimo_tests/; - 确认环境满足
marimo>=0.14.5,且使用mowidgets时 Python 版本不低于 3.11。
按这套约定产出的 widget,才能与render()的统一入口、__all__契约测试以及 cell 级测试体系无缝兼容。
【免费下载链接】dltdata load tool (dlt) is an open source Python library that makes data loading easy 🛠️项目地址: https://gitcode.com/GitHub_Trending/dl/dlt
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考