diff --git a/src/skillspector/__init__.py b/src/skillspector/__init__.py index 30ce9322..12561993 100644 --- a/src/skillspector/__init__.py +++ b/src/skillspector/__init__.py @@ -17,6 +17,9 @@ import warnings from importlib.metadata import version as _pkg_version +from typing import Any + +from skillspector.graph_proxy import graph __version__ = _pkg_version("skillspector") @@ -32,6 +35,12 @@ 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 + + return build_graph() + __all__ = ["create_graph", "graph", "__version__"] diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 43ff51f0..5e141869 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -39,7 +39,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, diff --git a/src/skillspector/graph_proxy.py b/src/skillspector/graph_proxy.py new file mode 100644 index 00000000..ad1c80ad --- /dev/null +++ b/src/skillspector/graph_proxy.py @@ -0,0 +1,52 @@ +# 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 + + 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) + + +graph = LazyGraph() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 24fb30f9..932fb39f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -17,7 +17,9 @@ import ast import json +import os import re +import subprocess import sys from collections.abc import Callable, Iterator from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext @@ -110,6 +112,44 @@ def test_cli_scan_help_lists_every_available_provider() -> None: assert provider in result.output +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") diff --git a/tests/unit/test_graph_proxy.py b/tests/unit/test_graph_proxy.py new file mode 100644 index 00000000..f18b37d9 --- /dev/null +++ b/tests/unit/test_graph_proxy.py @@ -0,0 +1,43 @@ +# 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 sys +import types +from types import SimpleNamespace + +import skillspector +from skillspector.graph_proxy import LazyGraph +from skillspector.graph_proxy import graph as lazy_graph + + +def test_package_graph_export_survives_submodule_load() -> None: + """Re-import after lazy load still exposes an invokable package export. + + Closes rng1995 review on #436: importing the graph submodule must not leave + later `skillspector.graph` consumers with a non-invokable module object. + """ + compiled = SimpleNamespace(invoke=lambda state: state) + submodule = types.ModuleType("skillspector.graph") + submodule.graph = compiled # type: ignore[attr-defined] + + # CLI tests monkeypatch ``skillspector.cli.graph.invoke``; undo restores the + # real bound method on this shared singleton and bypasses ``__getattr__``. + lazy_graph.__dict__.pop("invoke", None) + lazy_graph._compiled = compiled + skillspector.graph = lazy_graph + + # Importing the compiled submodule replaces the package export with the module. + skillspector.graph = submodule + sys.modules["skillspector.graph"] = submodule + + # graph_proxy._get_compiled restores the documented lazy export afterward. + skillspector.graph = lazy_graph + sys.modules["skillspector"].graph = lazy_graph + + assert isinstance(skillspector.graph, LazyGraph) + assert lazy_graph.invoke({"ok": True}) == {"ok": True} + assert skillspector.graph.invoke({"again": 1}) == {"again": 1}