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_SAFE_TOOLS`](https://cibuildwheel.pypa.io/en/stable/options/#safe-tools) | Binaries on the path that are safe to include 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],
safe_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 safe_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.
safe_tools_path = venv_path / "cibw_safe_tools"
safe_tools_path.mkdir()
for tool in safe_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} is a safe tool in the cross-build environment (using {original})")
(safe_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 safe tools
str(safe_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,
safe_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,
safe_tools=safe_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,
safe_tools=build_options.safe_tools,
)
pip_version = get_pip_version(env)

Expand Down
5 changes: 5 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
safe_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,9 @@ 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=" && "))
safe_tools = shlex.split(
self.reader.get("safe-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 +842,7 @@ def _compute_build_options(self, identifier: str | None) -> BuildOptions:
before_build=before_build,
before_all=before_all,
build_verbosity=build_verbosity,
safe_tools=safe_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"
},
"safe-tools": {
"description": "Binaries on the path that are safe to include in an isolated cross-build environment",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"title": "CIBW_SAFE_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"
},
"safe-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"
},
"safe-tools": {
"$ref": "#/properties/safe-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 = ""
safe-tools = []
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want a default of ["cmake", "ninja", "swig", "rustc"]? We're pretty sure these tools do the right thing I think? cc @mayeut I think this was your original plan?

@henryiii I think that without a default, scikit-build-core won't work without configuration...

Copy link
Member

Choose a reason for hiding this comment

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

Having a default seemed "a bit too political" for @freakboy3742 in the previous PR if I recall correctly.

Do we want a default of ["cmake", "ninja", "swig", "rustc"]?

If we have a default then yes, that would have been my proposition.

I think that without a default, scikit-build-core won't work without configuration...

I think that without a default, it'll try to build cmake & fail.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we want a default of ["cmake", "ninja", "swig", "rustc"]? We're pretty sure these tools do the right thing I think? cc @mayeut I think this was your original plan?

My thought here is essentially "explicit is better than implicit", for a number of reasons:

  1. If you bless those 4 tools, then someone says "what about ?" - you now have a project policy decision to make about what constitutes sufficient notability to be in the default set? For example - why ninja, but not meson? Similarly, is there a point at which a tool would be removed from the default set?
  2. It means we'd have to remove (or at least modify) the "error if not found" handling. If someone tries to build a project that we know needs cmake, but they don't have cmake in their environment, then it seems better to me to error out as early as possible, rather than get mid build and fail. Alternatively, @mayeut's suggestion about having the setting extend the default list, and not raise an error for the default list, means there's inconsistent behavior between builtin and default tools.
  3. If there's a default set (and especially if there's a default set that is appended), there's no way to opt out of the behavior. I'm not sure I have a scenario where this would be a problem in practice, but if one arises, there's no longer a way to explicitly not put cmake on the path, other than controlling the environment in which you invoke cibuildwheel - and if cmake is in the same binary path as cibuildwheel, even that option won't work.
  4. The less often that users need to explicitly define a safe tool set, the less likely it is that the ecosystem as a whole will be aware the option exists at all.

That said - consider this a "strong opinion, weakly held". If y'all would prefer to go for a list of default tools, I'm happy to oblige. And the "soft failure" approach does have some upsides: it means the ultimate failure mode for iOS builds would be the same as other platforms (i.e., no cmake installed? Error when cmake is invoked).

If we do add a default set, I'd suggest:

  1. Adding meson to the default set as well
  2. Making the explicitly provided list an override list, not an augmented list
  3. Making it a soft failure if a tool isn't found.

Let me know what you'd like me to do and I'll implement that.

@henryiii I think that without a default, scikit-build-core won't work without configuration...

Sure - but a build also won't work on iOS without a CIBW_TEST_SOURCES configuration. For that matter, cibuildwheel for any moderately complex project won't work on any platform without at least some configuration.

Copy link
Contributor

@joerick joerick Mar 18, 2025

Choose a reason for hiding this comment

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

Some good points above, thanks @freakboy3742, though I'm still undecided.

My concern is mostly around people getting stuck. It's surprising to have PATH changed out by cibuildwheel, so when users hit the 'mybuildtool: command not found' error, do they even consider that cibuildwheel might have removed it from path? Or do they end up going down a debugging rabbit hole ("okay, i have to install cmake for some reason") before finding this option.

On that note, if we do go the explicit opt-in route, rather than putting default symlinks at the start of path, we could put some entries at the bottom of PATH instead - these symlinks would run a program to print something helpful and error out:

Your build tried to call $0, but this tool isn't listed in cibuildwheel's
`xbuild-tools` option. cibuildwheel clears the PATH variable to remove tools
which commonly provide macOS rather than iOS build configs.

If you're sure "$0" will provide the right options for cross-compilation, add
"$0" to your `xbuild-tools` config. Alternatively, you can add a dir to PATH
 using cibuildwheel's `environment` option.

Copy link
Member

Choose a reason for hiding this comment

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

yet another option would be to warn if no xbuild-tools is set in the configuration (iOS only) ? this would raise awareness at the expense of some extra configuration for users that do not require additional tools in order to silence the warning.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@joerick Completely agreed that the PATH clearing is unusual/unexpected behavior, and the UX around this is important to get right.

The idea of having a "dummy" tool is an interesting one - if I'm understanding you correctly, we'd use the "link all declared tools, error if they're missing"; with an additional layer that any "known tool" that isn't explicitly declared would be added as a dummy warning script.

However, I think @mayeut's idea is even better. An iOS configuration must define test-sources; requiring an explicit xbuild-tools=[] as well doesn't seem too onerous. That requirement can be documented in the iOS platform guide; and if a user doesn't read the documentation (shocking, I know 🤣), it provides a clear and unambiguous reason to raise an error early in the build process. The messaging in that error can point to documentation that gives the deeper explanation, provides a workaround for the simple case, and details how to handle more complex cases. And those more complex cases are completely unambiguous - there's no need for a list of "blessed" tools, or a need to differentiate "cmake" from "other build tools".

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, so many tradeoffs! Ideally, I'd rather to avoid the concerns of complicated builds polluting the user journey of simple ones.1 Most/many builds would just set xbuild-tools = [] and move on.

An iOS configuration must define test-sources; requiring an explicit xbuild-tools=[] as well doesn't seem too onerous.

True, but only if test-command is set. The zero-config option still works - i.e. make a pure setuptools package, run cibuildwheel --platform ios, it builds you some wheels.

To stick with the current train of thought, we could soften the xbuild-tools=[] requirement a bit - make the missing setting a warning rather than an error. That would save users a modify/commit/push/CI cycle and they'd eventually get the message and set it.

Lets enumerate the options...

  1. Set a default like ["cmake", "ninja", "swig", "rustc", "meson"].
    • Pros:
      • Just works™ for most users
      • Simple to implement
    • Cons:
      • Project-level subjective decision of what to put in this list.
      • There's a UX cliff if your build requires something not in our list - the PATH hiding mechanism was hidden from you.
  2. Insert warning exes for common tools like cmake, ninja, swig, rustc, meson at the bottom of path
    • Pros:
      • Avoids the UX cliff of not understanding the PATH change
    • Cons:
      • Only works if the tool you require is in the list
      • It's a bit of a hack - would cause issues when combined with a with a user configuration of CIBW_ENVIRONMENT: "PATH=$PATH:/mybuildtools"
  3. Raise a warning if xbuild-tools is unset, but continue the build assuming that it's [].
    • Pros:
      • Avoids the UX cliff of not understanding the PATH change
      • Simple to implement
    • Cons:
      • Requires eventually setting xbuild-tools, even if not using any tools.

On reflection, I'm happy with (3). The other nice thing about (3), is that the warning can say that if the build succeeds, the right value is []. But if it fails with somebuildtool: command not found, that should be listed in xbuild-tools.

Footnotes

  1. My philosophy here is 'make the simple stuff easy, make the complicated stuff possible' and 'minimal configuration'. But it's all tradeoffs :)

repair-wheel-command = ""

test-command = ""
Expand Down
29 changes: 29 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,35 @@ 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_SAFE_TOOLS` {: #safe-tools}
> Binaries on the path that are safe to include 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 declared as "safe" using `CIBW_SAFE_TOOLS`. These binaries 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 safe, and that tool cannot be found in the runtime environment, an error will be raised.

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

#### Examples

!!! tab examples "Environment variables"

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

!!! tab examples "pyproject.toml"

```toml
[tool.cibuildwheel]

# Allow access to the cmake, ninja and rustc binaries in the isolated cross-build environment.
safe-tools = ["cmake", "ninja", "rustc"]
```

### `CIBW_REPAIR_WHEEL_COMMAND` {: #repair-wheel-command}
> Execute a shell command to repair each built wheel
Expand Down
57 changes: 53 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,39 @@ 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")
if "CIBW_SAFE_TOOLS" in build_config and shutil.which("cmake") is None:
pytest.xfail("test machine doesn't have cmake installed")

# 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 safe, 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_SAFE_TOOLS": "does-exist",
"CIBW_TEST_SOURCES": "tests",
"CIBW_TEST_COMMAND": "unittest discover tests test_platform.py",
**build_config,
Expand All @@ -71,14 +93,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 +116,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_safe_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_SAFE_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"}
safe-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.safe_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.safe_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_SAFE_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("safe-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