fix: isolate plugin CLI import side effects

This commit is contained in:
jinli.yl 2026-08-21 17:22:09 +08:00
parent d926b487f8
commit 5c02eb4136
2 changed files with 86 additions and 13 deletions

View file

@ -127,7 +127,13 @@ def _installed_manifest(plugin: InstalledPlugin) -> PluginManifest:
path = Path(distribution.locate_file(relative))
if path.is_file():
return parse_plugin_manifest(path.read_text(encoding="utf-8"), plugin_name=plugin.name)
return load_package_manifest(plugin.target, plugin_name=plugin.name)
# Editable installs may not expose package data through ``locate_file``.
# Importlib resources then has to import the package, whose ``__init__``
# may still contain legacy registration side effects.
from .components.component_registry import R
with R.preserve(allow_mutation=True):
return load_package_manifest(plugin.target, plugin_name=plugin.name)
def _plugin_details(plugin: InstalledPlugin) -> dict:
@ -233,6 +239,7 @@ def _validate_installed(name: str) -> list[str]:
def _validate_local(path: Path) -> list[str]:
from .components.component_registry import R
from .plugin import Backend, Plugin, _load_backend
project_file = path if path.name == "pyproject.toml" else path / "pyproject.toml"
@ -249,18 +256,21 @@ def _validate_local(path: Path) -> list[str]:
plugins = []
sys.path.insert(0, str(source_root.resolve()))
try:
for name, package in entry_points.items():
if not isinstance(name, str) or not isinstance(package, str) or ":" in package:
raise ValueError("Local validation requires package-only manifest entry points")
manifest_path = source_root.joinpath(*package.split(".")).joinpath(PLUGIN_MANIFEST)
if not manifest_path.is_file():
raise FileNotFoundError(f"Plugin manifest not found: {manifest_path}")
manifest = parse_plugin_manifest(manifest_path.read_text(encoding="utf-8"), plugin_name=name)
backends = tuple(
Backend(backend_name, _load_backend(target, plugin_name=name))
for backend_name, target in manifest.backends.items()
)
plugins.append(Plugin(name=name, backends=backends, config=manifest.application_defaults))
# Match installed-plugin loading: imports may execute compatibility
# decorators, but they must not mutate the frozen built-in template.
with R.preserve(allow_mutation=True):
for name, package in entry_points.items():
if not isinstance(name, str) or not isinstance(package, str) or ":" in package:
raise ValueError("Local validation requires package-only manifest entry points")
manifest_path = source_root.joinpath(*package.split(".")).joinpath(PLUGIN_MANIFEST)
if not manifest_path.is_file():
raise FileNotFoundError(f"Plugin manifest not found: {manifest_path}")
manifest = parse_plugin_manifest(manifest_path.read_text(encoding="utf-8"), plugin_name=name)
backends = tuple(
Backend(backend_name, _load_backend(target, plugin_name=name))
for backend_name, target in manifest.backends.items()
)
plugins.append(Plugin(name=name, backends=backends, config=manifest.application_defaults))
_validate_plugins(plugins)
finally:
sys.path.remove(str(source_root.resolve()))

View file

@ -7,6 +7,8 @@ from types import SimpleNamespace
from reme import plugin_cli as plugin_cli_module
from reme import reme as reme_module
from reme.components import R
from reme.enumeration import ComponentEnum
class _FakeDistribution:
@ -94,6 +96,34 @@ def test_show_plugin_reads_manifest_without_importing_backends(monkeypatch, tmp_
assert "auto_fin" in output
def test_show_plugin_preserves_registry_when_editable_fallback_imports_package(monkeypatch, tmp_path, capsys):
package = tmp_path / "fallback_plugin"
package.mkdir()
(package / "__init__.py").write_text("from .backend import FallbackStep\n", encoding="utf-8")
(package / "backend.py").write_text(
"from reme.components import ComponentMixin, R\n"
"from reme.enumeration import ComponentEnum\n"
"@R.register('fallback_import_side_effect')\n"
"class FallbackStep(ComponentMixin):\n"
" component_type = ComponentEnum.STEP\n",
encoding="utf-8",
)
(package / "plugin.yaml").write_text(
"""backends:
fallback_step: fallback_plugin.backend:FallbackStep
""",
encoding="utf-8",
)
entry = _FakeEntryPoint("fallback", "fallback_plugin", _FakeDistribution(tmp_path / "missing"))
monkeypatch.syspath_prepend(str(tmp_path))
monkeypatch.setattr(plugin_cli_module.metadata, "entry_points", lambda: _FakeEntryPoints([entry]))
assert plugin_cli_module.plugin_cli(["show", "fallback"]) == 0
assert "fallback_step" in capsys.readouterr().out
assert R.get(ComponentEnum.STEP, "fallback_import_side_effect") is None
def test_install_uses_current_python_pip(monkeypatch, capsys):
commands = []
monkeypatch.setattr(plugin_cli_module, "_run_pip", lambda command: commands.append(command) or 0)
@ -146,6 +176,39 @@ def test_validate_local_auto_fin_project():
assert names == ["auto-fin"]
def test_validate_local_preserves_registry_during_backend_imports(tmp_path):
package = tmp_path / "src" / "decorated_plugin"
package.mkdir(parents=True)
(tmp_path / "pyproject.toml").write_text(
"[project]\n"
"name = 'decorated-plugin'\n"
"version = '0.1.0'\n"
"[project.entry-points.'reme.plugins']\n"
"decorated = 'decorated_plugin'\n"
"[tool.setuptools.package-dir]\n"
"'' = 'src'\n",
encoding="utf-8",
)
(package / "__init__.py").write_text("", encoding="utf-8")
(package / "backend.py").write_text(
"from reme.components import ComponentMixin, R\n"
"from reme.enumeration import ComponentEnum\n"
"@R.register('local_import_side_effect')\n"
"class DecoratedStep(ComponentMixin):\n"
" component_type = ComponentEnum.STEP\n",
encoding="utf-8",
)
(package / "plugin.yaml").write_text(
"""backends:
decorated_step: decorated_plugin.backend:DecoratedStep
""",
encoding="utf-8",
)
assert plugin_cli_module._validate_local(tmp_path) == ["decorated"]
assert R.get(ComponentEnum.STEP, "local_import_side_effect") is None
def test_plugin_command_errors_are_clean(monkeypatch, capsys):
monkeypatch.setattr(plugin_cli_module.metadata, "entry_points", _FakeEntryPoints)