diff --git a/workspace/plugins_registry/__init__.py b/workspace/plugins_registry/__init__.py index 363f26fe..18e517ad 100644 --- a/workspace/plugins_registry/__init__.py +++ b/workspace/plugins_registry/__init__.py @@ -51,6 +51,32 @@ class AdaptorSource: def _load_module_from_path(module_name: str, path: Path): """Import a Python file by absolute path. Returns the module or None on failure.""" + + # KI-296: Before exec'ing plugin-adapter files (which import + # ``from plugins_registry import ...`` as a top-level name), register + # the molecule-runtime subpackage as ``plugins_registry`` in sys.modules. + # In the molecule-core workspace source this is already a top-level package, + # so the setdefault is a no-op. In the PyPI-installed runtime wheel + # (molecule-ai-workspace-runtime 0.1.129+), the package ships as + # ``molecule_runtime.plugins_registry`` and without this shim every + # plugin adapter would fail with ModuleNotFoundError. + import sys as _sys + + if "plugins_registry" not in _sys.modules: + try: + _mr_pr = __import__("molecule_runtime.plugins_registry", fromlist=[""]) + _sys.modules["plugins_registry"] = _mr_pr + # Also register submodules the adapters commonly import directly. + for _sub in ("builtins", "protocol", "raw_drop"): + _submod = getattr(_mr_pr, _sub, None) + if _submod is not None: + _sys.modules[f"plugins_registry.{_sub}"] = _submod + except ImportError: + # molecule-runtime not installed (e.g. test environment with + # workspace/ on sys.path directly) — skip shim; the top-level + # workspace/plugins_registry package is already findable. + pass + spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: return None diff --git a/workspace/tests/test_plugins_registry.py b/workspace/tests/test_plugins_registry.py index 44531eb4..ebfb7e1a 100644 --- a/workspace/tests/test_plugins_registry.py +++ b/workspace/tests/test_plugins_registry.py @@ -325,3 +325,44 @@ def test_resolve_registry_missing_module_falls_through(monkeypatch, tmp_path: Pa 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