diff --git a/cibuildwheel/util/python_build_standalone.py b/cibuildwheel/util/python_build_standalone.py index a48bf2f8..5f6cb2f5 100644 --- a/cibuildwheel/util/python_build_standalone.py +++ b/cibuildwheel/util/python_build_standalone.py @@ -5,6 +5,7 @@ import functools import hashlib import json import platform +import subprocess import typing from filelock import FileLock @@ -42,6 +43,26 @@ class PythonBuildStandaloneError(Exception): """Errors related to python-build-standalone.""" +def _is_musl_libc() -> bool: + """ + Detect whether the host uses musl libc (e.g. Alpine Linux). + + ``platform.libc_ver()`` only ever reports glibc, so we shell out to ``ldd``, + which prints "musl libc" on musl-based systems (and "GNU libc"/"GLIBC" on + glibc systems). If ``ldd`` is unavailable, we fall back to assuming glibc. + """ + try: + ldd = subprocess.run( + ["ldd", "--version"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return False + return "musl" in (ldd.stdout + ldd.stderr).lower() + + def _get_platform_identifiers() -> tuple[str, str, str | None]: """ Detects the current platform and returns architecture, platform, and libc @@ -67,7 +88,7 @@ def _get_platform_identifiers() -> tuple[str, str, str | None]: # Map OS + Libc if system == "Linux": platform_identifier = "unknown-linux" - libc_identifier = "musl" if "musl" in (platform.libc_ver() or ("", "")) else "gnu" + libc_identifier = "musl" if _is_musl_libc() else "gnu" elif system == "Darwin": platform_identifier = "apple-darwin" elif system == "Windows": diff --git a/unit_test/python_build_standalone_test.py b/unit_test/python_build_standalone_test.py new file mode 100644 index 00000000..e42a12d6 --- /dev/null +++ b/unit_test/python_build_standalone_test.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import platform +import subprocess +from typing import Any + +import pytest + +from cibuildwheel.util import python_build_standalone as pbs + +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Callable + +# Real-world `ldd --version` outputs. +GLIBC_STDOUT = "ldd (Ubuntu GLIBC 2.35-0ubuntu3.1) 2.35\n" +MUSL_STDERR = "musl libc (x86_64)\nVersion 1.2.4\nDynamic Program Loader\n" + + +def _fake_ldd( + stdout: str = "", stderr: str = "" +) -> Callable[..., subprocess.CompletedProcess[str]]: + def run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=args, returncode=0, stdout=stdout, stderr=stderr) + + return run + + +def test_is_musl_libc_detects_musl(monkeypatch: pytest.MonkeyPatch) -> None: + # musl's ldd prints to stderr and exits non-zero, but capture both streams + monkeypatch.setattr(subprocess, "run", _fake_ldd(stderr=MUSL_STDERR)) + assert pbs._is_musl_libc() is True + + +def test_is_musl_libc_detects_glibc(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(subprocess, "run", _fake_ldd(stdout=GLIBC_STDOUT)) + assert pbs._is_musl_libc() is False + + +def test_is_musl_libc_missing_ldd(monkeypatch: pytest.MonkeyPatch) -> None: + def run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + msg = "ldd" + raise FileNotFoundError(msg) + + monkeypatch.setattr(subprocess, "run", run) + assert pbs._is_musl_libc() is False + + +@pytest.mark.parametrize( + ("musl", "expected_libc"), + [(True, "musl"), (False, "gnu")], +) +def test_linux_platform_identifiers_libc( + monkeypatch: pytest.MonkeyPatch, musl: bool, expected_libc: str +) -> None: + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr(platform, "machine", lambda: "x86_64") + monkeypatch.setattr(pbs, "_is_musl_libc", lambda: musl) + + arch, platform_id, libc = pbs._get_platform_identifiers() + assert arch == "x86_64" + assert platform_id == "unknown-linux" + assert libc == expected_libc