Skip to content

Commit

Permalink
fix: bootstrap build (#568)
Browse files Browse the repository at this point in the history
* fix: do not require Ninja

This should prevent bootstrapping issues between CMake PyPI distribution and Ninja PyPI distribution when building either one from sources.

* fix: bootstrap build on Unix

* fix: bootstrap build on Windows

* test: bootstrap build on macOS

* chore: parse CMake version from CMakeLists.txt
  • Loading branch information
mayeut authored Dec 14, 2024
1 parent cfb38e0 commit 47e072b
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 8 deletions.
58 changes: 57 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 159 additions & 0 deletions _build_backend/backend.py
Original file line number Diff line number Diff line change
@@ -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<data>.*)"\)$', cmake_urls, flags=re.MULTILINE)[0]
archive_sha256 = re.findall(rf'set\({kind}_sha256\s+"(?P<data>.*)"\)$', 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)
27 changes: 20 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -65,22 +66,34 @@ 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-*"
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"
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"
Expand Down

0 comments on commit 47e072b

Please sign in to comment.