From 33ecabb1a920388a1a08c2e84a16b5d76d81e657 Mon Sep 17 00:00:00 2001 From: mayeut Date: Mon, 25 Nov 2024 20:46:51 +0100 Subject: [PATCH 1/5] fix: do not require Ninja This should prevent bootstrapping issues between CMake PyPI distribution and Ninja PyPI distribution when building either one from sources. --- _build_backend/backend.py | 44 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 22 +++++++++++++------- 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 _build_backend/backend.py diff --git a/_build_backend/backend.py b/_build_backend/backend.py new file mode 100644 index 00000000..b2bbe70f --- /dev/null +++ b/_build_backend/backend.py @@ -0,0 +1,44 @@ +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_wheel = _orig.build_wheel +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=None): + 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: + msg = f"CMake PyPI distibution requires {package} to be available on the build system" + raise ValueError(msg) + if package_name == "ninja" and not allow_ninja: + continue + packages.append(package) + return packages diff --git a/pyproject.toml b/pyproject.toml index 3de91603..933eacc8 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 = ">=3.13" # Since 3.24.0, CMake requires CMake 3.13+ to build itself +ninja.make-fallback = true wheel.py-api = "py3" wheel.expand-macos-universal-tags = true wheel.install-dir = "cmake/data" @@ -65,6 +66,10 @@ template = ''' version = "${version}" ''' +[[tool.scikit-build.overrides]] +if.env.CMAKE_PYTHON_DIST_FORCE_NINJA_DEP = true +ninja.make-fallback = false + [tool.cibuildwheel] build = "cp39-*" @@ -72,6 +77,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 +85,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" From 8b87b4a71a6bc53549b8bd7d074acf55d85b4a43 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 30 Nov 2024 10:13:33 +0100 Subject: [PATCH 2/5] fix: bootstrap build on Unix --- .github/workflows/build.yml | 44 ++++++++++++++- _build_backend/backend.py | 105 +++++++++++++++++++++++++++++++++--- pyproject.toml | 5 ++ 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fd57225..238fca94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -219,9 +219,51 @@ jobs: - name: Test installed SDist run: .venv/bin/pytest ./tests + bootstrap_build: + name: Bootstrap build + needs: [lint] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + id: python + with: + python-version: "3.x" + + - name: Remove cmake and ninja + 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 + sudo rm -f $(which -a ${TOOL}) + done + done + + - name: Build SDist + run: pipx run --python '${{ steps.python.outputs.python-path }}' build --sdist + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libssl-dev + + - name: Install SDist + env: + CMAKE_ARGS: "-DBUILD_CMAKE_FROM_SOURCE:BOOL=OFF" + CMAKE_BUILD_PARALLEL_LEVEL: "4" + run: | + python -m pip install -v --no-binary='cmake,ninja' dist/*.tar.gz + rm -rf dist + + - name: Test installed SDist + 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 index b2bbe70f..9bfdf5d2 100644 --- a/_build_backend/backend.py +++ b/_build_backend/backend.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from scikit_build_core import build as _orig @@ -7,13 +9,12 @@ 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_wheel = _orig.build_wheel 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: +def _strtobool(value: str) -> bool: """ Converts a environment variable string into a boolean value. """ @@ -25,20 +26,110 @@ def strtobool(value: str) -> bool: return value not in {"n", "no", "off", "false", "f"} -def get_requires_for_build_wheel(config_settings=None): +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_cmake = _strtobool(os.environ.get("CMAKE_PYTHON_DIST_ALLOW_CMAKE_DEP", "")) allow_ninja = any( - strtobool(os.environ.get(var, "")) + _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: - msg = f"CMake PyPI distibution requires {package} to be available on the build system" - raise ValueError(msg) + 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 re + import shutil + import subprocess + import tarfile + import urllib.request + from pathlib import Path + + env = os.environ.copy() + temp_path_ = Path(temp_path) + + 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"] + + archive_path = temp_path_ + if config_settings: + archive_path = Path(config_settings.get("cmake.define.CMakePythonDistributions_ARCHIVE_DOWNLOAD_DIR", archive_path)) + archive_path.mkdir(parents=True, exist_ok=True) + + cmake_urls = Path("CMakeUrls.cmake").read_text() + source_url = re.findall(r'set\(unix_source_url\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] + source_sha256 = re.findall(r'set\(unix_source_sha256\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] + + tarball_name = source_url.rsplit("/", maxsplit=1)[1] + assert tarball_name.endswith(".tar.gz") + source_tarball = archive_path / tarball_name + if not source_tarball.exists(): + with urllib.request.urlopen(source_url) as response: + source_tarball.write_bytes(response.read()) + + sha256 = hashlib.sha256(source_tarball.read_bytes()).hexdigest() + if source_sha256.lower() != sha256.lower(): + msg = f"Invalid sha256 for {source_url!r}. Expected {source_sha256!r}, got {sha256!r}" + raise ValueError(msg) + + tar_filter_kwargs = {"filter": "tar"} if hasattr(tarfile, "tar_filter") else {} + with tarfile.open(source_tarball) 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" + 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) + + return str(prefix_path / "bin" / "cmake") + + +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 != "posix": + 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 933eacc8..d2a93e11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,11 @@ version = "${version}" 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-*" From 0a660851cbad8bfa33007c196fca5443de71aa43 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 30 Nov 2024 19:19:01 +0100 Subject: [PATCH 3/5] fix: bootstrap build on Windows --- .github/workflows/build.yml | 19 +++++- _build_backend/backend.py | 122 +++++++++++++++++++++--------------- 2 files changed, 89 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 238fca94..c4e02a6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -220,9 +220,14 @@ jobs: run: .venv/bin/pytest ./tests bootstrap_build: - name: Bootstrap build + name: Source only build on ${{ matrix.os }} needs: [lint] - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "windows-latest"] + steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -231,6 +236,7 @@ jobs: python-version: "3.x" - name: Remove cmake and ninja + shell: bash run: | # Remove cmake and ninja set -euxo pipefail @@ -238,7 +244,11 @@ jobs: # 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 - sudo rm -f $(which -a ${TOOL}) + if [ "$RUNNER_OS" == "Windows" ]; then + rm -f "$(which ${TOOL})" + else + sudo rm -f $(which -a ${TOOL}) + fi done done @@ -246,11 +256,13 @@ jobs: 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" @@ -259,6 +271,7 @@ jobs: rm -rf dist - name: Test installed SDist + shell: bash run: python -m pip install pytest pytest-cov && pytest ./tests check_dist: diff --git a/_build_backend/backend.py b/_build_backend/backend.py index 9bfdf5d2..39d904a2 100644 --- a/_build_backend/backend.py +++ b/_build_backend/backend.py @@ -48,70 +48,94 @@ def get_requires_for_build_wheel( 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) - 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}" + 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) - env["MAKE"] = make_path - make_path = env["MAKE"] + kind = kinds[machine] - archive_path = temp_path_ - if config_settings: - archive_path = Path(config_settings.get("cmake.define.CMakePythonDistributions_ARCHIVE_DOWNLOAD_DIR", archive_path)) - archive_path.mkdir(parents=True, exist_ok=True) cmake_urls = Path("CMakeUrls.cmake").read_text() - source_url = re.findall(r'set\(unix_source_url\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] - source_sha256 = re.findall(r'set\(unix_source_sha256\s+"(?P.*)"\)$', cmake_urls, flags=re.MULTILINE)[0] - - tarball_name = source_url.rsplit("/", maxsplit=1)[1] - assert tarball_name.endswith(".tar.gz") - source_tarball = archive_path / tarball_name - if not source_tarball.exists(): - with urllib.request.urlopen(source_url) as response: - source_tarball.write_bytes(response.read()) - - sha256 = hashlib.sha256(source_tarball.read_bytes()).hexdigest() - if source_sha256.lower() != sha256.lower(): - msg = f"Invalid sha256 for {source_url!r}. Expected {source_sha256!r}, got {sha256!r}" + 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) - tar_filter_kwargs = {"filter": "tar"} if hasattr(tarfile, "tar_filter") else {} - with tarfile.open(source_tarball) 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" - 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) - - return str(prefix_path / "bin" / "cmake") + 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( @@ -124,7 +148,7 @@ def build_wheel( try: return _orig.build_wheel(wheel_directory, config_settings, metadata_directory) except CMakeNotFoundError: - if os.name != "posix": + if os.name not in {"posix", "nt"}: raise # Let's try bootstrapping CMake import tempfile From e9ea5160c70bb998d6c21c063be40632454efdb0 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 30 Nov 2024 19:25:22 +0100 Subject: [PATCH 4/5] test: bootstrap build on macOS --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4e02a6f..babb7854 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -226,7 +226,7 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-latest", "windows-latest"] + os: ["ubuntu-latest", "windows-latest", "macos-latest"] steps: - uses: actions/checkout@v4 @@ -266,6 +266,7 @@ jobs: 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 From 81b4977e2799a863586733a52082d43bfc47eeef Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 14 Dec 2024 09:40:55 +0100 Subject: [PATCH 5/5] chore: parse CMake version from CMakeLists.txt --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d2a93e11..d26a277f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ ctest = "cmake:ctest" [tool.scikit-build] minimum-version = "build-system.requires" build-dir = "build/{wheel_tag}" -cmake.version = ">=3.13" # Since 3.24.0, CMake requires CMake 3.13+ to build itself +cmake.version = "CMakeLists.txt" ninja.make-fallback = true wheel.py-api = "py3" wheel.expand-macos-universal-tags = true