Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions raven/plugin/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -108,16 +110,27 @@ 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(
f"plugin id {mf.id!r} activated twice",
)
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]
Expand Down Expand Up @@ -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 (``<root>/<id>/``) 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.
Expand Down
126 changes: 126 additions & 0 deletions tests/test_plugin_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<root>/<id>/``
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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"}
Loading