diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fd57225..babb7854 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -219,9 +219,65 @@ jobs: - name: Test installed SDist run: .venv/bin/pytest ./tests + bootstrap_build: + name: Source only build on ${{ matrix.os }} + needs: [lint] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + id: python + with: + python-version: "3.x" + + - name: Remove cmake and ninja + shell: bash + run: | + # Remove cmake and ninja + set -euxo pipefail + # https://github.com/scikit-build/scikit-build-core/blob/3943920fa267dc83f9295279bea1c774c0916f13/src/scikit_build_core/program_search.py#L51 + # https://github.com/scikit-build/scikit-build-core/blob/3943920fa267dc83f9295279bea1c774c0916f13/src/scikit_build_core/program_search.py#L70 + for TOOL in cmake cmake3 ninja-build ninja samu; do + while which ${TOOL}; do + if [ "$RUNNER_OS" == "Windows" ]; then + rm -f "$(which ${TOOL})" + else + sudo rm -f $(which -a ${TOOL}) + fi + done + done + + - name: Build SDist + run: pipx run --python '${{ steps.python.outputs.python-path }}' build --sdist + + - name: Install dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libssl-dev + + - name: Install SDist + shell: bash + env: + CMAKE_ARGS: "-DBUILD_CMAKE_FROM_SOURCE:BOOL=OFF" + CMAKE_BUILD_PARALLEL_LEVEL: "4" + MACOSX_DEPLOYMENT_TARGET: "10.10" + run: | + python -m pip install -v --no-binary='cmake,ninja' dist/*.tar.gz + rm -rf dist + + - name: Test installed SDist + shell: bash + run: python -m pip install pytest pytest-cov && pytest ./tests + check_dist: name: Check dist - needs: [build_wheels, build_manylinux2010_wheels, build_sdist, test_sdist] + needs: [build_wheels, build_manylinux2010_wheels, build_sdist, test_sdist, bootstrap_build] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 diff --git a/_build_backend/backend.py b/_build_backend/backend.py new file mode 100644 index 00000000..39d904a2 --- /dev/null +++ b/_build_backend/backend.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os + +from scikit_build_core import build as _orig + +if hasattr(_orig, "prepare_metadata_for_build_editable"): + prepare_metadata_for_build_editable = _orig.prepare_metadata_for_build_editable +if hasattr(_orig, "prepare_metadata_for_build_wheel"): + prepare_metadata_for_build_wheel = _orig.prepare_metadata_for_build_wheel +build_editable = _orig.build_editable +build_sdist = _orig.build_sdist +get_requires_for_build_editable = _orig.get_requires_for_build_editable +get_requires_for_build_sdist = _orig.get_requires_for_build_sdist + + +def _strtobool(value: str) -> bool: + """ + Converts a environment variable string into a boolean value. + """ + if not value: + return False + value = value.lower() + if value.isdigit(): + return bool(int(value)) + return value not in {"n", "no", "off", "false", "f"} + + +def get_requires_for_build_wheel( + config_settings: dict[str, str | list[str]] | None = None, +) -> list[str]: + packages_orig = _orig.get_requires_for_build_wheel(config_settings) + allow_cmake = _strtobool(os.environ.get("CMAKE_PYTHON_DIST_ALLOW_CMAKE_DEP", "")) + allow_ninja = any( + _strtobool(os.environ.get(var, "")) + for var in ("CMAKE_PYTHON_DIST_FORCE_NINJA_DEP", "CMAKE_PYTHON_DIST_ALLOW_NINJA_DEP") + ) + packages = [] + for package in packages_orig: + package_name = package.lower().split(">")[0].strip() + if package_name == "cmake" and not allow_cmake: + continue + if package_name == "ninja" and not allow_ninja: + continue + packages.append(package) + return packages + + +def _bootstrap_build(temp_path: str, config_settings: dict[str, list[str] | str] | None = None) -> str: + import hashlib + import platform + import re + import shutil + import subprocess + import tarfile + import urllib.request + import zipfile + from pathlib import Path + + env = os.environ.copy() + temp_path_ = Path(temp_path) + + archive_dir = temp_path_ + if config_settings: + archive_dir = Path(config_settings.get("cmake.define.CMakePythonDistributions_ARCHIVE_DOWNLOAD_DIR", archive_dir)) + archive_dir.mkdir(parents=True, exist_ok=True) + + if os.name == "posix": + if "MAKE" not in env: + make_path = None + make_candidates = ("gmake", "make", "smake") + for candidate in make_candidates: + make_path = shutil.which(candidate) + if make_path is not None: + break + if make_path is None: + msg = f"Could not find a make program. Tried {make_candidates!r}" + raise ValueError(msg) + env["MAKE"] = make_path + make_path = env["MAKE"] + kind = "unix_source" + else: + assert os.name == "nt" + machine = platform.machine() + kinds = { + "x86": "win32_binary", + "AMD64": "win64_binary", + "ARM64": "winarm64_binary", + } + if machine not in kinds: + msg = f"Could not find CMake required to build on a {machine} system" + raise ValueError(msg) + kind = kinds[machine] + + + cmake_urls = Path("CMakeUrls.cmake").read_text() + archive_url = re.findall(rf'set\({kind}_url\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] + archive_sha256 = re.findall(rf'set\({kind}_sha256\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] + + archive_name = archive_url.rsplit("/", maxsplit=1)[1] + archive_path = archive_dir / archive_name + if not archive_path.exists(): + with urllib.request.urlopen(archive_url) as response: + archive_path.write_bytes(response.read()) + + sha256 = hashlib.sha256(archive_path.read_bytes()).hexdigest() + if archive_sha256.lower() != sha256.lower(): + msg = f"Invalid sha256 for {archive_url!r}. Expected {archive_sha256!r}, got {sha256!r}" + raise ValueError(msg) + + if os.name == "posix": + assert archive_name.endswith(".tar.gz") + tar_filter_kwargs = {"filter": "tar"} if hasattr(tarfile, "tar_filter") else {} + with tarfile.open(archive_path) as tar: + tar.extractall(path=temp_path_, **tar_filter_kwargs) + + parallel_str = env.get("CMAKE_BUILD_PARALLEL_LEVEL", "1") + parallel = max(0, int(parallel_str) if parallel_str.isdigit() else 1) or os.cpu_count() or 1 + + bootstrap_path = next(temp_path_.glob("cmake-*/bootstrap")) + prefix_path = temp_path_ / "cmake-install" + cmake_path = prefix_path / "bin" / "cmake" + bootstrap_args = [f"--prefix={prefix_path}", "--no-qt-gui", "--no-debugger", "--parallel={parallel}", "--", "-DBUILD_TESTING=OFF", "-DBUILD_CursesDialog:BOOL=OFF"] + previous_cwd = Path().absolute() + os.chdir(bootstrap_path.parent) + try: + subprocess.run([bootstrap_path, *bootstrap_args], env=env, check=True) + subprocess.run([make_path, "-j", f"{parallel}"], env=env, check=True) + subprocess.run([make_path, "install"], env=env, check=True) + finally: + os.chdir(previous_cwd) + else: + assert archive_name.endswith(".zip") + with zipfile.ZipFile(archive_path) as zip_: + zip_.extractall(path=temp_path_) + cmake_path = next(temp_path_.glob("cmake-*/bin/cmake.exe")) + + return str(cmake_path) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, list[str] | str] | None = None, + metadata_directory: str | None = None, +) -> str: + from scikit_build_core.errors import CMakeNotFoundError + + try: + return _orig.build_wheel(wheel_directory, config_settings, metadata_directory) + except CMakeNotFoundError: + if os.name not in {"posix", "nt"}: + raise + # Let's try bootstrapping CMake + import tempfile + with tempfile.TemporaryDirectory() as temp_path: + cmake_path = _bootstrap_build(temp_path, config_settings) + assert cmake_path + os.environ["CMAKE_EXECUTABLE"] = cmake_path + return _orig.build_wheel(wheel_directory, config_settings, metadata_directory) diff --git a/pyproject.toml b/pyproject.toml index 3de91603..d26a277f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["scikit-build-core"] -build-backend = "scikit_build_core.build" +requires = ["scikit-build-core>=0.10"] +build-backend = "backend" +backend-path = ["_build_backend"] [project] name = "cmake" @@ -51,10 +52,10 @@ cpack = "cmake:cpack" ctest = "cmake:ctest" [tool.scikit-build] -minimum-version = "0.8" +minimum-version = "build-system.requires" build-dir = "build/{wheel_tag}" -cmake.version = "" # We are cmake, so don't request cmake -ninja.make-fallback = false +cmake.version = "CMakeLists.txt" +ninja.make-fallback = true wheel.py-api = "py3" wheel.expand-macos-universal-tags = true wheel.install-dir = "cmake/data" @@ -65,6 +66,15 @@ template = ''' version = "${version}" ''' +[[tool.scikit-build.overrides]] +if.env.CMAKE_PYTHON_DIST_FORCE_NINJA_DEP = true +ninja.make-fallback = false + +[[tool.scikit-build.overrides]] +if.state = "metadata_wheel" +wheel.cmake = false +wheel.platlib = true + [tool.cibuildwheel] build = "cp39-*" @@ -72,6 +82,7 @@ test-extras = "test" test-command = "pytest {project}/tests" build-verbosity = 1 build-frontend = "build[uv]" +environment = { CMAKE_PYTHON_DIST_FORCE_NINJA_DEP = "1" } musllinux-x86_64-image = "musllinux_1_1" musllinux-i686-image = "musllinux_1_1" musllinux-aarch64-image = "musllinux_1_1" @@ -79,8 +90,10 @@ musllinux-ppc64le-image = "musllinux_1_1" musllinux-s390x-image = "musllinux_1_1" musllinux-armv7l-image = "musllinux_1_2" -[tool.cibuildwheel.macos.environment] -MACOSX_DEPLOYMENT_TARGET = "10.10" +[[tool.cibuildwheel.overrides]] +select = "*-macos*" +inherit.environment = "append" +environment = { MACOSX_DEPLOYMENT_TARGET = "10.10" } [tool.cibuildwheel.linux] before-all = "./scripts/manylinux-build-and-install-openssl.sh"