From 35439ab140eeb5b7058f09433d3bebaf4ea7e7f3 Mon Sep 17 00:00:00 2001 From: saxster Date: Sat, 25 Apr 2026 10:28:58 +0530 Subject: [PATCH] feat(watch): GRAPHIFY_NO_VIZ + GRAPHIFY_VIZ_NODE_LIMIT env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #541. The post-commit hook calls _rebuild_code in watch.py. v0.5.0 catches ValueError from to_html (the >5000-node guard fires inside the visualization step itself), but on large graphs the hook still pays the full to_html attempt before the guard rejects. Two new opt-outs let users skip the attempt entirely: - GRAPHIFY_NO_VIZ=1 — unconditional skip; for CI runners and headless dev boxes that never open graph.html. - GRAPHIFY_VIZ_NODE_LIMIT=N — soft cap; skip when node count exceeds N. GRAPHIFY_NO_VIZ takes priority. Both default to off, preserving current behavior. Pre-existing ValueError catch stays as a backstop. 7 new tests cover: defaults off, NO_VIZ truthy/falsy values, NODE_LIMIT threshold (one below, equal, one above), invalid (non-int) NODE_LIMIT silently treated as unset, NO_VIZ priority over NODE_LIMIT. --- graphify/watch.py | 47 +++++++++++++++++++++++++++++++++---- tests/test_watch.py | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/graphify/watch.py b/graphify/watch.py index a09dd51e..13c96c07 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -1,6 +1,7 @@ # monitor a folder and auto-trigger --update when files change from __future__ import annotations import json +import os import sys import time from pathlib import Path @@ -8,6 +9,32 @@ from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS + +def _viz_skip_reason(node_count: int) -> str | None: + """Return a reason string if the HTML viz step should be skipped, else None. + + Two opt-outs, in priority order: + + - ``GRAPHIFY_NO_VIZ=1`` (or any truthy value): skip unconditionally. + For CI runners and headless dev boxes that never open graph.html. + - ``GRAPHIFY_VIZ_NODE_LIMIT=N``: skip if the graph has more than N + nodes. Defaults are off; callers who want a soft cap can set + this in their env. Useful when ``to_html`` is slow on large + graphs and the user knows they don't need the visualization. + """ + no_viz = os.environ.get("GRAPHIFY_NO_VIZ", "").strip().lower() + if no_viz and no_viz not in ("0", "false", "no", ""): + return "GRAPHIFY_NO_VIZ is set" + raw_limit = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT", "").strip() + if raw_limit: + try: + limit = int(raw_limit) + except ValueError: + return None + if node_count > limit: + return f"GRAPHIFY_VIZ_NODE_LIMIT={limit} (graph has {node_count} nodes)" + return None + _WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS _CODE_EXTENSIONS = CODE_EXTENSIONS @@ -106,15 +133,25 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: # to_html raises ValueError for graphs > MAX_NODES_FOR_VIZ (5000). # Wrap so core outputs (graph.json + GRAPH_REPORT.md) always land. + # Two early-exit paths via env vars (see _viz_skip_reason): explicit + # GRAPHIFY_NO_VIZ for CI / headless use, and GRAPHIFY_VIZ_NODE_LIMIT + # for a soft cap that avoids the wasted to_html attempt. html_written = False - try: - to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) - html_written = True - except ValueError as viz_err: - print(f"[graphify watch] Skipped graph.html: {viz_err}") + skip_reason = _viz_skip_reason(G.number_of_nodes()) + if skip_reason is not None: + print(f"[graphify watch] Skipped graph.html: {skip_reason}") stale = out / "graph.html" if stale.exists(): stale.unlink() + else: + try: + to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) + html_written = True + except ValueError as viz_err: + print(f"[graphify watch] Skipped graph.html: {viz_err}") + stale = out / "graph.html" + if stale.exists(): + stale.unlink() # clear stale needs_update flag if present flag = out / "needs_update" diff --git a/tests/test_watch.py b/tests/test_watch.py index ac396aa6..46b15223 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -94,3 +94,60 @@ def mock_import(name, *args, **kwargs): from graphify.watch import watch with pytest.raises(ImportError, match="watchdog not installed"): watch(tmp_path) + + +# --- _viz_skip_reason --- + +def test_viz_skip_reason_default_off(monkeypatch): + """No env vars set: never skip, regardless of node count.""" + from graphify.watch import _viz_skip_reason + monkeypatch.delenv("GRAPHIFY_NO_VIZ", raising=False) + monkeypatch.delenv("GRAPHIFY_VIZ_NODE_LIMIT", raising=False) + assert _viz_skip_reason(10) is None + assert _viz_skip_reason(100_000) is None + + +def test_viz_skip_reason_no_viz_truthy(monkeypatch): + """GRAPHIFY_NO_VIZ=1 short-circuits regardless of node count.""" + from graphify.watch import _viz_skip_reason + monkeypatch.setenv("GRAPHIFY_NO_VIZ", "1") + monkeypatch.delenv("GRAPHIFY_VIZ_NODE_LIMIT", raising=False) + reason = _viz_skip_reason(10) + assert reason is not None and "GRAPHIFY_NO_VIZ" in reason + + +def test_viz_skip_reason_no_viz_falsy(monkeypatch): + """GRAPHIFY_NO_VIZ=0 / false / no / empty: do not skip on this flag.""" + from graphify.watch import _viz_skip_reason + monkeypatch.delenv("GRAPHIFY_VIZ_NODE_LIMIT", raising=False) + for value in ("0", "false", "FALSE", "no", "", " "): + monkeypatch.setenv("GRAPHIFY_NO_VIZ", value) + assert _viz_skip_reason(10) is None, f"value {value!r} should not trigger skip" + + +def test_viz_skip_reason_node_limit_exceeded(monkeypatch): + """GRAPHIFY_VIZ_NODE_LIMIT=5000: skip when node count exceeds limit.""" + from graphify.watch import _viz_skip_reason + monkeypatch.delenv("GRAPHIFY_NO_VIZ", raising=False) + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "5000") + assert _viz_skip_reason(4999) is None + assert _viz_skip_reason(5000) is None + reason = _viz_skip_reason(5001) + assert reason is not None and "5000" in reason and "5001" in reason + + +def test_viz_skip_reason_node_limit_invalid(monkeypatch): + """GRAPHIFY_VIZ_NODE_LIMIT=abc: silently treated as unset rather than crashing.""" + from graphify.watch import _viz_skip_reason + monkeypatch.delenv("GRAPHIFY_NO_VIZ", raising=False) + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "not-a-number") + assert _viz_skip_reason(10) is None + + +def test_viz_skip_reason_no_viz_takes_priority(monkeypatch): + """When both vars are set, GRAPHIFY_NO_VIZ wins.""" + from graphify.watch import _viz_skip_reason + monkeypatch.setenv("GRAPHIFY_NO_VIZ", "1") + monkeypatch.setenv("GRAPHIFY_VIZ_NODE_LIMIT", "100") + reason = _viz_skip_reason(10) # under the limit but no-viz wins + assert reason is not None and "GRAPHIFY_NO_VIZ" in reason