Skip to content

Commit 66b720e

Browse files
committed
feat: Add Uses and Used By cross-reference sections
- Implement FunctionCallVisitor to extract function calls from AST - Add 'calls' field to MethodInfo and FunctionInfo data classes - Add two-pass generation: parse all modules first, then generate - Build cross-reference index for 'Used By' reverse dependencies - Add render_uses_section and render_used_by_section functions - Used By shows clickable links to caller documentation pages
1 parent 0f949fb commit 66b720e

File tree

1 file changed

+178
-11
lines changed

1 file changed

+178
-11
lines changed

praisonai_tools/docs_generator/generator.py

Lines changed: 178 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class MethodInfo:
4242
examples: List[str] = field(default_factory=list)
4343
notes: str = "" # Notes/More Information section
4444
see_also: List[Tuple[str, str]] = field(default_factory=list) # (name, description)
45+
calls: List[str] = field(default_factory=list) # Functions this method calls (Uses)
4546
source_file: str = "" # Relative path to source file
4647
source_line: int = 0 # Line number in source
4748
is_async: bool = False
@@ -77,6 +78,7 @@ class FunctionInfo:
7778
examples: List[str] = field(default_factory=list)
7879
notes: str = "" # Notes/More Information section
7980
see_also: List[Tuple[str, str]] = field(default_factory=list) # (name, description)
81+
calls: List[str] = field(default_factory=list) # Functions this function calls (Uses)
8082
source_file: str = "" # Relative path to source file
8183
source_line: int = 0 # Line number in source
8284
is_async: bool = False
@@ -687,6 +689,112 @@ def render_see_also_section(see_also: list) -> str:
687689
"""
688690

689691

692+
def render_uses_section(calls: list) -> str:
693+
"""Render a 'Uses' section showing what functions this code calls.
694+
695+
Args:
696+
calls: List of function/method names that are called
697+
698+
Returns:
699+
MDX string with uses section, or empty string if none
700+
"""
701+
if not calls:
702+
return ""
703+
704+
items = [f"- `{call}`" for call in calls]
705+
706+
return f"""
707+
## Uses
708+
709+
{chr(10).join(items)}
710+
"""
711+
712+
713+
def render_used_by_section(used_by: list) -> str:
714+
"""Render a 'Used By' section showing what code calls this function.
715+
716+
Args:
717+
used_by: List of (caller_name, caller_path) tuples
718+
719+
Returns:
720+
MDX string with used by section, or empty string if none
721+
"""
722+
if not used_by:
723+
return ""
724+
725+
items = []
726+
for caller_name, caller_path in used_by:
727+
if caller_path:
728+
items.append(f"- [`{caller_name}`]({caller_path})")
729+
else:
730+
items.append(f"- `{caller_name}`")
731+
732+
return f"""
733+
## Used By
734+
735+
{chr(10).join(items)}
736+
"""
737+
738+
739+
# =============================================================================
740+
# AST FUNCTION CALL EXTRACTION
741+
# =============================================================================
742+
743+
class FunctionCallVisitor(ast.NodeVisitor):
744+
"""Extract function/method calls from an AST node."""
745+
746+
def __init__(self):
747+
self.calls = []
748+
749+
def visit_Call(self, node):
750+
"""Extract the name of a function call."""
751+
if isinstance(node.func, ast.Name):
752+
# Simple function call: func_name()
753+
self.calls.append(node.func.id)
754+
elif isinstance(node.func, ast.Attribute):
755+
# Method call: obj.method() or self.method()
756+
if isinstance(node.func.value, ast.Name):
757+
if node.func.value.id == 'self':
758+
# self.method() - just use method name
759+
self.calls.append(node.func.attr)
760+
else:
761+
# obj.method()
762+
self.calls.append(f"{node.func.value.id}.{node.func.attr}")
763+
else:
764+
# Chained or complex: just get the attribute
765+
self.calls.append(node.func.attr)
766+
self.generic_visit(node)
767+
768+
def get_unique_calls(self) -> List[str]:
769+
"""Return unique function calls, excluding common built-ins."""
770+
# Filter out common built-ins and private methods
771+
excluded = {
772+
'print', 'len', 'str', 'int', 'float', 'bool', 'list', 'dict', 'set',
773+
'tuple', 'range', 'enumerate', 'zip', 'map', 'filter', 'sorted',
774+
'isinstance', 'issubclass', 'hasattr', 'getattr', 'setattr', 'delattr',
775+
'super', 'type', 'id', 'repr', 'format', 'open', 'input', 'round',
776+
'abs', 'min', 'max', 'sum', 'any', 'all', 'next', 'iter', 'reversed',
777+
'append', 'extend', 'insert', 'remove', 'pop', 'clear', 'copy',
778+
'keys', 'values', 'items', 'get', 'update', 'join', 'split', 'strip',
779+
'lower', 'upper', 'replace', 'format', 'startswith', 'endswith',
780+
}
781+
seen = set()
782+
result = []
783+
for call in self.calls:
784+
call_name = call.split('.')[-1] if '.' in call else call
785+
if call_name not in excluded and not call_name.startswith('_') and call not in seen:
786+
seen.add(call)
787+
result.append(call)
788+
return result[:10] # Limit to top 10 calls
789+
790+
791+
def extract_function_calls(node) -> List[str]:
792+
"""Extract function calls from a function/method AST node."""
793+
visitor = FunctionCallVisitor()
794+
visitor.visit(node)
795+
return visitor.get_unique_calls()
796+
797+
690798
# =============================================================================
691799
# CONFIGURATION
692800
# =============================================================================
@@ -1009,6 +1117,7 @@ def _parse_method(self, node) -> Optional[MethodInfo]:
10091117
examples=parsed_doc["examples"],
10101118
notes=parsed_doc["notes"],
10111119
see_also=parsed_doc["see_also"],
1120+
calls=extract_function_calls(node),
10121121
source_line=node.lineno,
10131122
is_async=is_async,
10141123
is_static=is_static,
@@ -1042,6 +1151,7 @@ def _parse_function(self, node) -> Optional[FunctionInfo]:
10421151
examples=parsed_doc["examples"],
10431152
notes=parsed_doc["notes"],
10441153
see_also=parsed_doc["see_also"],
1154+
calls=extract_function_calls(node),
10451155
source_line=node.lineno,
10461156
is_async=is_async,
10471157
)
@@ -1261,6 +1371,39 @@ def __init__(self, output_dir: Path, package_name: str, config: Dict[str, Any],
12611371
self.config = config
12621372
self.layout = layout
12631373
self.generated_files = set() # Use set for faster lookups and cleanup
1374+
# Index for "Used By" cross-references: {function_name: [(caller_name, caller_path), ...]}
1375+
self.used_by_index: Dict[str, List[Tuple[str, str]]] = {}
1376+
1377+
def _build_used_by_index(self, modules: List[ModuleInfo]) -> None:
1378+
"""Build reverse index of function calls for 'Used By' section."""
1379+
for mod in modules:
1380+
for cls in mod.classes:
1381+
for method in cls.methods + cls.class_methods:
1382+
caller_name = f"{cls.name}.{method.name}"
1383+
caller_path = f"../functions/{cls.name}-{method.name}"
1384+
for called in method.calls:
1385+
# Get just the function name (without module prefix)
1386+
call_name = called.split(".")[-1] if "." in called else called
1387+
if call_name not in self.used_by_index:
1388+
self.used_by_index[call_name] = []
1389+
self.used_by_index[call_name].append((caller_name, caller_path))
1390+
1391+
for func in mod.functions:
1392+
caller_name = func.name
1393+
caller_path = f"../functions/{func.name}"
1394+
for called in func.calls:
1395+
call_name = called.split(".")[-1] if "." in called else called
1396+
if call_name not in self.used_by_index:
1397+
self.used_by_index[call_name] = []
1398+
self.used_by_index[call_name].append((caller_name, caller_path))
1399+
1400+
def get_used_by(self, func_name: str) -> List[Tuple[str, str]]:
1401+
"""Get list of callers for a function."""
1402+
# Try exact match first, then short name
1403+
if func_name in self.used_by_index:
1404+
return self.used_by_index[func_name][:5] # Limit to top 5
1405+
short_name = func_name.split(".")[-1] if "." in func_name else func_name
1406+
return self.used_by_index.get(short_name, [])[:5]
12641407

12651408
def generate_module_doc(self, info: ModuleInfo, dry_run: bool = False) -> List[Path]:
12661409
"""Generate MDX documentation for a module (potentially multiple files)."""
@@ -1760,6 +1903,11 @@ def _render_function_page(self, func: FunctionInfo, module_info: ModuleInfo, cls
17601903
dedented_ex = textwrap.dedent(func.examples[0])
17611904
lines.append(f"```python\n{dedented_ex}\n```\n")
17621905

1906+
# Add Uses section if function calls other functions
1907+
uses_section = render_uses_section(func.calls)
1908+
if uses_section:
1909+
lines.append(uses_section)
1910+
17631911
# Add Notes section if available
17641912
notes_section = render_notes_section(func.notes)
17651913
if notes_section:
@@ -1770,6 +1918,13 @@ def _render_function_page(self, func: FunctionInfo, module_info: ModuleInfo, cls
17701918
if see_also_section:
17711919
lines.append(see_also_section)
17721920

1921+
# Add Used By section (what calls this function)
1922+
used_by = self.get_used_by(func.name)
1923+
if used_by:
1924+
used_by_section = render_used_by_section(used_by)
1925+
if used_by_section:
1926+
lines.append(used_by_section)
1927+
17731928
# Add Source link
17741929
github_repo = self.config.get("github_repo", "")
17751930
source_section = render_source_link(func.source_file, func.source_line, github_repo)
@@ -1986,23 +2141,35 @@ def generate_package(self, package_name: str, dry_run: bool = False):
19862141
modules = parser.get_modules()
19872142
print(f"Found {len(modules)} modules")
19882143

1989-
generated = 0
2144+
# Two-pass generation for Used By cross-references:
2145+
# Pass 1: Parse all modules and build the index
2146+
print(f" Pass 1: Building cross-reference index...")
2147+
parsed_modules = []
19902148
for module in modules:
19912149
module_name = module.split(".")[-1] if "." in module else module
19922150
if module_name in SKIP_MODULES:
19932151
continue
1994-
1995-
print(f" Processing: {module}")
19962152
info = parser.parse_module(module)
19972153
if info:
1998-
results = generator.generate_module_doc(info, dry_run=dry_run)
1999-
if results:
2000-
generated += 1
2001-
for r in results:
2002-
if dry_run:
2003-
print(f" Would generate: {r.name}")
2004-
else:
2005-
print(f" Generated: {r.name}")
2154+
parsed_modules.append(info)
2155+
2156+
# Build the Used By index from all parsed modules
2157+
generator._build_used_by_index(parsed_modules)
2158+
print(f" Built index with {len(generator.used_by_index)} functions tracked")
2159+
2160+
# Pass 2: Generate documentation with cross-references
2161+
print(f" Pass 2: Generating documentation...")
2162+
generated = 0
2163+
for info in parsed_modules:
2164+
print(f" Processing: {info.name}")
2165+
results = generator.generate_module_doc(info, dry_run=dry_run)
2166+
if results:
2167+
generated += 1
2168+
for r in results:
2169+
if dry_run:
2170+
print(f" Would generate: {r.name}")
2171+
else:
2172+
print(f" Generated: {r.name}")
20062173

20072174
if generator.generated_files and not dry_run:
20082175
# NOTE: Cleanup disabled - was incorrectly deleting valid files

0 commit comments

Comments
 (0)