Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Fixed a regression in quoting of pip arguments introduced in
[#185](https://github.com/pyodide/pyodide-build/pull/185).
[#209](https://github.com/pyodide/pyodide-build/pull/209)

- `pyodide-build` now correctly works with file paths with spaces passed to the `PIP_CONSTRAINT` environment variable.
[#210](https://github.com/pyodide/pyodide-build/pull/210)

## [0.30.4] - 2025/05/20

- Fixed compatibility with `virtualenv` 20.31 and later. The Pyodide virtual environment via `pyodide venv` no longer seeds
Expand Down
15 changes: 8 additions & 7 deletions pyodide_build/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
from packaging.tags import Tag, compatible_tags, cpython_tags

from pyodide_build import __version__
from pyodide_build.common import default_xbuildenv_path, search_pyproject_toml, to_bool
from pyodide_build.common import (
default_xbuildenv_path,
path_to_uri_if_spaces,
search_pyproject_toml,
to_bool,
)

RUST_BUILD_PRELUDE = """
rustup default ${RUST_TOOLCHAIN}
Expand Down Expand Up @@ -334,13 +339,9 @@ def _create_constraints_file() -> str:
if not constraints:
return ""

if len(constraints.split(maxsplit=1)) > 1:
raise ValueError(
"PIP_CONSTRAINT contains spaces so pip will misinterpret it. Make sure the path to pyodide has no spaces.\n"
"See https://github.com/pypa/pip/issues/13283"
)

constraints_file = Path(constraints)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose you'd have to split and take the first entry if you're keeping the following code? constraints might contain two files, so Path(constraints) wouldn't refer to a file.

constraints = path_to_uri_if_spaces(constraints_file)

if not constraints_file.is_file():
constraints_file.parent.mkdir(parents=True, exist_ok=True)
constraints_file.write_text("")
Expand Down
21 changes: 21 additions & 0 deletions pyodide_build/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,24 @@ def retrying_rmtree(d):
else:
raise
raise RuntimeError(f"shutil.rmtree('{d}') failed with ENOTEMPTY three times")


def path_to_uri_if_spaces(path: str | Path) -> str:
"""
Convert a file path to a URI if it contains spaces, otherwise return as string.

This works around a pip bug where paths with spaces in PIP_CONSTRAINT are
misinterpreted as multiple files; see https://github.com/pypa/pip/issues/13283

Parameters
----------
path
The file path to potentially convert

Returns
-------
str
The path as a URI if it contains spaces, or otherwise as a string.
"""
path_obj = Path(path)
return path_obj.as_uri() if " " in str(path_obj) else str(path_obj)
20 changes: 12 additions & 8 deletions pyodide_build/recipe/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
find_matching_wheel,
make_zip_archive,
modify_wheel,
path_to_uri_if_spaces,
retag_wheel,
retrying_rmtree,
)
Expand Down Expand Up @@ -136,11 +137,9 @@ def __init__(
self.build_dir = (
Path(build_dir).resolve() if build_dir else self.pkg_root / "build"
)
if len(str(self.build_dir).split(maxsplit=1)) > 1:
raise ValueError(
"PIP_CONSTRAINT contains spaces so pip will misinterpret it. Make sure the path to the package build directory has no spaces.\n"
"See https://github.com/pypa/pip/issues/13283"
)
# If a path to a file specified PIP_CONSTRAINT contains spaces, pip will misinterpret
# it as multiple files; see https://github.com/pypa/pip/issues/13283
# We work around this by converting the path to a URI when needed.
self.library_install_prefix = self.build_dir.parent.parent / ".libs"
self.src_extract_dir = (
self.build_dir / self.fullname
Expand Down Expand Up @@ -367,7 +366,7 @@ def _download_and_extract(self) -> None:
shutil.move(self.build_dir / extract_dir_name, self.src_extract_dir)
self.src_dist_dir.mkdir(parents=True, exist_ok=True)

def _create_constraints_file(self) -> str:
def _create_constraints_file(self, filename: str = "constraints.txt") -> str:
"""
Creates a pip constraints file by concatenating global constraints (PIP_CONSTRAINT)
with constraints specific to this package.
Expand All @@ -381,12 +380,17 @@ def _create_constraints_file(self) -> str:
# nothing to override
return host_constraints

new_constraints_file = self.build_dir / "constraints.txt"
new_constraints_file = self.build_dir / filename
with new_constraints_file.open("w") as f:
for constraint in constraints:
f.write(constraint + "\n")

return host_constraints + " " + str(new_constraints_file)
new_constraints_str = path_to_uri_if_spaces(new_constraints_file)

if host_constraints:
return host_constraints + " " + new_constraints_str
else:
return new_constraints_str

def _compile(
self,
Expand Down
23 changes: 23 additions & 0 deletions pyodide_build/tests/recipe/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,29 @@ def test_create_constraints_file_override(tmp_path, dummy_xbuildenv):
assert data[-3:] == ["numpy < 2.0", "pytest == 7.0", "setuptools < 75"], data


def test_create_constraints_file_space_in_path_uri_conversion(
tmp_path, dummy_xbuildenv
):
build_dir_with_spaces = tmp_path / "build dir with spaces"
build_dir_with_spaces.mkdir()

builder = RecipeBuilder.get_builder(
recipe=RECIPE_DIR / "pkg_test_constraint",
build_args=BuildArgs(),
build_dir=build_dir_with_spaces,
)

paths = builder._create_constraints_file(filename="constraints with space.txt")

parts = paths.split()
if len(parts) > 1:
last_part = parts[-1]
if "with%20space" in last_part or last_part.startswith("file://"):
assert True
else:
assert "constraints with space.txt" in last_part


class MockSourceSpec(_SourceSpec):
@pydantic.model_validator(mode="after")
def _check_patches_extra(self) -> Self:
Expand Down
20 changes: 20 additions & 0 deletions pyodide_build/tests/test_build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,23 @@ def test_wheel_paths(dummy_xbuildenv):
f"{current_version}-none-any",
]
)


def test_create_constraints_file_with_spaces(tmp_path, monkeypatch, reset_cache):
from pyodide_build.build_env import _create_constraints_file

constraints_dir = tmp_path / "path with spaces"
constraints_dir.mkdir()
constraints_file = constraints_dir / "constraints.txt"
constraints_file.write_text("numpy==1.0\n")

def mock_get_build_flag(name):
if name == "PIP_CONSTRAINT":
return str(constraints_file)

monkeypatch.setattr("pyodide_build.build_env.get_build_flag", mock_get_build_flag)

result = _create_constraints_file()

assert result.startswith("file://")
assert "path%20with%20spaces" in result or "path with spaces" in result