Files
tirth8205--code-review-graph/code_review_graph/exports.py
T
2026-07-13 12:42:18 +08:00

410 lines
13 KiB
Python

"""Additional export formats: GraphML, Neo4j Cypher, Obsidian vault, SVG."""
from __future__ import annotations
import html
import logging
import re
from pathlib import Path
from .graph import GraphStore, _sanitize_name
from .visualization import export_graph_data
logger = logging.getLogger(__name__)
# -------------------------------------------------------------------
# GraphML export (for Gephi, yEd, Cytoscape)
# -------------------------------------------------------------------
def export_graphml(store: GraphStore, output_path: Path) -> Path:
"""Export the graph as GraphML XML for Gephi/yEd/Cytoscape.
Returns the path to the written file.
"""
data = export_graph_data(store)
nodes = data["nodes"]
edges = data["edges"]
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<graphml xmlns="http://graphml.graphstruct.org/graphml"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://graphml.graphstruct.org/graphml">',
' <key id="kind" for="node" attr.name="kind" '
'attr.type="string"/>',
' <key id="file" for="node" attr.name="file" '
'attr.type="string"/>',
' <key id="language" for="node" attr.name="language" '
'attr.type="string"/>',
' <key id="community" for="node" attr.name="community" '
'attr.type="int"/>',
' <key id="edge_kind" for="edge" attr.name="kind" '
'attr.type="string"/>',
' <graph id="code-review-graph" edgedefault="directed">',
]
for n in nodes:
nid = html.escape(n["qualified_name"], quote=True)
lines.append(f' <node id="{nid}">')
lines.append(f' <data key="kind">'
f'{html.escape(n.get("kind", ""))}</data>')
lines.append(f' <data key="file">'
f'{html.escape(n.get("file_path", ""))}</data>')
lang = n.get("language", "") or ""
lines.append(f' <data key="language">'
f'{html.escape(lang)}</data>')
cid = n.get("community_id")
if cid is not None:
lines.append(f' <data key="community">'
f'{cid}</data>')
lines.append(' </node>')
for i, e in enumerate(edges):
src = html.escape(e["source"], quote=True)
tgt = html.escape(e["target"], quote=True)
kind = html.escape(e.get("kind", ""), quote=True)
lines.append(
f' <edge id="e{i}" source="{src}" target="{tgt}">'
)
lines.append(f' <data key="edge_kind">{kind}</data>')
lines.append(' </edge>')
lines.append(' </graph>')
lines.append('</graphml>')
output_path.write_text("\n".join(lines), encoding="utf-8")
logger.info("GraphML exported to %s (%d nodes, %d edges)",
output_path, len(nodes), len(edges))
return output_path
# -------------------------------------------------------------------
# Neo4j Cypher export
# -------------------------------------------------------------------
def export_neo4j_cypher(store: GraphStore, output_path: Path) -> Path:
"""Export the graph as Neo4j Cypher CREATE statements.
Returns the path to the written file.
"""
data = export_graph_data(store)
nodes = data["nodes"]
edges = data["edges"]
lines = [
"// Generated by code-review-graph",
"// Import: paste into Neo4j Browser or run via cypher-shell",
"",
]
# Create nodes
for n in nodes:
kind = n.get("kind", "Node")
props = {
"qualified_name": n["qualified_name"],
"name": n.get("name", ""),
"file_path": n.get("file_path", ""),
"language": n.get("language", "") or "",
}
cid = n.get("community_id")
if cid is not None:
props["community_id"] = cid
props_str = _cypher_props(props)
lines.append(f"CREATE (:{kind} {props_str});")
lines.append("")
# Create edges via MATCH
for e in edges:
kind = e.get("kind", "RELATES_TO")
src_qn = _cypher_escape(e["source"])
tgt_qn = _cypher_escape(e["target"])
lines.append(
f"MATCH (a {{qualified_name: '{src_qn}'}}), "
f"(b {{qualified_name: '{tgt_qn}'}}) "
f"CREATE (a)-[:{kind}]->(b);"
)
output_path.write_text("\n".join(lines), encoding="utf-8")
logger.info("Neo4j Cypher exported to %s (%d nodes, %d edges)",
output_path, len(nodes), len(edges))
return output_path
def _cypher_escape(s: str) -> str:
"""Escape a string for Cypher single-quoted literals."""
return s.replace("\\", "\\\\").replace("'", "\\'")
def _cypher_props(d: dict) -> str:
"""Format a dict as Cypher property map."""
parts = []
for k, v in d.items():
if isinstance(v, str):
parts.append(f"{k}: '{_cypher_escape(v)}'")
elif isinstance(v, (int, float)):
parts.append(f"{k}: {v}")
elif isinstance(v, bool):
parts.append(f"{k}: {'true' if v else 'false'}")
return "{" + ", ".join(parts) + "}"
# -------------------------------------------------------------------
# Obsidian vault export
# -------------------------------------------------------------------
def export_obsidian_vault(
store: GraphStore, output_dir: Path
) -> Path:
"""Export the graph as an Obsidian vault with wikilinks.
Creates:
- One .md per node with YAML frontmatter and [[wikilinks]]
- _COMMUNITY_*.md overview notes per community
- _INDEX.md with links to all nodes
Returns the output directory path.
"""
data = export_graph_data(store)
nodes = data["nodes"]
edges = data["edges"]
communities = data.get("communities", [])
output_dir.mkdir(parents=True, exist_ok=True)
# Build adjacency for wikilinks
neighbors: dict[str, list[dict]] = {}
for e in edges:
src = e["source"]
tgt = e["target"]
kind = e.get("kind", "RELATES_TO")
neighbors.setdefault(src, []).append(
{"target": tgt, "kind": kind}
)
neighbors.setdefault(tgt, []).append(
{"target": src, "kind": kind}
)
# Node name -> slug mapping
slugs: dict[str, str] = {}
for n in nodes:
slug = _obsidian_slug(n.get("name", n["qualified_name"]))
# Handle collisions
base_slug = slug
counter = 1
while slug in slugs.values():
slug = f"{base_slug}-{counter}"
counter += 1
slugs[n["qualified_name"]] = slug
# Write node pages
for n in nodes:
qn = n["qualified_name"]
slug = slugs[qn]
name = n.get("name", qn)
frontmatter = {
"kind": n.get("kind", ""),
"file": n.get("file_path", ""),
"language": n.get("language", "") or "",
"community": n.get("community_id"),
"tags": [n.get("kind", "").lower()],
}
lines = ["---"]
for k, v in frontmatter.items():
if isinstance(v, list):
lines.append(f"{k}:")
for item in v:
lines.append(f" - {item}")
elif v is not None:
lines.append(f"{k}: {v}")
lines.append("---")
lines.append(f"# {_sanitize_name(name)}")
lines.append("")
lines.append(f"**Kind:** {n.get('kind', '')}")
lines.append(f"**File:** `{n.get('file_path', '')}`")
lines.append("")
# Wikilinks to neighbors
nbrs = neighbors.get(qn, [])
if nbrs:
lines.append("## Connections")
lines.append("")
seen = set()
for nb in nbrs:
tgt_slug = slugs.get(nb["target"])
if tgt_slug and tgt_slug not in seen:
seen.add(tgt_slug)
tgt_name = tgt_slug.replace("-", " ").title()
lines.append(
f"- {nb['kind']}: "
f"[[{tgt_slug}|{tgt_name}]]"
)
page_path = output_dir / f"{slug}.md"
page_path.write_text("\n".join(lines), encoding="utf-8")
# Write community overview pages
community_map: dict[int, list[str]] = {}
for n in nodes:
cid = n.get("community_id")
if cid is not None:
community_map.setdefault(cid, []).append(
n["qualified_name"]
)
for c in communities:
cid = c.get("id")
cname = c.get("name", f"community-{cid}")
members = community_map.get(cid, [])
lines = [f"# Community: {_sanitize_name(cname)}", ""]
lines.append(f"**Size:** {c.get('size', len(members))}")
lines.append(f"**Cohesion:** {c.get('cohesion', 0):.2f}")
lang = c.get("dominant_language", "")
if lang:
lines.append(f"**Language:** {lang}")
lines.append("")
lines.append("## Members")
lines.append("")
for qn in members[:50]:
slug = slugs.get(qn)
if slug:
lines.append(f"- [[{slug}]]")
page_path = output_dir / f"_COMMUNITY_{cid}.md"
page_path.write_text("\n".join(lines), encoding="utf-8")
# Write index
index_lines = ["# Code Graph Index", ""]
index_lines.append(f"**Nodes:** {len(nodes)}")
index_lines.append(f"**Edges:** {len(edges)}")
index_lines.append(
f"**Communities:** {len(communities)}"
)
index_lines.append("")
index_lines.append("## All Nodes")
index_lines.append("")
for n in sorted(nodes, key=lambda x: x.get("name", "")):
slug = slugs.get(n["qualified_name"])
if slug:
index_lines.append(
f"- [[{slug}]] ({n.get('kind', '')})"
)
(output_dir / "_INDEX.md").write_text(
"\n".join(index_lines), encoding="utf-8"
)
logger.info(
"Obsidian vault exported to %s (%d pages)",
output_dir, len(nodes)
)
return output_dir
def _obsidian_slug(name: str) -> str:
"""Convert a name to an Obsidian-friendly filename slug."""
slug = re.sub(r"[^\w\s-]", "", name.lower())
slug = re.sub(r"[\s_]+", "-", slug).strip("-")
return slug[:100] or "unnamed"
# -------------------------------------------------------------------
# SVG export (matplotlib-based)
# -------------------------------------------------------------------
def export_svg(store: GraphStore, output_path: Path) -> Path:
"""Export a static SVG graph visualization.
Requires matplotlib (optional dependency).
Returns the path to the written file.
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
raise ImportError(
"matplotlib is required for SVG export. "
"Install with: pip install matplotlib"
)
import networkx as nx
data = export_graph_data(store)
nodes_data = data["nodes"]
edges_data = data["edges"]
nxg: nx.DiGraph = nx.DiGraph() # type: ignore[type-arg]
for n in nodes_data:
nxg.add_node(
n["qualified_name"],
label=n.get("name", ""),
kind=n.get("kind", ""),
)
for e in edges_data:
if e["source"] in nxg and e["target"] in nxg:
nxg.add_edge(e["source"], e["target"])
if nxg.number_of_nodes() == 0:
raise ValueError("Graph is empty, nothing to export")
# Color by kind
kind_colors = {
"File": "#6c757d",
"Class": "#0d6efd",
"Function": "#198754",
"Type": "#ffc107",
"Test": "#dc3545",
}
colors = [
kind_colors.get(
nxg.nodes[n].get("kind", ""), "#adb5bd"
)
for n in nxg.nodes()
]
fig, ax = plt.subplots(1, 1, figsize=(16, 12))
pos = nx.spring_layout(
nxg, k=2 / (nxg.number_of_nodes() ** 0.5),
iterations=50, seed=42
)
# Limit labels to avoid clutter
labels = {}
if nxg.number_of_nodes() <= 100:
labels = {
n: nxg.nodes[n].get("label", n.split("::")[-1])
for n in nxg.nodes()
}
nx.draw_networkx_nodes(
nxg, pos, ax=ax, node_color=colors,
node_size=30, alpha=0.8
)
nx.draw_networkx_edges(
nxg, pos, ax=ax, alpha=0.2,
arrows=True, arrowsize=5
)
if labels:
nx.draw_networkx_labels(
nxg, pos, labels=labels, ax=ax,
font_size=6
)
ax.set_title("Code Review Graph", fontsize=14)
ax.axis("off")
fig.savefig(
str(output_path), format="svg",
bbox_inches="tight", dpi=150
)
plt.close(fig)
logger.info("SVG exported to %s (%d nodes)",
output_path, nxg.number_of_nodes())
return output_path