diff --git a/changes/101.feature.md b/changes/101.feature.md new file mode 100644 index 0000000..b4dc2bd --- /dev/null +++ b/changes/101.feature.md @@ -0,0 +1 @@ +If the `XBUILD_PATH` environment variable is set, its contents are now prepended to the `PATH` used by `xbuild` when building for a target platform. diff --git a/docs/en/reference/environment-variables.md b/docs/en/reference/environment-variables.md index 30fadc3..8961bc7 100644 --- a/docs/en/reference/environment-variables.md +++ b/docs/en/reference/environment-variables.md @@ -17,3 +17,17 @@ darwin ``` Clearing the variable, or setting it (case-insensitively) to `1` or `on`, leaves the cross-platform patches active. + +## `XBUILD_PATH` + +Additional directories to *prepend* to `PATH` when `xbuild` prepares the build environment for the target platform. Uses the same syntax as the platform's `PATH` variable. + +This variable is honored on all platforms, but it is most useful when building for iOS. When building for iOS, `xbuild` replaces `PATH` with a minimal, clean value so that no build-machine tools leak into the build, but some builds still need specific build-machine tools (e.g. `cmake` or `ninja`). Directories in `XBUILD_PATH` are added ahead of that clean `PATH`: + +```console +(venv) $ XBUILD_PATH=/opt/homebrew/opt/cmake/bin xbuild --platform ios --arch arm64-iphonesimulator +``` + +Directories in `XBUILD_PATH` take precedence over the tools `xbuild` provides for the target platform. Point `XBUILD_PATH` at directories that contain only the specific tools you need, rather than at a general directory like `/opt/homebrew/bin`, which could shadow the target-platform compiler or Python interpreter. + +`XBUILD_PATH` only applies to builds performed by `xbuild`. It has no effect on a cross-platform environment that is activated and used directly. diff --git a/docs/en/topics/how-it-works.md b/docs/en/topics/how-it-works.md index 63b83ec..f994223 100644 --- a/docs/en/topics/how-it-works.md +++ b/docs/en/topics/how-it-works.md @@ -38,6 +38,8 @@ In addition to converting a virtual environment into a cross-platform environmen - **Android**: `CC`, `AR`, `AS`, `CXX`, `LD`, `NM`, `RANLIB`, `READELF`, `STRIP`, `CFLAGS`, `LDFLAGS`, `CXXFLAGS`, `CPU_COUNT`, and (if available) `PKG_CONFIG`/`PKG_CONFIG_LIBDIR` are set by running the target Android Python build's own bundled `android.py env` command (which installs the exact required NDK version under `$ANDROID_HOME/ndk/` if it isn't already present). This requires `ANDROID_HOME` to already be set - see [Platform setup: Android](../how-to/platform-setup/android.md). - **iOS**: `PATH` is replaced with the cross-platform environment's own `bin/` directory, followed by the target-platform `Python.xcframework` slice's `bin/` directory (containing the `clang`/`ar`/`strip` shims for the target architecture), followed by a fixed, minimal set of system directories, ensuring no build-machine-native tools leak into the build. +On all platforms, if the [`XBUILD_PATH`](../reference/environment-variables.md#xbuild_path) environment variable is set, its contents are prepended to `PATH`. This allows specific build-machine tools to be made available to the build, even on platforms (such as iOS) where `PATH` is otherwise replaced. + Build-platform tools (e.g. Android's `android.py`, or the iOS testbed driver) are always run with `XBUILD_ENV=off`, so that they behave correctly even when `xbuild` or `xpython` is itself running inside a cross-platform environment. This preparation is `xbuild`-specific; it does not apply to a cross-platform environment created by `xvenv` and used directly outside of `xbuild`. diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 9c526f4..db1195b 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -13,6 +13,7 @@ monkeypatching natively NDK pre +prepended PyPI README SDK diff --git a/src/xvenv/convert.py b/src/xvenv/convert.py index ae762e6..b81a7a1 100644 --- a/src/xvenv/convert.py +++ b/src/xvenv/convert.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import pprint import re import sys @@ -361,6 +362,11 @@ def create(self, venv_path: Path, with_pip: bool = True): def prepare_env(self) -> dict[str, str]: """Prepare the environment variables needed to build for this cross environment. + If the `XBUILD_PATH` environment variable is set (and non-empty), it is + prepended to `PATH`. If the platform module provides a `PATH` (e.g., iOS, + which replaces `PATH` entirely), `XBUILD_PATH` is prepended to that value; + otherwise, it is prepended to the inherited `PATH`. + :returns: A dict of environment variables to merge into `os.environ` for the duration of the build. :raises ValueError: if the platform module's own `prepare_env()` @@ -369,7 +375,15 @@ def prepare_env(self) -> dict[str, str]: :raises NotImplementedError: for platforms that don't support environment preparation yet (currently: emscripten). """ - return self.platform_module.prepare_env(self) + env = dict(self.platform_module.prepare_env(self)) + + if xbuild_path := os.environ.get("XBUILD_PATH"): + base_path = env.get("PATH", os.environ.get("PATH", "")) + env["PATH"] = os.pathsep.join( + part for part in [xbuild_path, base_path] if part + ) + + return env def packages_path(self, work_path: Path) -> Path: """Return the location, relative to the provided path, where the diff --git a/tests/_xvenv/test_convert.py b/tests/_xvenv/test_convert.py index 17c3c1f..454aecd 100644 --- a/tests/_xvenv/test_convert.py +++ b/tests/_xvenv/test_convert.py @@ -1,5 +1,6 @@ import io import json +import os import sys from pathlib import Path from unittest.mock import Mock @@ -389,17 +390,58 @@ def test_config_missing_sysconfigdata_file(tmp_path): ) -def test_prepare_env_dispatches_to_platform_module(tmp_path, mock_config): - """`prepare_env()` delegates to the platform module.""" +@pytest.mark.parametrize( + ("orig_path", "xbuild_path", "final_path"), + [ + # XBUILD_PATH isn't defined + ( + ["/venv/bin", "/usr/bin"], + None, + ["/venv/bin", "/usr/bin"], + ), + # XBUILD_PATH defined, but empty + ( + ["/venv/bin", "/usr/bin"], + "", + ["/venv/bin", "/usr/bin"], + ), + # XBUILD_PATH with actual values + ( + ["/venv/bin", "/usr/bin"], + "/local/bin", + ["/local/bin", "/venv/bin", "/usr/bin"], + ), + ( + ["/venv/bin", "/usr/bin"], + ["/other/bin", "/local/bin"], + ["/other/bin", "/local/bin", "/venv/bin", "/usr/bin"], + ), + ], +) +def test_prepare_env(mock_config, orig_path, xbuild_path, final_path, monkeypatch): + """`prepare_env()` delegates to the platform module and handles `XBUILD_PATH`.""" + if xbuild_path is None: + monkeypatch.delenv("XBUILD_PATH", raising=False) + else: + if isinstance(xbuild_path, list): + xbuild_path = os.pathsep.join(xbuild_path) + monkeypatch.setenv("XBUILD_PATH", xbuild_path) + fake_platform_module = Mock() - fake_platform_module.prepare_env.return_value = {"CC": "fake-clang"} + fake_platform_module.prepare_env.return_value = { + "CC": "fake-clang", + "PATH": os.pathsep.join(orig_path), + } mock_config.platform_module = fake_platform_module result = mock_config.prepare_env() fake_platform_module.prepare_env.assert_called_once_with(mock_config) - assert result == {"CC": "fake-clang"} + assert result == { + "CC": "fake-clang", + "PATH": os.pathsep.join(final_path), + } def _fake_venv(venv_path, version):