Skip to content

Commit 2ca0bfc

Browse files
libin.zhangclaude
andcommitted
fix(plugin): add user/project plugin dirs to sys.path so factories import
File-based USER/PROJECT plugins ship their factory module inside the plugin directory (~/.raven/plugins/<id>/), which nothing put on sys.path. Discovery found the manifest but _resolve_factory's bare importlib.import_module always failed — a self-contained plugin dropped into the user dir could never load. activate() now threads each DiscoveredPlugin's source and location into _activate_one, which calls _ensure_importable before resolving factories: for USER/PROJECT plugins it appends (not prepends, so installed packages keep priority) the plugin directory to sys.path, guarded for idempotency. BUNDLED (in-package) and ENTRY_POINTS (installed) are untouched. Add regression tests that write real on-disk factory packages (not pre-seeded into sys.modules) under user/project dirs and assert the backend/tool build end to end. Co-authored-by: Claude (claude-opus-4-8) <noreply@anthropic.com>
1 parent 8a2aca0 commit 2ca0bfc

2 files changed

Lines changed: 165 additions & 3 deletions

File tree

raven/plugin/registry.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@
2424

2525
import importlib
2626
import logging
27+
import sys
2728
from collections.abc import Callable
2829
from dataclasses import dataclass
30+
from pathlib import Path
2931
from typing import Any
3032

31-
from raven.plugin.discover import DiscoveredPlugin
33+
from raven.plugin.discover import DiscoveredPlugin, Source
3234
from raven.plugin.manifest import PluginManifest
3335

3436
logger = logging.getLogger(__name__)
@@ -108,16 +110,27 @@ def activate(
108110
mf.id,
109111
)
110112
continue
111-
self._activate_one(mf)
113+
self._activate_one(mf, source=d.source, location=d.location)
112114

113-
def _activate_one(self, mf: PluginManifest) -> None:
115+
def _activate_one(
116+
self,
117+
mf: PluginManifest,
118+
*,
119+
source: Source,
120+
location: Path | None,
121+
) -> None:
114122
if mf.id in self._manifests:
115123
# Discovery should have deduped this already; defensive.
116124
raise PluginConflictError(
117125
f"plugin id {mf.id!r} activated twice",
118126
)
119127
self._manifests[mf.id] = mf
120128

129+
# Call-order sensitive: a file-based USER/PROJECT plugin ships its
130+
# factory module inside the plugin directory, which nothing puts on
131+
# sys.path — make it importable before _resolve_factory runs below.
132+
self._ensure_importable(source, location)
133+
121134
for contribution in mf.contributes.memory_backends:
122135
if contribution.name in self._memory_backends:
123136
prev = self._memory_backends[contribution.name]
@@ -150,6 +163,29 @@ def _activate_one(self, mf: PluginManifest) -> None:
150163
)
151164
logger.debug("registered tool %s from %s", tool.name, mf.id)
152165

