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