Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to declare safe tools in a cross-build environment. #2317

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Options
| | [`CIBW_ENVIRONMENT_PASS_LINUX`](https://cibuildwheel.pypa.io/en/stable/options/#environment-pass) | Set environment variables on the host to pass-through to the container during the build. |
| | [`CIBW_BEFORE_ALL`](https://cibuildwheel.pypa.io/en/stable/options/#before-all) | Execute a shell command on the build system before any wheels are built. |
| | [`CIBW_BEFORE_BUILD`](https://cibuildwheel.pypa.io/en/stable/options/#before-build) | Execute a shell command preparing each wheel's build |
| | [`CIBW_XBUILD_TOOLS`](https://cibuildwheel.pypa.io/en/stable/options/#xbuild-tools) | Binaries on the path that should be included in an isolated cross-build environment. |
| | [`CIBW_REPAIR_WHEEL_COMMAND`](https://cibuildwheel.pypa.io/en/stable/options/#repair-wheel-command) | Execute a shell command to repair each built wheel |
| | [`CIBW_MANYLINUX_*_IMAGE`<br/>`CIBW_MUSLLINUX_*_IMAGE`](https://cibuildwheel.pypa.io/en/stable/options/#linux-image) | Specify alternative manylinux / musllinux Docker images |
| | [`CIBW_CONTAINER_ENGINE`](https://cibuildwheel.pypa.io/en/stable/options/#container-engine) | Specify which container engine to use when building Linux wheels |
Expand Down
40 changes: 33 additions & 7 deletions cibuildwheel/ios.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ def cross_virtualenv(
build_python: Path,
venv_path: Path,
dependency_constraint_flags: Sequence[PathOrStr],
xbuild_tools: Sequence[str],
) -> dict[str, str]:
"""Create a cross-compilation virtual environment.

Expand Down Expand Up @@ -174,6 +175,8 @@ def cross_virtualenv(
created.
:param dependency_constraint_flags: Any flags that should be used when
constraining dependencies in the environment.
:param xbuild_tools: A list of executable names (without paths) that are
on the path, but must be preserved in the cross environment.
"""
# Create an initial macOS virtual environment
env = virtualenv(
Expand Down Expand Up @@ -206,14 +209,33 @@ def cross_virtualenv(
#
# To prevent problems, set the PATH to isolate the build environment from
# sources that could introduce incompatible binaries.
#
# However, there may be some tools on the path that are needed for the
# build. Find their location on the path, and link the underlying binaries
# (fully resolving symlinks) to a "safe" location that will *only* contain
# those tools. This avoids needing to add *all* of Homebrew to the path just
# to get access to (for example) cmake for build purposes.
xbuild_tools_path = venv_path / "cibw_xbuild_tools"
xbuild_tools_path.mkdir()
for tool in xbuild_tools:
tool_path = shutil.which(tool)
if tool_path is None:
msg = f"Could not find a {tool!r} executable on the path."
raise errors.FatalError(msg)
Comment on lines +223 to +224
Copy link
Contributor

Choose a reason for hiding this comment

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

(if we do set a default, this would be:)

Suggested change
msg = f"Could not find a {tool!r} executable on the path."
raise errors.FatalError(msg)
continue

Copy link
Member

Choose a reason for hiding this comment

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

I'm seeing this a bit differently.
If it's been requested in the configuration, then fail, it's a default tool then continue. (this would probably mean the default list does not initialize safe-tools but rather extends it).


# Link the binary into the safe tools directory
original = Path(tool_path).resolve()
print(f"{tool!r} will be included in the cross-build environment (using {original})")
(xbuild_tools_path / tool).symlink_to(original)

env["PATH"] = os.pathsep.join(
[
# The target python's binary directory
str(target_python.parent),
# The cross-platform environments binary directory
# The cross-platform environment's binary directory
str(venv_path / "bin"),
# Cargo's binary directory (to allow for Rust compilation)
str(Path.home() / ".cargo" / "bin"),
# The directory of cross-build tools
str(xbuild_tools_path),
# The bare minimum Apple system paths.
"/usr/bin",
"/bin",
Expand All @@ -231,10 +253,12 @@ def cross_virtualenv(

def setup_python(
tmp: Path,
*,
python_configuration: PythonConfiguration,
dependency_constraint_flags: Sequence[PathOrStr],
environment: ParsedEnvironment,
build_frontend: BuildFrontendName,
xbuild_tools: Sequence[str],
) -> tuple[Path, dict[str, str]]:
if build_frontend == "build[uv]":
msg = "uv doesn't support iOS"
Expand Down Expand Up @@ -287,6 +311,7 @@ def setup_python(
build_python=build_python,
venv_path=venv_path,
dependency_constraint_flags=dependency_constraint_flags,
xbuild_tools=xbuild_tools,
)
venv_bin_path = venv_path / "bin"
assert venv_bin_path.exists()
Expand Down Expand Up @@ -410,10 +435,11 @@ def build(options: Options, tmp_path: Path) -> None:

target_install_path, env = setup_python(
identifier_tmp_dir / "build",
config,
dependency_constraint_flags,
build_options.environment,
build_frontend.name,
python_configuration=config,
dependency_constraint_flags=dependency_constraint_flags,
environment=build_options.environment,
build_frontend=build_frontend.name,
xbuild_tools=build_options.xbuild_tools,
)
pip_version = get_pip_version(env)

Expand Down
7 changes: 7 additions & 0 deletions cibuildwheel/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class BuildOptions:
environment: ParsedEnvironment
before_all: str
before_build: str | None
xbuild_tools: list[str]
repair_command: str
manylinux_images: dict[str, str] | None
musllinux_images: dict[str, str] | None
Expand Down Expand Up @@ -696,6 +697,11 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions:

test_command = self.reader.get("test-command", option_format=ListFormat(sep=" && "))
before_test = self.reader.get("before-test", option_format=ListFormat(sep=" && "))
xbuild_tools = shlex.split(
self.reader.get(
"xbuild-tools", option_format=ListFormat(sep=" ", quote=shlex.quote)
)
)
test_sources = shlex.split(
self.reader.get(
"test-sources", option_format=ListFormat(sep=" ", quote=shlex.quote)
Expand Down Expand Up @@ -838,6 +844,7 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions:
before_build=before_build,
before_all=before_all,
build_verbosity=build_verbosity,
xbuild_tools=xbuild_tools,
repair_command=repair_command,
environment=environment,
dependency_constraints=dependency_constraints,
Expand Down
21 changes: 21 additions & 0 deletions cibuildwheel/resources/cibuildwheel.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,21 @@
"description": "Specify alternative manylinux / musllinux container images",
"title": "CIBW_MUSLLINUX_X86_64_IMAGE"
},
"xbuild-tools": {
"description": "Binaries on the path that should be included in an isolated cross-build environment",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"title": "CIBW_XBUILD_TOOLS"
},
"repair-wheel-command": {
"description": "Execute a shell command to repair each built wheel.",
"oneOf": [
Expand Down Expand Up @@ -566,6 +581,9 @@
"environment-pass": {
"$ref": "#/$defs/inherit"
},
"xbuild-tools": {
"$ref": "#/$defs/inherit"
},
"repair-wheel-command": {
"$ref": "#/$defs/inherit"
},
Expand Down Expand Up @@ -991,6 +1009,9 @@
"repair-wheel-command": {
"$ref": "#/properties/repair-wheel-command"
},
"xbuild-tools": {
"$ref": "#/properties/xbuild-tools"
},
"test-command": {
"$ref": "#/properties/test-command"
},
Expand Down
1 change: 1 addition & 0 deletions cibuildwheel/resources/defaults.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ build-verbosity = 0

before-all = ""
before-build = ""
xbuild-tools = []
repair-wheel-command = ""

test-command = ""
Expand Down
30 changes: 30 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,36 @@ Platform-specific environment variables are also available:<br/>
[PEP 517]: https://www.python.org/dev/peps/pep-0517/
[PEP 518]: https://www.python.org/dev/peps/pep-0517/

### `CIBW_XBUILD_TOOLS` {: #xbuild-tools}
> Binaries on the path that should be included in an isolated cross-build environment.

Copy link
Contributor

Choose a reason for hiding this comment

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

(If we do specify a default, also list it here.)

When building in a cross-platform environment, it is sometimes necessary to isolate the ``PATH`` so that binaries from the build machine don't accidentally get linked into the cross-platform binary. However, this isolation process will also hide tools that might be required to build your wheel.

If there are binaries present on the `PATH` when you invoke cibuildwheel, and those binaries are required to build your wheels, those binaries can be explicitly included in the isolated cross-build environment using `CIBW_XBUILD_TOOLS`. The binaries listed in this setting will be linked into an isolated location, and that isolated location will be put on the `PATH` of the isolated environment. You do not need to provide the full path to the binary - only the executable name that would be found by the shell.

If you declare a tool as a cross-build tool, and that tool cannot be found in the runtime environment, an error will be raised.

*Any* tool used by the build process must be included in the `CIBW_XBUILD_TOOLS` list, not just tools that cibuildwheel will invoke directly. For example, if your build invokes `cmake`, and the `cmake` script invokes `magick` to perform some image transformations, both `cmake` and `magick` must be included in your safe tools list.

Platform-specific environment variables are also available on platforms that use cross-platform environment isolation:<br/>
`CIBW_XBUILD_TOOLS_IOS`

#### Examples

!!! tab examples "Environment variables"

```yaml
# Allow access to the cmake and rustc binaries in the isolated cross-build environment.
CIBW_XBUILD_TOOLS: cmake rustc
```

!!! tab examples "pyproject.toml"

```toml
[tool.cibuildwheel]
# Allow access to the cmake and rustc binaries in the isolated cross-build environment.
xbuild-tools = ["cmake", "rustc"]
```

### `CIBW_REPAIR_WHEEL_COMMAND` {: #repair-wheel-command}
> Execute a shell command to repair each built wheel
Expand Down
4 changes: 3 additions & 1 deletion docs/platforms/ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ iOS builds support both the `pip` and `build` build frontends. In principle, sup

## Build environment

The environment used to run builds does not inherit the full user environment - in particular, `PATH` is deliberately re-written. This is because UNIX C tooling doesn't do a great job differentiating between "macOS ARM64" and "iOS ARM64" binaries. If (for example) Homebrew is on the path when compilation commands are invoked, it's easy for a macOS version of a library to be linked into the iOS binary, rendering it unusable on iOS. To prevent this, iOS builds always force `PATH` to a "known minimal" path, that includes only the bare system utilities, plus the current user's cargo folder (to facilitate Rust builds).
The environment used to run builds does not inherit the full user environment - in particular, `PATH` is deliberately re-written. This is because UNIX C tooling doesn't do a great job differentiating between "macOS ARM64" and "iOS ARM64" binaries. If (for example) Homebrew is on the path when compilation commands are invoked, it's easy for a macOS version of a library to be linked into the iOS binary, rendering it unusable on iOS. To prevent this, iOS builds always force `PATH` to a "known minimal" path, that includes only the bare system utilities, and the iOS compiler toolchain.

If your project requires additional tools to build (such as `cmake`, `ninja`, or `rustc`), those tools must be explicitly declared as cross-build tools using [`CIBW_XBUILD_TOOLS`](../../options#xbuild-tools). *Any* tool used by the build process must be included in the `CIBW_XBUILD_TOOLS` list, not just tools that cibuildwheel will invoke directly. For example, if your build script invokes `cmake`, and the `cmake` script invokes `magick` to perform some image transformations, both `cmake` and `magick` must be included in your safe tools list.

## Tests

Expand Down
56 changes: 52 additions & 4 deletions test/test_ios.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import os
import platform
import shutil
import subprocess

import pytest

from . import test_projects, utils

basic_project = test_projects.new_c_project()
basic_project.files["tests/test_platform.py"] = f"""
basic_project_files = {
"tests/test_platform.py": f"""
import platform
from unittest import TestCase

Expand All @@ -18,6 +19,7 @@ def test_platform(self):
self.assertEqual(platform.machine(), "{platform.machine()}")

"""
}


# iOS tests shouldn't be run in parallel, because they're dependent on starting
Expand All @@ -33,19 +35,38 @@ def test_platform(self):
{"CIBW_PLATFORM": "ios", "CIBW_BUILD_FRONTEND": "build"},
],
)
def test_ios_platforms(tmp_path, build_config):
def test_ios_platforms(tmp_path, build_config, monkeypatch):
if utils.platform != "macos":
pytest.skip("this test can only run on macOS")
if utils.get_xcode_version() < (13, 0):
pytest.skip("this test only works with Xcode 13.0 or greater")

# Create a temporary "bin" directory, symlink a tool that we know eixsts
# (/usr/bin/true) into that location under a name that should be unique,
# and add the temp bin directory to the PATH.
tools_dir = tmp_path / "bin"
tools_dir.mkdir()
tools_dir.joinpath("does-exist").symlink_to(shutil.which("true"))

monkeypatch.setenv("PATH", str(tools_dir), prepend=os.pathsep)

# Generate a test project that has an additional before-build step using the
# known-to-exist tool.
project_dir = tmp_path / "project"
setup_py_add = "import subprocess\nsubprocess.run('does-exist', check=True)\n"
basic_project = test_projects.new_c_project(setup_py_add=setup_py_add)
basic_project.files.update(basic_project_files)
basic_project.generate(project_dir)

# Build the wheels. Mark the "does-exist" tool as a cross-build tool, and
# invoke it during a `before-build` step. It will also be invoked when
# `setup.py` is invoked.
actual_wheels = utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_BEFORE_BUILD": "does-exist",
"CIBW_BUILD": "cp313-*",
"CIBW_XBUILD_TOOLS": "does-exist",
"CIBW_TEST_SOURCES": "tests",
"CIBW_TEST_COMMAND": "unittest discover tests test_platform.py",
**build_config,
Expand All @@ -71,14 +92,15 @@ def test_ios_platforms(tmp_path, build_config):
assert set(actual_wheels) == expected_wheels


@pytest.mark.xdist_group(name="ios")
def test_no_test_sources(tmp_path, capfd):
if utils.platform != "macos":
pytest.skip("this test can only run on macOS")
if utils.get_xcode_version() < (13, 0):
pytest.skip("this test only works with Xcode 13.0 or greater")

project_dir = tmp_path / "project"
basic_project = test_projects.new_c_project()
basic_project.files.update(basic_project_files)
basic_project.generate(project_dir)

with pytest.raises(subprocess.CalledProcessError):
Expand All @@ -93,3 +115,29 @@ def test_no_test_sources(tmp_path, capfd):

captured = capfd.readouterr()
assert "Testing on iOS requires a definition of test-sources." in captured.err


def test_missing_xbuild_tool(tmp_path, capfd):
if utils.platform != "macos":
pytest.skip("this test can only run on macOS")
if utils.get_xcode_version() < (13, 0):
pytest.skip("this test only works with Xcode 13.0 or greater")

project_dir = tmp_path / "project"
basic_project = test_projects.new_c_project()
basic_project.files.update(basic_project_files)
basic_project.generate(project_dir)

with pytest.raises(subprocess.CalledProcessError):
utils.cibuildwheel_run(
project_dir,
add_env={
"CIBW_PLATFORM": "ios",
"CIBW_BUILD": "cp313-*",
"CIBW_TEST_COMMAND": "tests",
"CIBW_XBUILD_TOOLS": "does-not-exist",
},
)

captured = capfd.readouterr()
assert "Could not find a 'does-not-exist' executable on the path." in captured.err
3 changes: 3 additions & 0 deletions unit_test/options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
build = ["cp38-*", "cp313-*"]
skip = ["*musllinux*"]
environment = {FOO="BAR"}
xbuild-tools = ["cmake", "rustc"]

test-command = "pyproject"
test-sources = ["test", "other dir"]
Expand Down Expand Up @@ -73,12 +74,14 @@ def test_options_1(tmp_path, monkeypatch):
pinned_x86_64_container_image = all_pinned_container_images["x86_64"]

local = options.build_options("cp38-manylinux_x86_64")
assert local.xbuild_tools == ["cmake", "rustc"]
assert local.manylinux_images is not None
assert local.test_command == "pyproject"
assert local.test_sources == ["test", "other dir"]
assert local.manylinux_images["x86_64"] == pinned_x86_64_container_image["manylinux1"]

local = options.build_options("cp313-manylinux_x86_64")
assert local.xbuild_tools == ["cmake", "rustc"]
assert local.manylinux_images is not None
assert local.test_command == "pyproject-override"
assert local.test_sources == ["test", "other dir"]
Expand Down
5 changes: 5 additions & 0 deletions unit_test/options_toml_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def test_envvar_override(tmp_path, platform):
env={
"CIBW_BUILD": "cp38*",
"CIBW_MANYLINUX_X86_64_IMAGE": "manylinux_2_24",
"CIBW_XBUILD_TOOLS": "cmake rustc",
"CIBW_TEST_COMMAND": "mytest",
"CIBW_TEST_REQUIRES": "docs",
"CIBW_TEST_GROUPS": "mgroup two",
Expand All @@ -104,6 +105,10 @@ def test_envvar_override(tmp_path, platform):
assert options_reader.get("manylinux-x86_64-image") == "manylinux_2_24"
assert options_reader.get("manylinux-i686-image") == "manylinux2014"

assert (
options_reader.get("xbuild-tools", option_format=ListFormat(" ", quote=shlex.quote))
== "cmake rustc"
)
assert (
options_reader.get("test-sources", option_format=ListFormat(" ", quote=shlex.quote))
== 'first "second third"'
Expand Down
Loading