Skip to content

Commit dfd01af

Browse files
zachlewispre-commit-ci[bot]mayeut
authored
fix: more reliably validate Podman API version (#2016)
* 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cleanup: pacify ruff * cleanup: further pacify ruff * fix: properly define _version_from_string method Also, lift the method up and prefix with a "_" to better match the existing conventions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: more robust podman ver check Use the "podman --version" command instead of "podman version -f {{json .}}" for better reliability across distributions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * 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. * fix: UX on OCIEngineTooOldError * Add FlexibleVersion per review comment --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: mayeut <[email protected]>
1 parent 735e88d commit dfd01af

File tree

5 files changed

+172
-18
lines changed

5 files changed

+172
-18
lines changed

cibuildwheel/errors.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ def __init__(self, wheel_name: str) -> None:
6161

6262

6363
class OCIEngineTooOldError(FatalError):
64-
return_code = 7
64+
def __init__(self, message: str) -> None:
65+
super().__init__(message)
66+
self.return_code = 7

cibuildwheel/oci_container.py

+35-15
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import shutil
99
import subprocess
1010
import sys
11+
import textwrap
1112
import typing
1213
import uuid
1314
from collections.abc import Mapping, Sequence
@@ -17,14 +18,13 @@
1718
from types import TracebackType
1819
from typing import IO, Dict, Literal
1920

20-
from packaging.version import InvalidVersion, Version
21-
2221
from ._compat.typing import Self, assert_never
2322
from .errors import OCIEngineTooOldError
2423
from .logger import log
2524
from .typing import PathOrStr, PopenBytes
2625
from .util import (
2726
CIProvider,
27+
FlexibleVersion,
2828
call,
2929
detect_ci_provider,
3030
parse_key_value_string,
@@ -103,25 +103,45 @@ def _check_engine_version(engine: OCIContainerEngineConfig) -> None:
103103
version_string = call(engine.name, "version", "-f", "{{json .}}", capture_stdout=True)
104104
version_info = json.loads(version_string.strip())
105105
if engine.name == "docker":
106-
# --platform support was introduced in 1.32 as experimental
107-
# docker cp, as used by cibuildwheel, has been fixed in v24 => API 1.43, https://github.com/moby/moby/issues/38995
108-
client_api_version = Version(version_info["Client"]["ApiVersion"])
109-
engine_api_version = Version(version_info["Server"]["ApiVersion"])
110-
version_supported = min(client_api_version, engine_api_version) >= Version("1.43")
106+
client_api_version = FlexibleVersion(version_info["Client"]["ApiVersion"])
107+
server_api_version = FlexibleVersion(version_info["Server"]["ApiVersion"])
108+
# --platform support was introduced in 1.32 as experimental, 1.41 removed the experimental flag
109+
version = min(client_api_version, server_api_version)
110+
minimum_version = FlexibleVersion("1.41")
111+
minimum_version_str = "20.10.0" # docker version
112+
error_msg = textwrap.dedent(
113+
f"""
114+
Build failed because {engine.name} is too old.
115+
116+
cibuildwheel requires {engine.name}>={minimum_version_str} running API version {minimum_version}.
117+
The API version found by cibuildwheel is {version}.
118+
"""
119+
)
111120
elif engine.name == "podman":
112-
client_api_version = Version(version_info["Client"]["APIVersion"])
121+
# podman uses the same version string for "Version" & "ApiVersion"
122+
client_version = FlexibleVersion(version_info["Client"]["Version"])
113123
if "Server" in version_info:
114-
engine_api_version = Version(version_info["Server"]["APIVersion"])
124+
server_version = FlexibleVersion(version_info["Server"]["Version"])
115125
else:
116-
engine_api_version = client_api_version
126+
server_version = client_version
117127
# --platform support was introduced in v3
118-
version_supported = min(client_api_version, engine_api_version) >= Version("3")
128+
version = min(client_version, server_version)
129+
minimum_version = FlexibleVersion("3")
130+
error_msg = textwrap.dedent(
131+
f"""
132+
Build failed because {engine.name} is too old.
133+
134+
cibuildwheel requires {engine.name}>={minimum_version}.
135+
The version found by cibuildwheel is {version}.
136+
"""
137+
)
119138
else:
120139
assert_never(engine.name)
121-
if not version_supported:
122-
raise OCIEngineTooOldError() from None
123-
except (subprocess.CalledProcessError, KeyError, InvalidVersion) as e:
124-
raise OCIEngineTooOldError() from e
140+
if version < minimum_version:
141+
raise OCIEngineTooOldError(error_msg) from None
142+
except (subprocess.CalledProcessError, KeyError, ValueError) as e:
143+
msg = f"Build failed because {engine.name} is too old or is not working properly."
144+
raise OCIEngineTooOldError(msg) from e
125145

126146

127147
class OCIContainer:

cibuildwheel/util.py

+49-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence
2020
from dataclasses import dataclass
2121
from enum import Enum
22-
from functools import lru_cache
22+
from functools import lru_cache, total_ordering
2323
from pathlib import Path, PurePath
2424
from tempfile import TemporaryDirectory
2525
from time import sleep
@@ -899,3 +899,51 @@ def combine_constraints(
899899
env["UV_CONSTRAINT"] = env["PIP_CONSTRAINT"] = " ".join(
900900
c for c in [our_constraints, user_constraints] if c
901901
)
902+
903+
904+
@total_ordering
905+
class FlexibleVersion:
906+
version_str: str
907+
version_parts: tuple[int, ...]
908+
suffix: str
909+
910+
def __init__(self, version_str: str) -> None:
911+
self.version_str = version_str
912+
913+
# Split into numeric parts and the optional suffix
914+
match = re.match(r"^[v]?(\d+(\.\d+)*)(.*)$", version_str)
915+
if not match:
916+
msg = f"Invalid version string: {version_str}"
917+
raise ValueError(msg)
918+
919+
version_part, _, suffix = match.groups()
920+
921+
# Convert numeric version part into a tuple of integers
922+
self.version_parts = tuple(map(int, version_part.split(".")))
923+
self.suffix = suffix.strip() if suffix else ""
924+
925+
# Normalize by removing trailing zeros
926+
self.version_parts = self._remove_trailing_zeros(self.version_parts)
927+
928+
def _remove_trailing_zeros(self, parts: tuple[int, ...]) -> tuple[int, ...]:
929+
# Remove trailing zeros for accurate comparisons
930+
# without this, "3.0" would be considered greater than "3"
931+
while parts and parts[-1] == 0:
932+
parts = parts[:-1]
933+
return parts
934+
935+
def __eq__(self, other: object) -> bool:
936+
if not isinstance(other, FlexibleVersion):
937+
raise NotImplementedError()
938+
return (self.version_parts, self.suffix) == (other.version_parts, other.suffix)
939+
940+
def __lt__(self, other: object) -> bool:
941+
if not isinstance(other, FlexibleVersion):
942+
raise NotImplementedError()
943+
return (self.version_parts, self.suffix) < (other.version_parts, other.suffix)
944+
945+
def __repr__(self) -> str:
946+
return f"FlexibleVersion('{self.version_str}')"
947+
948+
def __str__(self) -> str:
949+
return self.version_str

unit_test/oci_container_test.py

+70-1
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@
88
import subprocess
99
import sys
1010
import textwrap
11+
from contextlib import nullcontext
1112
from pathlib import Path, PurePath, PurePosixPath
1213

1314
import pytest
1415
import tomli_w
1516

17+
import cibuildwheel.oci_container
1618
from cibuildwheel.environment import EnvironmentAssignmentBash
17-
from cibuildwheel.oci_container import OCIContainer, OCIContainerEngineConfig, OCIPlatform
19+
from cibuildwheel.errors import OCIEngineTooOldError
20+
from cibuildwheel.oci_container import (
21+
OCIContainer,
22+
OCIContainerEngineConfig,
23+
OCIPlatform,
24+
_check_engine_version,
25+
)
1826
from cibuildwheel.util import CIProvider, detect_ci_provider
1927

2028
# Test utilities
@@ -569,3 +577,64 @@ def test_multiarch_image(container_engine, platform):
569577
OCIPlatform.S390X: "s390x",
570578
}
571579
assert output_map[platform] == output.strip()
580+
581+
582+
@pytest.mark.parametrize(
583+
("engine_name", "version", "context"),
584+
[
585+
(
586+
"docker",
587+
None, # 17.12.1-ce does supports "docker version --format '{{json . }}'" so a version before that
588+
pytest.raises(OCIEngineTooOldError),
589+
),
590+
(
591+
"docker",
592+
'{"Client":{"Version":"19.03.15","ApiVersion": "1.40"},"Server":{"ApiVersion": "1.40"}}',
593+
pytest.raises(OCIEngineTooOldError),
594+
),
595+
(
596+
"docker",
597+
'{"Client":{"Version":"20.10.0","ApiVersion":"1.41"},"Server":{"ApiVersion":"1.41"}}',
598+
nullcontext(),
599+
),
600+
(
601+
"docker",
602+
'{"Client":{"Version":"24.0.0","ApiVersion":"1.43"},"Server":{"ApiVersion":"1.43"}}',
603+
nullcontext(),
604+
),
605+
(
606+
"docker",
607+
'{"Client":{"ApiVersion":"1.43"},"Server":{"ApiVersion":"1.30"}}',
608+
pytest.raises(OCIEngineTooOldError),
609+
),
610+
(
611+
"docker",
612+
'{"Client":{"ApiVersion":"1.30"},"Server":{"ApiVersion":"1.43"}}',
613+
pytest.raises(OCIEngineTooOldError),
614+
),
615+
("podman", '{"Client":{"Version":"5.2.0"},"Server":{"Version":"5.1.2"}}', nullcontext()),
616+
("podman", '{"Client":{"Version":"4.9.4-rhel"}}', nullcontext()),
617+
(
618+
"podman",
619+
'{"Client":{"Version":"5.2.0"},"Server":{"Version":"2.1.2"}}',
620+
pytest.raises(OCIEngineTooOldError),
621+
),
622+
(
623+
"podman",
624+
'{"Client":{"Version":"2.2.0"},"Server":{"Version":"5.1.2"}}',
625+
pytest.raises(OCIEngineTooOldError),
626+
),
627+
("podman", '{"Client":{"Version":"3.0~rc1-rhel"}}', nullcontext()),
628+
("podman", '{"Client":{"Version":"2.1.0~rc1"}}', pytest.raises(OCIEngineTooOldError)),
629+
],
630+
)
631+
def test_engine_version(engine_name, version, context, monkeypatch):
632+
def mockcall(*args, **kwargs):
633+
if version is None:
634+
raise subprocess.CalledProcessError(1, " ".join(str(arg) for arg in args))
635+
return version
636+
637+
monkeypatch.setattr(cibuildwheel.oci_container, "call", mockcall)
638+
engine = OCIContainerEngineConfig.from_config_string(engine_name)
639+
with context:
640+
_check_engine_version(engine)

unit_test/utils_test.py

+15
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from cibuildwheel.util import (
9+
FlexibleVersion,
910
find_compatible_wheel,
1011
fix_ansi_codes_for_github_actions,
1112
format_safe,
@@ -206,3 +207,17 @@ def test_parse_key_value_string():
206207
"name": ["docker"],
207208
"create_args": [],
208209
}
210+
211+
212+
def test_flexible_version_comparisons():
213+
assert FlexibleVersion("2.0") == FlexibleVersion("2")
214+
assert FlexibleVersion("2.0") < FlexibleVersion("2.1")
215+
assert FlexibleVersion("2.1") > FlexibleVersion("2")
216+
assert FlexibleVersion("1.9.9") < FlexibleVersion("2.0")
217+
assert FlexibleVersion("1.10") > FlexibleVersion("1.9.9")
218+
assert FlexibleVersion("3.0.1") > FlexibleVersion("3.0")
219+
assert FlexibleVersion("3.0") < FlexibleVersion("3.0.1")
220+
# Suffix should not affect comparisons
221+
assert FlexibleVersion("1.0.1-rhel") > FlexibleVersion("1.0")
222+
assert FlexibleVersion("1.0.1-rhel") < FlexibleVersion("1.1")
223+
assert FlexibleVersion("1.0.1") == FlexibleVersion("v1.0.1")

0 commit comments

Comments
 (0)