Coverage for agentos/docs/generator.py: 28%
183 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
1"""v0.80 — 从模块源码自动生成 Markdown API 文档。"""
3from __future__ import annotations
5import ast
6import inspect
7import os
8import sys
9from dataclasses import dataclass, field
10from pathlib import Path
11from typing import Any
14@dataclass
15class DocConfig:
16 """文档生成配置。"""
17 output_dir: str = "docs/api"
18 include_private: bool = False
19 include_dunders: bool = False
20 max_signature_width: int = 88
23@dataclass
24class _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)
32@dataclass
33class _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
43@dataclass
44class _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)
53def _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}"
68def _get_docstring(node: ast.AST) -> str:
69 doc = ast.get_docstring(node)
70 return doc.strip() if doc else ""
73class DocGenerator:
74 """从 Python 包源码生成 Markdown API 文档。"""
76 def __init__(self, config: DocConfig | None = None):
77 self.config = config or DocConfig()
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)
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, ".")
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
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] = []
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)
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 ))
161 return classes, funcs
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))
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(["", "---", ""])
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("")
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("")
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("")
229 if m.submodules:
230 lines.append("**导入子模块:** " + ", ".join(f"`{s}`" for s in m.submodules))
231 lines.append("")
233 lines.append("---")
234 lines.append("")
236 return "\n".join(lines)
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 "?.?.?"
248def 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
259def generate_quickstart(output_path: str) -> str:
260 """生成 Quick Start 模板。"""
261 content = """\
262# AgentOS Quick Start
264## 安装
266```bash
267pip install agentos
268```
270## 最小示例
272```python
273from agentos import AgentLoop, LoopConfig
275loop = AgentLoop(LoopConfig(max_iterations=3))
276result = loop.run("用一句话解释什么是递归")
277print(result.output)
278```
280## 配置
282```python
283from agentos import AgentOSConfig, load_config
285config = load_config("agentos.yaml")
286print(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