diff --git a/cibuildwheel/errors.py b/cibuildwheel/errors.py index e441bd40b..a1cc6a33c 100644 --- a/cibuildwheel/errors.py +++ b/cibuildwheel/errors.py @@ -61,4 +61,6 @@ def __init__(self, wheel_name: str) -> None: class OCIEngineTooOldError(FatalError): - return_code = 7 + def __init__(self, message: str) -> None: + super().__init__(message) + self.return_code = 7 diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index b13417e3b..bdd663b30 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -8,6 +8,7 @@ import shutil import subprocess import sys +import textwrap import typing import uuid from collections.abc import Mapping, Sequence @@ -17,14 +18,13 @@ from types import TracebackType from typing import IO, Dict, Literal -from packaging.version import InvalidVersion, Version - from ._compat.typing import Self, assert_never from .errors import OCIEngineTooOldError from .logger import log from .typing import PathOrStr, PopenBytes from .util import ( CIProvider, + FlexibleVersion, call, detect_ci_provider, parse_key_value_string, @@ -103,25 +103,45 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None: version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) version_info = json.loads(version_string.strip()) if engine.name == "docker": - # --platform support was introduced in 1.32 as experimental - # docker cp, as used by cibuildwheel, has been fixed in v24 => API 1.43, https://github.com/moby/moby/issues/38995 - client_api_version = Version(version_info["Client"]["ApiVersion"]) - engine_api_version = Version(version_info["Server"]["ApiVersion"]) - version_supported = min(client_api_version, engine_api_version) >= Version("1.43") + client_api_version = FlexibleVersion(version_info["Client"]["ApiVersion"]) + server_api_version = FlexibleVersion(version_info["Server"]["ApiVersion"]) + # --platform support was introduced in 1.32 as experimental, 1.41 removed the experimental flag + version = min(client_api_version, server_api_version) + minimum_version = FlexibleVersion("1.41") + minimum_version_str = "20.10.0" # docker version + error_msg = textwrap.dedent( + f""" + Build failed because {engine.name} is too old. + + cibuildwheel requires {engine.name}>={minimum_version_str} running API version {minimum_version}. + The API version found by cibuildwheel is {version}. + """ + ) elif engine.name == "podman": - client_api_version = Version(version_info["Client"]["APIVersion"]) + # podman uses the same version string for "Version" & "ApiVersion" + client_version = FlexibleVersion(version_info["Client"]["Version"]) if "Server" in version_info: - engine_api_version = Version(version_info["Server"]["APIVersion"]) + server_version = FlexibleVersion(version_info["Server"]["Version"]) else: - engine_api_version = client_api_version + server_version = client_version # --platform support was introduced in v3 - version_supported = min(client_api_version, engine_api_version) >= Version("3") + version = min(client_version, server_version) + minimum_version = FlexibleVersion("3") + error_msg = textwrap.dedent( + f""" + Build failed because {engine.name} is too old. + + cibuildwheel requires {engine.name}>={minimum_version}. + The version found by cibuildwheel is {version}. + """ + ) else: assert_never(engine.name) - if not version_supported: - raise OCIEngineTooOldError() from None - except (subprocess.CalledProcessError, KeyError, InvalidVersion) as e: - raise OCIEngineTooOldError() from e + if version < minimum_version: + raise OCIEngineTooOldError(error_msg) from None + except (subprocess.CalledProcessError, KeyError, ValueError) as e: + msg = f"Build failed because {engine.name} is too old or is not working properly." + raise OCIEngineTooOldError(msg) from e class OCIContainer: diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 31b3eaf86..b9db4268b 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -19,7 +19,7 @@ from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence from dataclasses import dataclass from enum import Enum -from functools import lru_cache +from functools import lru_cache, total_ordering from pathlib import Path, PurePath from tempfile import TemporaryDirectory from time import sleep @@ -899,3 +899,51 @@ def combine_constraints( env["UV_CONSTRAINT"] = env["PIP_CONSTRAINT"] = " ".join( c for c in [our_constraints, user_constraints] if c ) + + +@total_ordering +class FlexibleVersion: + version_str: str + version_parts: tuple[int, ...] + suffix: str + + def __init__(self, version_str: str) -> None: + self.version_str = version_str + + # Split into numeric parts and the optional suffix + match = re.match(r"^[v]?(\d+(\.\d+)*)(.*)$", version_str) + if not match: + msg = f"Invalid version string: {version_str}" + raise ValueError(msg) + + version_part, _, suffix = match.groups() + + # Convert numeric version part into a tuple of integers + self.version_parts = tuple(map(int, version_part.split("."))) + self.suffix = suffix.strip() if suffix else "" + + # Normalize by removing trailing zeros + self.version_parts = self._remove_trailing_zeros(self.version_parts) + + def _remove_trailing_zeros(self, parts: tuple[int, ...]) -> tuple[int, ...]: + # Remove trailing zeros for accurate comparisons + # without this, "3.0" would be considered greater than "3" + while parts and parts[-1] == 0: + parts = parts[:-1] + return parts + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FlexibleVersion): + raise NotImplementedError() + return (self.version_parts, self.suffix) == (other.version_parts, other.suffix) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, FlexibleVersion): + raise NotImplementedError() + return (self.version_parts, self.suffix) < (other.version_parts, other.suffix) + + def __repr__(self) -> str: + return f"FlexibleVersion('{self.version_str}')" + + def __str__(self) -> str: + return self.version_str diff --git a/unit_test/oci_container_test.py b/unit_test/oci_container_test.py index d88ca52eb..0f99618a6 100644 --- a/unit_test/oci_container_test.py +++ b/unit_test/oci_container_test.py @@ -8,13 +8,21 @@ import subprocess import sys import textwrap +from contextlib import nullcontext from pathlib import Path, PurePath, PurePosixPath import pytest import tomli_w +import cibuildwheel.oci_container from cibuildwheel.environment import EnvironmentAssignmentBash -from cibuildwheel.oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform +from cibuildwheel.errors import OCIEngineTooOldError +from cibuildwheel.oci_container import ( + OCIContainer, + OCIContainerEngineConfig, + OCIPlatform, + _check_engine_version, +) from cibuildwheel.util import CIProvider, detect_ci_provider # Test utilities @@ -569,3 +577,64 @@ def test_multiarch_image(container_engine, platform): OCIPlatform.S390X: "s390x", } assert output_map[platform] == output.strip() + + +@pytest.mark.parametrize( + ("engine_name", "version", "context"), + [ + ( + "docker", + None, # 17.12.1-ce does supports "docker version --format '{{json . }}'" so a version before that + pytest.raises(OCIEngineTooOldError), + ), + ( + "docker", + '{"Client":{"Version":"19.03.15","ApiVersion": "1.40"},"Server":{"ApiVersion": "1.40"}}', + pytest.raises(OCIEngineTooOldError), + ), + ( + "docker", + '{"Client":{"Version":"20.10.0","ApiVersion":"1.41"},"Server":{"ApiVersion":"1.41"}}', + nullcontext(), + ), + ( + "docker", + '{"Client":{"Version":"24.0.0","ApiVersion":"1.43"},"Server":{"ApiVersion":"1.43"}}', + nullcontext(), + ), + ( + "docker", + '{"Client":{"ApiVersion":"1.43"},"Server":{"ApiVersion":"1.30"}}', + pytest.raises(OCIEngineTooOldError), + ), + ( + "docker", + '{"Client":{"ApiVersion":"1.30"},"Server":{"ApiVersion":"1.43"}}', + pytest.raises(OCIEngineTooOldError), + ), + ("podman", '{"Client":{"Version":"5.2.0"},"Server":{"Version":"5.1.2"}}', nullcontext()), + ("podman", '{"Client":{"Version":"4.9.4-rhel"}}', nullcontext()), + ( + "podman", + '{"Client":{"Version":"5.2.0"},"Server":{"Version":"2.1.2"}}', + pytest.raises(OCIEngineTooOldError), + ), + ( + "podman", + '{"Client":{"Version":"2.2.0"},"Server":{"Version":"5.1.2"}}', + pytest.raises(OCIEngineTooOldError), + ), + ("podman", '{"Client":{"Version":"3.0~rc1-rhel"}}', nullcontext()), + ("podman", '{"Client":{"Version":"2.1.0~rc1"}}', pytest.raises(OCIEngineTooOldError)), + ], +) +def test_engine_version(engine_name, version, context, monkeypatch): + def mockcall(*args, **kwargs): + if version is None: + raise subprocess.CalledProcessError(1, " ".join(str(arg) for arg in args)) + return version + + monkeypatch.setattr(cibuildwheel.oci_container, "call", mockcall) + engine = OCIContainerEngineConfig.from_config_string(engine_name) + with context: + _check_engine_version(engine) diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 4725163ff..b433800a5 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -6,6 +6,7 @@ import pytest from cibuildwheel.util import ( + FlexibleVersion, find_compatible_wheel, fix_ansi_codes_for_github_actions, format_safe, @@ -206,3 +207,17 @@ def test_parse_key_value_string(): "name": ["docker"], "create_args": [], } + + +def test_flexible_version_comparisons(): + assert FlexibleVersion("2.0") == FlexibleVersion("2") + assert FlexibleVersion("2.0") < FlexibleVersion("2.1") + assert FlexibleVersion("2.1") > FlexibleVersion("2") + assert FlexibleVersion("1.9.9") < FlexibleVersion("2.0") + assert FlexibleVersion("1.10") > FlexibleVersion("1.9.9") + assert FlexibleVersion("3.0.1") > FlexibleVersion("3.0") + assert FlexibleVersion("3.0") < FlexibleVersion("3.0.1") + # Suffix should not affect comparisons + assert FlexibleVersion("1.0.1-rhel") > FlexibleVersion("1.0") + assert FlexibleVersion("1.0.1-rhel") < FlexibleVersion("1.1") + assert FlexibleVersion("1.0.1") == FlexibleVersion("v1.0.1")