Skip to content

Commit b339eba

Browse files
committed
fix(cli): preserve lazy graph export across import orders
Address rng1995 and yashrajp22 reviews on #436: restore the lazy proxy as the package-level graph export after the create_graph() factory's submodule import (try/finally) and after mcp_server's top-level submodule import, via the new graph_proxy.restore_package_graph_export() helper. Add module __getattr__ delegation for invoke/ainvoke/stream on skillspector.graph so a direct submodule import still leaves skillspector.graph invokable. Rewrite test_graph_proxy.py to exercise the real factory/MCP/direct-import orders with monkeypatch/try-finally isolation of all shared module state. Signed-off-by: Deepak Jain <[email protected]>
1 parent 5798fd7 commit b339eba

5 files changed

Lines changed: 91 additions & 24 deletions

File tree

‎src/skillspector/__init__.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from importlib.metadata import version as _pkg_version
2020
from typing import Any
2121

22-
from skillspector.graph_proxy import graph
22+
from skillspector.graph_proxy import graph, restore_package_graph_export
2323

2424
__version__ = _pkg_version("skillspector")
2525

@@ -40,7 +40,13 @@ def create_graph() -> Any:
4040
"""Build and return a new SkillSpector workflow graph."""
4141
from skillspector.graph import create_graph as build_graph
4242

43-
return build_graph()
43+
try:
44+
return build_graph()
45+
finally:
46+
# Importing the submodule shadows the lazy proxy on the package;
47+
# restore the documented export even when this factory is the first
48+
# graph access.
49+
restore_package_graph_export()
4450

4551

4652
__all__ = ["create_graph", "graph", "__version__"]

‎src/skillspector/graph.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
from __future__ import annotations
2323

24+
from typing import Any
25+
2426
from langgraph.graph import END, START, StateGraph
2527

2628
from skillspector.inspection_ledger import guard_analyzer_node
@@ -86,3 +88,18 @@ def create_graph():
8688

8789

8890
graph = create_graph()
91+
92+
93+
def __getattr__(name: str) -> Any:
94+
"""Delegate invokable graph API when this module shadows the package export.
95+
96+
Importing ``skillspector.graph`` directly assigns this module to the
97+
parent package's ``graph`` attribute (the import system sets it after
98+
module execution, so it cannot be restored from here). Delegating
99+
``invoke``/``ainvoke``/``stream`` keeps ``skillspector.graph`` invokable
100+
in that import order; prefer ``from skillspector import graph`` for the
101+
lazy proxy instead.
102+
"""
103+
if name in {"invoke", "ainvoke", "stream", "astream", "batch", "abatch"}:
104+
return getattr(graph, name)
105+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

‎src/skillspector/graph_proxy.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,18 @@ def __getattr__(self, name: str) -> Any:
4949
return getattr(self._get_compiled(), name)
5050

5151

52+
def restore_package_graph_export() -> None:
53+
"""Re-assert the lazy proxy as the package-level ``graph`` export.
54+
55+
Importing the ``skillspector.graph`` submodule assigns the module object
56+
to the parent package's ``graph`` attribute, shadowing this proxy. Call
57+
this after any eager submodule import (the ``create_graph()`` factory
58+
wrapper, ``mcp_server``) so ``skillspector.graph`` stays invokable
59+
regardless of import order.
60+
"""
61+
import skillspector as _pkg
62+
63+
_pkg.graph = graph
64+
65+
5266
graph = LazyGraph()

‎src/skillspector/mcp_server.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,20 @@
3535
from skillspector.cleanup import cleanup_result
3636
from skillspector.constants import RISK_THRESHOLD
3737
from skillspector.graph import graph
38+
from skillspector.graph_proxy import restore_package_graph_export
3839
from skillspector.inspection_ledger import LedgerReason
3940
from skillspector.llm_utils import is_llm_available
4041
from skillspector.logging_config import get_logger
4142
from skillspector.nodes.analyzers import ANALYZER_MODULES
4243
from skillspector.semantic_runtime import llm_runtime_available, semantic_runtime_accounting
4344
from skillspector.suppression import effective_findings
4445

