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
17 changes: 16 additions & 1 deletion src/skillspector/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

import warnings
from importlib.metadata import version as _pkg_version
from typing import Any

from skillspector.graph_proxy import graph, restore_package_graph_export

__version__ = _pkg_version("skillspector")

Expand All @@ -32,6 +35,18 @@
category=Warning,
)

from skillspector.graph import create_graph, graph # noqa: E402 (after filter setup)

def create_graph() -> Any:
"""Build and return a new SkillSpector workflow graph."""
from skillspector.graph import create_graph as build_graph
Comment thread
deepujain marked this conversation as resolved.

try:
return build_graph()
finally:
# Importing the submodule shadows the lazy proxy on the package;
# restore the documented export even when this factory is the first
# graph access.
restore_package_graph_export()


__all__ = ["create_graph", "graph", "__version__"]
2 changes: 1 addition & 1 deletion src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from skillspector import __version__, transitive
from skillspector.cleanup import cleanup_result
from skillspector.constants import RISK_THRESHOLD
from skillspector.graph import graph
from skillspector.graph_proxy import graph
from skillspector.input_handler import validate_local_input_path
from skillspector.inspection_ledger import (
MAX_INSPECTION_LEDGER_EVENTS,
Expand Down
17 changes: 17 additions & 0 deletions src/skillspector/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from __future__ import annotations

from typing import Any

from langgraph.graph import END, START, StateGraph

from skillspector.inspection_ledger import guard_analyzer_node
Expand Down Expand Up @@ -86,3 +88,18 @@ def create_graph():


graph = create_graph()


def __getattr__(name: str) -> Any:
"""Delegate invokable graph API when this module shadows the package export.

Importing ``skillspector.graph`` directly assigns this module to the
parent package's ``graph`` attribute (the import system sets it after
module execution, so it cannot be restored from here). Delegating
``invoke``/``ainvoke``/``stream`` keeps ``skillspector.graph`` invokable
in that import order; prefer ``from skillspector import graph`` for the
lazy proxy instead.
"""
if name in {"invoke", "ainvoke", "stream", "astream", "batch", "abatch"}:
return getattr(graph, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
66 changes: 66 additions & 0 deletions src/skillspector/graph_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Lightweight lazy access to the compiled SkillSpector workflow graph."""

from __future__ import annotations

import sys
from threading import Lock
from typing import Any


class LazyGraph:
"""Load the compiled workflow only when a caller first uses it."""

def __init__(self) -> None:
self._compiled: Any | None = None
self._lock = Lock()

def _get_compiled(self) -> Any:
if self._compiled is None:
with self._lock:
if self._compiled is None:
from skillspector.graph import graph as compiled_graph
Comment thread
deepujain marked this conversation as resolved.

self._compiled = compiled_graph
# Importing the submodule assigns it to the parent package.
# Restore the documented package-level lazy export before
# another caller imports it.
import skillspector as _skillspector_pkg

_skillspector_pkg.graph = graph
sys.modules["skillspector"].graph = graph
return self._compiled

def __getattr__(self, name: str) -> Any:
return getattr(self._get_compiled(), name)


def restore_package_graph_export() -> None:
"""Re-assert the lazy proxy as the package-level ``graph`` export.

Importing the ``skillspector.graph`` submodule assigns the module object
to the parent package's ``graph`` attribute, shadowing this proxy. Call
this after any eager submodule import (the ``create_graph()`` factory
wrapper, ``mcp_server``) so ``skillspector.graph`` stays invokable
regardless of import order.
"""
import skillspector as _pkg

_pkg.graph = graph


graph = LazyGraph()
7 changes: 7 additions & 0 deletions src/skillspector/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,20 @@
from skillspector.cleanup import cleanup_result
from skillspector.constants import RISK_THRESHOLD
from skillspector.graph import graph
from skillspector.graph_proxy import restore_package_graph_export
from skillspector.inspection_ledger import LedgerReason
from skillspector.llm_utils import is_llm_available
from skillspector.logging_config import get_logger
from skillspector.nodes.analyzers import ANALYZER_MODULES
from skillspector.semantic_runtime import llm_runtime_available, semantic_runtime_accounting
from skillspector.suppression import effective_findings

# Importing `skillspector.graph` above assigns the submodule to the parent
# package's `graph` attribute, shadowing the documented lazy proxy. Restore
# the proxy so `skillspector.graph` stays invokable when MCP is imported
# before any other graph access.
restore_package_graph_export()

if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP

Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@

import ast
import json
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext
Expand Down Expand Up @@ -237,6 +239,44 @@ def test_scan_state_records_explicit_llm_request_intent(no_llm: bool, expected:
assert state["llm_requested"] is expected


def test_cli_help_does_not_initialize_analyzers() -> None:
"""Help should not compile the scan graph or warn about missing credentials."""
env = os.environ.copy()
env["SKILLSPECTOR_PROVIDER"] = "nv_build"
for name in ("ANTHROPIC_API_KEY", "NVIDIA_INFERENCE_KEY", "OPENAI_API_KEY"):
env.pop(name, None)

completed = subprocess.run(
[
sys.executable,
"-c",
"from skillspector.cli import app; app()",
"--help",
],
capture_output=True,
check=False,
env=env,
text=True,
timeout=15,
)

assert completed.returncode == 0
assert "Usage:" in completed.stdout
assert "Skipping analyzer" not in completed.stderr


def test_package_graph_export_stays_lazy_after_first_load() -> None:
"""The package export must not be replaced by the graph submodule."""
from skillspector import graph as first

assert first._get_compiled() is not None

from skillspector import graph as later

assert later is first
assert callable(later.invoke)


def test_cli_scan_local_directory(tmp_path: Path) -> None:
"""scan with local directory runs graph and prints report."""
(tmp_path / "SKILL.md").write_text("---\nname: scan-test\n---\n# Safe", encoding="utf-8")
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/test_graph_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Tests for the lazy package-level graph export."""

from __future__ import annotations

import importlib
import sys
from collections.abc import Generator

import pytest

import skillspector
from skillspector.graph_proxy import LazyGraph
from skillspector.graph_proxy import graph as lazy_graph


@pytest.fixture
def _graph_import_state(
monkeypatch: pytest.MonkeyPatch,
) -> Generator[None, None, None]:
"""Snapshot and restore all shared graph import state.

Closes rng1995/yashrajp22 reviews on #436: graph import-order tests must
exercise the real factory/import paths without leaking ``sys.modules``
entries, the package ``graph`` attribute, or ``LazyGraph`` compiled
state into other tests.
"""
monkeypatch.delitem(sys.modules, "skillspector.graph", raising=False)
monkeypatch.delitem(sys.modules, "skillspector.mcp_server", raising=False)
monkeypatch.setattr(skillspector, "graph", lazy_graph, raising=False)
monkeypatch.setattr(lazy_graph, "_compiled", None, raising=False)
# A leaked instance attribute would shadow __getattr__ delegation;
# drop it so the proxy is pristine, restoring the exact prior value
# (or its absence) after the test. monkeypatch.delattr cannot cover
# this: LazyGraph.__getattr__ delegation makes hasattr() true even
# when the instance dict holds no such attribute, so the instance
# dict is snapshotted and restored explicitly.
had_invoke = "invoke" in lazy_graph.__dict__
prior_invoke = lazy_graph.__dict__.pop("invoke", None)
try:
yield
finally:
if had_invoke:
lazy_graph.__dict__["invoke"] = prior_invoke


def _assert_graph_export_invokable() -> None:
exported = skillspector.graph
for name in ("invoke", "ainvoke", "stream"):
assert callable(getattr(exported, name)), (
f"skillspector.graph lost {name} after import-order change"
)


def test_create_graph_first_preserves_lazy_export(
_graph_import_state: None,
) -> None:
"""rng1995 #436: create_graph() as first access keeps the lazy export."""
skillspector.create_graph()
assert isinstance(skillspector.graph, LazyGraph)
_assert_graph_export_invokable()


def test_mcp_import_first_preserves_lazy_export(_graph_import_state: None) -> None:
"""yashrajp22 #436: importing MCP first keeps the lazy export."""
importlib.import_module("skillspector.mcp_server")
assert isinstance(skillspector.graph, LazyGraph)
_assert_graph_export_invokable()


def test_direct_submodule_import_keeps_graph_invokable(
_graph_import_state: None,
) -> None:
"""yashrajp22 #436: direct submodule import keeps skillspector.graph invokable."""
importlib.import_module("skillspector.graph")
_assert_graph_export_invokable()


def test_graph_import_state_restores_preexisting_invoke_attribute(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""rng1995 #436: isolation must not delete a pre-existing ``invoke`` attribute."""
sentinel = object()
monkeypatch.setitem(lazy_graph.__dict__, "invoke", sentinel)
fixture_gen = _graph_import_state.__wrapped__(monkeypatch)
next(fixture_gen)
try:
assert "invoke" not in lazy_graph.__dict__
finally:
with pytest.raises(StopIteration):
next(fixture_gen)
assert lazy_graph.__dict__["invoke"] is sentinel
12 changes: 3 additions & 9 deletions tests/unit/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"""Tests for the MCP server wrapper (run_scan core + scan_skill tool)."""

import asyncio
import importlib
import json
import os
import sys
Expand Down Expand Up @@ -306,14 +305,9 @@ async def test_late_provider_binding_cannot_claim_a_complete_semantic_scan(
) -> None:
"""A graph built without credentials keeps semantic nodes for a later provider."""
_write_skill(tmp_path)
graph_module = importlib.import_module("skillspector.graph")
monkeypatch.setattr(
graph_module,
"is_llm_available",
lambda: (False, "not configured"),
raising=False,
)
late_bound_graph = graph_module.create_graph()
# The public graph is now credential-independent: semantic analyzers are
# always wired and report their disabled state at execution time.
late_bound_graph = workflow_graph

def transport_failure(*_args: object, **_kwargs: object) -> object:
raise RuntimeError("simulated late-bound provider failure")
Expand Down
Loading