Coverage for agentos/docs/generator.py: 28%
181 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 01:44 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 01:44 +0800
1"""v0.80 — 从模块源码自动生成 Markdown API 文档。"""
3from __future__ import annotations
5import ast
6import os
7import sys
8from dataclasses import dataclass, field
9from pathlib import Path
12@dataclass
13class DocConfig:
14 """文档生成配置。"""
16 output_dir: str = "docs/api"
17 include_private: bool = False
18 include_dunders: bool = False
19 max_signature_width: int = 88
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)
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
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)
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}"
67def _get_docstring(node: ast.AST) -> str:
68 doc = ast.get_docstring(node)
69 return doc.strip() if doc else ""
72class DocGenerator:
73 """从 Python 包源码生成 Markdown API 文档。"""
75 def __init__(self, config: DocConfig | None = None):
76 self.config = config or DocConfig()
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)
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, ".")
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
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] = []
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)
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 )
174 return classes, funcs
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))
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(["", "---", ""])
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("")
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("")
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("")
242 if m.submodules:
243 lines.append("**导入子模块:** " + ", ".join(f"`{s}`" for s in m.submodules))
244 lines.append("")
246 lines.append("---")
247 lines.append("")
249 return "\n".join(lines)
251 @staticmethod
252 def _get_version(root: str) -> str:
253 try:
254 sys.path.insert(0, os.path.dirname(root))
255 import agentos
257 return getattr(agentos, "__version__", "?.?.?")
258 except Exception:
259 return "?.?.?"
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
273def generate_quickstart(output_path: str) -> str:
274 """生成 Quick Start 模板。"""
275 content = """\
276# AgentOS Quick Start
278## 安装
280```bash
281pip install agentos
282```
284## 最小示例
286```python
287from agentos import AgentLoop, LoopConfig
289loop = AgentLoop(LoopConfig(max_iterations=3))
290result = loop.run("用一句话解释什么是递归")
291print(result.output)
292```
294## 配置
296```python
297from agentos import AgentOSConfig, load_config
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