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

2

3 from __future__ import annotations

4

5 import ast

6 import inspect

7 import os

8 import sys

9 from dataclasses import dataclass, field

10 from pathlib import Path

11 from typing import Any

12

13

14@dataclass

15 class 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

24 class _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

33 class _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

44 class _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

53 def _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

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

69 doc = ast.get_docstring(node)

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

71

72

73 class 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

248 def 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

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

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

261 content = """\

262# AgentOS Quick Start

263

264## 安装

265

266```bash

267 pip install agentos

268```

269

270## 最小示例

271

272```python

273 from agentos import AgentLoop, LoopConfig

274

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

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

277 print(result.output)

278```

279

280## 配置

281

282```python

283 from agentos import AgentOSConfig, load_config

284

285 config = load_config("agentos.yaml")

286 print(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