mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-08 03:10:24 +00:00
fix: support standard plugin source layouts
This commit is contained in:
parent
31f46649ff
commit
972564593c
2 changed files with 117 additions and 12 deletions
|
|
@ -236,6 +236,33 @@ def _validate_installed(name: str) -> list[str]:
|
|||
return [name]
|
||||
|
||||
|
||||
def _local_source_roots(project_file: Path, project: dict) -> list[Path]:
|
||||
"""Return declared and conventional Python source roots for a local project."""
|
||||
project_root = project_file.parent
|
||||
candidates: list[Path] = []
|
||||
|
||||
setuptools = project.get("tool", {}).get("setuptools", {})
|
||||
if isinstance(setuptools, dict):
|
||||
package_dir = setuptools.get("package-dir", {})
|
||||
if isinstance(package_dir, dict) and isinstance(package_dir.get(""), str):
|
||||
candidates.append(project_root / package_dir[""])
|
||||
|
||||
packages = setuptools.get("packages", {})
|
||||
package_find = packages.get("find", {}) if isinstance(packages, dict) else {}
|
||||
if isinstance(package_find, dict):
|
||||
where = package_find.get("where", [])
|
||||
if isinstance(where, str):
|
||||
where = [where]
|
||||
if isinstance(where, list):
|
||||
candidates.extend(project_root / item for item in where if isinstance(item, str))
|
||||
|
||||
# ``src`` is a build-backend-independent Python project convention used by
|
||||
# Hatchling, Poetry, Flit, and setuptools. Root-layout projects remain the
|
||||
# final fallback.
|
||||
candidates.extend((project_root / "src", project_root))
|
||||
return list(dict.fromkeys(candidate.resolve() for candidate in candidates))
|
||||
|
||||
|
||||
def _validate_local(path: Path) -> list[str]:
|
||||
from .components.component_registry import R
|
||||
from .plugin import PluginManager, _plugin_from_manifest
|
||||
|
|
@ -248,26 +275,38 @@ def _validate_local(path: Path) -> list[str]:
|
|||
if not isinstance(entry_points, dict) or not entry_points:
|
||||
raise ValueError(f"No {PLUGIN_ENTRY_POINT_GROUP} entry points found in {project_file}")
|
||||
|
||||
setuptools = project.get("tool", {}).get("setuptools", {})
|
||||
package_dir = setuptools.get("package-dir", {}) if isinstance(setuptools, dict) else {}
|
||||
source_root = project_file.parent / (package_dir.get("") or ".")
|
||||
source_roots = _local_source_roots(project_file, project)
|
||||
plugins = []
|
||||
sys.path.insert(0, str(source_root.resolve()))
|
||||
selected_roots: list[Path] = []
|
||||
manifests = []
|
||||
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")
|
||||
relative_manifest = Path(*package.split(".")).joinpath(PLUGIN_MANIFEST)
|
||||
manifest_path = next(
|
||||
(root / relative_manifest for root in source_roots if (root / relative_manifest).is_file()),
|
||||
None,
|
||||
)
|
||||
if manifest_path is None:
|
||||
searched = ", ".join(str(root / relative_manifest) for root in source_roots)
|
||||
raise FileNotFoundError(f"Plugin manifest not found; searched: {searched}")
|
||||
selected_roots.append(manifest_path.parents[len(package.split("."))])
|
||||
manifests.append((name, manifest_path))
|
||||
|
||||
inserted_roots = list(dict.fromkeys(str(root) for root in selected_roots))
|
||||
for source_root in reversed(inserted_roots):
|
||||
sys.path.insert(0, source_root)
|
||||
try:
|
||||
# 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}")
|
||||
for name, manifest_path in manifests:
|
||||
manifest = parse_plugin_manifest(manifest_path.read_text(encoding="utf-8"), plugin_name=name)
|
||||
plugins.append(_plugin_from_manifest(name, manifest))
|
||||
_validate_plugins(PluginManager(plugins))
|
||||
finally:
|
||||
sys.path.remove(str(source_root.resolve()))
|
||||
for source_root in inserted_roots:
|
||||
sys.path.remove(source_root)
|
||||
return [plugin.name for plugin in plugins]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,72 @@ def test_validate_local_auto_fin_project():
|
|||
assert names == ["auto-fin"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"build_system",
|
||||
[
|
||||
"requires = ['hatchling']\nbuild-backend = 'hatchling.build'\n",
|
||||
"requires = ['poetry-core']\nbuild-backend = 'poetry.core.masonry.api'\n",
|
||||
"requires = ['flit-core']\nbuild-backend = 'flit_core.buildapi'\n",
|
||||
],
|
||||
)
|
||||
def test_validate_local_supports_backend_independent_src_layout(tmp_path, build_system):
|
||||
package = tmp_path / "src" / "src_layout_plugin"
|
||||
package.mkdir(parents=True)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"[project]\n"
|
||||
"name = 'example-plugin'\n"
|
||||
"version = '0.1.0'\n"
|
||||
"[project.entry-points.'reme.plugins']\n"
|
||||
"example = 'src_layout_plugin'\n"
|
||||
"[build-system]\n"
|
||||
f"{build_system}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "backend.py").write_text(
|
||||
"from reme.components 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: src_layout_plugin.backend:ExampleStep\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert plugin_cli_module._validate_local(tmp_path) == ["example"]
|
||||
|
||||
|
||||
def test_validate_local_supports_setuptools_find_source_root(tmp_path):
|
||||
package = tmp_path / "python" / "find_layout_plugin"
|
||||
package.mkdir(parents=True)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"[project]\n"
|
||||
"name = 'example-plugin'\n"
|
||||
"version = '0.1.0'\n"
|
||||
"[project.entry-points.'reme.plugins']\n"
|
||||
"example = 'find_layout_plugin'\n"
|
||||
"[tool.setuptools.packages.find]\n"
|
||||
"where = ['python']\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "backend.py").write_text(
|
||||
"from reme.components 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: find_layout_plugin.backend:ExampleStep\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert plugin_cli_module._validate_local(tmp_path) == ["example"]
|
||||
|
||||
|
||||
def test_validate_local_preserves_registry_during_backend_imports(tmp_path):
|
||||
package = tmp_path / "src" / "decorated_plugin"
|
||||
package.mkdir(parents=True)
|
||||
|
|
@ -219,7 +285,7 @@ def test_plugin_command_errors_are_clean(monkeypatch, capsys):
|
|||
assert "Plugin 'missing' is not installed" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["plugins", "--plugins"])
|
||||
@pytest.mark.parametrize("action", ["plugins", "-plugins", "--plugins"])
|
||||
def test_main_routes_plugins_before_loading_environment(monkeypatch, action):
|
||||
events = []
|
||||
monkeypatch.setattr("sys.argv", ["reme", action, "list"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue