ReMe/tests/unit/test_plugin.py
jinliyl efcc2b34d1
Some checks are pending
CI / Documentation / Test and build documentation (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
feat: simplify plugin setup and add management CLI (#485)
* feat: simplify plugin setup and add management CLI

* fix: isolate plugin CLI import side effects

* refactor: streamline plugin validation

* fix: route plugin CLI arguments independently

* fix: support standard plugin source layouts
2026-08-23 17:58:20 +08:00

288 lines
9.5 KiB
Python

"""Tests for installed plugin discovery and application-local registration."""
# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access
from pathlib import Path
import pytest
from reme.application import Application
from reme.components.base_component import BaseComponent, ComponentMixin
from reme.components.component_registry import ComponentRegistry, R
from reme.config.config_parser import _load_config
from reme.enumeration import ComponentEnum
from reme.plugin import Backend, Plugin, PluginManager, _load_backend
from reme.plugin_manifest import parse_plugin_manifest
class _PluginStep(ComponentMixin):
component_type = ComponentEnum.STEP
class _PluginComponent(BaseComponent):
component_type = "example.reranker"
class _FakeEntryPoint:
def __init__(self, name, value, loader, group):
self.name = name
self.value = value
self._loader = loader
self.group = group
def load(self):
return self._loader()
class _FakeEntryPoints(list):
def select(self, *, group, name):
return [entry for entry in self if entry.group == group and entry.name == name]
def _set_entry_points(monkeypatch, *entries):
monkeypatch.setattr("reme.entry_point.metadata.entry_points", lambda: _FakeEntryPoints(entries))
def test_plugin_application_defaults_are_below_application_config():
manager = PluginManager(
[
Plugin(
name="example",
config={"jobs": {"task": {"backend": "base", "value": 1}}},
),
],
)
merged = manager.merge_config({"jobs": {"task": {"value": 2}}})
assert merged["jobs"]["task"] == {"backend": "base", "value": 2}
def test_plugin_application_defaults_expand_environment(monkeypatch):
monkeypatch.setenv("PLUGIN_LIMIT", "12")
manager = PluginManager([Plugin(name="example", config={"limit": "${PLUGIN_LIMIT}"})])
assert manager.merge_config({})["limit"] == 12
def test_plugin_registers_into_only_the_supplied_registry():
manager = PluginManager([Plugin(name="example", backends=(Backend("example_step", _PluginStep),))])
first = ComponentRegistry()
second = ComponentRegistry()
manager.register(first)
assert first.get(ComponentEnum.STEP, "example_step") is _PluginStep
assert second.get(ComponentEnum.STEP, "example_step") is None
@pytest.mark.asyncio
async def test_plugin_registers_and_runs_custom_component_type(monkeypatch, tmp_path):
manager = PluginManager(
[
Plugin(
name="example",
backends=(Backend("cross_encoder", _PluginComponent),),
config={
"components": {
"example.reranker": {
"default": {"backend": "cross_encoder"},
},
},
},
),
],
)
monkeypatch.setattr(PluginManager, "discover", classmethod(lambda cls, specs: manager))
app = Application(
plugins=["example"],
workspace_dir=str(tmp_path),
enable_logo=False,
log_to_console=False,
log_to_file=False,
service={"backend": "cli"},
)
component = app.context.components["example.reranker"]["default"]
assert isinstance(component, _PluginComponent)
assert app.context.registry.get("example.reranker", "cross_encoder") is _PluginComponent
await app.start()
assert component.is_started is True
await app.update_component("example.reranker", "default", backend="updated")
assert component.backend == "updated"
await app.close()
assert component.is_started is False
def test_plugin_backend_collision_fails_with_both_owners():
registry = ComponentRegistry()
registry.add("same", _PluginStep, owner="first")
class OtherStep(ComponentMixin):
component_type = ComponentEnum.STEP
with pytest.raises(ValueError, match="both 'first' and 'second'"):
registry.add("same", OtherStep, owner="second")
def test_plugin_manager_loads_explicit_entry_point(monkeypatch):
descriptor = Plugin(name="example", backends=(Backend("example_step", _PluginStep),))
_set_entry_points(
monkeypatch,
_FakeEntryPoint("example", "example:plugin", lambda: descriptor, "reme.plugins"),
)
manager = PluginManager.discover(["example"])
assert manager.plugins == (descriptor,)
def test_plugin_manager_loads_package_manifest(monkeypatch, tmp_path):
package = tmp_path / "example_plugin"
package.mkdir()
(package / "__init__.py").write_text("", encoding="utf-8")
(package / "backend.py").write_text(
"from reme.components.base_component import ComponentMixin\n"
"from reme.enumeration import ComponentEnum\n"
"class ExampleStep(ComponentMixin):\n"
" component_type = ComponentEnum.STEP\n",
encoding="utf-8",
)
(package / "plugin.yaml").write_text(
"backends:\n"
" example_step: example_plugin.backend:ExampleStep\n"
"application_defaults:\n"
" jobs:\n"
" example:\n"
" backend: base\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
_set_entry_points(
monkeypatch,
_FakeEntryPoint("example", "example_plugin", lambda: None, "reme.plugins"),
)
manager = PluginManager.discover(["example"])
registry = ComponentRegistry()
manager.register(registry)
backend = registry.get(ComponentEnum.STEP, "example_step")
assert backend is not None
assert backend.__name__ == "ExampleStep"
assert manager.merge_config({})["jobs"]["example"]["backend"] == "base"
def test_plugin_manifest_rejects_legacy_defaults_field():
with pytest.raises(ValueError, match="unknown keys: defaults"):
parse_plugin_manifest("defaults: {}\n", plugin_name="example")
def test_plugin_manifest_requires_application_defaults_mapping():
with pytest.raises(TypeError, match="manifest 'application_defaults' must be a mapping"):
parse_plugin_manifest("application_defaults: []\n", plugin_name="example")
def test_plugin_manifest_reports_missing_backend_attribute():
with pytest.raises(ValueError, match="cannot load backend.*MissingStep"):
_load_backend("reme.plugin:MissingStep", plugin_name="missing")
def test_plugin_manager_rejects_multiple_entry_point_providers(monkeypatch):
descriptor = Plugin(name="example")
_set_entry_points(
monkeypatch,
_FakeEntryPoint("example", "first:plugin", lambda: descriptor, "reme.plugins"),
_FakeEntryPoint("example", "second:plugin", lambda: descriptor, "reme.plugins"),
)
with pytest.raises(
ValueError,
match="Plugin 'example' has multiple installed providers: first:plugin, second:plugin",
):
PluginManager.discover(["example"])
def test_plugin_entry_point_import_side_effect_does_not_leak(monkeypatch, tmp_path):
class UndeclaredClient(ComponentMixin):
component_type = ComponentEnum.CLIENT
descriptor = Plugin(name="example")
def load_plugin():
R.register(UndeclaredClient, "undeclared-client")
return descriptor
_set_entry_points(
monkeypatch,
_FakeEntryPoint("example", "example:plugin", load_plugin, "reme.plugins"),
)
app = Application(
plugins=["example"],
workspace_dir=str(tmp_path),
enable_logo=False,
log_to_console=False,
log_to_file=False,
service={"backend": "cli"},
)
assert R.get(ComponentEnum.CLIENT, "undeclared-client") is None
assert app.context.registry.get(ComponentEnum.CLIENT, "undeclared-client") is None
def test_plugin_manager_rejects_non_string_name():
with pytest.raises(TypeError, match="Invalid plugin name"):
PluginManager.discover([{"name": "example"}])
def test_config_can_extend_another_config(tmp_path: Path):
parent = tmp_path / "parent.yaml"
child = tmp_path / "child.yaml"
parent.write_text("service:\n backend: http\n port: 8000\n", encoding="utf-8")
child.write_text("extends: parent.yaml\nservice:\n port: 9000\n", encoding="utf-8")
assert _load_config(str(child))["service"] == {"backend": "http", "port": 9000}
def test_config_can_come_from_installed_entry_point(tmp_path: Path, monkeypatch):
config = tmp_path / "example.yaml"
config.write_text("plugins: [example]\n", encoding="utf-8")
_set_entry_points(
monkeypatch,
_FakeEntryPoint("example", "example:CONFIG_PATH", lambda: config, "reme.configs"),
)
assert _load_config("example") == {"plugins": ["example"]}
def test_config_entry_point_import_side_effect_does_not_leak(tmp_path: Path, monkeypatch):
config = tmp_path / "side-effect.yaml"
config.write_text("service:\n backend: cli\n", encoding="utf-8")
class UndeclaredClient(ComponentMixin):
component_type = ComponentEnum.CLIENT
def load_config():
R.register(UndeclaredClient, "config-side-effect-client")
return config
_set_entry_points(
monkeypatch,
_FakeEntryPoint("side-effect", "example:CONFIG_PATH", load_config, "reme.configs"),
)
loaded = _load_config("side-effect")
app = Application(
**loaded,
workspace_dir=str(tmp_path / "workspace"),
enable_logo=False,
log_to_console=False,
log_to_file=False,
)
assert loaded == {"service": {"backend": "cli"}}
assert R.get(ComponentEnum.CLIENT, "config-side-effect-client") is None
assert app.context.registry.get(ComponentEnum.CLIENT, "config-side-effect-client") is None