From a1b4026b562377fb6bf64861660bda318f204f40 Mon Sep 17 00:00:00 2001 From: zachlewis Date: Wed, 18 Sep 2024 17:50:24 -0400 Subject: [PATCH 01/11] fix: parse version strings that include dashes It's possible for some container engines to report their versions with a dash (e.g., "4.9.4-rhel"), which breaks packaging.version.Version's ability to parse the string. This commit introduces a version_from_string method which santizies the version string and returns an instance of Version. --- cibuildwheel/oci_container.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index b13417e3b..10d9b9534 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -102,16 +102,20 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) version_info = json.loads(version_string.strip()) + + def version_from_string(str: ver): + return Version(ver.replace("-", "+")) + 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"]) + client_api_version = version_from_string(version_info["Client"]["ApiVersion"]) + engine_api_version = version_from_string(version_info["Server"]["ApiVersion"]) version_supported = min(client_api_version, engine_api_version) >= Version("1.43") elif engine.name == "podman": - client_api_version = Version(version_info["Client"]["APIVersion"]) + client_api_version = version_from_string(version_info["Client"]["APIVersion"]) if "Server" in version_info: - engine_api_version = Version(version_info["Server"]["APIVersion"]) + engine_api_version = version_from_string(version_info["Server"]["APIVersion"]) else: engine_api_version = client_api_version # --platform support was introduced in v3 From 8f9b3ee02febf86cf41367db072a9874d6f2d6ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 21:51:10 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cibuildwheel/oci_container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 10d9b9534..887b15318 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -105,7 +105,7 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None: def version_from_string(str: ver): return Version(ver.replace("-", "+")) - + 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 From 8e579100e53f41d76055ab1451ea91f1c888e803 Mon Sep 17 00:00:00 2001 From: zachlewis Date: Wed, 18 Sep 2024 22:05:50 +0000 Subject: [PATCH 03/11] cleanup: pacify ruff --- cibuildwheel/oci_container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 887b15318..f1e634cc0 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -103,8 +103,8 @@ 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()) - def version_from_string(str: ver): - return Version(ver.replace("-", "+")) + def version_from_string(version) -> Version: + return Version(version.replace("-", "+")) if engine.name == "docker": # --platform support was introduced in 1.32 as experimental From eb0b2f0aa6d9a4f167e808e48a41cff3567af898 Mon Sep 17 00:00:00 2001 From: zachlewis Date: Wed, 18 Sep 2024 22:17:53 +0000 Subject: [PATCH 04/11] cleanup: further pacify ruff --- cibuildwheel/oci_container.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index f1e634cc0..4eaece91a 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -103,8 +103,8 @@ 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()) - def version_from_string(version) -> Version: - return Version(version.replace("-", "+")) + def version_from_string(str: version) -> Version: # noqa: ARG001, F821 + return Version(version.replace("-", "+")) # noqa: F821 if engine.name == "docker": # --platform support was introduced in 1.32 as experimental From 060d96c4b994d343493c0207e117b65fd52bc03e Mon Sep 17 00:00:00 2001 From: zachlewis Date: Wed, 18 Sep 2024 22:24:13 +0000 Subject: [PATCH 05/11] fix: properly define _version_from_string method Also, lift the method up and prefix with a "_" to better match the existing conventions --- cibuildwheel/oci_container.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 4eaece91a..3ded32ade 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -97,25 +97,23 @@ def options_summary(self) -> str | dict[str, str]: DEFAULT_ENGINE = OCIContainerEngineConfig("docker") +def _version_from_string(version: str) -> Version: + return Version(version.replace("-", "+")) def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) version_info = json.loads(version_string.strip()) - - def version_from_string(str: version) -> Version: # noqa: ARG001, F821 - return Version(version.replace("-", "+")) # noqa: F821 - 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_from_string(version_info["Client"]["ApiVersion"]) - engine_api_version = version_from_string(version_info["Server"]["ApiVersion"]) + client_api_version = _version_from_string(version_info["Client"]["ApiVersion"]) + engine_api_version = _version_from_string(version_info["Server"]["ApiVersion"]) version_supported = min(client_api_version, engine_api_version) >= Version("1.43") elif engine.name == "podman": - client_api_version = version_from_string(version_info["Client"]["APIVersion"]) + client_api_version = _version_from_string(version_info["Client"]["APIVersion"]) if "Server" in version_info: - engine_api_version = version_from_string(version_info["Server"]["APIVersion"]) + engine_api_version = _version_from_string(version_info["Server"]["APIVersion"]) else: engine_api_version = client_api_version # --platform support was introduced in v3 From a6ae1c4ee2af6d707db55cf509774eeb81786cc9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 22:25:59 +0000 Subject: [PATCH 06/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cibuildwheel/oci_container.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 3ded32ade..67036cb0c 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -97,9 +97,11 @@ def options_summary(self) -> str | dict[str, str]: DEFAULT_ENGINE = OCIContainerEngineConfig("docker") + def _version_from_string(version: str) -> Version: return Version(version.replace("-", "+")) + def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) From f19deee3d6080401f5b961cfb848156524358d11 Mon Sep 17 00:00:00 2001 From: zachlewis Date: Thu, 19 Sep 2024 11:59:25 -0400 Subject: [PATCH 07/11] refactor: more robust podman ver check Use the "podman --version" command instead of "podman version -f {{json .}}" for better reliability across distributions. --- cibuildwheel/oci_container.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 67036cb0c..bca9fd82f 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -4,6 +4,7 @@ import json import os import platform +import re import shlex import shutil import subprocess @@ -97,29 +98,24 @@ def options_summary(self) -> str | dict[str, str]: DEFAULT_ENGINE = OCIContainerEngineConfig("docker") - -def _version_from_string(version: str) -> Version: - return Version(version.replace("-", "+")) - - def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: - 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_from_string(version_info["Client"]["ApiVersion"]) - engine_api_version = _version_from_string(version_info["Server"]["ApiVersion"]) + # docker cp, as used by cibuildwheel, has been fixed in v24 => API 1.43 + # https://github.com/moby/moby/issues/38995 + version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) + version_info = json.loads(version_string.strip()) + 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") elif engine.name == "podman": - client_api_version = _version_from_string(version_info["Client"]["APIVersion"]) - if "Server" in version_info: - engine_api_version = _version_from_string(version_info["Server"]["APIVersion"]) - else: - engine_api_version = client_api_version + # Parse the version from the `podman --version` output + pattern = r"(?P\w+)\s+version\s+(?P[0-9.]+)" + match = re.search(pattern, call(engine.name, "--version", capture_stdout=True)) + engine_version = Version(match.group("version")) if match else Version("0") # --platform support was introduced in v3 - version_supported = min(client_api_version, engine_api_version) >= Version("3") + version_supported = engine_version >= Version("3") else: assert_never(engine.name) if not version_supported: From 4ccfcaf36f1670e363c7bed12d04d996767eaceb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 15:59:52 +0000 Subject: [PATCH 08/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cibuildwheel/oci_container.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index bca9fd82f..acc31c3ce 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -98,6 +98,7 @@ def options_summary(self) -> str | dict[str, str]: DEFAULT_ENGINE = OCIContainerEngineConfig("docker") + def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: if engine.name == "docker": From 3c8dafff0a4a9e2c6d1af0fe81e9f00d540eec98 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 21 Sep 2024 13:07:36 +0200 Subject: [PATCH 09/11] fix: oci engine version check Lower Docker API check to 1.41 Podman versions are not PEP440 compliant, remove distro specific suffixes before parsing. Add tests with real-world outputs and some made up ones. --- cibuildwheel/oci_container.py | 31 ++++++++------ unit_test/oci_container_test.py | 71 ++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index acc31c3ce..33c02cdc3 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -4,7 +4,6 @@ import json import os import platform -import re import shlex import shutil import subprocess @@ -101,22 +100,28 @@ def options_summary(self) -> str | dict[str, str]: def _check_engine_version(engine: OCIContainerEngineConfig) -> None: try: + 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 - version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True) - version_info = json.loads(version_string.strip()) 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") + server_api_version = Version(version_info["Server"]["ApiVersion"]) + # --platform support was introduced in 1.32 as experimental, 1.41 removed the experimental flag + version_supported = min(client_api_version, server_api_version) >= Version("1.41") elif engine.name == "podman": - # Parse the version from the `podman --version` output - pattern = r"(?P\w+)\s+version\s+(?P[0-9.]+)" - match = re.search(pattern, call(engine.name, "--version", capture_stdout=True)) - engine_version = Version(match.group("version")) if match else Version("0") + # podman uses the same version string for "Version" & "ApiVersion" + # the version string is not PEP440 compliant here + def _version(version_string: str) -> Version: + for sep in ("-", "~", "^", "+"): + version_string = version_string.split(sep, maxsplit=1)[0] + return Version(version_string) + + client_version = _version(version_info["Client"]["Version"]) + if "Server" in version_info: + server_version = _version(version_info["Server"]["Version"]) + else: + server_version = client_version # --platform support was introduced in v3 - version_supported = engine_version >= Version("3") + version_supported = min(client_version, server_version) >= Version("3") else: assert_never(engine.name) if not version_supported: 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) From 4b73c0e7dfff0cb64df8acc0059205a6c12b2124 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sat, 21 Sep 2024 13:49:37 +0200 Subject: [PATCH 10/11] fix: UX on OCIEngineTooOldError --- cibuildwheel/errors.py | 4 +++- cibuildwheel/oci_container.py | 31 ++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) 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 33c02cdc3..74b3237a5 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 @@ -106,7 +107,17 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None: client_api_version = Version(version_info["Client"]["ApiVersion"]) server_api_version = Version(version_info["Server"]["ApiVersion"]) # --platform support was introduced in 1.32 as experimental, 1.41 removed the experimental flag - version_supported = min(client_api_version, server_api_version) >= Version("1.41") + version = min(client_api_version, server_api_version) + minimum_version = Version("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": # podman uses the same version string for "Version" & "ApiVersion" # the version string is not PEP440 compliant here @@ -121,13 +132,23 @@ def _version(version_string: str) -> Version: else: server_version = client_version # --platform support was introduced in v3 - version_supported = min(client_version, server_version) >= Version("3") + version = min(client_version, server_version) + minimum_version = Version("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 + if version < minimum_version: + raise OCIEngineTooOldError(error_msg) from None except (subprocess.CalledProcessError, KeyError, InvalidVersion) as e: - raise OCIEngineTooOldError() from e + msg = f"Build failed because {engine.name} is too old or is not working properly." + raise OCIEngineTooOldError(msg) from e class OCIContainer: From bbfb496eec4c8e2b3150cbd9e600a07758df1ef1 Mon Sep 17 00:00:00 2001 From: mayeut Date: Sun, 29 Sep 2024 13:03:28 +0200 Subject: [PATCH 11/11] Add FlexibleVersion per review comment --- cibuildwheel/oci_container.py | 23 ++++++---------- cibuildwheel/util.py | 50 ++++++++++++++++++++++++++++++++++- unit_test/utils_test.py | 15 +++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 74b3237a5..bdd663b30 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -18,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, @@ -104,11 +103,11 @@ 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": - client_api_version = Version(version_info["Client"]["ApiVersion"]) - server_api_version = Version(version_info["Server"]["ApiVersion"]) + 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 = Version("1.41") + minimum_version = FlexibleVersion("1.41") minimum_version_str = "20.10.0" # docker version error_msg = textwrap.dedent( f""" @@ -120,20 +119,14 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None: ) elif engine.name == "podman": # podman uses the same version string for "Version" & "ApiVersion" - # the version string is not PEP440 compliant here - def _version(version_string: str) -> Version: - for sep in ("-", "~", "^", "+"): - version_string = version_string.split(sep, maxsplit=1)[0] - return Version(version_string) - - client_version = _version(version_info["Client"]["Version"]) + client_version = FlexibleVersion(version_info["Client"]["Version"]) if "Server" in version_info: - server_version = _version(version_info["Server"]["Version"]) + server_version = FlexibleVersion(version_info["Server"]["Version"]) else: server_version = client_version # --platform support was introduced in v3 version = min(client_version, server_version) - minimum_version = Version("3") + minimum_version = FlexibleVersion("3") error_msg = textwrap.dedent( f""" Build failed because {engine.name} is too old. @@ -146,7 +139,7 @@ def _version(version_string: str) -> Version: assert_never(engine.name) if version < minimum_version: raise OCIEngineTooOldError(error_msg) from None - except (subprocess.CalledProcessError, KeyError, InvalidVersion) as e: + 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 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/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")