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

181 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +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 output_dir: str = "docs/api" 

16 include_private: bool = False 

17 include_dunders: bool = False 

18 max_signature_width: int = 88 

19 

20 

21@dataclass 

22class _ClassDoc: 

23 name: str 

24 qualname: str 

25 doc: str 

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

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

28 

29 

30@dataclass 

31class _FuncDoc: 

32 name: str 

33 qualname: str 

34 doc: str 

35 signature: str 

36 is_async: bool = False 

37 is_static: bool = False 

38 is_classmethod: bool = False 

39 

40 

41@dataclass 

42class _ModuleDoc: 

43 name: str 

44 path: str 

45 doc: str 

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

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

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

49 

50 

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

52 args = [] 

53 for arg in node.args.args: 

54 name = arg.arg 

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

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

57 if node.args.vararg: 

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

59 if node.args.kwarg: 

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

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

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

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

64 

65 

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

67 doc = ast.get_docstring(node) 

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

69 

70 

71class DocGenerator: 

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

73 

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

75 self.config = config or DocConfig() 

76 

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

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

79 package_path = os.path.abspath(package_path) 

80 modules = self._scan(package_path) 

81 return self._render(modules, package_path) 

82 

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

84 results: list[_ModuleDoc] = [] 

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

86 for fn in sorted(filenames): 

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

88 continue 

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

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

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

92 

93 try: 

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

95 source = f.read() 

96 tree = ast.parse(source) 

97 doc = _get_docstring(tree) 

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

99 sub = self._find_submodules(tree, mod_name) 

100 results.append(_ModuleDoc( 

101 name=mod_name, path=rel, doc=doc, 

102 classes=classes, functions=funcs, submodules=sub, 

103 )) 

104 except Exception: 

105 pass 

106 return results 

107 

108 def _extract_top_level( 

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

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

111 classes: list[_ClassDoc] = [] 

112 funcs: list[_FuncDoc] = [] 

113 

114 for node in ast.iter_child_nodes(tree): 

115 if isinstance(node, ast.ClassDef): 

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

117 continue 

118 cd = _ClassDoc( 

119 name=node.name, 

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

121 doc=_get_docstring(node), 

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

123 ) 

124 for body in node.body: 

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

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

127 continue 

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

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

130 continue 

131 cd.methods.append(_FuncDoc( 

132 name=body.name, 

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

134 doc=_get_docstring(body), 

135 signature=_parse_signature(body), 

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

137 is_static=any( 

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

139 for d in body.decorator_list 

140 ), 

141 is_classmethod=any( 

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

143 for d in body.decorator_list 

144 ), 

145 )) 

146 classes.append(cd) 

147 

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

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

150 continue 

151 funcs.append(_FuncDoc( 

152 name=node.name, 

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

154 doc=_get_docstring(node), 

155 signature=_parse_signature(node), 

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

157 )) 

158 

159 return classes, funcs 

160 

161 @staticmethod 

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

163 subs = [] 

164 for node in ast.iter_child_nodes(tree): 

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

166 for alias in node.names: 

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

168 subs.append(alias.name) 

169 return sorted(set(subs)) 

170 

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

172 lines = [ 

173 "# AgentOS API Reference", 

174 "", 

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

176 "", 

177 "---", 

178 "", 

179 "## 目录", 

180 "", 

181 ] 

182 for m in modules: 

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

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

185 

186 for m in modules: 

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

188 lines.append("") 

189 if m.doc: 

190 lines.append(m.doc) 

191 lines.append("") 

192 

193 if m.classes: 

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

195 lines.append("") 

196 for c in m.classes: 

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

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

199 lines.append("") 

200 if c.doc: 

201 lines.append(c.doc) 

202 lines.append("") 

203 if c.methods: 

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

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

206 for meth in c.methods: 

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

208 prefix = "" 

209 if meth.is_classmethod: 

210 prefix = "@classmethod " 

211 elif meth.is_static: 

212 prefix = "@staticmethod " 

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

214 lines.append("") 

215 

216 if m.functions: 

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

218 lines.append("") 

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

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

221 for f in m.functions: 

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

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

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

225 lines.append("") 

226 

227 if m.submodules: 

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

229 lines.append("") 

230 

231 lines.append("---") 

232 lines.append("") 

233 

234 return "\n".join(lines) 

235 

236 @staticmethod 

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

238 try: 

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

240 import agentos 

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

242 except Exception: 

243 return "?.?.?" 

244 

245 

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

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

248 gen = DocGenerator() 

249 md = gen.generate(package_path) 

250 if output_path: 

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

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

253 f.write(md) 

254 return md 

255 

256 

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

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

259 content = """\ 

260# AgentOS Quick Start 

261 

262## 安装 

263 

264```bash 

265pip install agentos 

266``` 

267 

268## 最小示例 

269 

270```python 

271from agentos import AgentLoop, LoopConfig 

272 

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

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

275print(result.output) 

276``` 

277 

278## 配置 

279 

280```python 

281from agentos import AgentOSConfig, load_config 

282 

283config = load_config("agentos.yaml") 

284print(config.models) 

285``` 

286""" 

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

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

289 f.write(content) 

290 return content