#!/usr/bin/env python3 """ Python SDK Documentation Generator Generates MDX documentation from Python source code using griffe. Output is written to the docs content directory. Run: cd python && uv run --with griffe python scripts/generate-docs.py """ from __future__ import annotations import json import re import shutil import textwrap from pathlib import Path from typing import Any import typing as t if t.TYPE_CHECKING: import griffe as griffe_t _griffe: Any | None = None def load_griffe() -> Any: """Load griffe lazily so parser helpers can be unit-tested without it.""" global _griffe if _griffe is None: try: import griffe as griffe_module except ImportError: print("Error: griffe not installed. Run: pip install griffe") raise SystemExit(1) _griffe = griffe_module return _griffe # Paths SCRIPT_DIR = Path(__file__).parent PACKAGE_DIR = SCRIPT_DIR.parent OUTPUT_DIR = ( PACKAGE_DIR.parent / "docs" / "content" / "reference" / "sdk-reference" / "python" ) # GitHub base URL for source links GITHUB_BASE = "https://github.com/composiohq/composio/blob/next/python" # Decorators to document DECORATORS_TO_DOCUMENT = [ ("before_execute", "composio.core.models._modifiers"), ("after_execute", "composio.core.models._modifiers"), ("before_file_upload", "composio.core.models._modifiers"), ("schema_modifier", "composio.core.models._modifiers"), ] # Classes to skip (internal/helper classes) SKIP_CLASSES = {"WithLogger", "SDKConfig", "TProvider"} # Expected classes to document (maps class name -> property name on Composio) EXPECTED_CLASSES = { "Tools": "tools", "Toolkits": "toolkits", "Triggers": "triggers", "ConnectedAccounts": "connected_accounts", "AuthConfigs": "auth_configs", "MCP": "mcp", } # Additional public-facing classes worth documenting even though they are not # exposed as direct properties on ``Composio``. # # ``SessionContextImpl`` is intentionally omitted: it is an internal # implementation detail handed to custom-tool execute functions as a # ``SessionContext`` instance, never referenced directly, so it must not # appear in the public SDK reference. ADDITIONAL_CLASSES = { "ToolRouterSession": "core.models.tool_router_session", } # Modules to search for classes CLASS_MODULES = [ "core.models.tools", "core.models.toolkits", "core.models.triggers", "core.models.connected_accounts", "core.models.auth_configs", "core.models.mcp", ] # Pure-docs presentation overrides. The public class name ``ToolRouterSession`` # is kept in source (renaming is a breaking change), but the SDK reference # presents the session object under the canonical "Session" naming. DISPLAY_NAME_OVERRIDES = { "ToolRouterSession": "Session", } SLUG_OVERRIDES = { "ToolRouterSession": "session", } def to_kebab_case(name: str) -> str: """Convert PascalCase to kebab-case.""" s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() def display_name_for(class_name: str) -> str: """Resolve the display title for a class (falls back to the class name).""" return DISPLAY_NAME_OVERRIDES.get(class_name, class_name) def slug_for(class_name: str) -> str: """Resolve the URL slug / filename stem (falls back to kebab-case).""" return SLUG_OVERRIDES.get(class_name, to_kebab_case(class_name)) def escape_yaml_string(s: str) -> str: """Escape a string for YAML frontmatter.""" return json.dumps(s) def get_source_link(obj: griffe_t.Object) -> str | None: """Get GitHub source link for an object.""" if not hasattr(obj, "filepath") or not obj.filepath: return None try: raw_filepath = obj.filepath # Handle case where filepath might be a list (griffe edge case) if isinstance(raw_filepath, list): resolved_path: Path | None = raw_filepath[0] if raw_filepath else None else: resolved_path = raw_filepath if not resolved_path: return None rel_path = resolved_path.relative_to(PACKAGE_DIR) except ValueError: return None line = obj.lineno if hasattr(obj, "lineno") and obj.lineno else 1 return f"{GITHUB_BASE}/{rel_path}#L{line}" def format_type(annotation: Any) -> str: """Format a type annotation to readable string.""" if annotation is None: return "Any" type_str = str(annotation) # Clean up common prefixes type_str = type_str.replace("typing.", "").replace("typing_extensions.", "") type_str = type_str.replace("composio.client.types.", "") type_str = re.sub(r"\bt\.", "", type_str) type_str = re.sub(r"\bte\.", "", type_str) # typing_extensions alias type_str = re.sub(r"Optional\[([^\]]+)\]", r"\1 | None", type_str) # Clean up Unpack to just show the type type_str = re.sub(r"Unpack\[([^\]]+)\]", r"\1", type_str) # Truncate very long types if len(type_str) > 60: return type_str[:57] + "..." return type_str def parse_docstring(docstring: str | None) -> dict[str, Any]: """Parse docstring into components.""" if not docstring: return { "description": "", "params": {}, "returns": None, "examples": [], "deprecated": None, } lines = docstring.strip().split("\n") description_lines = [] params: dict[str, str] = {} returns = None examples: list[str] = [] deprecated_lines: list[str] = [] section = "description" current_param = None example_lines: list[str] = [] for line in lines: stripped = line.strip() # Check for a ``.. deprecated::`` directive (reStructuredText). if re.match(r"\.\.\s+deprecated::", stripped): section = "deprecated" rest = re.sub(r"\.\.\s+deprecated::\s*", "", stripped).strip() if rest: deprecated_lines.append(rest) continue # Check for :param name: description param_match = re.match(r":param\s+(\w+):\s*(.*)", stripped) if param_match: section = "params" current_param = param_match.group(1) params[current_param] = param_match.group(2) continue # Check for :returns: or :return: return_match = re.match(r":returns?:\s*(.*)", stripped) if return_match: section = "returns" returns = return_match.group(1) continue # Check for Example section if stripped.lower().startswith("example"): section = "examples" continue # Add to appropriate section if section == "description": description_lines.append(stripped) elif section == "params" and current_param and stripped: params[current_param] += " " + stripped elif section == "returns" and returns and stripped: returns += " " + stripped elif section == "examples": example_lines.append(line) elif section == "deprecated" and stripped: deprecated_lines.append(stripped) if example_lines: examples.append(normalize_example("\n".join(example_lines))) return { "description": " ".join(description_lines).strip(), "params": params, "returns": returns, "examples": examples, "deprecated": " ".join(deprecated_lines).strip() if deprecated_lines else None, } def normalize_example(example: str) -> str: """Normalize a docstring example before wrapping it in an MDX code fence.""" normalized = textwrap.dedent(example).strip() lines = normalized.splitlines() if len(lines) >= 2 and lines[0].strip().startswith("```"): closing_index = len(lines) - 1 if lines[closing_index].strip() == "```": normalized = "\n".join(lines[1:closing_index]).strip() return normalized def extract_class_info( cls: griffe_t.Class, class_name: str, config: dict ) -> dict[str, Any]: """Extract documentation from a class.""" griffe_module = load_griffe() doc = parse_docstring(cls.docstring.value if cls.docstring else None) info = { "name": class_name, "access": config.get("access"), "source_link": get_source_link(cls), "description": doc["description"], "deprecated": doc.get("deprecated"), "properties": [], "methods": [], } # Extract properties/attributes for name, member in cls.members.items(): if name.startswith("_"): continue if isinstance(member, griffe_module.Attribute): attr_doc = member.docstring.value if member.docstring else "" info["properties"].append( { "name": name, "type": format_type(member.annotation), "description": attr_doc.strip() if attr_doc else "", } ) # Extract methods for name, member in cls.members.items(): if name.startswith("_"): continue if isinstance(member, griffe_module.Function): method_doc = parse_docstring( member.docstring.value if member.docstring else None ) params = [] for p in member.parameters: if p.name in ("self", "cls"): continue params.append( { "name": p.name, "type": format_type(p.annotation), "optional": p.default is not None, "description": method_doc["params"].get(p.name, ""), } ) info["methods"].append( { "name": name, "source_link": get_source_link(member), "description": method_doc["description"], "parameters": params, "return_type": format_type(member.returns), "return_description": method_doc["returns"], "examples": method_doc["examples"], } ) return info def generate_class_mdx( info: dict[str, Any], prop_to_class: dict[str, str] | None = None ) -> str: """Generate MDX for a class.""" lines = [] # Frontmatter desc = ( info["description"].split("\n")[0] if info["description"] else f"{info['name']} class" ) # Normalize reStructuredText ``double backticks`` to single backticks so the # frontmatter description reads cleanly. desc = re.sub(r"``([^`]+)``", r"`\1`", desc) if len(desc) > 150: desc = desc[:147] + "..." lines.append("---") lines.append(f"title: {escape_yaml_string(display_name_for(info['name']))}") lines.append(f"description: {escape_yaml_string(desc)}") lines.append("---") lines.append("") # Class-level deprecation callout (rendered as a fumadocs warning callout). deprecated_note = info.get("deprecated") if deprecated_note: # Normalize reStructuredText ``double backticks`` to MDX `single` so # inline code renders correctly. deprecated_note = re.sub(r"``([^`]+)``", r"`\1`", deprecated_note) lines.append('') lines.append(deprecated_note) lines.append("") lines.append("") source_link = info.get("source_link", "") # Properties property_rows = [] if info["properties"]: if prop_to_class: for prop in info["properties"]: prop_name = prop["name"] if prop_name in prop_to_class: class_name = prop_to_class[prop_name] link = ( f"/reference/sdk-reference/python/{to_kebab_case(class_name)}" ) property_rows.append( { "name": f"[`{prop_name}`]({link})", "type": f"`{class_name}`", "description": prop["description"], } ) elif info["name"] in ADDITIONAL_CLASSES: for prop in info["properties"]: property_rows.append( { "name": f"`{prop['name']}`", "type": f"`{prop['type']}`", "description": prop["description"], } ) if property_rows: include_descriptions = any(row["description"] for row in property_rows) lines.append("## Properties") lines.append("") if include_descriptions: lines.append("| Name | Type | Description |") lines.append("|------|------|-------------|") for row in property_rows: lines.append( f"| {row['name']} | {row['type']} | {row['description']} |" ) else: lines.append("| Name | Type |") lines.append("|------|------|") for row in property_rows: lines.append(f"| {row['name']} | {row['type']} |") lines.append("") # Methods if info["methods"]: lines.append("## Methods") lines.append("") for method in info["methods"]: lines.append(f"### {method['name']}()") lines.append("") if method["description"]: lines.append(method["description"]) lines.append("") # Signature params_str = ", ".join( f"{p['name']}: {p['type']}" + (" = ..." if p["optional"] else "") for p in method["parameters"] ) ret_type = method["return_type"] if method["return_type"] != "Any" else "" sig = f"def {method['name']}({params_str})" if ret_type: sig += f" -> {ret_type}" lines.append("```python") lines.append(sig) lines.append("```") lines.append("") # Parameters if method["parameters"]: lines.append("**Parameters**") lines.append("") lines.append("| Name | Type |") lines.append("|------|------|") for p in method["parameters"]: opt = "?" if p["optional"] else "" safe_type = p["type"].replace("|", "\\|") lines.append(f"| `{p['name']}{opt}` | `{safe_type}` |") lines.append("") # Returns if method["return_type"] and method["return_type"] not in ("None", "Any"): ret_desc = method["return_description"] or "" lines.append("**Returns**") lines.append("") if ret_desc: lines.append(f"`{method['return_type']}` — {ret_desc}") else: lines.append(f"`{method['return_type']}`") lines.append("") # Examples if method["examples"]: lines.append("**Example**") lines.append("") for ex in method["examples"]: lines.append("```python") lines.append(ex) lines.append("```") lines.append("") lines.append("---") lines.append("") # Source link at bottom if source_link: lines.append(f"[View source]({source_link})") lines.append("") return "\n".join(lines) def generate_index_mdx(classes: list[dict], decorators: list[dict]) -> str: """Generate index page.""" # Classes table class_rows = [] for c in classes: link = f"/reference/sdk-reference/python/{slug_for(c['name'])}" desc = ( c["description"][:80] + "..." if len(c["description"]) > 80 else c["description"] ) class_rows.append(f"| [`{display_name_for(c['name'])}`]({link}) | {desc} |") # Decorators section dec_section = "" if decorators: dec_lines = ["## Decorators", ""] for d in decorators: dec_lines.append(f"### {d['name']}") dec_lines.append("") if d["description"]: dec_lines.append(d["description"]) dec_lines.append("") if d["source_link"]: dec_lines.append(f"[View source]({d['source_link']})") dec_lines.append("") # Signature params = ", ".join( f"{p['name']}: {p['type']}" + (" = ..." if p["optional"] else "") for p in d["parameters"] ) dec_lines.append("```python") dec_lines.append(f"@{d['name']}({params})") dec_lines.append("def my_modifier(...):") dec_lines.append(" ...") dec_lines.append("```") dec_lines.append("") dec_section = "\n".join(dec_lines) return f"""--- title: {escape_yaml_string("Python SDK Reference")} description: {escape_yaml_string("API reference for the Composio Python SDK")} --- # Python SDK Reference Complete API reference for the `composio` Python package. ## Installation ## Classes | Class | Description | |-------|-------------| {chr(10).join(class_rows)} ## Quick Start ```python from composio import Composio composio = Composio(api_key="your-api-key") # Get tools for a user tools = composio.tools.get("user-123", toolkits=["github"]) # Execute a tool result = composio.tools.execute( "GITHUB_GET_REPOS", arguments={{"owner": "composio"}}, user_id="user-123" ) ``` {dec_section} """ def main(): griffe_module = load_griffe() print("Starting Python SDK documentation generation...\n") print(f"Output: {OUTPUT_DIR}\n") # Clean output if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR) OUTPUT_DIR.mkdir(parents=True) # Load package print("Loading composio package...") try: package = griffe_module.load("composio", search_paths=[str(PACKAGE_DIR)]) except Exception as e: print(f"Error: {e}") raise SystemExit(1) # Find Composio class composio_cls = package.members["sdk"].members["Composio"] print(" Found Composio class") # Discover classes from EXPECTED_CLASSES classes_to_doc = {"Composio": {"cls": composio_cls, "access": None, "prop": None}} prop_to_class = {} # Maps property name -> class name for module_name in CLASS_MODULES: try: parts = module_name.split(".") current = package for part in parts: current = current.members[part] # Find expected classes in this module for class_name, prop_name in EXPECTED_CLASSES.items(): if class_name in current.members: cls = current.members[class_name] if isinstance(cls, griffe_module.Class): classes_to_doc[class_name] = { "cls": cls, "access": f"composio.{prop_name}", "prop": prop_name, } prop_to_class[prop_name] = class_name print(f" Found {class_name} (via composio.{prop_name})") except (KeyError, AttributeError): continue # Add standalone public-facing classes that are not surfaced via a direct # ``composio.`` access pattern. for class_name, module_name in ADDITIONAL_CLASSES.items(): try: parts = module_name.split(".") current = package for part in parts: current = current.members[part] if class_name in current.members: cls = current.members[class_name] if isinstance(cls, griffe_module.Class): classes_to_doc[class_name] = { "cls": cls, "access": None, "prop": None, } print(f" Found {class_name}") except (KeyError, AttributeError): continue # Generate docs for each class documented_classes = [] for class_name, config in classes_to_doc.items(): cls = config["cls"] print(f" Processing {class_name}...") info = extract_class_info(cls, class_name, {"access": config["access"]}) # Only Composio gets prop_to_class for linking if class_name == "Composio": mdx = generate_class_mdx(info, prop_to_class) else: mdx = generate_class_mdx(info, None) file_path = OUTPUT_DIR / f"{slug_for(class_name)}.mdx" file_path.write_text(mdx) documented_classes.append( { "name": class_name, "description": info["description"] or f"{class_name} class", } ) # Process decorators decorators = [] for dec_name, dec_module in DECORATORS_TO_DOCUMENT: module_path = dec_module.split(".") current = package for part in module_path[1:]: if part in current.members: current = current.members[part] if dec_name in current.members: func = current.members[dec_name] if isinstance(func, griffe_module.Function): print(f" Processing decorator {dec_name}...") doc = parse_docstring(func.docstring.value if func.docstring else None) params = [] for p in func.parameters: if p.name in ("self", "cls"): continue params.append( { "name": p.name, "type": format_type(p.annotation), "optional": p.default is not None, "description": doc["params"].get(p.name, ""), } ) decorators.append( { "name": dec_name, "source_link": get_source_link(func), "description": doc["description"], "parameters": params, } ) # Generate index index_content = generate_index_mdx(documented_classes, decorators) (OUTPUT_DIR / "index.mdx").write_text(index_content) # Generate meta.json meta = { "title": "Python SDK", "pages": [slug_for(c["name"]) for c in documented_classes], } (OUTPUT_DIR / "meta.json").write_text(json.dumps(meta, indent=2)) print(f"\nDone! Generated {len(documented_classes)} class docs + index") print(f" Classes: {', '.join(c['name'] for c in documented_classes)}") print(f" Decorators: {', '.join(d['name'] for d in decorators)}") if __name__ == "__main__": main()