Coverage for agentos/docs/generator.py: 28%

183 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1"""v0.80 — 从模块源码自动生成 Markdown API 文档。""" 

2 

3from __future__ import annotations 

4 

5import ast 

6import inspect 

7import os 

8import sys 

9from dataclasses import dataclass, field 

10from pathlib import Path 

11from typing import Any 

12 

13 

14@dataclass 

15class DocConfig: 

16 """文档生成配置。""" 

17 output_dir: str = "docs/api" 

18 include_private: bool = False 

19 include_dunders: bool = False 

20 max_signature_width: int = 88 

21 

22 

23@dataclass 

24class _ClassDoc: 

25 name: str 

26 qualname: str 

27 doc: str 

28 methods: list[_FuncDoc] = field(default_factory=list) 

29 base_classes: list[str] = field(default_factory=list) 

30 

31 

32@dataclass 

33class _FuncDoc: 

34 name: str 

35 qualname: str 

36 doc: str 

37 signature: str 

38 is_async: bool = False 

39 is_static: bool = False 

40 is_classmethod: bool = False 

41 

42 

43@dataclass 

44class _ModuleDoc: 

45 name: str 

46 path: str 

47 doc: str 

48 classes: list[_ClassDoc] = field(default_factory=list) 

49 functions: list[_FuncDoc] = field(default_factory=list) 

50 submodules: list[str] = field(default_factory=list) 

51 

52 

53def _parse_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: 

54 args = [] 

55 for arg in node.args.args: 

56 name = arg.arg 

57 annotation = ast.unparse(arg.annotation) if arg.annotation else "" 

58 args.append(f"{name}: {annotation}" if annotation else name) 

59 if node.args.vararg: 

60 args.append(f"*{node.args.vararg.arg}") 

61 if node.args.kwarg: 

62 args.append(f"**{node.args.kwarg.arg}") 

63 returns = ast.unparse(node.returns) if node.returns else "None" 

64 prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else "" 

65 return f"{prefix}def ({', '.join(args)}) -> {returns}" 

66 

67 

68def _get_docstring(node: ast.AST) -> str: 

69 doc = ast.get_docstring(node) 

70 return doc.strip() if doc else "" 

71 

72 

73class DocGenerator: 

74 """从 Python 包源码生成 Markdown API 文档。""" 

75 

76 def __init__(self, config: DocConfig | None = None): 

77 self.config = config or DocConfig() 

78 

79 def generate(self, package_path: str) -> str: 

80 """扫描包目录,生成完整 Markdown 文档。""" 

81 package_path = os.path.abspath(package_path) 

82 modules = self._scan(package_path) 

83 return self._render(modules, package_path) 

84 

85 def _scan(self, root: str) -> list[_ModuleDoc]: 

86 results: list[_ModuleDoc] = [] 

87 for dirpath, _, filenames in os.walk(root): 

88 for fn in sorted(filenames): 

89 if not fn.endswith(".py") or fn.startswith("_"): 

90 continue 

91 full = os.path.join(dirpath, fn) 

92 rel = os.path.relpath(full, root) 

93 mod_name = rel[:-3].replace(os.sep, ".") 

94 

95 try: 

96 with open(full, "r") as f: 

97 source = f.read() 

98 tree = ast.parse(source) 

99 doc = _get_docstring(tree) 

100 classes, funcs = self._extract_top_level(tree, mod_name) 

101 sub = self._find_submodules(tree, mod_name) 

102 results.append(_ModuleDoc( 

103 name=mod_name, path=rel, doc=doc, 

104 classes=classes, functions=funcs, submodules=sub, 

105 )) 

106 except Exception: 

107 pass 

108 return results 

109 

110 def _extract_top_level( 

111 self, tree: ast.Module, mod_name: str 

112 ) -> tuple[list[_ClassDoc], list[_FuncDoc]]: 

113 classes: list[_ClassDoc] = [] 

114 funcs: list[_FuncDoc] = [] 

115 

116 for node in ast.iter_child_nodes(tree): 

117 if isinstance(node, ast.ClassDef): 

118 if not self.config.include_private and node.name.startswith("_"): 

119 continue 

120 cd = _ClassDoc( 

121 name=node.name, 

122 qualname=f"{mod_name}.{node.name}", 

123 doc=_get_docstring(node), 

124 base_classes=[ast.unparse(b) for b in node.bases], 

125 ) 

126 for body in node.body: 

127 if isinstance(body, (ast.FunctionDef, ast.AsyncFunctionDef)): 

128 if not self.config.include_private and body.name.startswith("_") and not body.name.startswith("__"): 

129 continue 

130 if body.name.startswith("__") and not self.config.include_dunders: 

131 if body.name not in ("__init__", "__str__", "__repr__", "__call__"): 

132 continue 

