chore: restore original directory structure (project under code-review-graph-main/)
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"""Additional export formats: JSON, GraphML, Neo4j Cypher, Obsidian, SVG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .graph import GraphStore, _sanitize_name
|
||||
from .visualization import export_graph_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# JSON export
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def export_json(store: GraphStore, output_path: Path) -> Path:
|
||||
"""Export the complete local graph payload as UTF-8 JSON atomically.
|
||||
|
||||
The payload can contain absolute local paths and code-structure metadata.
|
||||
Callers are responsible for deciding whether it is safe to publish.
|
||||
"""
|
||||
data = export_graph_data(store)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=output_path.parent,
|
||||
prefix=f".{output_path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as handle:
|
||||
temporary = Path(handle.name)
|
||||
json.dump(data, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, output_path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
logger.info("JSON exported to %s", output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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
|
||||
Reference in New Issue
Block a user