Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/pyproject_api/_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ def create_args_from_folder(

:param folder: the python project folder
:return: the frontend creation args
:raises ValueError: if ``build-system`` is not a table, or one of its keys has a type PEP-518 rules out
:raises ValueError: if ``build-system`` is not a table, one of its keys has a type PEP-518 rules out, or a
``backend-path`` entry does not resolve inside the project (PEP-517 in-tree backends)

E.g., to create a frontend from a python project folder:

Expand All @@ -228,7 +229,9 @@ def create_args_from_folder(
msg = f"build-system must be a table, got {build_system!r}"
raise ValueError(msg)
if "backend-path" in build_system:
backend_paths: tuple[Path, ...] = tuple(folder / p for p in _str_list(build_system, "backend-path"))
backend_paths: tuple[Path, ...] = tuple(
_in_tree_path(folder, entry) for entry in _str_list(build_system, "backend-path")
)
else:
backend_paths = ()
if "requires" in build_system:
Expand Down Expand Up @@ -539,6 +542,19 @@ def _str_list(build_system: dict[str, Any], key: str) -> list[str]:
return value


def _in_tree_path(folder: Path, entry: str) -> Path:
path = folder / entry
# Python 3.15 on Windows resolves a path containing NUL instead of raising
try:
inside = "\0" not in entry and not Path(entry).is_absolute() and path.resolve().is_relative_to(folder.resolve())
except (OSError, RuntimeError): # RuntimeError: symlink loop before Python 3.13
inside = False
if not inside:
msg = f"build-system.backend-path entry {entry!r} must be a relative path inside the project {folder}"
raise ValueError(msg)
return path


__all__ = [
"BackendFailed",
"CmdStatus",
Expand Down
34 changes: 34 additions & 0 deletions tests/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
if TYPE_CHECKING:
from collections.abc import Callable

import pytest_mock


@pytest.fixture
def local_builder(tmp_path: Path) -> Callable[[str], Path]:
Expand Down Expand Up @@ -183,6 +185,38 @@ def test_empty_pyproject(tmp_path: Path) -> None:
assert str(left) == str(right)


@pytest.mark.parametrize(
"backend_path",
[
pytest.param("../outside", id="parent"),
pytest.param("{tmp}/outside", id="absolute-outside"),
pytest.param("{tmp}/project", id="absolute-inside"),
pytest.param("link", id="symlink-outside"),
pytest.param("\\u0000", id="nul"),
],
)
def test_create_args_from_folder_rejects_out_of_tree_backend_path(tmp_path: Path, backend_path: str) -> None:
project = tmp_path / "project"
project.mkdir()
(project / "link").symlink_to(tmp_path, target_is_directory=True)
(project / "pyproject.toml").write_text(
f'[build-system]\nbackend-path = ["{backend_path.format(tmp=tmp_path.as_posix())}"]\nbuild-backend = "b"\n'
)

with pytest.raises(ValueError, match=r"backend-path entry .* must be a relative path inside the project"):
SubprocessFrontend.create_args_from_folder(project)


def test_create_args_from_folder_rejects_unresolvable_backend_path(
tmp_path: Path, mocker: pytest_mock.MockerFixture
) -> None:
(tmp_path / "pyproject.toml").write_text('[build-system]\nbackend-path = ["."]\nbuild-backend = "b"\n')
mocker.patch.object(Path, "resolve", autospec=True, side_effect=RuntimeError("Symlink loop"))

with pytest.raises(ValueError, match=r"backend-path entry '\.' must be a relative path inside the project"):
SubprocessFrontend.create_args_from_folder(tmp_path)


@pytest.fixture(scope="session")
def demo_pkg_inline() -> Path:
return Path(__file__).absolute().parent / "demo_pkg_inline"
Expand Down
Loading