133 cd.methods.append(_FuncDoc( 

134 name=body.name, 

135 qualname=f"{mod_name}.{node.name}.{body.name}", 

136 doc=_get_docstring(body), 

137 signature=_parse_signature(body), 

138 is_async=isinstance(body, ast.AsyncFunctionDef), 

139 is_static=any( 

140 isinstance(d, ast.Name) and d.id == "staticmethod" 

141 for d in body.decorator_list 

142 ), 

143 is_classmethod=any( 

144 isinstance(d, ast.Name) and d.id == "classmethod" 

145 for d in body.decorator_list 

146 ), 

147 )) 

148 classes.append(cd) 

149 

150 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

151 if not self.config.include_private and node.name.startswith("_"): 

152 continue 

153 funcs.append(_FuncDoc( 

154 name=node.name, 

155 qualname=f"{mod_name}.{node.name}", 

156 doc=_get_docstring(node), 

157 signature=_parse_signature(node), 

158 is_async=isinstance(node, ast.AsyncFunctionDef), 

159 )) 

160 

161 return classes, funcs 

162 

163 @staticmethod 

164 def _find_submodules(tree: ast.Module, mod_name: str) -> list[str]: 

165 subs = [] 

166 for node in ast.iter_child_nodes(tree): 

167 if isinstance(node, (ast.Import, ast.ImportFrom)): 

168 for alias in node.names: 

169 if alias.name.startswith("agentos."): 

170 subs.append(alias.name) 

171 return sorted(set(subs)) 

172 

173 def _render(self, modules: list[_ModuleDoc], root: str) -> str: 

174 lines = [ 

175 "# AgentOS API Reference", 

176 "", 

177 f"> 自动生成 | 版本 {self._get_version(root)} | {len(modules)} 个模块", 

178 "", 

179 "---", 

180 "", 

181 "## 目录", 

182 "", 

183 ] 

184 for m in modules: 

185 lines.append(f"- [{m.name}](#{m.name.replace('.', '')})") 

186 lines.extend(["", "---", ""]) 

187 

188 for m in modules: 

189 lines.append(f"## {m.name}") 

190 lines.append("") 

191 if m.doc: 

192 lines.append(m.doc) 

193 lines.append("") 

194 

195 if m.classes: 

196 lines.append("### 类") 

197 lines.append("") 

198 for c in m.classes: 

199 bases = f"({', '.join(c.base_classes)})" if c.base_classes else "" 

200 lines.append(f"#### `{c.name}{bases}`") 

201 lines.append("") 

202 if c.doc: 

203 lines.append(c.doc) 

204 lines.append("") 

205 if c.methods: 

206 lines.append("| 方法 | 签名 |") 

207 lines.append("|------|------|") 

208 for meth in c.methods: 

209 sig = meth.signature[:self.config.max_signature_width] 

210 prefix = "" 

211 if meth.is_classmethod: 

212 prefix = "@classmethod " 

213 elif meth.is_static: 

214 prefix = "@staticmethod " 

215 lines.append(f"| `{prefix}{meth.name}` | `{sig}` |") 

216 lines.append("") 

217 

218 if m.functions: 

219 lines.append("### 函数") 

220 lines.append("") 

221 lines.append("| 函数 | 签名 |") 

222 lines.append("|------|------|") 

223 for f in m.functions: 

224 sig = f.signature[:self.config.max_signature_width] 

225 prefix = "async " if f.is_async else "" 

226 lines.append(f"| `{prefix}{f.name}` | `{sig}` |") 

227 lines.append("") 

228 

229 if m.submodules: 

230 lines.append("**导入子模块:** " + ", ".join(f"`{s}`" for s in m.submodules)) 

231 lines.append("") 

232 

233 lines.append("---") 

234 lines.append("") 

235 

236 return "\n".join(lines) 

237 

238 @staticmethod 

239 def _get_version(root: str) -> str: 

240 try: 

241 sys.path.insert(0, os.path.dirname(root)) 

242 import agentos 

243 return getattr(agentos, "__version__", "?.?.?") 

244 except Exception: 

245 return "?.?.?" 

246 

247 

248def generate_api_docs(package_path: str, output_path: str | None = None) -> str: 

249 """便捷函数:生成 API 文档到文件。""" 

250 gen = DocGenerator() 

251 md = gen.generate(package_path) 

252 if output_path: 

253 Path(output_path).parent.mkdir(parents=True, exist_ok=True) 

254 with open(output_path, "w") as f: 

255 f.write(md) 

256 return md 

257 

258 

259def generate_quickstart(output_path: str) -> str: 

260 """生成 Quick Start 模板。""" 

261 content = """\ 

262# AgentOS Quick Start 

263 

264## 安装 

265 

266```bash 

267pip install agentos 

268``` 

269 

270## 最小示例 

271 

272```python 

273from agentos import AgentLoop, LoopConfig 

274 

275loop = AgentLoop(LoopConfig(max_iterations=3)) 

276result = loop.run("用一句话解释什么是递归") 

277print(result.output) 

278``` 

279 

280## 配置 

281 

282```python 

283from agentos import AgentOSConfig, load_config 

284 

285config = load_config("agentos.yaml") 

286print(config.models) 

287``` 

288""" 

289 Path(output_path).parent.mkdir(parents=True, exist_ok=True) 

290 with open(output_path, "w") as f: 

291 f.write(content) 

292 return content