xberg Python 绑定契约测试实战:用 output_format=markdown 验证提取结果的格式契约
xberg Python 绑定契约测试实战:用 output_format=markdown 验证提取结果的格式契约
本篇指南围绕 xberg 的 Python 契约测试 output_format_markdown 展开,讲解它如何用一段 Python 代码、一份 JSON fixture 和 mock 服务器三者配合,验证 extract() 在指定 output_format: markdown 配置后,返回的 mime_type、content 与 metadata.output_format 是否符合契约。读完后你将掌握 xberg 契约测试(contract test)的完整机制:fixture 如何驱动多语言绑定生成同构测试、mock server 如何消除外部网络依赖,以及 OutputFormat 在 Rust 核心中的定义与解析规则。
契约测试样本:Markdown 输出格式
该样本在文档站生成片段 output_format_markdown.md 中以 Python 代码形式发布,核心逻辑如下:
import asyncio
from xberg import extract, ExtractInput, ExtractInputKind
from xberg._xberg import ExtractionConfig
async def main() -> None:
input = ExtractInput(kind=ExtractInputKind("uri"), uri="https://example.com/pdf/fake_memo.pdf")
config = ExtractionConfig.from_json("{\"output_format\":\"markdown\"}")
result = await extract(input, config)
print(result.results[0].mime_type)
print(result.results[0].content)
print(result.results[0].metadata.output_format)
asyncio.run(main())
三个关键点值得注意:
- 输入是 URI 而非本地字节:
ExtractInput通过ExtractInputKind("uri")构造,extract()会由 xberg 自己去拉取文档。URI 指向的文件内容在测试环境中由 mock 服务器提供(见下文)。 - 配置用 JSON 字符串解析:
ExtractionConfig.from_json("{\"output_format\":\"markdown\"}")是绑定层提供的入口。Python 绑定由 pyo3 生成,crates/xberg-py/src/lib.rs 中每个配置类型都暴露了对应的from_json方法,底层直接复用 Rust 核心的 serde 反序列化,因此 Python、Node、Rust 各绑定对同一段配置 JSON 的解释完全一致。 - 验证点落在三处:结果的
mime_type应为application/pdf;content应为非空且长度足够的提取正文;metadata.output_format应回显markdown。第三点尤其重要——它证明配置不仅被接受,而且真正影响了管线行为并被如实记录在元数据中。
该片段文件头部标注了 level: typecheck 与 side_effect: server,说明此样本在 CI 中默认只做类型检查,真正执行时需要 mock 服务器在线;文件由 alef 工具自动生成(alef e2e generate 再生成、alef verify 校验新鲜度),不应手工编辑。
Fixture:契约测试的单一事实来源
片段背后的真实来源是 fixtures/contract/output_format_markdown.json,它定义了 mock 响应、输入构造和断言三部分:
{
"id": "output_format_markdown",
"description": "Tests Markdown output format",
"tags": ["contract", "output_format"],
"call": "extract",
"input": {
"mock_responses": [
{
"path": "/pdf/fake_memo.pdf",
"status_code": 200,
"headers": { "content-type": "application/octet-stream" },
"body_file": "../test_documents/pdf/fake_memo.pdf"
}
],
"extract_input": { "kind": "uri", "uri": "$mock_url/pdf/fake_memo.pdf" }
},
"assertions": [
{ "type": "equals", "field": "results[0].mime_type", "value": "application/pdf" },
{ "type": "min_length", "field": "results[0].content", "value": 10 },
{ "type": "equals", "field": "results[0].metadata.output_format", "value": "markdown" }
],
"config": { "output_format": "markdown" }
}
各字段的作用:
mock_responses:告诉 mock 服务器在/pdf/fake_memo.pdf路径上返回 HTTP 200、application/octet-stream的响应体,文件实体取自仓库内的fake_memo.pdf测试文档。这正是片段中"看似外部 URL"的https://example.com/...在真实运行时被替换为 mock 地址的原因。extract_input中的$mock_url占位符:生成各语言测试代码时,占位符会被展开为MOCK_SERVER_URL + "/fixtures/<fixture_id>"拼出的实际地址。assertions:字段级断言(equals/min_length),与 Python 片段里打印的三个字段一一对应。config:与断言分开声明,最终注入ExtractionConfig。
同目录下还有姊妹样本 output_format_bytes_markdown.json(bytes 输入路径的 Markdown 输出),两者合起来覆盖了 URI 与 bytes 两种输入形态下的同一契约。
生成测试:Python 侧如何消费 fixture
fixture 是模板,落到 Python 语言后生成 e2e/python/tests/test_contract.py 中的同名测试(第 27336 行附近的 test_output_format_markdown):
@pytest.mark.asyncio
async def test_output_format_markdown() -> None:
"""Tests Markdown output format."""
input_mock_base_url = os.environ["MOCK_SERVER_URL"] + "/fixtures/output_format_markdown"
input_json = '{"kind":"uri","uri":"$mock_url/pdf/fake_memo.pdf"}'.replace("$mock_url", input_mock_base_url)
input_data = json.loads(input_json)
input = ExtractInput(kind=ExtractInputKind("uri"), uri=input_data["uri"])
config = ExtractionConfig.from_json('{"output_format":"markdown"}')
result = await extract(input, config)
assert result.results[0].mime_type == "application/pdf"
assert len(result.results[0].content) >= 10
assert result.results[0].metadata.output_format == "markdown"
可以看到生成逻辑把 fixture 的三个部分逐字翻译成代码:MOCK_SERVER_URL 环境变量 + fixture id 拼出拉取地址,断言逐条转为 assert。同文件的 test_output_format_bytes_markdown(第 27319 行起)则演示了另一种写法——直接把本地 pdf/fake_memo.pdf 读成字节、并用 FileExtractionConfig(output_format=OutputFormat("markdown")) 在输入级再声明一次输出格式,验证 bytes API 路径上同一契约成立。
这一模式的工程价值在于:一份 JSON 契约可以派生出 Python、Rust、Node 等全部 15 种绑定的同构测试,任何绑定对 output_format 的实现出现漂移,都会在对应语言的生成测试中失败,而不是只暴露在某一个语言的手写用例里。
源码纵深:OutputFormat 在 Rust 核心中的定义
"markdown" 这个字符串最终由 Rust 核心的 OutputFormat 枚举消费,定义见 crates/xberg/src/core/config/formats.rs:
/// Controls the format of the `content` field in `ExtractedDocument`.
/// When set to `Markdown`, `Djot`, or `Html`, the output uses that format.
/// `Plain` returns the raw extracted text.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
/// Plain text content only (default)
#[default]
Plain,
/// Markdown format
Markdown,
/// Djot markup format
Djot,
/// HTML format
Html,
/// JSON tree format with heading-driven sections.
Json,
/// Docling DocTags format (tables rendered as OTSL).
DocTags,
/// Custom renderer registered via the RendererRegistry.
#[serde(untagged)]
Custom(String),
}
从这份源码可以读出契约测试背后的一整套解析规则,同文件中的单元测试(第 188–330 行)把它们逐一固化:
| 输入字符串 | 解析结果 | 依据 |
|---|---|---|
plain / text(大小写不敏感) |
Plain(默认值) |
FromStr 第 73 行 |
markdown / md(大小写不敏感) |
Markdown |
FromStr 第 74 行 |
djot、html、json |
对应内置变体 | FromStr 第 75–77 行 |
doctags |
一等变体 DocTags,不落入 Custom |
测试 should_parse_doctags_to_the_doctags_variant_not_custom |
其他任意字符串(如 docx、markdwon) |
Custom(字符串),交由 RendererRegistry 解析 |
FromStr 第 79 行及 typo 回退测试 |
值得注意的两个细节:
- 大小写不敏感但精确匹配:
"MARKDOWN"能解析为Markdown,但拼错的"markdwon"会被静默归入Custom,后续在渲染器注册表中找不到对应渲染器才报错。测试should_treat_typo_of_a_keyword_as_custom_not_a_real_variant明确固化了"不做模糊纠错"的语义。 - 内置格式与渲染器注册表的关系:
renderer_name()方法(第 42–51 行)显示Markdown、Html、Djot、DocTags都映射到注册表里的具名渲染器(如Some("markdown")),而Plain与Json走独立路径返回None。这说明output_format: markdown触发的是一条"提取结果 → Markdown 渲染器"的管线,而非简单的字符串标注——这正是断言content长度 ≥ 10 且metadata.output_format == "markdown"两者要同时成立的原因:前者验证渲染确实发生,后者验证元数据如实回显配置。
配置解析入口可沿 crates/xberg/src/core/config/extraction/types.rs、crates/xberg/src/core/config/merge.rs 一路追下去;格式在管线中的落地位置见 crates/xberg/src/core/pipeline/format.rs 与 crates/xberg/src/core/formats.rs。
运行与验证方式
该契约测试属于"有服务器副作用"(side_effect: server)的一类,运行前提与步骤:
- 准备 mock 服务器:fixture 的
mock_responses依赖 mock 服务在线,e2e 套件通过环境变量MOCK_SERVER_URL定位它;仓库提供了 scripts/e2e/run-with-mock-server.sh 用于在 mock 服务器环境上跑 e2e 用例。 - 安装 Python 绑定:绑定源码在 crates/xberg-py,发布物见 packages/python,公开 API(
extract、ExtractInput等重导出)由 packages/python/xberg/init.py 暴露,类型声明见 packages/python/xberg/_xberg.pyi。 - 执行生成测试:
test_contract.py中的test_output_format_markdown在MOCK_SERVER_URL已设置的环境中即可运行;文档站片段声明的level: typecheck表示默认流水线先做静态类型检查,完整执行留给 mock server 可用的 CI 阶段。 - 多语言对照:同一 fixture id 在其他语言的 e2e 目录中也有对应生成文件(如 e2e/node/tests、e2e/rust/tests、e2e/go 下的 contract 测试),可用于交叉比对各绑定行为是否一致。
小结
output_format_markdown 这个看似只有几十行的样本,完整呈现了 xberg 跨语言契约测试的三个层次:文档站片段(docs-site/src/snippets-generated/python/contract/output_format_markdown.md)给出可阅读的最小用法;fixture(fixtures/contract/output_format_markdown.json)定义 mock 输入与字段级断言;生成测试(e2e/python/tests/test_contract.py)将其落到可执行代码,最终由 Rust 核心的 OutputFormat 枚举(crates/xberg/src/core/config/formats.rs)保证语义一致性。理解了这条链路,你就能为任意新配置项(例如其他 output_format 取值)按同样的"fixture → 多语言生成测试 → 源码枚举"三步补齐契约覆盖。