Skip to content

Commit 61463a0

Browse files
Add auto-generated Python API reference documentation
- Create GitHub Actions workflow to sync API docs from agent-sdk - Add Python script that uses AST parsing to extract docstrings - Generate MDX files for 11 SDK modules (agent, conversation, llm, tool, workspace, mcp, event, context, security, io, git) - Update docs.json navigation to include API Reference section - Workflow runs daily at 2 AM UTC and on workflow_dispatch - Auto-generated docs include class and function signatures with docstrings Fixes #65
1 parent cbc11bc commit 61463a0

15 files changed

Lines changed: 3303 additions & 75 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate Python API reference documentation from the software-agent-sdk.
4+
5+
This script extracts docstrings from Python modules and generates MDX documentation
6+
files compatible with Mintlify.
7+
"""
8+
9+
import ast
10+
import json
11+
import os
12+
import sys
13+
from pathlib import Path
14+
from typing import Dict, List, Optional
15+
16+
# Modules to document (from openhands.sdk)
17+
MODULES_TO_DOCUMENT = [
18+
"agent",
19+
"conversation",
20+
"llm",
21+
"tool",
22+
"workspace",
23+
"mcp",
24+
"event",
25+
"context",
26+
"security",
27+
"io",
28+
"git",
29+
]
30+
31+
32+
def extract_docstring(node: ast.AST) -> Optional[str]:
33+
"""Extract docstring from an AST node."""
34+
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Module)):
35+
return ast.get_docstring(node)
36+
return None
37+
38+
39+
def extract_function_signature(node: ast.FunctionDef) -> str:
40+
"""Extract function signature as a string."""
41+
args = []
42+
43+
# Regular arguments
44+
for arg in node.args.args:
45+
arg_str = arg.arg
46+
if arg.annotation:
47+
arg_str += f": {ast.unparse(arg.annotation)}"
48+
args.append(arg_str)
49+
50+
# Return annotation
51+
returns = ""
52+
if node.returns:
53+
returns = f" -> {ast.unparse(node.returns)}"
54+
55+
return f"{node.name}({', '.join(args)}){returns}"
56+
57+
58+
def parse_module_file(file_path: Path) -> Dict:
59+
"""Parse a Python file and extract documentation."""
60+
try:
61+
content = file_path.read_text()
62+
tree = ast.parse(content)
63+
except Exception as e:
64+
print(f"Warning: Could not parse {file_path}: {e}")
65+
return {"module_doc": "", "classes": [], "functions": []}
66+
67+
module_doc = ast.get_docstring(tree) or ""
68+
69+
classes = []
70+
functions = []
71+
72+
# Only process top-level nodes
73+
for node in tree.body:
74+
if isinstance(node, ast.ClassDef):
75+
class_doc = extract_docstring(node)
76+
methods = []
77+
78+
for item in node.body:
79+
if isinstance(item, ast.FunctionDef):
80+
method_doc = extract_docstring(item)
81+
methods.append({
82+
"name": item.name,
83+
"signature": extract_function_signature(item),
84+
"docstring": method_doc or ""
85+
})
86+
87+
classes.append({
88+
"name": node.name,
89+
"docstring": class_doc or "",
90+
"methods": methods
91+
})
92+
93+
elif isinstance(node, ast.FunctionDef):
94+
func_doc = extract_docstring(node)
95+
functions.append({
96+
"name": node.name,
97+
"signature": extract_function_signature(node),
98+
"docstring": func_doc or ""
99+
})
100+
101+
return {
102+
"module_doc": module_doc,
103+
"classes": classes,
104+
"functions": functions
105+
}
106+
107+
108+
def format_docstring(docstring: str) -> str:
109+
"""Format a docstring for MDX output."""
110+
if not docstring:
111+
return ""
112+
113+
# Basic formatting - could be enhanced to handle Google/NumPy style docstrings
114+
lines = docstring.split('\n')
115+
formatted = []
116+
117+
for line in lines:
118+
# Convert common docstring patterns to markdown
119+
line = line.strip()
120+
if line.startswith('Args:') or line.startswith('Parameters:'):
121+
formatted.append('\n**Parameters:**\n')
122+
elif line.startswith('Returns:'):
123+
formatted.append('\n**Returns:**\n')
124+
elif line.startswith('Raises:'):
125+
formatted.append('\n**Raises:**\n')
126+
elif line.startswith('Example:') or line.startswith('Examples:'):
127+
formatted.append('\n**Example:**\n')
128+
else:
129+
formatted.append(line)
130+
131+
return '\n'.join(formatted)
132+
133+
134+
def generate_mdx_for_module(module_name: str, module_data: Dict, github_base: str) -> str:
135+
"""Generate MDX content for a module."""
136+
title = f"{module_name.capitalize()}"
137+
description = f"API reference for openhands.sdk.{module_name}"
138+
139+
content = f"""---
140+
title: {title}
141+
description: {description}
142+
---
143+
144+
# {title}
145+
146+
"""
147+
148+
if module_data.get("module_doc"):
149+
content += f"{format_docstring(module_data['module_doc'])}\n\n"
150+
151+
# Add source link
152+
content += f"""**Source:** [`openhands/sdk/{module_name}/`]({github_base}/openhands-sdk/openhands/sdk/{module_name}/)
153+
154+
---
155+
156+
"""
157+
158+
# Add classes
159+
classes = [c for c in module_data.get("classes", []) if not c["name"].startswith("_")]
160+
if classes:
161+
content += "## Classes\n\n"
162+
for cls in classes:
163+
content += f"### `{cls['name']}`\n\n"
164+
if cls["docstring"]:
165+
content += f"{format_docstring(cls['docstring'])}\n\n"
166+
167+
# Add methods
168+
methods = [m for m in cls.get("methods", [])
169+
if not m["name"].startswith("_") or m["name"] == "__init__"]
170+
if methods:
171+
content += "#### Methods\n\n"
172+
for method in methods:
173+
content += f"##### `{method['signature']}`\n\n"
174+
if method["docstring"]:
175+
content += f"{format_docstring(method['docstring'])}\n\n"
176+
177+
content += "---\n\n"
178+
179+
# Add functions
180+
functions = [f for f in module_data.get("functions", []) if not f["name"].startswith("_")]
181+
if functions:
182+
content += "## Functions\n\n"
183+
for func in functions:
184+
content += f"### `{func['signature']}`\n\n"
185+
if func["docstring"]:
186+
content += f"{format_docstring(func['docstring'])}\n\n"
187+
content += "---\n\n"
188+
189+
return content
190+
191+
192+
def generate_docs_for_all_modules(sdk_path: Path, output_dir: Path):
193+
"""Generate documentation for all modules."""
194+
print(f"Generating documentation from {sdk_path}...")
195+
196+
# Ensure output directory exists
197+
output_dir.mkdir(parents=True, exist_ok=True)
198+
199+
github_base = "https://github.com/OpenHands/software-agent-sdk/tree/main"
200+
sdk_module_path = sdk_path / "openhands-sdk" / "openhands" / "sdk"
201+
202+
if not sdk_module_path.exists():
203+
print(f"Error: SDK module path not found: {sdk_module_path}")
204+
sys.exit(1)
205+
206+
# Process each module
207+
for module_name in MODULES_TO_DOCUMENT:
208+
module_dir = sdk_module_path / module_name
209+
210+
if not module_dir.exists():
211+
print(f"Warning: Module directory not found: {module_dir}")
212+
continue
213+
214+
print(f"Processing module: {module_name}")
215+
216+
# Start with init file
217+
module_data = {"module_doc": "", "classes": [], "functions": []}
218+
init_file = module_dir / "__init__.py"
219+
220+
if init_file.exists():
221+
module_data = parse_module_file(init_file)
222+
223+
# Also parse other Python files in the module
224+
for py_file in sorted(module_dir.glob("*.py")):
225+
if py_file.name != "__init__.py" and not py_file.name.startswith("_"):
226+
print(f" Parsing {py_file.name}")
227+
file_data = parse_module_file(py_file)
228+
module_data["classes"].extend(file_data.get("classes", []))
229+
module_data["functions"].extend(file_data.get("functions", []))
230+
231+
# Generate MDX
232+
mdx_content = generate_mdx_for_module(module_name, module_data, github_base)
233+
output_file = output_dir / f"{module_name}.mdx"
234+
output_file.write_text(mdx_content)
235+
print(f" Generated: {output_file.name}")
236+
237+
print("Documentation generation complete")
238+
239+
240+
def create_index_page(output_dir: Path):
241+
"""Create the API reference index page."""
242+
content = """---
243+
title: API Reference
244+
description: Python API reference for the OpenHands SDK
245+
---
246+
247+
# API Reference
248+
249+
This is the auto-generated Python API reference for the OpenHands SDK.
250+
251+
## Available Modules
252+
253+
"""
254+
255+
# Add links to each module
256+
for module in MODULES_TO_DOCUMENT:
257+
module_title = module.replace("_", " ").title()
258+
content += f"- **[{module_title}](./{module})** - API reference for `openhands.sdk.{module}`\n"
259+
260+
content += """
261+
## Overview
262+
263+
The OpenHands SDK provides a Python interface for building AI agents with:
264+
265+
- **Agent Management**: Core agent orchestration and execution
266+
- **Conversation Handling**: Structured dialogue and message management
267+
- **LLM Integration**: Support for multiple language models via LiteLLM
268+
- **Tool System**: Extensible tool registry and execution
269+
- **Workspace Management**: Isolated execution environments
270+
- **MCP Support**: Model Context Protocol integration
271+
- **Event System**: Agent lifecycle and execution events
272+
- **Context Management**: Conversation and execution context
273+
- **Security**: Safe code execution and sandboxing
274+
- **I/O Handling**: Stream management and output handling
275+
- **Git Integration**: Version control operations
276+
277+
For usage examples and guides, see the [SDK Documentation](/sdk/guides/quickstart).
278+
279+
## Source Code
280+
281+
The complete source code is available on GitHub: [OpenHands/software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)
282+
"""
283+
284+
index_file = output_dir / "index.mdx"
285+
index_file.write_text(content)
286+
print(f"Created overview page: {index_file}")
287+
288+
289+
def update_docs_json(docs_json_path: Path):
290+
"""Update docs.json to include API reference in navigation."""
291+
print("Updating docs.json navigation...")
292+
293+
with open(docs_json_path, 'r') as f:
294+
docs_config = json.load(f)
295+
296+
# Find the SDK tab
297+
sdk_tab = None
298+
for tab in docs_config.get("navigation", {}).get("tabs", []):
299+
if tab.get("tab") == "SDK":
300+
sdk_tab = tab
301+
break
302+
303+
if not sdk_tab:
304+
print("Warning: SDK tab not found in docs.json")
305+
return
306+
307+
# Check if API Reference already exists
308+
api_ref_exists = False
309+
for page in sdk_tab.get("pages", []):
310+
if isinstance(page, dict) and page.get("group") == "API Reference":
311+
api_ref_exists = True
312+
break
313+
314+
if not api_ref_exists:
315+
# Add API Reference section
316+
api_ref_section = {
317+
"group": "API Reference",
318+
"pages": [
319+
"sdk/api/index",
320+
"sdk/api/agent",
321+
"sdk/api/conversation",
322+
"sdk/api/llm",
323+
"sdk/api/tool",
324+
"sdk/api/workspace",
325+
"sdk/api/mcp",
326+
"sdk/api/event",
327+
"sdk/api/context",
328+
"sdk/api/security",
329+
"sdk/api/io",
330+
"sdk/api/git",
331+
]
332+
}
333+
334+
# Insert after Architecture section if it exists
335+
pages = sdk_tab.get("pages", [])
336+
arch_index = None
337+
for i, page in enumerate(pages):
338+
if isinstance(page, dict) and page.get("group") == "Architecture":
339+
arch_index = i
340+
break
341+
342+
if arch_index is not None:
343+
pages.insert(arch_index + 1, api_ref_section)
344+
else:
345+
# Just append at the end
346+
pages.append(api_ref_section)
347+
348+
sdk_tab["pages"] = pages
349+
350+
# Write back to docs.json
351+
with open(docs_json_path, 'w') as f:
352+
json.dump(docs_config, f, indent=2)
353+
f.write('\n') # Add trailing newline
354+
355+
print("docs.json updated successfully")
356+
else:
357+
print("API Reference already exists in docs.json")
358+
359+
360+
def main():
361+
"""Main entry point."""
362+
# Get paths
363+
script_dir = Path(__file__).parent
364+
docs_root = script_dir.parent.parent
365+
sdk_path = Path(os.environ.get("AGENT_SDK_PATH", docs_root.parent / "agent-sdk"))
366+
367+
if not sdk_path.exists():
368+
print(f"Error: SDK path not found: {sdk_path}")
369+
print("Set AGENT_SDK_PATH environment variable to the agent-sdk repository path")
370+
sys.exit(1)
371+
372+
# Output directory
373+
output_dir = docs_root / "sdk" / "api"
374+
output_dir.mkdir(parents=True, exist_ok=True)
375+
376+
# Generate documentation
377+
generate_docs_for_all_modules(sdk_path, output_dir)
378+
379+
# Create index page
380+
create_index_page(output_dir)
381+
382+
# Update docs.json
383+
docs_json = docs_root / "docs.json"
384+
update_docs_json(docs_json)
385+
386+
print("\n✅ API documentation generation complete!")
387+
print(f" Output: {output_dir}")
388+
389+
390+
if __name__ == "__main__":
391+
main()

0 commit comments

Comments
 (0)