diff --git a/reme/plugin_cli.py b/reme/plugin_cli.py index ce06b7a2..bfe87dba 100644 --- a/reme/plugin_cli.py +++ b/reme/plugin_cli.py @@ -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] diff --git a/tests/unit/test_plugin_cli.py b/tests/unit/test_plugin_cli.py index 10bba944..ff06868b 100644 --- a/tests/unit/test_plugin_cli.py +++ b/tests/unit/test_plugin_cli.py @@ -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"])