notebooklm-py CLI 的 `--json` 类型化错误信封契约:从 `ClickException` 盲区到全路径 JSON 化(ADR-0015 深度解读)
2026/9/13 1:18:33 网站建设 项目流程

notebooklm-py CLI 的--json类型化错误信封契约:从ClickException盲区到全路径 JSON 化(ADR-0015 深度解读)

【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py

本篇技术指南以notebooklm-py仓库中的架构决策记录 ADR-0015 为核心骨架,完整讲解该 CLI 在--json模式下如何把click.ClickException及其子类(UsageErrorBadParameter等)统一收编进类型化 JSON 错误信封,覆盖 parse-time 与 post-parse 两个阶段的语义差异、根命令SectionedGroup的非 standalone 模式实现、以及内联 marker 注释 + 守卫测试的强制机制。读完你将掌握:notebooklm命令在--json下每一种失败路径的退出码与 stdout/stderr 行为,如何正确地在命令体与服务层中触发VALIDATION_ERROR信封,以及如何用json.loads(stdout)编写可靠的自动化脚本。

背景:稳定错误契约与ClickException的盲区

notebooklm-py的 CLI(入口为 src/notebooklm/notebooklm_cli.py)为自动化场景维护了一套稳定的错误契约:在--json模式下,每一条致命命令路径都会在stdout上输出一个typed JSON error envelope(类型化 JSON 错误信封)——一个扁平对象,形状如下:

{ "error": true, "code": "<STABLE_CODE>", "message": "<human text>", ...extras }

同时进程以 docs/cli-exit-codes.md 表格中对应的标准退出码退出。该契约的规范实现是 src/notebooklm/cli/error_handler.py 中的_output_error(...),以及它导出的公开别名output_error

对于库异常(AuthErrorRateLimitErrorValidationErrorNotFoundError等),契约早已清晰——handle_errors(...)上下文管理器会按异常类型分派到对应分支,映射出RATE_LIMITEDAUTH_ERRORVALIDATION_ERRORNOT_FOUND等稳定code。真正的模糊地带在click.ClickException及其子类上:handle_errors(...)except click.ClickException: raise分支(error_handler.py 第 464-466 行)中刻意原样重抛它们,随后 Click 用自己的Usage: ... / Error: ...散文格式把错误写到 stderr 并退出。这意味着自动化脚本只要踩到一条 Click 异常路径,json.loads(stdout)就会立刻失败。

该 ADR 的定位正是填补 docs/cli-exit-codes.md 对 post-parseUsageError的"沉默":原文档第 52 行的表格行只描述了 Click 重抛自身异常,第 64 行的 JSON 小节只描述了库异常的信封,两者从未交叉。ADR-0015 把这条缝隙正式封上。

问题的根源:两个不同阶段的ClickException

ClickException子类会在两个语义截然不同的阶段被抛出,它们在 Click 层面的表现相同(都渲染 usage 文本、都以类级exit_code退出),但对调用方意义完全不同:

  • Parse-time(解析期)抛出:发生在命令体运行之前。Click 自己的解析器在 argv 未通过选项/类型校验时抛出UsageError/BadParameter,例如--limit foo--limitIntRange)、未知选项、缺少必需参数。此时命令函数尚未被调用,handle_errors(...)还不在调用栈上;--json也许已经在命令行上被输入了,但解析器从未完成对它解析,所以从 Click 解析器内部满足调用方对 JSON 信封的期望在结构上是不可能的。
  • Post-parse(解析后)抛出:发生在命令体内部或它所调用的服务层中,此时 argv 解析已成功,--json标志的值已绑定到click.Context.params。这些是程序自己做出的验证决策——标志组合冲突、计算出的前置条件、文件格式校验——只是恰好用抛出ClickException子类来表达,而不是抛库异常。它们会命中 error_handler.py 的except click.ClickException: raise分支,从而跳过信封。

CLI 审计(编号P1#2 "command-bodyUsageError/BadParameterbypass",原文定位在审计报告 54-90 行)枚举了骑乘这条 bypass 的 post-parse 抛出点,结合当前仓库源码可逐一印证:

站点(原 ADR 列举)触发场景仓库现状
cli/services/download.py:257--force/--no-clobber冲突当前 cli/services/download.py 中该冲突消息("Cannot specify both --force and --no-clobber")仍存在
cli/services/generate.py:394--style custom要求--style-prompt当前 cli/generate_cmd.py 第 121 行仍有custom_style_prompt_required校验字面量
cli/research_cmd.py:158--cited-only要求--import-allcli/research_cmd.py 第 577 行仍有--cited-only requires --import-allUsageError
cli/source_cmd.py:626--cited-only要求--import-all同类校验仍在
cli/chat_cmd.py:193--new--conversation-id互斥cli/chat_cmd.py 第 357 行仍有互斥UsageError
cli/generate_cmd.py:57语言代码校验校验逻辑仍在

对这些站点中的任何一个执行<cmd> ... --json,结果都是:退出码2、Click 的 usage 文本落在 stderr、stdout 上没有 JSON。依赖json.loads(stdout)分支的自动化脚本(即 docs/cli-exit-codes.md 中公布的配方)就此中断。

审计的元分析(meta-audit)从三个角度重新框定了这一发现,ADR-0015 逐一采纳:

  • C1:将契约决策声明为 stop-sentinel——在契约被决定并记录之前,不应有任何实现 PR 改造这些站点,因为两个候选修复形态("路由进信封" vs "把ClickException整体豁免出--json")会驱动互相矛盾的补丁。
  • C3:将层级归属扩大——同样的形态存在于服务模块(cli/services/generate.py383/394/396/398 行、cli/services/download.py257/259/261 行、cli/services/source_mutations.py18 行),因此任何契约决定同时适用于命令代码与服务代码。当前 cli/services/source_mutations.py 第 123 行仍返回VALIDATION_ERROR这一错误码,印证该契约已渗透到服务层。
  • I1:纠正审计中"docs/cli-exit-codes.md内部自相矛盾"的说法——文档只是沉默,ADR-0015 负责填补。

决策:post-parseClickException全部流经类型化信封

ADR-0015 的核心决定是一句话:--json下,从命令体或服务层抛出的每一个 post-parseclick.ClickException子类失败,都必须输出 docs/cli-exit-codes.md 定义的类型化 JSON 错误信封,以对应标准码退出,且 stderr 不写任何 usage 文本。

决策分五条规则,逐条落地:

  1. Parse-timeClickException原样保留(后被 2026-06-02 修订取代,见下文专节)。Click 解析器继续为 argv 级校验失败抛出UsageError/BadParameter/ClickException,Click 继续把Usage: ... / Error: ...写到 stderr 并以2UsageError/BadParameter)或1(基类ClickException)退出。error_handler.py 的重抛保持不动。handle_errors本身在这种情况下不产生 JSON 信封——因为解析器触发时它还没被进入——但位于其上方的根组会兜底(见修订节)。
  2. Post-parseClickException流经类型化信封。命令体和服务层代码若要在 JSON 契约下表达验证失败,不得直接抛出click.UsageError/click.BadParameter/click.ClickException;必须改为调用cli.error_handleroutput_error(...),或抛出handle_errors(...)已经映射到信封上的库异常ValidationError/ConfigurationError。标志组合或前置条件冲突的自然选择是VALIDATION_ERROR(退出1);完整映射表见 docs/cli-exit-codes.md。本 ADR 不引入任何新的错误码键。
  3. 线上格式不变。信封仍是_output_error早已产生的扁平对象{ "error": true, "code": "<CODE>", "message": "<text>", ...extras }。已经用json.loads(stdout)解析并按code分支的调用方无需任何迁移。
  4. 文本模式契约保留。未设置--json时,post-parse 验证失败仍以人类可读消息出现在 stderr 并以对应标准码退出。对按规则 2 重构的站点,消息来自output_error(...)而非 Click 的Usage: ... / Error: ...格式化器,因此不再附带命令 usage 页脚——这是与规则 2 一致的最小用户可见差异;规则 5 标记的站点则保持 Click 格式化器不变。
  5. 标记化的残余ClickException抛出。少量站点今天正确地抛着ClickException子类并应继续如此——它们处在命令产生任何输出之前的输入校验边界上,匹配 Click 自己的解析器风格错误渲染是正确的 UX(例如 cli/input.py 中的 UTF-8 / 文件读取校验、cli/profile_cmd.py的 profile 名称参数校验、cli/resolve.py 第 58 行的实体 ID 参数校验、cli/services/login/profile_targets.py的共享 profile 名校验)。这些站点由内联 marker 注释穷尽追踪:# cli-input-validation: <reason>用于绕过信封的 Click 异常,# cli-raw-exit: <reason>用于error_handler.py之外的裸SystemExit站点。tests/_guardrails/test_error_handler_allowlist.py 强制这些 marker 存在、非空且不过期。新站点需要携带带理由的本地 marker;任何其他post-parse 验证失败的默认形态都是规则 2。

