From 5c02eb4136c7cd0813232847d50faa68b2597e90 Mon Sep 17 00:00:00 2001 From: "jinli.yl" Date: Fri, 21 Aug 2026 17:22:09 +0800 Subject: [PATCH] fix: isolate plugin CLI import side effects --- reme/plugin_cli.py | 36 ++++++++++++-------- tests/unit/test_plugin_cli.py | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/reme/plugin_cli.py b/reme/plugin_cli.py index 75fd303a..16fd3963 100644 --- a/reme/plugin_cli.py +++ b/reme/plugin_cli.py @@ -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())) diff --git a/tests/unit/test_plugin_cli.py b/tests/unit/test_plugin_cli.py index 94a76e53..a8e614b0 100644 --- a/tests/unit/test_plugin_cli.py +++ b/tests/unit/test_plugin_cli.py @@ -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)