graph_export.py•13.6 kB
"""
Lightweight Graph Export Tool for SCS-MCP
Generates dependency graphs in text-based formats without external dependencies
"""
import json
from pathlib import Path
from typing import Dict, List, Set, Tuple, Optional, Any
from collections import defaultdict
class GraphExporter:
"""Export dependency graphs in various text formats"""
def __init__(self, dependency_analyzer=None, coupling_analyzer=None):
"""
Initialize graph exporter
Args:
dependency_analyzer: DependencyAnalyzer instance
coupling_analyzer: CouplingAnalyzer instance
"""
self.dependency_analyzer = dependency_analyzer
self.coupling_analyzer = coupling_analyzer
def export_to_dot(self,
graph_type: str = "imports",
title: str = "Dependency Graph") -> str:
"""
Export graph to DOT format (Graphviz)
Args:
graph_type: Type of graph ("imports", "calls", "inheritance")
title: Graph title
Returns:
DOT format string
"""
dot_lines = [
f'digraph "{title}" {{',
' rankdir=LR;',
' node [shape=box, style=rounded];',
''
]
# Get the appropriate graph data
if graph_type == "imports" and self.dependency_analyzer:
graph = self.dependency_analyzer.import_graph
dot_lines.append(' // Import dependencies')
for source, targets in graph.items():
source_name = self._clean_node_name(source)
for target in targets:
target_name = self._clean_node_name(target)
dot_lines.append(f' "{source_name}" -> "{target_name}";')
elif graph_type == "calls" and self.dependency_analyzer:
graph = self.dependency_analyzer.call_graph
dot_lines.append(' // Function call graph')
for caller, callees in graph.items():
caller_name = self._clean_node_name(caller)
for callee in callees:
callee_name = self._clean_node_name(callee)
dot_lines.append(f' "{caller_name}" -> "{callee_name}";')
elif graph_type == "inheritance" and self.dependency_analyzer:
graph = self.dependency_analyzer.inheritance_tree
dot_lines.append(' // Class inheritance')
for child, parents in graph.items():
child_name = self._clean_node_name(child)
for parent in parents:
parent_name = self._clean_node_name(parent)
dot_lines.append(f' "{child_name}" -> "{parent_name}" [label="inherits"];')
dot_lines.append('}')
return '\n'.join(dot_lines)
def export_to_mermaid(self,
graph_type: str = "imports",
title: str = "Dependency Graph") -> str:
"""
Export graph to Mermaid diagram format (renders in GitHub/GitLab markdown)
Args:
graph_type: Type of graph ("imports", "calls", "inheritance")
title: Graph title
Returns:
Mermaid format string
"""
mermaid_lines = [
'```mermaid',
'graph TD',
f' %% {title}'
]
# Track nodes to avoid duplicates
nodes_seen = set()
# Get the appropriate graph data
if graph_type == "imports" and self.dependency_analyzer:
graph = self.dependency_analyzer.import_graph
mermaid_lines.append(' %% Import dependencies')
for source, targets in graph.items():
source_id = self._to_mermaid_id(source)
source_label = self._clean_node_name(source)
if source_id not in nodes_seen:
mermaid_lines.append(f' {source_id}["{source_label}"]')
nodes_seen.add(source_id)
for target in targets:
target_id = self._to_mermaid_id(target)
target_label = self._clean_node_name(target)
if target_id not in nodes_seen:
mermaid_lines.append(f' {target_id}["{target_label}"]')
nodes_seen.add(target_id)
mermaid_lines.append(f' {source_id} --> {target_id}')
elif graph_type == "calls" and self.dependency_analyzer:
graph = self.dependency_analyzer.call_graph
mermaid_lines.append(' %% Function call graph')
for caller, callees in graph.items():
caller_id = self._to_mermaid_id(caller)
caller_label = self._clean_node_name(caller)
if caller_id not in nodes_seen:
mermaid_lines.append(f' {caller_id}["{caller_label}"]')
nodes_seen.add(caller_id)
for callee in callees:
callee_id = self._to_mermaid_id(callee)
callee_label = self._clean_node_name(callee)
if callee_id not in nodes_seen:
mermaid_lines.append(f' {callee_id}["{callee_label}"]')
nodes_seen.add(callee_id)
mermaid_lines.append(f' {caller_id} --> {callee_id}')
elif graph_type == "inheritance" and self.dependency_analyzer:
graph = self.dependency_analyzer.inheritance_tree
mermaid_lines.append(' %% Class inheritance')
for child, parents in graph.items():
child_id = self._to_mermaid_id(child)
child_label = self._clean_node_name(child)
if child_id not in nodes_seen:
mermaid_lines.append(f' {child_id}["{child_label}"]')
nodes_seen.add(child_id)
for parent in parents:
parent_id = self._to_mermaid_id(parent)
parent_label = self._clean_node_name(parent)
if parent_id not in nodes_seen:
mermaid_lines.append(f' {parent_id}["{parent_label}"]')
nodes_seen.add(parent_id)
mermaid_lines.append(f' {child_id} -.->|inherits| {parent_id}')
mermaid_lines.append('```')
return '\n'.join(mermaid_lines)
def export_to_json(self,
graph_type: str = "imports",
include_metadata: bool = True) -> str:
"""
Export graph to JSON format for custom visualization
Args:
graph_type: Type of graph ("imports", "calls", "inheritance")
include_metadata: Include additional metadata
Returns:
JSON string
"""
graph_data = {
"type": graph_type,
"nodes": [],
"edges": [],
"metadata": {}
}
nodes_map = {} # Track node IDs
node_id = 0
# Get the appropriate graph data
graph = None
if graph_type == "imports" and self.dependency_analyzer:
graph = self.dependency_analyzer.import_graph
elif graph_type == "calls" and self.dependency_analyzer:
graph = self.dependency_analyzer.call_graph
elif graph_type == "inheritance" and self.dependency_analyzer:
graph = self.dependency_analyzer.inheritance_tree
if graph:
for source, targets in graph.items():
# Add source node
if source not in nodes_map:
nodes_map[source] = node_id
graph_data["nodes"].append({
"id": node_id,
"label": self._clean_node_name(source),
"type": self._get_node_type(source)
})
node_id += 1
# Add target nodes and edges
for target in targets:
if target not in nodes_map:
nodes_map[target] = node_id
graph_data["nodes"].append({
"id": node_id,
"label": self._clean_node_name(target),
"type": self._get_node_type(target)
})
node_id += 1
graph_data["edges"].append({
"source": nodes_map[source],
"target": nodes_map[target],
"type": graph_type
})
if include_metadata:
graph_data["metadata"] = {
"total_nodes": len(graph_data["nodes"]),
"total_edges": len(graph_data["edges"]),
"graph_type": graph_type
}
# Calculate basic metrics
if graph_data["edges"]:
in_degree = defaultdict(int)
out_degree = defaultdict(int)
for edge in graph_data["edges"]:
out_degree[edge["source"]] += 1
in_degree[edge["target"]] += 1
graph_data["metadata"]["max_out_degree"] = max(out_degree.values()) if out_degree else 0
graph_data["metadata"]["max_in_degree"] = max(in_degree.values()) if in_degree else 0
return json.dumps(graph_data, indent=2)
def detect_circular_dependencies(self, graph_type: str = "imports") -> List[List[str]]:
"""
Detect circular dependencies in the graph
Args:
graph_type: Type of graph to analyze
Returns:
List of circular dependency paths
"""
graph = None
if graph_type == "imports" and self.dependency_analyzer:
graph = self.dependency_analyzer.import_graph
elif graph_type == "calls" and self.dependency_analyzer:
graph = self.dependency_analyzer.call_graph
if not graph:
return []
cycles = []
visited = set()
rec_stack = set()
def _dfs(node, path):
visited.add(node)
rec_stack.add(node)
path.append(node)
if node in graph:
for neighbor in graph[node]:
if neighbor not in visited:
_dfs(neighbor, path[:])
elif neighbor in rec_stack:
# Found a cycle
cycle_start = path.index(neighbor)
cycle = path[cycle_start:] + [neighbor]
cycles.append(cycle)
rec_stack.remove(node)
for node in graph:
if node not in visited:
_dfs(node, [])
return cycles
def _clean_node_name(self, name: str) -> str:
"""Clean node name for display"""
# Remove long paths, keep just filename or last component
if '/' in name or '\\' in name:
return Path(name).name
return name
def _to_mermaid_id(self, name: str) -> str:
"""Convert name to valid Mermaid node ID"""
# Replace special characters with underscores
import re
return 'node_' + re.sub(r'[^a-zA-Z0-9_]', '_', name)
def _get_node_type(self, name: str) -> str:
"""Determine node type from name"""
if name.endswith('.py'):
return 'file'
elif '.' in name:
return 'module'
elif name[0].isupper():
return 'class'
else:
return 'function'
def generate_dependency_graph(analyzer_data: Dict[str, Any],
output_format: str = "mermaid",
graph_type: str = "imports") -> str:
"""
MCP tool function to generate dependency graphs
Args:
analyzer_data: Data from dependency or coupling analyzer
output_format: Output format ("dot", "mermaid", "json")
graph_type: Type of graph ("imports", "calls", "inheritance")
Returns:
Graph in requested format
"""
# Create mock analyzer with the provided data
from src.core.dependency_analyzer import DependencyAnalyzer
analyzer = DependencyAnalyzer(Path.cwd())
# Populate analyzer with provided data
if "import_graph" in analyzer_data:
analyzer.import_graph = analyzer_data["import_graph"]
if "call_graph" in analyzer_data:
analyzer.call_graph = analyzer_data["call_graph"]
if "inheritance_tree" in analyzer_data:
analyzer.inheritance_tree = analyzer_data["inheritance_tree"]
exporter = GraphExporter(dependency_analyzer=analyzer)
if output_format == "dot":
return exporter.export_to_dot(graph_type)
elif output_format == "mermaid":
return exporter.export_to_mermaid(graph_type)
elif output_format == "json":
return exporter.export_to_json(graph_type)
else:
raise ValueError(f"Unsupported format: {output_format}")