46+
# Importing `skillspector.graph` above assigns the submodule to the parent
47+
# package's `graph` attribute, shadowing the documented lazy proxy. Restore
48+
# the proxy so `skillspector.graph` stays invokable when MCP is imported
49+
# before any other graph access.
50+
restore_package_graph_export()
51+
4552
if TYPE_CHECKING:
4653
from mcp.server.fastmcp import FastMCP
4754

‎tests/unit/test_graph_proxy.py‎

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,62 @@
55

66
from __future__ import annotations
77

8+
import importlib
89
import sys
9-
import types
10-
from types import SimpleNamespace
10+
11+
import pytest
1112

1213
import skillspector
1314
from skillspector.graph_proxy import LazyGraph
1415
from skillspector.graph_proxy import graph as lazy_graph
1516

1617

17-
def test_package_graph_export_survives_submodule_load() -> None:
18-
"""Re-import after lazy load still exposes an invokable package export.
18+
@pytest.fixture
19+
def _graph_import_state(monkeypatch: pytest.MonkeyPatch) -> None:
20+
"""Snapshot and restore all shared graph import state.
1921
20-
Closes rng1995 review on #436: importing the graph submodule must not leave
21-
later `skillspector.graph` consumers with a non-invokable module object.
22+
Closes rng1995/yashrajp22 reviews on #436: graph import-order tests must
23+
exercise the real factory/import paths without leaking ``sys.modules``
24+
entries, the package ``graph`` attribute, or ``LazyGraph`` compiled
25+
state into other tests.
2226
"""
23-
compiled = SimpleNamespace(invoke=lambda state: state)
24-
submodule = types.ModuleType("skillspector.graph")
25-
submodule.graph = compiled # type: ignore[attr-defined]
26-
27-
# CLI tests monkeypatch ``skillspector.cli.graph.invoke``; undo restores the
28-
# real bound method on this shared singleton and bypasses ``__getattr__``.
27+
monkeypatch.delitem(sys.modules, "skillspector.graph", raising=False)
28+
monkeypatch.delitem(sys.modules, "skillspector.mcp_server", raising=False)
29+
monkeypatch.setattr(skillspector, "graph", lazy_graph, raising=False)
30+
monkeypatch.setattr(lazy_graph, "_compiled", None, raising=False)
31+
# A leaked instance attribute would shadow __getattr__ delegation;
32+
# drop it so the proxy is pristine (nothing to restore: the pristine
33+
# singleton carries no such attribute).
2934
lazy_graph.__dict__.pop("invoke", None)
30-
lazy_graph._compiled = compiled
31-
skillspector.graph = lazy_graph
3235

33-
# Importing the compiled submodule replaces the package export with the module.
34-
skillspector.graph = submodule
35-
sys.modules["skillspector.graph"] = submodule
3636

37-
# graph_proxy._get_compiled restores the documented lazy export afterward.
38-
skillspector.graph = lazy_graph
39-
sys.modules["skillspector"].graph = lazy_graph
37+
def _assert_graph_export_invokable() -> None:
38+
exported = skillspector.graph
39+
for name in ("invoke", "ainvoke", "stream"):
40+
assert callable(getattr(exported, name)), (
41+
f"skillspector.graph lost {name} after import-order change"
42+
)
43+
44+
45+
def test_create_graph_first_preserves_lazy_export(
46+
_graph_import_state: None,
47+
) -> None:
48+
"""rng1995 #436: create_graph() as first access keeps the lazy export."""
49+
skillspector.create_graph()
50+
assert isinstance(skillspector.graph, LazyGraph)
51+
_assert_graph_export_invokable()
52+
4053

54+
def test_mcp_import_first_preserves_lazy_export(_graph_import_state: None) -> None:
55+
"""yashrajp22 #436: importing MCP first keeps the lazy export."""
56+
importlib.import_module("skillspector.mcp_server")
4157
assert isinstance(skillspector.graph, LazyGraph)
42-
assert lazy_graph.invoke({"ok": True}) == {"ok": True}
43-
assert skillspector.graph.invoke({"again": 1}) == {"again": 1}
58+
_assert_graph_export_invokable()
59+
60+
61+
def test_direct_submodule_import_keeps_graph_invokable(
62+
_graph_import_state: None,
63+
) -> None:
64+
"""yashrajp22 #436: direct submodule import keeps skillspector.graph invokable."""
65+
importlib.import_module("skillspector.graph")
66+
_assert_graph_export_invokable()

0 commit comments

Comments
 (0)