166+
@staticmethod
167+
def _ensure_importable(source: Source, location: Path | None) -> None:
168+
"""Put a file-based plugin's directory on ``sys.path`` so its
169+
factory module imports.
170+
171+
Only USER / PROJECT plugins need this: their Python package lives
172+
in the plugin directory (``<root>/<id>/``) that nothing else adds
173+
to the path. BUNDLED code ships inside the raven package and
174+
ENTRY_POINTS plugins are installed into site-packages, so both
175+
already import without help.
176+
177+
Appended (not prepended) so an installed package of the same name
178+
keeps priority, and guarded so repeated activations don't grow the
179+
path. This widens the process-wide import surface for the lifetime
180+
of the process: every module under that directory becomes
181+
importable, not just the referenced factory.
182+
"""
183+
if source not in (Source.USER, Source.PROJECT) or location is None:
184+
return
185+
plugin_dir = str(location.parent)
186+
if plugin_dir not in sys.path:
187+
sys.path.append(plugin_dir)
188+
153189
@staticmethod
154190
def _resolve_factory(plugin_id: str, ref: str) -> MemoryBackendFactory:
155191
"""Import ``module`` and grab ``callable`` from it.

tests/test_plugin_bootstrap.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,66 @@ def _cleanup_modules():
6161
sys.modules.pop(k, None)
6262

6363

64+
@pytest.fixture(autouse=True)
65+
def _restore_syspath():
66+
snapshot = list(sys.path)
67+
yield
68+
sys.path[:] = snapshot
69+
70+
71+
def _write_real_plugin(
72+
root: Path,
73+
plugin_id: str,
74+
*,
75+
pkg: str,
76+
backend_name: str | None = None,
77+
tool_name: str | None = None,
78+
) -> None:
79+
"""Write a genuine on-disk plugin: manifest + an importable package
80+
whose factory module is *not* pre-seeded into ``sys.modules``.
81+
82+
Loading it therefore only succeeds if activation put ``<root>/<id>/``
83+
on ``sys.path`` — the exact behaviour under test. Contrast with
84+
``_install_test_module``, which sidesteps import resolution entirely.
85+
"""
86+
sub = root / plugin_id
87+
(sub / pkg).mkdir(parents=True, exist_ok=True)
88+
(sub / pkg / "__init__.py").write_text("", encoding="utf-8")
89+
(sub / pkg / "factories.py").write_text(
90+
textwrap.dedent("""
91+
def make_backend(ctx):
92+
return {"kind": "backend", **ctx.config}
93+
94+
def make_tool(ctx):
95+
return {"kind": "tool"}
96+
"""),
97+
encoding="utf-8",
98+
)
99+
blocks = ""
100+
if backend_name is not None:
101+
blocks += textwrap.dedent(f"""
102+
[[plugin.contributes.memory_backends]]
103+
name = "{backend_name}"
104+
factory = "{pkg}.factories:make_backend"
105+
""")
106+
if tool_name is not None:
107+
blocks += textwrap.dedent(f"""
108+
[[plugin.contributes.tools]]
109+
name = "{tool_name}"
110+
factory = "{pkg}.factories:make_tool"
111+
""")
112+
(sub / "raven-plugin.toml").write_text(
113+
textwrap.dedent(f"""
114+
[plugin]
115+
id = "{plugin_id}"
116+
version = "0.1.0"
117+
enabled_by_default = true
118+
""")
119+
+ blocks,
120+
encoding="utf-8",
121+
)
122+
123+
64124
# ---------------------------------------------------------------------------
65125
# End-to-end: discover → activate → build
66126
# ---------------------------------------------------------------------------
@@ -202,3 +262,69 @@ def test_no_sources_returns_empty_registry(self) -> None:
202262
registry = assemble_plugin_registry(entry_points_group=None)
203263
assert isinstance(registry, PluginRegistry)
204264
assert registry.activated_ids() == []
265+
266+
267+
# ---------------------------------------------------------------------------
268+
# User/project-dir plugins ship their factory package in the plugin dir;
269+
# activation must put that dir on sys.path so the factory imports.
270+
# ---------------------------------------------------------------------------
271+
272+
273+
class TestUserDirImport:
274+
def test_user_dir_backend_loads_from_ondisk_package(self, tmp_path: Path) -> None:
275+
user = tmp_path / "user"
276+
_write_real_plugin(
277+
user,
278+
"ud-backend",
279+
pkg="udpkg_backend",
280+
backend_name="ud_mem",
281+
)
282+
registry = assemble_plugin_registry(user_dir=user, entry_points_group=None)
283+
assert registry.activated_ids() == ["ud-backend"]
284+
backend = registry.build_memory_backend(
285+
"ud_mem",
286+
config={"mode": "embedded"},
287+
services=ServiceLocator(workspace=tmp_path),
288+
)
289+
assert backend == {"kind": "backend", "mode": "embedded"}
290+
291+
def test_project_dir_tool_loads_from_ondisk_package(self, tmp_path: Path) -> None:
292+
project = tmp_path / "project"
293+
_write_real_plugin(
294+
project,
295+
"pd-tool",
296+
pkg="pdpkg_tool",
297+
tool_name="pd_tool",
298+
)
299+
registry = assemble_plugin_registry(project_dir=project, entry_points_group=None)
300+
assert registry.activated_ids() == ["pd-tool"]
301+
tool = registry.build_tool(
302+
"pd_tool",
303+
config={},
304+
services=ServiceLocator(workspace=tmp_path),
305+
)
306+
assert tool == {"kind": "tool"}
307+
308+
def test_user_dir_mixed_backend_and_tool_load(self, tmp_path: Path) -> None:
309+
user = tmp_path / "user"
310+
_write_real_plugin(
311+
user,
312+
"ud-mixed",
313+
pkg="udpkg_mixed",
314+
backend_name="mixed_mem",
315+
tool_name="mixed_tool",
316+
)
317+
registry = assemble_plugin_registry(user_dir=user, entry_points_group=None)
318+
assert registry.activated_ids() == ["ud-mixed"]
319+
backend = registry.build_memory_backend(
320+
"mixed_mem",
321+
config={},
322+
services=ServiceLocator(workspace=tmp_path),
323+
)
324+
tool = registry.build_tool(
325+
"mixed_tool",
326+
config={},
327+
services=ServiceLocator(workspace=tmp_path),
328+
)
329+
assert backend == {"kind": "backend"}
330+
assert tool == {"kind": "tool"}

0 commit comments

Comments
 (0)