Wire --no-viz flag in cluster-only + GRAPHIFY_VIZ_NODE_LIMIT env var (closes #541) (#565)

* 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>
This commit is contained in:
market4drill
2026-05-02 14:15:15 +01:00
committed by GitHub
co-authored by Claude Opus 4.7 Tayfun Sert,MBA
parent 3145ca2e52
commit 6c9b5ef63c
3 changed files with 111 additions and 7 deletions
+21 -2
View File
@@ -1002,6 +1002,7 @@ def main() -> None:
print(" watch <path> watch a folder and rebuild the graph on code changes")
print(" update <path> re-extract code files and update the graph (no LLM needed)")
print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --budget N cap output at N tokens (default 2000)")
@@ -1379,6 +1380,7 @@ def main() -> None:
elif cmd == "cluster-only":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
no_viz = "--no-viz" in sys.argv
graph_json = watch_path / "graphify-out" / "graph.json"
if not graph_json.exists():
print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr)
@@ -1407,8 +1409,25 @@ def main() -> None:
out = watch_path / "graphify-out"
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
to_json(G, communities, str(out / "graph.json"))
to_html(G, communities, str(out / "graph.html"), community_labels=labels or None)
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
# Mirror watch.py pattern: gate to_html so core outputs (graph.json +
# GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
# fall back to ValueError handling so an oversized graph doesn't crash
# the CLI mid-write and leave a stale graph.html on disk.
html_target = out / "graph.html"
if no_viz:
if html_target.exists():
html_target.unlink()
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
else:
try:
to_html(G, communities, str(html_target), community_labels=labels or None)
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
except ValueError as viz_err:
if html_target.exists():
html_target.unlink()
print(f"Skipped graph.html: {viz_err}")
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
elif cmd == "update":
if len(sys.argv) > 2:
+23 -4
View File
@@ -25,6 +25,22 @@ COMMUNITY_COLORS = [
MAX_NODES_FOR_VIZ = 5_000
def _viz_node_limit() -> int:
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
Set to 0 to disable HTML viz unconditionally (useful for CI runners).
"""
import os
raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
if raw is None or not raw.strip():
return MAX_NODES_FOR_VIZ
try:
return int(raw)
except ValueError:
return MAX_NODES_FOR_VIZ
def _html_styles() -> str:
return """<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -395,15 +411,18 @@ def to_html(
Features: node size by degree, click-to-inspect panel, search box,
community filter, physics clustering by community, confidence-styled edges.
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
Raises ValueError if graph exceeds the effective viz node limit
(MAX_NODES_FOR_VIZ by default; overridable via GRAPHIFY_VIZ_NODE_LIMIT env var).
If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.
"""
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
limit = _viz_node_limit()
if G.number_of_nodes() > limit:
raise ValueError(
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz. "
f"Use --no-viz or reduce input size."
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
f"or reduce input size."
)
node_community = _node_community_map(communities)
+67 -1
View File
@@ -1,9 +1,19 @@
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
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"
@@ -127,6 +137,62 @@ def test_to_html_contains_nodes_and_edges():
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()