跳到主内容
快讯直播
AI智模界
教程

用 Sphinx 给 AI 写的代码加一道出货前自测关卡

AI 写代码很快,但「能跑」和「写对了」是两回事。一个成本很低的办法是:要求模型在写每个函数时顺手在 docstring 里留几行可执行的示例(>>> 交互)——也就是 doctest,然后用 Sphinx 的 doctest 构建器把这些示例当成测试真跑一遍。跑不过就不许提交,跑不过 CI 就变红。这样"自测"这件事就被左移到了提交之前,而且测试和文档是同一份内容,不会过期。

适用场景

这套方案适合已经在用 AI 辅助写 Python 代码、但缺少单元测试覆盖的团队或个人:库函数、脚本工具、数据清洗函数都属于典型场景。核心解决的问题是"AI 生成的代码看起来合理但边界没验证过",同时又不想额外维护一套与文档脱节的测试。只要你的项目用 Python、愿意维护一份 docs/ 目录,成本就不高。

环境与前置条件

  • 操作系统:Linux / macOS / Windows 均可,命令以 bash 为准,Windows 下把 python3 换成 python 即可。
  • Python:3.9 及以上版本,具体支持范围以 Sphinx 官方文档当前版本为准。
  • 内存与磁盘:纯文档构建很轻,512MB 内存、几百 MB 磁盘足够;如果同时跑大型项目导入,建议 2GB 以上内存。
  • 依赖:sphinx(含 sphinx.ext.autodoc、sphinx.ext.doctest、sphinx.ext.napoleon 三个内置扩展,安装 sphinx 即自带,无需单独装)、pre-commit(想接提交钩子时)。CI 侧用任意支持 Python 的流水线,本文以 GitHub Actions 为例,其他平台照搬命令即可。
  • 前提知识:知道什么是虚拟环境、能在终端里切目录。

整体目录结构约定如下,后文所有命令都基于它:

```

demo/

├── src/demo/

│ ├── __init__.py

│ └── calc.py

├── docs/

│ ├── conf.py

│ ├── index.rst

│ └── api.rst

├── .pre-commit-config.yaml

└── requirements-docs.txt

```

分步骤部署

第 1 步:建虚拟环境与目录骨架

先隔离依赖,避免污染系统 Python。

```bash

cd demo

python3 -m venv .venv

source .venv/bin/activate # Windows: .venv\Scripts\activate

python -V

mkdir -p src/demo docs

```

成功标志:python -V 打印出版本号,且提示符前出现 (.venv)。之后所有命令都在这个已激活的环境里执行。

第 2 步:安装 Sphinx

把文档相关依赖单独写进一个文件,方便 CI 复用:

```bash

cat > requirements-docs.txt <<'EOF'

sphinx

pre-commit

EOF

pip install -r requirements-docs.txt

```

成功标志:pip list | grep -i sphinx 能看到 sphinx 及其版本。具体版本号以官方文档当前版本为准,不要钉死到某个数字上。

第 3 步:初始化 Sphinx 骨架

```bash

sphinx-quickstart -q -p "demo" -a "your-name" -v 0.1 --sep --ext-autodoc --ext-doctest docs

```

这一步生成 docs/conf.py、docs/index.rst 和 docs/Makefile。-q 表示不进入交互问答,--sep 表示源码与文档分目录。成功标志:docs/conf.py 文件存在,且里面已经有 extensions = [...] 这一行。

第 4 步:改写 conf.py,把 doctest 打开

这是整套方案的核心配置。打开 docs/conf.py,在文件靠前的位置补上模块搜索路径和扩展列表(保留 quickstart 生成的其他配置不动):

```python

import doctest

import os

import sys

把 src 加进搜索路径,autodoc 才找得到被测试的包

sys.path.insert(0, os.path.abspath("../src"))

extensions = [

"sphinx.ext.autodoc", # 从代码 docstring 生成文档

"sphinx.ext.doctest", # 把文档里的 >>> 示例当测试跑

"sphinx.ext.napoleon", # 支持 Google / NumPy 风格 docstring

]

输出比对放宽一点,减少无意义的假失败

doctest_default_flags = (

doctest.DONT_ACCEPT_TRUE_FOR_1

| doctest.ELLIPSIS # 允许 ... 通配

| doctest.NORMALIZE_WHITESPACE # 忽略空白差异

| doctest.IGNORE_EXCEPTION_DETAIL # 只比异常类型,不比消息正文

)

每个 doctest 执行前都会先跑这段代码,用于准备公共依赖

doctest_global_setup = """

import json

"""

```