源码级剖析:output_errorhandle_errors的映射引擎

决策的规范实现集中在 src/notebooklm/cli/error_handler.py 中,是理解全契约的钥匙。

_output_error(第 159-195 行)是唯一产出信封的函数。JSON 分支构造{"error": true, "code": code, "message": message},把extra字典展开进顶层,用click.echo(json.dumps(response, indent=2, default=str, ensure_ascii=False))输出到 stdout,随后raise SystemExit(exit_code)。文本分支用safe_echo(message, err=True)写 stderr(可附加hint),同样以SystemExit退出。注意两个关键细节:

  • default=str让信封可以序列化非 JSON 原生类型(如时间对象);
  • 模块顶部的注释明确:模块外的click.ClickException/ 裸raise SystemExit站点由内联 marker 治理(第 33-38 行),旧的行号 allowlist 已在 issue #1298 中移除,因为任何编辑都会导致行号漂移、无行为变化却触发 CI 失败。

handle_errors(...)(第 254 行起)是库异常的分派引擎。它内部的emit(...)闭包先把"幂等探测未决"(unconfirmed=True)的写操作改写为UNCONFIRMED_WRITE码并替换掉分支原有的重试建议(避免自动化按RATE_LIMITED的指引盲目重试造成重复写入),再把操作元数据(operation_metadata_payload)、未决写入提示(exception_json_fields)、部分上传保留的source_id/stage折叠进信封,最后调用_output_error。异常映射完整对应 docs/cli-exit-codes.md 的中央表格:

异常或失败JSONcode退出码
标记unconfirmed=True的异常UNCONFIRMED_WRITE继承匹配分支(库错误1,未预期异常2
RateLimitErrorRATE_LIMITED1
AuthErrorAUTH_ERROR1
ValidationErrorVALIDATION_ERROR1
ConfigurationErrorCONFIG_ERROR1
NetworkErrorNETWORK_ERROR1
NotebookLimitErrorNOTEBOOK_LIMIT1
ArtifactTimeoutErrorARTIFACT_TIMEOUT1
NotFoundError及领域*NotFoundErrorNOT_FOUND1
其他NotebookLMErrorNOTEBOOKLM_ERROR1
KeyboardInterruptCANCELLED130
未处理的ExceptionUNEXPECTED_ERROR2

NOT_FOUND分支尤其值得注意:它通过_NOT_FOUND_ID_ATTRS元组(notebook_idsource_idartifact_idnote_idmind_map_idlabel_idcollection_id)在运行时反射出具体子类携带的资源 ID,同时在原生键和通用id键下暴露,并支持 issue #1787 的近拼写 "did you mean" 候选(candidates字段 +did_you_mean_hint)。ArtifactTimeoutError分支则序列化task_idtimeout_secondsstatus_historystatus_transitions等完整字段,供自动化诊断轮询停滞。

output_error_output_error的公开别名(第 202 行),专供跨 CLI 包边界上行的层级(如cli/services/*)导入,以满足 tests/_guardrails/test_cli_boundary.py 强制执行的公开边界契约。这是规则 2 落地的正式入口。

2026-06-02 修订:parse-timeClickException也被 JSON 包装

原决策规则 1 声称 parse-timeClickException保持不变、"此场景不产生 JSON 信封"。这一前提(handle_errors确实永远看不到解析期错误)是正确的,但结论已不再成立——因为 CLI 在handle_errors之上长出了第二条、更高的边界。

根组 src/notebooklm/cli/grouped.py 中的SectionedGroup.mainnon-standalone 模式运行 Click 超类的main,专门为了捕获 Click 本会自行渲染的解析期click.ClickException。其 docstring 记录了这一意图:在这个根边界捕获失败,可以"为每一个当前与未来的子命令选项统一转换一次"失败。实现要点如下:

  • _json_requested(args)(第 26-35 行)在原始 argv上扫描--json标志(遇到--分隔符即停止),因为此时 Click 尚未解析出json_output参数值。
  • 捕获click.ClickException后,若_json_requested(args)为真,调用_emit_json_click_error(exc)(第 38-45 行),即output_error(exc.format_message(), "VALIDATION_ERROR", json_output=True, exit_code=exc.exit_code);否则走exc.show()+exit_with_code(exc.exit_code)的文本路径。
  • click.Abort在同一边界同等处理:--json下输出CANCELLED信封 + 退出1,文本模式下输出Aborted!到 stderr + 退出1

由此,--json下 parse-time 失败的具体行为是:

  • 类型化 JSON 信封输出到stdout——{ "error": true, "code": "VALIDATION_ERROR", "message": "<Click 格式化后的消息>" }——stderr 完全无输出;
  • 退出码被保留而非归一化:信封透传exit_code=exc.exit_code,所以UsageError/BadParameter仍以2退出,基类ClickException仍以1退出。这与 post-parse 信封路径(按决策规则 2/4 统一为退出1)不同:parse-time 包装只改变通道(stderr → JSON stdout),不改变退出码。

修订的动机与原始契约完全同源:一个传入--json的 JSON 消费者,即使失败发生在 argv 层面(非法--limit、未知选项、缺少必需参数),也绝不应在 stderr 上收到 usage 散文而非可解析信封。在根边界转换一次,比原立场("argv 级失败在结构上无法包装"——这在 Click 解析器内部是真的,但在以 non-standalone 模式运行解析器并捕获其抛出的边界上不成立)对自动化严格更有用。

契约测试:行为被钉死在测试套件里

修订与决策均由 tests/unit/cli/test_json_validation_contract.py 钉死:

  • test_json_validation_errors_emit_json:参数化覆盖非法 limit / 非法 interval / 非法 retry / 缺少参数 / 未知选项 / 根回调校验失败六类 parse-time 场景,断言 stdout 上有VALIDATION_ERROR信封、stderr 为空、退出码非零、error is True
  • test_command_body_click_validation_emit_json:验证命令体(note create的位置参数与--content冲突)在--json下同样产出VALIDATION_ERROR信封,且消息包含 "Cannot use both"。
  • test_json_abort_emit_json:用SectionedGroup定义的最小根组钉死click.Abort--json下输出{ "error": true, "code": "CANCELLED", "message": "Cancelled by user" }且退出1
  • test_validated_json_options_emit_json_on_bad_values:程序化遍历整个 CLI 命令树_walk_leaf_commands),为每个带--json选项的叶子命令的每个可校验参数生成非法值(IntRange取 min-1、IntParamTypenot-an-intChoice取非法项),断言全部产出VALIDATION_ERROR信封——这是"每条当前与未来子命令选项都得到统一信封"承诺的机器验证。
  • test_text_validation_errors_keep_click_usage_output:钉死文本模式不变——退出2、stderr 含Usage:、stdout 为空。

此外 tests/_guardrails/test_error_handler_allowlist.py 以 AST 静态分析扫描全部cli/*.py(排除error_handler.py),强制执行 marker 治理:

  • 每个ClickException及信封旁路子类(UsageErrorBadParameterMissingParameterNoSuchOptionBadArgumentUsageFileError)调用点必须有# cli-input-validation:marker;raise SystemExit必须有# cli-raw-exit:marker;
  • marker 与调用点 1:1 匹配(一行上的单个 marker 不能同时满足两个调用);
  • 陈旧 marker(没有调用点可认领)与空理由 marker 均判失败;
  • SystemExit站点另有MAX_RAW_SYSEXIT_SITES = 5的上限兜底;
  • click.Abortclick.exceptions.Exit被刻意排除(它们是控制流而非错误消息退出,issue #1307)。

从当前仓库实际扫描可见 marker 已铺满各校验边界,例如 cli/input.py 的 UTF-8/互斥/prompt-file 校验(31/72/82/88/92/99 行)、cli/chat_cmd.py 第 357 行的互斥校验、cli/note_cmd.py 第 158 行的位置参数冲突、cli/resolve.py 第 58 行的实体 ID 校验等,每条都带明确的理由字符串。

自动化落地:--json下的可靠脚本配方

综合 ADR 与 docs/cli-exit-codes.md 的用法,--json下的推荐脚本模式是:把 stdout 重定向到文件,用$?分支退出码,用jq读取code作为机器可读错误类别:

notebooklm ask -n "$NOTEBOOK_ID" "Summarize" --json >out.json case $? in 0) ;; # 成功 1) jq -r '.code' out.json >&2 ;; # 预期内的命令失败(含 VALIDATION_ERROR / RATE_LIMITED / AUTH_ERROR / NOT_FOUND …) 2) echo "invalid invocation or CLI bug" >&2 ;; 130) echo "cancelled" >&2 ;; esac

实践要点:

  • 不要用?==2区分"坏 argv"与"坏组合"。ADR-0015 有意把 post-parse 验证失败从 Click 的2归一到12保留给系统/未预期错误与 parse-time 路径;标志组合失败属于用户/应用错误,归1。修订后 parse-time 失败在--json下虽输出VALIDATION_ERROR信封但仍以2退出——退出码语义因此是精确且无歧义的。
  • code是稳定契约,message允许变化。docs/cli-exit-codes.md 明确说明"用稳定的 JSONcode区分错误类别,人类可读消息可以变化"。NOT_FOUND信封可携带id与资源专属 ID 字段;RATE_LIMITED携带retry_after
  • stdout 纯净性是硬保证:--json下信封只出现在 stdout,stderr 为空(test_json_validation_contract.py直接断言result.stderr == ""result.output == result.stdout),配套的 tests/unit/test_json_error_exit.py 与 tests/unit/test_json_stdout_purity.py 守护 JSON 纯净性,错误路径 argv 用例可直接追加而不需要新测试基建。

后果与权衡

期望得到的收益:

  • --json成为覆盖所有命令体失败的可靠机器契约——自动化按json.loads(stdout)+code分支时,验证冲突与RATE_LIMITEDAUTH_ERROR表现完全一致。
  • docs/cli-exit-codes.md 不再对 post-parseUsageError沉默,关闭元审计项 I1,消灭一类"但文档说……"的报告。
  • 命令代码与服务代码收敛到单一错误发射路径(output_error(...)/ 库异常经handle_errors(...)),与 ADR-0008 的cli/services/抽取模式干净组合:服务模块不再需要在"抛 Click 异常(跳过信封)"与"导入output_error(耦合 CLI 层)"之间二选一,ADR-0008 边界成为强制 typed-outcome 返回的正确位置。
  • 测试可沿用既有 JSON 纯净性扫描,无需新基建。

必须接受的代价:

  • 部分 post-parse 失败此前带 Click 的Usage: ... / Error: ...页脚;按规则 4,这些站点的文本模式输出会失去 usage 页脚,换成一致的 stderr 消息。审计认为可接受:usage 页脚约定服务于 argv 形状错误,而 post-parse 失败是语义形状错误,消息体本身已经自解释。
  • post-parse 失败的退出码从2(Click 的UsageError.exit_code)变为1(标准VALIDATION_ERROR退出)。这是有意的:2保留给系统/未预期错误与 parse-time 路径。以前在 post-parseUsageError后按?==2分支的脚本,本来就是在混淆"坏 argv"与"坏组合";新语义无歧义。
  • 契约制造了 parse-time 与 post-parseClickException处理之间的永久分叉:审查者在命令/服务代码中新增校验时必须套用规则 2,修改 Click 解析器配置时必须套用规则 1。分叉已在本文档记录,避免下一位审查者重新争辩。
  • 元审计 C4 枚举的 Pattern A / Pattern B 服务层站点(约 10 个模块)仍需要后续 PR 真正路由进信封;ADR-0015 只记录契约,本身不移动任何代码。每个后续 PR 引用本 ADR 并从一个 bypass 站点移除一个站点。当前 cli/services/source_mutations.py 第 123 行已出现VALIDATION_ERROR码,说明收敛工作正在逐步落地。

被否决的替代方案及其理由

ADR 记录了对四条替代路径的评估,理解它们有助于把握契约边界:

  1. ClickException子类整体豁免出--json,文档化例外。被否决:这把审计标记的行为冻结为契约,--json自动化在命令体验证失败时仍无法依赖 JSON 输出,文档还得枚举哪些校验"被覆盖"、哪些没有;矩阵随新命令不稳定,且审计 P1 排名(元审计 C2)明确把自动化破坏列为影响最高的用户可见问题。
  2. 把每个ClickException抛出都转成库ValidationError作为契约决策被否决(但作为每个站点的合法修复形态被接受)。部分当前ClickException站点确实是 argv 级的(规则 5 白名单中的那些),强行走ValidationError会给交互用户暴露更不友好的消息。决策是"路由进信封",不是"停用 Click 异常";白名单存在的意义正是让每个站点选择正确形态。
  3. json_output灌进每个服务辅助函数、让辅助函数直接调output_error(..., json_output=...)作为主要契约形态被否决(会让服务模块耦合 CLI 错误层,违反 ADR-0008 的边界),但作为任何单站点在修复压力下的最小可行补丁被接受。首选长期形态是"服务返回 typed outcome;命令把 outcome 路由进output_error";短期补丁是"服务直接调output_error"。两者都满足本 ADR。
  4. handle_errors无条件捕获ClickException并转换为信封。handle_errors被否决:这会连 parse-timeClickException一起吸收,但handle_errors在命令体内部进入,parse-time 路径永远到不了它(Click 在调用命令前就通过BaseCommand.main渲染解析器错误)。拦截 parse-time 错误需要子类化 Click 的命令/组机制,原判断这超出错误处理契约变更的范围——而 2026-06-02 更新明确:该"子类化 Click 组机制"的思路随后被采纳,只是落在根组而非handle_errorsSectionedGroup.main以 non-standalone 模式运行 Click 超类并在--json下把 parse-timeClickException转成 JSON 信封。这是独立于handle_errors的边界,所以"handle_errors自身看不到 parse-time 错误"的推理依然成立。

延伸阅读

  • docs/cli-exit-codes.md:完整退出码约定、JSON 信封形状、source stale/source wait等命令专属契约。
  • src/notebooklm/cli/error_handler.py:信封产生与异常映射的规范实现。
  • src/notebooklm/cli/grouped.py:根组SectionedGroup.main的 non-standalone 模式与 parse-time JSON 包装。
  • tests/unit/cli/test_json_validation_contract.py:全命令树非法参数扫描与信封断言。
  • tests/_guardrails/test_error_handler_allowlist.py:marker 强制机制与原始SystemExit上限。
  • docs/adr/0008-cli-services-extraction-pattern.md:cli/services/抽取模式,与信封契约组合的层级边界。

【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询