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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10:59 +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 """文档生成配置。"""
15 output_dir: str = "docs/api"
16 include_private: bool = False
17 include_dunders: bool = False
18 max_signature_width: int = 88
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)
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
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)
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}"
66def _get_docstring(node: ast.AST) -> str:
67 doc = ast.get_docstring(node)
68 return doc.strip() if doc else ""
71class DocGenerator:
72 """从 Python 包源码生成 Markdown API 文档。"""
74 def __init__(self, config: DocConfig | None = None):
75 self.config = config or DocConfig()
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)
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, ".")
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
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] = []
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)
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 ))
159 return classes, funcs
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))
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(["", "---", ""])
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("")
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("")
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("")
227 if m.submodules:
228 lines.append("**导入子模块:** " + ", ".join(f"`{s}`" for s in m.submodules))
229 lines.append("")
231 lines.append("---")
232 lines.append("")
234 return "\n".join(lines)
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 "?.?.?"
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
257def generate_quickstart(output_path: str) -> str:
258 """生成 Quick Start 模板。"""
259 content = """\
260# AgentOS Quick Start
262## 安装
264```bash
265pip install agentos
266```
268## 最小示例
270```python
271from agentos import AgentLoop, LoopConfig
273loop = AgentLoop(LoopConfig(max_iterations=3))
274result = loop.run("用一句话解释什么是递归")
275print(result.output)
276```
278## 配置
280```python
281from agentos import AgentOSConfig, load_config
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