成功标志:保存后执行 python -c "import ast,sys; ast.parse(open('docs/conf.py', encoding='utf-8').read())",没有任何输出即语法正确。

第 5 步:把模块接进文档

只有被 autodoc 拉进文档的 docstring,其中的 >>> 才会被 doctest 收集执行。新建 docs/api.rst:

```rst

API 参考

========

.. automodule:: demo.calc

:members:

:undoc-members:

```

再把它挂到 docs/index.rst 的 toctree 里,否则构建会报"文档未包含在 toctree 中"的警告:

```rst

.. toctree::

:maxdepth: 2

api

```

成功标志:api.rst 与 index.rst 都在 docs/ 下,且 toctree 中的 api 与文件名完全一致。

第 6 步:让 AI 按约定写可执行示例

新建 src/demo/calc.py,并把这套约定写进你给 AI 的提示词里:

```python

def parse_amount(text: str) -> int:

"""把「12.30」这类金额字符串解析成以分为单位的整数。

Args:

text: 金额字符串,允许带小数。

Example:

>>> parse_amount("12.30")

1230

>>> parse_amount("12")

1200

>>> parse_amount("abc")

Traceback (most recent call last):

...

ValueError: 无法解析金额

"""

text = text.strip()

if not text:

raise ValueError("无法解析金额")

try:

return round(float(text) * 100)

except ValueError as exc:

raise ValueError(f"无法解析金额: {text!r}") from exc

```

给 AI 的提示词里至少写清三条硬约束:每个公开函数必须有 Example: 段;示例里不能访问网络、不能读本地文件、不能依赖当前时间;输出必须是确定的(不要打印排序不稳定的结果)。这样模型产出的示例才具备"当作测试跑"的资格。Sphinx 在跑 docstring 里的示例时会带上该模块的命名空间,所以同模块的函数可以直接调用;跨模块的依赖放到 doctest_global_setup 里统一 import。

第 7 步:本地跑一次 doctest

```bash

sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest

```

这一步在做的事情是:解析文档 → 抽出所有可执行示例 → 逐条执行并比对输出。正常输出类似:

```

Document: api

-------------

1 items passed all tests:

3 tests in demo.calc.parse_amount

3 tests in 1 items.

3 passed and 0 failed.

Test passed.

```

看到 Test passed. 且退出码为 0(echo $? 输出 0)即成功。想省事可以直接用 quickstart 生成的 Makefile:make -C docs doctest。

第 8 步:接成 pre-commit 钩子

把上面那条命令固定成提交前必跑的一关,新建 .pre-commit-config.yaml:

```yaml

repos:

  • repo: local

hooks:

  • id: sphinx-doctest

name: sphinx doctest

entry: python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest

language: system

files: '\.(py|rst)$'

pass_filenames: false

```

pass_filenames: false 是必须的,因为 Sphinx 不接受"只构建某个文件"的参数,它总是构建整个文档树。用 python -m sphinx 而不是 sphinx-build,能避开不同系统下可执行文件不在 PATH 里的问题。安装钩子:

```bash

pre-commit install

```

成功标志:终端输出 pre-commit installed at .git/hooks/pre-commit。之后每次 git commit 只要改到 .py 或 .rst 文件,就会先跑一遍 doctest。

第 9 步:接入 CI,挡住绕过钩子的人

本地钩子可以用 --no-verify 跳过,所以 CI 上要再挡一遍,配置放在 .github/workflows/doctest.yml:

```yaml

name: doctest

on:

pull_request:

push:

branches: [main]

jobs:

doctest:

runs-on: ubuntu-latest

steps:

  • uses: actions/checkout@v4
  • uses: actions/setup-python@v5

with:

python-version: '3.12' # 按项目实际支持的版本填写

  • run: pip install -r requirements-docs.txt
  • run: python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest

```

成功标志:PR 页面出现该检查项且为绿色;把分支保护规则里这项设为必需,AI 生成的改动就无法在示例失败的情况下被合并。

