xberg Python 绑定契约测试实战:用 output_format=markdown 验证提取结果的格式契约

原创2026-10-08 14:18:051,766 阅读
文章标签:后端AI 应用NLP

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())

三个关键点值得注意:

  1. 输入是 URI 而非本地字节:ExtractInput 通过 ExtractInputKind("uri") 构造,extract() 会由 xberg 自己去拉取文档。URI 指向的文件内容在测试环境中由 mock 服务器提供(见下文)。
  2. 配置用 JSON 字符串解析:ExtractionConfig.from_json("{\"output_format\":\"markdown\"}") 是绑定层提供的入口。Python 绑定由 pyo3 生成,crates/xberg-py/src/lib.rs 中每个配置类型都暴露了对应的 from_json 方法,底层直接复用 Rust 核心的 serde 反序列化,因此 Python、Node、Rust 各绑定对同一段配置 JSON 的解释完全一致。
  3. 验证点落在三处:结果的 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)的一类,运行前提与步骤:

  1. 准备 mock 服务器:fixture 的 mock_responses 依赖 mock 服务在线,e2e 套件通过环境变量 MOCK_SERVER_URL 定位它;仓库提供了 scripts/e2e/run-with-mock-server.sh 用于在 mock 服务器环境上跑 e2e 用例。
  2. 安装 Python 绑定:绑定源码在 crates/xberg-py,发布物见 packages/python,公开 API(extract、ExtractInput 等重导出)由 packages/python/xberg/init.py 暴露,类型声明见 packages/python/xberg/_xberg.pyi。
  3. 执行生成测试:test_contract.py 中的 test_output_format_markdown 在 MOCK_SERVER_URL 已设置的环境中即可运行;文档站片段声明的 level: typecheck 表示默认流水线先做静态类型检查,完整执行留给 mock server 可用的 CI 阶段。
  4. 多语言对照:同一 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 → 多语言生成测试 → 源码枚举"三步补齐契约覆盖。

登录后查看全文
xberg