KI-296 fix: when the PyPI-installed runtime (molecule-ai-workspace-runtime 0.1.129+) ships plugins_registry as molecule_runtime.plugins_registry (a subpackage), plugin adapter files that do ``from plugins_registry import ...`` as a top-level name fail with ModuleNotFoundError because Python's import system cannot find a top-level ``plugins_registry`` package. The fix in plugins_registry/__init__.py:_load_module_from_path() registers molecule_runtime.plugins_registry as ``plugins_registry`` in sys.modules before exec'ing any plugin adapter file, so the top-level import resolves correctly in both environments: - PyPI wheel (molecule_runtime.plugins_registry → sys.modules["plugins_registry"]) - molecule-core workspace source (top-level workspace/plugins_registry already on sys.path; the setdefault is a no-op) Submodules (builtins, protocol, raw_drop) are also registered so adapters that import ``from plugins_registry.builtins import ...`` work without error. Added test_load_module_from_path_registers_plugins_registry_sys_modules. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
369 lines
15 KiB
Python
369 lines
15 KiB
Python
"""Tests for the per-runtime plugin adaptor resolver.
|
|
|
|
Covers:
|
|
- Resolution order (registry > plugin-shipped > raw-drop)
|
|
- Both adaptor-module conventions (Adaptor class + get_adaptor factory)
|
|
- RawDropAdaptor copies plugin files and surfaces a warning
|
|
- resolve() never raises — always returns a usable adaptor
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Resolve workspace/ so `import plugins_registry` works in CI without
|
|
# requiring an installed package.
|
|
_WS_TEMPLATE = Path(__file__).resolve().parents[1]
|
|
if str(_WS_TEMPLATE) not in sys.path:
|
|
sys.path.insert(0, str(_WS_TEMPLATE))
|
|
|
|
from plugins_registry import ( # noqa: E402
|
|
AdaptorSource,
|
|
InstallContext,
|
|
PluginAdaptor,
|
|
RawDropAdaptor,
|
|
resolve,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def configs_dir(tmp_path: Path) -> Path:
|
|
d = tmp_path / "configs"
|
|
d.mkdir()
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin_root(tmp_path: Path) -> Path:
|
|
p = tmp_path / "demo-plugin"
|
|
(p / "rules").mkdir(parents=True)
|
|
(p / "rules" / "rules.md").write_text("- be excellent\n")
|
|
(p / "plugin.yaml").write_text("name: demo-plugin\nruntimes: [test_runtime]\n")
|
|
return p
|
|
|
|
|
|
def _ctx(configs_dir: Path, plugin_root: Path, runtime: str = "test_runtime") -> InstallContext:
|
|
return InstallContext(
|
|
configs_dir=configs_dir,
|
|
workspace_id="ws-test",
|
|
runtime=runtime,
|
|
plugin_root=plugin_root,
|
|
logger=logging.getLogger("test"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RawDropAdaptor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def test_raw_drop_copies_plugin_and_warns(configs_dir: Path, plugin_root: Path):
|
|
adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
|
|
result = await adaptor.install(_ctx(configs_dir, plugin_root))
|
|
|
|
dst = configs_dir / "plugins" / "demo-plugin"
|
|
assert dst.exists()
|
|
assert (dst / "rules" / "rules.md").read_text() == "- be excellent\n"
|
|
assert result.source == "raw_drop"
|
|
assert any("no adaptor" in w for w in result.warnings)
|
|
assert result.tools_registered == []
|
|
|
|
|
|
async def test_raw_drop_is_idempotent(configs_dir: Path, plugin_root: Path):
|
|
adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
|
|
await adaptor.install(_ctx(configs_dir, plugin_root))
|
|
# Second install must not raise (shutil.copytree would otherwise complain)
|
|
result = await adaptor.install(_ctx(configs_dir, plugin_root))
|
|
assert result.source == "raw_drop"
|
|
|
|
|
|
async def test_raw_drop_uninstall_removes_files(configs_dir: Path, plugin_root: Path):
|
|
adaptor = RawDropAdaptor("demo-plugin", "test_runtime")
|
|
ctx = _ctx(configs_dir, plugin_root)
|
|
await adaptor.install(ctx)
|
|
await adaptor.uninstall(ctx)
|
|
assert not (configs_dir / "plugins" / "demo-plugin").exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve() — order: registry > plugin-shipped > raw_drop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_resolve_falls_back_to_raw_drop_when_no_adaptor(plugin_root: Path):
|
|
adaptor, source = resolve("nonexistent-plugin", "claude_code", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
assert isinstance(adaptor, RawDropAdaptor)
|
|
|
|
|
|
def test_resolve_prefers_plugin_shipped_over_raw_drop(plugin_root: Path):
|
|
"""Plugin ships its own adaptor → must beat raw-drop."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
|
|
class Adaptor:
|
|
def __init__(self, plugin_name, runtime):
|
|
self.plugin_name = plugin_name
|
|
self.runtime = runtime
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
|
|
async def uninstall(self, ctx):
|
|
pass
|
|
"""))
|
|
|
|
adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.PLUGIN
|
|
assert not isinstance(adaptor, RawDropAdaptor)
|
|
|
|
|
|
def test_resolve_supports_get_adaptor_factory(plugin_root: Path):
|
|
"""Adaptor module exposing get_adaptor() instead of Adaptor class."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
|
|
class _Impl:
|
|
def __init__(self, plugin_name, runtime):
|
|
self.plugin_name = plugin_name
|
|
self.runtime = runtime
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
|
|
async def uninstall(self, ctx):
|
|
pass
|
|
|
|
def get_adaptor(plugin_name, runtime):
|
|
return _Impl(plugin_name, runtime)
|
|
"""))
|
|
|
|
adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.PLUGIN
|
|
|
|
|
|
async def test_resolve_get_adaptor_factory_install(plugin_root: Path, tmp_path: Path):
|
|
"""Installing an adaptor returned by get_adaptor() works end-to-end."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
class _Impl:
|
|
def __init__(self, plugin_name, runtime):
|
|
self.plugin_name = plugin_name
|
|
self.runtime = runtime
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
|
|
async def uninstall(self, ctx): pass
|
|
def get_adaptor(plugin_name, runtime):
|
|
return _Impl(plugin_name, runtime)
|
|
"""))
|
|
adaptor, _ = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
result = await adaptor.install(_ctx(tmp_path, plugin_root))
|
|
assert result.source == "plugin"
|
|
|
|
|
|
async def test_resolve_registry_beats_plugin_shipped(plugin_root: Path, monkeypatch, tmp_path: Path):
|
|
"""Platform registry must override plugin-shipped adaptor (promote-to-default path)."""
|
|
# Plant a plugin-shipped adaptor first.
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
class Adaptor:
|
|
def __init__(self, plugin_name, runtime):
|
|
self.plugin_name = plugin_name
|
|
self.runtime = runtime
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
|
|
async def uninstall(self, ctx): pass
|
|
"""))
|
|
|
|
# Now plant a registry override by monkeypatching _REGISTRY_ROOT to a temp dir.
|
|
fake_registry = tmp_path / "fake_registry"
|
|
(fake_registry / "demo-plugin").mkdir(parents=True)
|
|
(fake_registry / "demo-plugin" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
class Adaptor:
|
|
def __init__(self, plugin_name, runtime):
|
|
self.plugin_name = plugin_name
|
|
self.runtime = runtime
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="registry")
|
|
async def uninstall(self, ctx): pass
|
|
"""))
|
|
|
|
import plugins_registry as pr
|
|
monkeypatch.setattr(pr, "_REGISTRY_ROOT", fake_registry)
|
|
|
|
adaptor, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.REGISTRY
|
|
result = await adaptor.install(_ctx(tmp_path, plugin_root))
|
|
assert result.source == "registry"
|
|
|
|
|
|
def test_resolve_handles_broken_adaptor_module(plugin_root: Path):
|
|
"""Broken adaptor file falls back gracefully — never crashes the install."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text("syntax error this is not python")
|
|
|
|
adaptor, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
# Falls through to raw-drop because the broken module fails to import.
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_protocol_runtime_check():
|
|
"""RawDropAdaptor must satisfy the Protocol at runtime."""
|
|
assert isinstance(RawDropAdaptor("p", "r"), PluginAdaptor)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases on adaptor loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_resolve_module_with_neither_adaptor_nor_factory(plugin_root: Path):
|
|
"""Adaptor file that defines neither ``Adaptor`` nor ``get_adaptor()``
|
|
falls back to raw-drop (can't instantiate anything)."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(
|
|
"# no Adaptor, no get_adaptor — just a valid module\nX = 1\n"
|
|
)
|
|
_, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_resolve_get_adaptor_factory_raises(plugin_root: Path):
|
|
"""get_adaptor() that raises → falls back to raw-drop gracefully."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
def get_adaptor(plugin_name, runtime):
|
|
raise ValueError("kaboom")
|
|
"""))
|
|
_, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_resolve_adaptor_class_construction_raises(plugin_root: Path):
|
|
"""Adaptor class whose __init__ raises → falls back to raw-drop."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
class Adaptor:
|
|
def __init__(self, *args, **kwargs):
|
|
raise RuntimeError("nope")
|
|
"""))
|
|
_, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_resolve_adaptor_class_zero_arg_fallback(plugin_root: Path):
|
|
"""Adaptor class whose (name, runtime) ctor raises TypeError → try zero-arg."""
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text(textwrap.dedent("""
|
|
from plugins_registry.protocol import InstallResult
|
|
class Adaptor:
|
|
plugin_name = "demo-plugin"
|
|
runtime = "test_runtime"
|
|
def __init__(self):
|
|
pass
|
|
async def install(self, ctx):
|
|
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
|
|
async def uninstall(self, ctx):
|
|
pass
|
|
"""))
|
|
# TypeError forces the fallback path: `cls(plugin_name, runtime)` fails
|
|
# because the class takes no args, so we retry with `cls()`.
|
|
_, source = resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.PLUGIN
|
|
|
|
|
|
def test_load_module_bailout_when_spec_is_none(monkeypatch, plugin_root: Path):
|
|
"""Defensive path: ``spec_from_file_location`` returns None. Forced via
|
|
monkeypatch since real filesystems never trigger it for .py files."""
|
|
import importlib.util as iu
|
|
import plugins_registry as pr
|
|
|
|
(plugin_root / "adapters").mkdir()
|
|
(plugin_root / "adapters" / "test_runtime.py").write_text("class Adaptor: pass\n")
|
|
|
|
real = iu.spec_from_file_location
|
|
def fake_spec(name, path, *a, **kw):
|
|
if path.name == "test_runtime.py":
|
|
return None
|
|
return real(name, path, *a, **kw)
|
|
monkeypatch.setattr(pr.importlib.util, "spec_from_file_location", fake_spec)
|
|
|
|
_, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_resolve_registry_bails_when_load_returns_none(monkeypatch, tmp_path: Path, plugin_root: Path):
|
|
"""Registry path exists but the module fails to load → falls through to
|
|
plugin-shipped (or raw-drop if that's also missing). Exercises the
|
|
``if module is None: return None`` bail-out in ``_resolve_registry``."""
|
|
import plugins_registry as pr
|
|
|
|
fake_registry = tmp_path / "fake_registry"
|
|
(fake_registry / "demo-plugin").mkdir(parents=True)
|
|
(fake_registry / "demo-plugin" / "test_runtime.py").write_text("class Adaptor: pass\n")
|
|
monkeypatch.setattr(pr, "_REGISTRY_ROOT", fake_registry)
|
|
|
|
# Force _load_module_from_path to return None when asked for this module.
|
|
monkeypatch.setattr(pr, "_load_module_from_path", lambda name, path: None)
|
|
|
|
_, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
|
|
# Both registry and plugin-shipped now yield None → raw-drop.
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_resolve_registry_missing_module_falls_through(monkeypatch, tmp_path: Path, plugin_root: Path):
|
|
"""Registry root exists but has neither plugin dir for this name →
|
|
plugin-shipped or raw-drop takes over (not a crash)."""
|
|
import plugins_registry as pr
|
|
monkeypatch.setattr(pr, "_REGISTRY_ROOT", tmp_path / "empty-registry")
|
|
_, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
|
|
assert source == AdaptorSource.RAW_DROP
|
|
|
|
|
|
def test_load_module_from_path_registers_plugins_registry_sys_modules(tmp_path: Path):
|
|
"""KI-296: _load_module_from_path registers ``plugins_registry`` in sys.modules
|
|
before exec'ing the adapter, so adapter files that do
|
|
``from plugins_registry import ...`` resolve correctly when the runtime is
|
|
installed from the PyPI wheel (where the package ships as
|
|
``molecule_runtime.plugins_registry`` rather than a top-level ``plugins_registry``).
|
|
"""
|
|
import sys as _sys
|
|
import plugins_registry as pr
|
|
|
|
# Create a fake adapter that imports plugins_registry at top level.
|
|
adapter_file = tmp_path / "fake_runtime_adapter.py"
|
|
adapter_file.write_text(
|
|
"from plugins_registry import InstallContext # noqa: F401\n"
|
|
"from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401\n"
|
|
)
|
|
|
|
# Evict any pre-existing sys.modules entries for the shim keys so the
|
|
# import inside _load_module_from_path actually runs.
|
|
_saved = {
|
|
k: _sys.modules.pop(k, None)
|
|
for k in (
|
|
"plugins_registry", "plugins_registry.builtins",
|
|
"plugins_registry.protocol", "plugins_registry.raw_drop",
|
|
"_plugin_adaptor.test.fake_runtime",
|
|
)
|
|
}
|
|
|
|
try:
|
|
result = pr._load_module_from_path("_plugin_adaptor.test.fake_runtime", adapter_file)
|
|
assert result is not None, "module should load without ImportError"
|
|
assert hasattr(result, "Adaptor"), "AgentskillsAdaptor alias should be in namespace"
|
|
finally:
|
|
# Restore sys.modules state.
|
|
for k, v in _saved.items():
|
|
if v is None:
|
|
_sys.modules.pop(k, None)
|
|
else:
|
|
_sys.modules[k] = v
|