mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-14 11:27:10 +00:00
6c9b5ef63c
* feat(export): GRAPHIFY_VIZ_NODE_LIMIT env var to override MAX_NODES_FOR_VIZ Adds opt-in env var so users with large graphs (>5000 nodes) can either raise the HTML viz limit or disable viz entirely (set to 0) without patching the package. - New _viz_node_limit() helper reads GRAPHIFY_VIZ_NODE_LIMIT, falls back to MAX_NODES_FOR_VIZ on unset/empty/non-integer values. - to_html() now uses the helper; ValueError message references both --no-viz and GRAPHIFY_VIZ_NODE_LIMIT for discoverability. - 7 new tests in test_export.py covering default, raise/lower, zero, invalid, empty, and end-to-end to_html behavior. Addresses option 2 in #541 (env var as default behavior, zero-config for most users, tunable for large repos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> EOF ) * feat(cluster-only): wire --no-viz flag and tolerate ValueError mid-write Before this change, `graphify cluster-only <path>` called to_html() unconditionally, so on graphs >5000 nodes the ValueError fired AFTER graph.json was written but BEFORE the "Done — ..." print, leaving a stale graph.html on disk and no clear signal in the CLI output. This mirrors the watch.py rebuild pattern (already merged): try/except around to_html, delete the stale graph.html when skipping, and emit a "Done" line that accurately reflects which artifacts landed. - Parse "--no-viz" from sys.argv (documented as such in skill.md but previously never wired into the Python CLI). - Wrap to_html() in try/except so core outputs (graph.json + GRAPH_REPORT.md) always land. - Remove stale graph.html when viz is skipped or fails — avoids the silent desync where graph.html lags behind graph.json by days. - Update help text under `cluster-only` to advertise --no-viz. Addresses option 1 in #541 (--no-viz flag, simplest opt-in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Tayfun Sert,MBA <tayfunsert@hotmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
import json
|
|
import os
|
|
import tempfile
|
|
import pytest
|
|
from pathlib import Path
|
|
from graphify.build import build_from_json
|
|
from graphify.cluster import cluster
|
|
from graphify.export import (
|
|
to_json,
|
|
to_cypher,
|
|
to_graphml,
|
|
to_html,
|
|
to_canvas,
|
|
_viz_node_limit,
|
|
MAX_NODES_FOR_VIZ,
|
|
)
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
def make_graph():
|
|
return build_from_json(json.loads((FIXTURES / "extraction.json").read_text()))
|
|
|
|
def test_to_json_creates_file():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.json"
|
|
to_json(G, communities, str(out))
|
|
assert out.exists()
|
|
|
|
def test_to_json_valid_json():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.json"
|
|
to_json(G, communities, str(out))
|
|
data = json.loads(out.read_text())
|
|
assert "nodes" in data
|
|
assert "links" in data
|
|
|
|
def test_to_json_nodes_have_community():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.json"
|
|
to_json(G, communities, str(out))
|
|
data = json.loads(out.read_text())
|
|
for node in data["nodes"]:
|
|
assert "community" in node
|
|
|
|
def test_to_cypher_creates_file():
|
|
G = make_graph()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "cypher.txt"
|
|
to_cypher(G, str(out))
|
|
assert out.exists()
|
|
|
|
def test_to_cypher_contains_merge_statements():
|
|
G = make_graph()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "cypher.txt"
|
|
to_cypher(G, str(out))
|
|
content = out.read_text()
|
|
assert "MERGE" in content
|
|
|
|
def test_to_graphml_creates_file():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.graphml"
|
|
to_graphml(G, communities, str(out))
|
|
assert out.exists()
|
|
|
|
def test_to_graphml_valid_xml():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.graphml"
|
|
to_graphml(G, communities, str(out))
|
|
content = out.read_text()
|
|
assert "<graphml" in content
|
|
assert "<node" in content
|
|
|
|
def test_to_graphml_has_community_attribute():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.graphml"
|
|
to_graphml(G, communities, str(out))
|
|
content = out.read_text()
|
|
assert "community" in content
|
|
|
|
def test_to_html_creates_file():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out))
|
|
assert out.exists()
|
|
|
|
def test_to_html_contains_visjs():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out))
|
|
content = out.read_text()
|
|
assert "vis-network" in content
|
|
|
|
def test_to_html_contains_search():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out))
|
|
content = out.read_text()
|
|
assert "search" in content.lower()
|
|
|
|
def test_to_html_contains_legend_with_labels():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
labels = {cid: f"Group {cid}" for cid in communities}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out), community_labels=labels)
|
|
content = out.read_text()
|
|
assert "Group 0" in content
|
|
|
|
def test_to_html_contains_nodes_and_edges():
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out))
|
|
content = out.read_text()
|
|
assert "RAW_NODES" in content
|
|
assert "RAW_EDGES" in content
|
|
|
|
|
|
# --- GRAPHIFY_VIZ_NODE_LIMIT env var --------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def restore_viz_env(monkeypatch):
|
|
"""Ensure each test runs without GRAPHIFY_VIZ_NODE_LIMIT bleeding across cases."""
|
|
monkeypatch.delenv("GRAPHIFY_VIZ_NODE_LIMIT", raising=False)
|
|
yield
|
|
|
|
|
|
def test_viz_node_limit_default(restore_viz_env):
|
|
assert _viz_node_limit() == MAX_NODES_FOR_VIZ
|
|
|
|
|
|
def test_viz_node_limit_env_override_higher(restore_viz_env, monkeypatch):
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "20000")
|
|
assert _viz_node_limit() == 20000
|
|
|
|
|
|
def test_viz_node_limit_env_override_zero_disables(restore_viz_env, monkeypatch):
|
|
"""Setting to 0 lets users disable HTML viz unconditionally (CI runners)."""
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "0")
|
|
assert _viz_node_limit() == 0
|
|
|
|
|
|
def test_viz_node_limit_invalid_falls_back_to_default(restore_viz_env, monkeypatch):
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "not-an-int")
|
|
assert _viz_node_limit() == MAX_NODES_FOR_VIZ
|
|
|
|
|
|
def test_viz_node_limit_empty_falls_back_to_default(restore_viz_env, monkeypatch):
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", " ")
|
|
assert _viz_node_limit() == MAX_NODES_FOR_VIZ
|
|
|
|
|
|
def test_to_html_raises_with_lowered_limit(restore_viz_env, monkeypatch):
|
|
"""Lowering the limit below the test graph's size triggers ValueError."""
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "1")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
with pytest.raises(ValueError, match="too large for HTML viz"):
|
|
to_html(G, communities, str(out))
|
|
|
|
|
|
def test_to_html_writes_with_raised_limit(restore_viz_env, monkeypatch):
|
|
"""Raising the limit above the graph's size lets to_html proceed normally."""
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "100000")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out))
|
|
assert out.exists()
|
|
|
|
|
|
def test_to_html_member_counts_accepted():
|
|
"""to_html accepts member_counts without raising."""
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
member_counts = {cid: len(members) for cid, members in communities.items()}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.html"
|
|
to_html(G, communities, str(out), member_counts=member_counts)
|
|
assert out.exists()
|
|
|
|
|
|
def test_to_canvas_file_paths_relative_to_vault():
|
|
"""Node file paths in canvas must be vault-root-relative (just fname.md), not hardcoded."""
|
|
G = make_graph()
|
|
communities = cluster(G)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "graph.canvas"
|
|
to_canvas(G, communities, str(out))
|
|
data = json.loads(out.read_text())
|
|
file_nodes = [n for n in data["nodes"] if n.get("type") == "file"]
|
|
assert file_nodes, "canvas should contain file nodes"
|
|
for node in file_nodes:
|
|
assert "/" not in node["file"], f"file path should not contain '/': {node['file']}"
|
|
assert node["file"].endswith(".md")
|