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

181 statements  

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

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

2 

3from __future__ import annotations 

4 

5import ast 

6import os 

7import sys 

8from dataclasses import dataclass, field 

9from pathlib import Path 

10 

11 

12@dataclass 

13class DocConfig: 

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

15 

16 output_dir: str = "docs/api" 

17 include_private: bool = False 

18 include_dunders: bool = False 

19 max_signature_width: int = 88 

20 

21 

22@dataclass 

23class _ClassDoc: 

24 name: str 

25 qualname: str 

26 doc: str 

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

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

29 

30 

31@dataclass 

32class _FuncDoc: 

33 name: str 

34 qualname: str 

35 doc: str 

36 signature: str 

37 is_async: bool = False 

38 is_static: bool = False 

39 is_classmethod: bool = False 

40 

41 

42@dataclass 

43class _ModuleDoc: 

44 name: str 

45 path: str 

46 doc: str 

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

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

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

50 

51 

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

53 args = [] 

54 for arg in node.args.args: 

55 name = arg.arg 

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

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

58 if node.args.vararg: 

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

60 if node.args.kwarg: 

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

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

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

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

65 

66 

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

68 doc = ast.get_docstring(node) 

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

70 

71 

72class DocGenerator: 

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

74 

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

76 self.config = config or DocConfig() 

77 

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

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

80 package_path = os.path.abspath(package_path) 

81 modules = self._scan(package_path) 

82 return self._render(modules, package_path) 

83 

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

85 results: list[_ModuleDoc] = [] 

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

87 for fn in sorted(filenames): 

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

89 continue 

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

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

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

93 

94 try: 

95 with open(full) as f: 

96 source = f.read() 

97 tree = ast.parse(source) 

98 doc = _get_docstring(tree) 

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

100 sub = self._find_submodules(tree, mod_name) 

101 results.append( 

102 _ModuleDoc( 

103 name=mod_name, 

104 path=rel, 

105 doc=doc, 

106 classes=classes, 

107 functions=funcs, 

108 submodules=sub, 

109 ) 

110 ) 

111 except Exception: 

112 pass 

113 return results 

114 

115 def _extract_top_level( 

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

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

118 classes: list[_ClassDoc] = [] 

119 funcs: list[_FuncDoc] = [] 

120 

121 for node in ast.iter_child_nodes(tree): 

122 if isinstance(node, ast.ClassDef): 

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

124 continue 

125 cd = _ClassDoc( 

126 name=node.name, 

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

128 doc=_get_docstring(node), 

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

130 ) 

131 for body in node.body: 

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

133 if ( 

134 not self.config.include_private 

135 and body.name.startswith("_") 

136 and not body.name.startswith("__") 

137 ): 

138 continue 

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

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

141 continue 

142 cd.methods.append( 

143 _FuncDoc( 

144 name=body.name, 

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

146 doc=_get_docstring(body), 

147 signature=_parse_signature(body), 

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

149 is_static=any( 

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

151 for d in body.decorator_list 

152 ), 

153 is_classmethod=any( 

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

155 for d in body.decorator_list 

156 ), 

157 ) 

158 ) 

159 classes.append(cd) 

160 

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

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

163 continue 

164 funcs.append( 

165 _FuncDoc( 

166 name=node.name, 

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

168 doc=_get_docstring(node), 

169 signature=_parse_signature(node), 

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

171 ) 

172 ) 

173 

174 return classes, funcs 

175 

176 @staticmethod 

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

178 subs = [] 

179 for node in ast.iter_child_nodes(tree): 

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

181 for alias in node.names: 

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

183 subs.append(alias.name) 

184 return sorted(set(subs)) 

185 

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

187 lines = [ 

188 "# AgentOS API Reference", 

189 "", 

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

191 "", 

192 "---", 

193 "", 

194 "## 目录", 

195 "", 

196 ] 

197 for m in modules: 

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

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

200 

201 for m in modules: 

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

203 lines.append("") 

204 if m.doc: 

205 lines.append(m.doc) 

206 lines.append("") 

207 

208 if m.classes: 

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

210 lines.append("") 

211 for c in m.classes: 

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

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

214 lines.append("") 

215 if c.doc: 

216 lines.append(c.doc) 

217 lines.append("") 

218 if c.methods: 

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

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

221 for meth in c.methods: 

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

223 prefix = "" 

224 if meth.is_classmethod: 

225 prefix = "@classmethod " 

226 elif meth.is_static: 

227 prefix = "@staticmethod " 

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

229 lines.append("") 

230 

231 if m.functions: 

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

233 lines.append("") 

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

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

236 for f in m.functions: 

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

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

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

240 lines.append("") 

241 

242 if m.submodules: 

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

244 lines.append("") 

245 

246 lines.append("---") 

247 lines.append("") 

248 

249 return "\n".join(lines) 

250 

251 @staticmethod 

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

253 try: 

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

255 import agentos 

256 

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

258 except Exception: 

259 return "?.?.?" 

260 

261 

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

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

264 gen = DocGenerator() 

265 md = gen.generate(package_path) 

266 if output_path: 

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

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

269 f.write(md) 

270 return md 

271 

272 

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

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

275 content = """\ 

276# AgentOS Quick Start 

277 

278## 安装 

279 

280```bash 

281pip install agentos 

282``` 

283 

284## 最小示例 

285 

286```python 

287from agentos import AgentLoop, LoopConfig 

288 

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

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

291print(result.output) 

292``` 

293 

294## 配置 

295 

296```python 

297from agentos import AgentOSConfig, load_config 

298 

299config = load_config("agentos.yaml") 

300print(config.models) 

301``` 

302""" 

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

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

305 f.write(content) 

306 return content