diff --git a/raven/plugin/registry.py b/raven/plugin/registry.py index 32b617d..a97757d 100644 --- a/raven/plugin/registry.py +++ b/raven/plugin/registry.py @@ -24,11 +24,13 @@ import importlib import logging +import sys from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any -from raven.plugin.discover import DiscoveredPlugin +from raven.plugin.discover import DiscoveredPlugin, Source from raven.plugin.manifest import PluginManifest logger = logging.getLogger(__name__) @@ -108,9 +110,15 @@ def activate( mf.id, ) continue - self._activate_one(mf) + self._activate_one(mf, source=d.source, location=d.location) - def _activate_one(self, mf: PluginManifest) -> None: + def _activate_one( + self, + mf: PluginManifest, + *, + source: Source, + location: Path | None, + ) -> None: if mf.id in self._manifests: # Discovery should have deduped this already; defensive. raise PluginConflictError( @@ -118,6 +126,11 @@ def _activate_one(self, mf: PluginManifest) -> None: ) self._manifests[mf.id] = mf + # Call-order sensitive: a file-based USER/PROJECT plugin ships its + # factory module inside the plugin directory, which nothing puts on + # sys.path — make it importable before _resolve_factory runs below. + self._ensure_importable(source, location) + for contribution in mf.contributes.memory_backends: if contribution.name in self._memory_backends: prev = self._memory_backends[contribution.name] @@ -150,6 +163,29 @@ def _activate_one(self, mf: PluginManifest) -> None: ) logger.debug("registered tool %s from %s", tool.name, mf.id) + @staticmethod + def _ensure_importable(source: Source, location: Path | None) -> None: + """Put a file-based plugin's directory on ``sys.path`` so its + factory module imports. + + Only USER / PROJECT plugins need this: their Python package lives + in the plugin directory (``//``) that nothing else adds + to the path. BUNDLED code ships inside the raven package and + ENTRY_POINTS plugins are installed into site-packages, so both + already import without help. + + Appended (not prepended) so an installed package of the same name + keeps priority, and guarded so repeated activations don't grow the + path. This widens the process-wide import surface for the lifetime + of the process: every module under that directory becomes + importable, not just the referenced factory. + """ + if source not in (Source.USER, Source.PROJECT) or location is None: + return + plugin_dir = str(location.parent) + if plugin_dir not in sys.path: + sys.path.append(plugin_dir) + @staticmethod def _resolve_factory(plugin_id: str, ref: str) -> MemoryBackendFactory: """Import ``module`` and grab ``callable`` from it. diff --git a/tests/test_plugin_bootstrap.py b/tests/test_plugin_bootstrap.py index 702c349..30264ab 100644 --- a/tests/test_plugin_bootstrap.py +++ b/tests/test_plugin_bootstrap.py @@ -61,6 +61,66 @@ def _cleanup_modules(): sys.modules.pop(k, None) +@pytest.fixture(autouse=True) +def _restore_syspath(): + snapshot = list(sys.path) + yield + sys.path[:] = snapshot + + +def _write_real_plugin( + root: Path, + plugin_id: str, + *, + pkg: str, + backend_name: str | None = None, + tool_name: str | None = None, +) -> None: + """Write a genuine on-disk plugin: manifest + an importable package + whose factory module is *not* pre-seeded into ``sys.modules``. + + Loading it therefore only succeeds if activation put ``//`` + on ``sys.path`` — the exact behaviour under test. Contrast with + ``_install_test_module``, which sidesteps import resolution entirely. + """ + sub = root / plugin_id + (sub / pkg).mkdir(parents=True, exist_ok=True) + (sub / pkg / "__init__.py").write_text("", encoding="utf-8") + (sub / pkg / "factories.py").write_text( + textwrap.dedent(""" + def make_backend(ctx): + return {"kind": "backend", **ctx.config} + + def make_tool(ctx): + return {"kind": "tool"} + """), + encoding="utf-8", + ) + blocks = "" + if backend_name is not None: + blocks += textwrap.dedent(f""" + [[plugin.contributes.memory_backends]] + name = "{backend_name}" + factory = "{pkg}.factories:make_backend" + """) + if tool_name is not None: + blocks += textwrap.dedent(f""" + [[plugin.contributes.tools]] + name = "{tool_name}" + factory = "{pkg}.factories:make_tool" + """) + (sub / "raven-plugin.toml").write_text( + textwrap.dedent(f""" + [plugin] + id = "{plugin_id}" + version = "0.1.0" + enabled_by_default = true + """) + + blocks, + encoding="utf-8", + ) + + # --------------------------------------------------------------------------- # End-to-end: discover → activate → build # --------------------------------------------------------------------------- @@ -202,3 +262,69 @@ def test_no_sources_returns_empty_registry(self) -> None: registry = assemble_plugin_registry(entry_points_group=None) assert isinstance(registry, PluginRegistry) assert registry.activated_ids() == [] + + +# --------------------------------------------------------------------------- +# User/project-dir plugins ship their factory package in the plugin dir; +# activation must put that dir on sys.path so the factory imports. +# --------------------------------------------------------------------------- + + +class TestUserDirImport: + def test_user_dir_backend_loads_from_ondisk_package(self, tmp_path: Path) -> None: + user = tmp_path / "user" + _write_real_plugin( + user, + "ud-backend", + pkg="udpkg_backend", + backend_name="ud_mem", + ) + registry = assemble_plugin_registry(user_dir=user, entry_points_group=None) + assert registry.activated_ids() == ["ud-backend"] + backend = registry.build_memory_backend( + "ud_mem", + config={"mode": "embedded"}, + services=ServiceLocator(workspace=tmp_path), + ) + assert backend == {"kind": "backend", "mode": "embedded"} + + def test_project_dir_tool_loads_from_ondisk_package(self, tmp_path: Path) -> None: + project = tmp_path / "project" + _write_real_plugin( + project, + "pd-tool", + pkg="pdpkg_tool", + tool_name="pd_tool", + ) + registry = assemble_plugin_registry(project_dir=project, entry_points_group=None) + assert registry.activated_ids() == ["pd-tool"] + tool = registry.build_tool( + "pd_tool", + config={}, + services=ServiceLocator(workspace=tmp_path), + ) + assert tool == {"kind": "tool"} + + def test_user_dir_mixed_backend_and_tool_load(self, tmp_path: Path) -> None: + user = tmp_path / "user" + _write_real_plugin( + user, + "ud-mixed", + pkg="udpkg_mixed", + backend_name="mixed_mem", + tool_name="mixed_tool", + ) + registry = assemble_plugin_registry(user_dir=user, entry_points_group=None) + assert registry.activated_ids() == ["ud-mixed"] + backend = registry.build_memory_backend( + "mixed_mem", + config={}, + services=ServiceLocator(workspace=tmp_path), + ) + tool = registry.build_tool( + "mixed_tool", + config={}, + services=ServiceLocator(workspace=tmp_path), + ) + assert backend == {"kind": "backend"} + assert tool == {"kind": "tool"}