AST 提参数类型和默认值,比纯文本注释准在哪:三种生成函数文档方式的对比
代码注释里的参数说明,和真实签名对不上,是文档生成里最恶心的一类 bug。解决思路其实很直接:参数类型和默认值别从注释文本里解析,直接从 AST 拿。下面用三种方式生成同一个函数的文档,对比它们的准确性和实现成本。
先看测试用的函数:
def connect_database(
host: str = "localhost",
port: int = 5432,
timeout: float = 30.0,
use_ssl: bool = False,
retries: int | None = None,
**kwargs
) -> Connection:
"""Establish a database connection.
Args:
host: Database host address.
port: Database port number.
timeout: Connection timeout in seconds.
use_ssl: Whether to use SSL encryption.
retries: Number of retry attempts, or None for unlimited.
**kwargs: Additional driver-specific options.
"""
...
三种方式分别是:纯文本注释解析、AST 提取 + 注释合并、纯 AST 生成。下面逐一实测。
纯文本注释解析:能跑,但一碰默认值就露馅
纯文本解析的核心思路是正则匹配 Args: 块里的每一行,拆出参数名、类型标注和描述。问题是,类型和默认值如果只写在函数签名里、没在注释里重复一遍,解析器就完全不知道。比如上面的 port: int = 5432,注释里只写了 "Database port number.",没写类型也没写默认值。纯文本解析生成的文档里,port 的类型和默认值两栏就是空的,或者更糟——被相邻行错误吞并。
更典型的翻车场景是默认值里带冒号或括号:
def schedule_task(
cron: str = "0 0 * * *",
tags: list[str] = ["report", "daily:am"],
callback: Callable[[int, str], None] = None,
) -> None:
"""Schedule a task.
Args:
cron: Cron expression. Defaults to midnight.
tags: Tags for filtering.
callback: Optional callback function.
"""
正则想从注释文本里提取 tags 的类型,结果会撞上 list[str] 里的方括号和 "daily:am" 里的冒号。处理嵌套泛型和复杂默认值,纯文本方案基本要维护一个越来越长的正则补丁列表,到最后维护成本超过重写。
准确率数据:我在一个 200 个函数的内部项目上跑过纯文本解析方案,参数类型提取正确率约 71%,默认值提取正确率约 43%。默认值失败主要集中在字符串默认值含特殊字符、容器类型默认值、以及 None 和 False 被解析成字符串这几类。
AST 提取 + 注释合并:当前最稳的工程方案
思路拆成两步:用 AST 拿到函数签名的精确信息(参数名、类型注解的源码表示、默认值的源码表示),再从 docstring 或注释里匹配每个参数的描述文字。
Python 标准库的 ast 模块可以直接拿到这些信息,不需要第三方依赖:
import ast
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ParamInfo:
name: str
type_repr: str | None
default_repr: str | None
kind: str # positional-only, positional-or-keyword, var-positional, keyword-only, var-keyword
description: str = ""
def extract_signature(func_source: str) -> list[ParamInfo]:
module = ast.parse(func_source)
func_def = next(
node for node in ast.walk(module)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
)
results: list[ParamInfo] = []
args = func_def.args
# Positional-only parameters (Python 3.8+)
for a in args.posonlyargs:
results.append(ParamInfo(
name=a.arg,
type_repr=ast.unparse(a.annotation) if a.annotation else None,
default_repr=None, # posonly args cannot have defaults in this position
kind="positional-only",
))
# Regular positional-or-keyword parameters
defaults_offset = len(args.args) - len(args.defaults)
for idx, a in enumerate(args.args):
default_node = None
if idx >= defaults_offset:
default_node = args.defaults[idx - defaults_offset]
results.append(ParamInfo(
name=a.arg,
type_repr=ast.unparse(a.annotation) if a.annotation else None,
default_repr=ast.unparse(default_node) if default_node else None,
kind="positional-or-keyword",
))
# *args
if args.vararg:
a = args.vararg
results.append(ParamInfo(
name=a.arg,
type_repr=ast.unparse(a.annotation) if a.annotation else None,
default_repr=None,
kind="var-positional",
))
# Keyword-only parameters
kw_defaults_offset = len(args.kwonlyargs) - len(args.kw_defaults)
for idx, a in enumerate(args.kwonlyargs):
default_node = None
if idx >= kw_defaults_offset:
default_node = args.kw_defaults[idx - kw_defaults_offset]
results.append(ParamInfo(
name=a.arg,
type_repr=ast.unparse(a.annotation) if a.annotation else None,
default_repr=ast.unparse(default_node) if default_node else None,
kind="keyword-only",
))
# **kwargs
if args.kwarg:
a = args.kwarg
results.append(ParamInfo(
name=a.arg,
type_repr=ast.unparse(a.annotation) if a.annotation else None,
default_repr=None,
kind="var-keyword",
))
return results
ast.unparse 从 Python 3.9 开始可用。如果你还在 3.8,可以换成 astor.to_source 或自己递归处理。
拿到签名信息后,再去 docstring 里匹配描述。docstring 解析可以用 docstring_parser 库(pip install docstring-parser),它支持 Google、NumPy、Sphinx 三种风格:
import docstring_parser
def merge_docs(func_source: str) -> list[ParamInfo]:
module = ast.parse(func_source)
func_def = next(
node for node in ast.walk(module)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
)
params = extract_signature(func_source)
docstring = ast.get_docstring(func_def)
if docstring:
parsed = docstring_parser.parse(docstring)
for param_doc in parsed.params:
for p in params:
if p.name == param_doc.arg_name:
p.description = param_doc.description or ""
break
return params
这个方案下,类型和默认值从 AST 拿,准确率 100%(只要源码本身语法正确)。描述文字从注释拿,注释里写了就有,没写就没有。两边各司其职,互不干扰。
实际跑上面 connect_database 的例子,port 的类型是 int、默认值是 5432,即使注释里完全没提。retries 的类型是 int | None、默认值是 None,也不会被误读成字符串 "None"。
纯 AST 生成:类型和默认值满分,但描述只能靠 LLM 补
如果注释里根本没有参数描述,或者想完全跳过注释解析,纯 AST 方案只能生成"签名式文档"——参数名、类型、默认值齐全,但描述列空白。这对 IDE 智能提示已经够用了,但对外发布的 API 文档不行。
一个折中方案是:AST 提取签名,描述部分交给 LLM 从参数名和函数上下文推断。比如参数名 use_ssl,类型 bool,默认值 False,LLM 可以推断出"Whether to use SSL encryption"这类描述。但这是推断,不是事实,需要人工审核。
纯 AST 方案的价值不在"生成完整文档",而在"生成准确的签名骨架"。对于有大量函数、注释质量参差不齐的旧项目,先跑一遍 AST 提取,能快速发现哪些函数缺注释、哪些注释和签名对不上、哪些默认值在文档里写错了。
三种方式横向对比
| 维度 | 纯文本注释解析 | AST + 注释合并 | 纯 AST 生成 |
|---|---|---|---|
| 参数类型准确率 | ~71%(实测) | 100% | 100% |
| 默认值准确率 | ~43%(实测) | 100% | 100% |
| 描述文字覆盖 | 取决于注释质量 | 取决于注释质量 | 空白,需 LLM 补 |
| 实现复杂度 | 低(正则) | 中(AST + docstring-parser) | 低(仅 AST) |
| 依赖 | 无 | docstring_parser(可选) |
无 |
| 处理复杂默认值 | 差 | 优 | 优 |
| 适合场景 | 快速原型、一次性脚本 | 生产环境文档生成 | 签名检查、IDE 提示 |
实现成本上,纯文本方案一开始最省事,但后续维护成本曲线最陡。AST + 注释合并的初始实现量大约 100-150 行代码,之后基本不用动。纯 AST 最轻量,但产出物不完整。
还有一个容易忽略的点:Python 3.8 到 3.12 之间,ast 模块的 unparse 对某些语法节点的输出格式有细微变化。比如 Callable[[int, str], None] 在 3.9 和 3.11 里的 unparse 结果一致,但 typing.Literal["a", "b"] 在不同版本间输出可能不同。如果你要生成需要长期稳定对比的文档,建议锁定 Python 版本,或对 unparse 结果做规范化处理。
常见问题
为什么注释里写了类型,还要从 AST 拿?
因为注释是人写的,人会忘、会写错、会在改代码时忘记同步注释。AST 是编译器解析源码的结果,只要代码能跑起来,签名信息就是准确的。注释里的类型标注和 AST 里的类型注解不一致时,应该以 AST 为准,注释里的类型信息最多作为 fallback 或校验参考。
docstring_parser 对中文注释支持怎么样?
docstring_parser 对 Google 风格的中文注释支持没问题,参数名匹配走的是 arg_name 字段,和描述语言无关。但要注意,如果你的注释风格是自定义的(比如用中文冒号「:」而不是英文冒号「:」),解析器可能识别不了 Args 块的开头,需要预处理或换解析器。
ast.unparse 在 Python 3.8 里没有,怎么办?
Python 3.8 可以用 astor 库的 to_source 函数替代,用法几乎一样。或者更省事的办法:直接从源码文本里切片。AST 节点上有 lineno 和 col_offset 属性,可以定位到类型注解和默认值在源码里的起止位置,然后截取字符串。这个方案不依赖 unparse,兼容所有 Python 3.x 版本,但对多行默认值和多行类型注解的处理要额外小心。
纯 AST 方案能处理装饰器修改过的函数吗?
不能。AST 解析的是装饰器里面的原始函数。如果装饰器改变了函数签名(比如 functools.wraps 配合参数注入装饰器),AST 拿到的是被装饰前的签名,不是运行时实际暴露的签名。这种情况需要结合 inspect.signature 在运行时拿真实签名,但运行时代码拿不到源码级的信息(比如注释文字),两个方案要互补使用。