fix: support standard plugin source layouts

This commit is contained in:
jinli.yl 2026-08-22 18:52:13 +08:00
parent 31f46649ff
commit 972564593c
2 changed files with 117 additions and 12 deletions

View file

@ -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]

View file

@ -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"])