验证部署是否成功

分两步验证,第二步是关键——要确认它真的会拦住问题,而不只是"跑过了一次"。

1. 正常路径:

```bash

python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest; echo "exit=$?"

```

预期:输出 Test passed.,最后一行 exit=0。

2. 故障路径(故意制造一次失败):

```bash

临时把示例期望值改成错的

sed -i 's/^ 1230$/ 9999/' src/demo/calc.py

python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest; echo "exit=$?"

```

预期:输出里出现 Failed example:、Expected:、Got: 三行,退出码提示失败,docs/_build/doctest/output.txt 里有一份完整的失败明细。此时执行 git commit 应当被 pre-commit 拦下。验证完记得把 9999 改回 1230,再跑一次恢复绿色。

常见报错与解决

报错:WARNING: autodoc: failed to import module 'calc' from module 'demo'; No module named 'demo'

原因:conf.py 里的 sys.path 没指到源码目录,或者路径写成了相对当前工作目录而非相对 docs/。

解决:确认 conf.py 中写的是 os.path.abspath("../src"),并检查 src/demo/__init__.py 确实存在(空文件也可以)。

```bash

ls src/demo/__init__.py && grep -n "sys.path.insert" docs/conf.py

```

报错:Expected: 1230 / Got: 1230.0,或输出里出现 <object at 0x7f...>

原因:浮点与整数的 repr 不同;对象默认打印包含内存地址,每次运行都不一样,天然不稳定。

解决:示例里显式转换类型(如 int(parse_amount("12.30"))),对含地址的输出用 ... 省略,并确认 ELLIPSIS 已在 doctest_default_flags 中开启。

```python

>>> int(parse_amount("12.30"))

1230

>>> repr(obj)

'<demo.calc.Point object at ...>'

```

报错:构建成功但显示 0 items passed all tests,明明写了示例却没被执行

原因:模块没有被 automodule 拉进文档;或 docstring 中的 >>> 缩进不在同一个块内、示例前有空行打断了段落。

解决:确认 api.rst 里有 .. automodule:: demo.calc 且带 :members:,并确认 api.rst 已被 index.rst 的 toctree 引用。

```bash

grep -n "automodule" docs/api.rst && grep -n "api" docs/index.rst

```

报错:Executable 'python -m sphinx' not found 或 pre-commit 提示找不到 sphinx

原因:language: system 表示直接用当前环境里的可执行文件,如果提交时没有激活虚拟环境,就会找不到。

解决:要么保证提交前已 source .venv/bin/activate,要么把钩子改成自带环境的方式:

```yaml

  • id: sphinx-doctest

name: sphinx doctest

entry: sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest

language: python

additional_dependencies: ["sphinx"]

files: '\.(py|rst)$'

pass_filenames: false

```

报错:加上 -W 后构建直接失败,提示 document isn't included in any toctree

原因:-W 会把所有警告升级为错误,尚未挂进 toctree 的文档就会导致失败。

解决:把 api.rst 补进 index.rst 的 toctree;确认没有遗留的草稿 rst 文件。

后续维护

  • 备份:docs/conf.py、docs/*.rst 和 .pre-commit-config.yaml 必须进版本库,它们就是测试配置本身。docs/_build/ 属于构建产物,加进 .gitignore,不要提交。
  • 升级:升级 Sphinx 前先跑一遍 make -C docs doctest 留一个基线,升级后再跑一遍对比。新版本对 docstring 解析和警告策略可能有调整,具体行为以官方文档当前版本为准。升级后如果出现大量新警告,先处理警告再收紧密度。
  • 日志:每次失败的完整明细都在 docs/_build/doctest/output.txt,CI 上把这个文件用 artifact 上传,出问题不用重新复现。日常可以只看退出码。
  • 监控:在 CI 里统计一次构建执行了多少条示例(输出里的 N tests in M items),把这个数字记录下来。数字突然下降通常意味着某次 AI 改动把 docstring 示例删了或者模块没被 automodule 收进去——这本身就是一种有价值的信号。
  • 节奏建议:先只对核心模块开强制关卡,稳定一段时间后再扩大 automodule 的覆盖范围。示例数量增长后构建会变慢,可以在 pre-commit 里只保留快速检查,全量构建交给 CI。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。