Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions graphify/watch.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
# 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


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

Expand Down Expand Up @@ -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"
Expand Down
57 changes: 57 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading