diff --git a/cibuildwheel/oci_container.py b/cibuildwheel/oci_container.py index 1cbabc0d..59932e2e 100644 --- a/cibuildwheel/oci_container.py +++ b/cibuildwheel/oci_container.py @@ -18,7 +18,7 @@ from typing import IO, Dict from ._compat.typing import Literal from .typing import PathOrStr, PopenBytes -from .util import CIProvider, detect_ci_provider +from .util import CIProvider, detect_ci_provider, parse_key_value_string ContainerEngineName = Literal["docker", "podman"] @@ -28,6 +28,25 @@ class OCIContainerEngineConfig: name: ContainerEngineName create_args: Sequence[str] = () + @staticmethod + def from_config_string(config_string: str) -> OCIContainerEngineConfig: + config_dict = parse_key_value_string(config_string, ["name"]) + name = " ".join(config_dict["name"]) + if name not in ["docker", "podman"]: + msg = f"unknown container engine {name}" + raise ValueError(msg) + + name = typing.cast(ContainerEngineName, name) + # some flexibility in the option name to cope with TOML conventions + create_args = config_dict.get("create_args") or config_dict.get("create-args") or [] + return OCIContainerEngineConfig(name=name, create_args=create_args) + + def options_summary(self) -> str | dict[str, str]: + if not self.create_args: + return self.name + else: + return {"name": self.name, "create_args": repr(self.create_args)} + DEFAULT_ENGINE = OCIContainerEngineConfig("docker") diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index 50cb91cd..f9d96d0f 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -10,7 +10,6 @@ import shlex import sys import textwrap import traceback -import typing from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Set from pathlib import Path from typing import Any, Dict, List, Union @@ -136,8 +135,14 @@ DISALLOWED_OPTIONS = { class TableFmt(TypedDict): + # a format string, used with '.format', with `k` and `v` parameters + # e.g. "{k}={v}" item: str + # the string that is inserted between items + # e.g. " " sep: str + # a quoting function that, if supplied, is called to quote each value + # e.g. shlex.quote quote: NotRequired[Callable[[str], str]] @@ -454,15 +459,17 @@ class Options: ) test_selector = TestSelector(skip_config=test_skip) - container_engine_str = self.reader.get("container-engine") + container_engine_str = self.reader.get( + "container-engine", table={"item": "{k}:{v}", "sep": "; ", "quote": shlex.quote} + ) - if container_engine_str not in ["docker", "podman"]: - msg = f"cibuildwheel: Unrecognised container_engine {container_engine_str!r}, only 'docker' and 'podman' are supported" + try: + container_engine = OCIContainerEngineConfig.from_config_string(container_engine_str) + except ValueError as e: + msg = f"cibuildwheel: Failed to parse container config. {e}" print(msg, file=sys.stderr) sys.exit(2) - container_engine = typing.cast(ContainerEngine, container_engine_str) - return GlobalOptions( package_dir=package_dir, output_dir=output_dir, diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 8b5f9f62..f10c0ef8 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -13,6 +13,7 @@ import textwrap import time import typing import urllib.request +from collections import defaultdict from collections.abc import Generator, Iterable, Mapping, Sequence from dataclasses import dataclass from enum import Enum @@ -697,3 +698,41 @@ def fix_ansi_codes_for_github_actions(text: str) -> str: ansi_codes.append(code) return output + + +def parse_key_value_string( + key_value_string: str, positional_arg_names: list[str] | None = None +) -> dict[str, list[str]]: + """ + Parses a string like "docker; create_args: --some-option=value another-option" + """ + if positional_arg_names is None: + positional_arg_names = [] + + shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";:") + shlexer.commenters = "" + shlexer.whitespace_split = True + parts = list(shlexer) + # parts now looks like + # ['docker', ';', 'create_args',':', '--some-option=value', 'another-option'] + + # split by semicolon + fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k] + + result: dict[str, list[str]] = defaultdict(list) + for field_i, field in enumerate(fields): + if len(field) > 1 and field[1] == ":": + field_name = field[0] + values = field[2:] + else: + try: + field_name = positional_arg_names[field_i] + except IndexError: + msg = f"Failed to parse {key_value_string!r}. Too many positional arguments - expected a maximum of {len(positional_arg_names)}" + raise ValueError(msg) from None + + values = field + + result[field_name] += values + + return result diff --git a/unit_test/oci_container_test.py b/unit_test/oci_container_test.py index e0fbcb50..b5ed38c8 100644 --- a/unit_test/oci_container_test.py +++ b/unit_test/oci_container_test.py @@ -30,6 +30,8 @@ elif pm == "s390x": else: DEFAULT_IMAGE = "" +PODMAN = OCIContainerEngineConfig(name="podman") + @pytest.fixture(params=["docker", "podman"]) def container_engine(request): @@ -280,7 +282,7 @@ def test_podman_vfs(tmp_path: Path, monkeypatch, request): monkeypatch.setenv("CONTAINERS_CONF", str(vfs_containers_conf_fpath)) monkeypatch.setenv("CONTAINERS_STORAGE_CONF", str(vfs_containers_storage_conf_fpath)) - with OCIContainer(engine="podman", image=DEFAULT_IMAGE) as container: + with OCIContainer(engine=PODMAN, image=DEFAULT_IMAGE) as container: # test running a command assert container.call(["echo", "hello"], capture_output=True) == "hello\n" @@ -311,3 +313,49 @@ def test_create_args(tmp_path: Path): image=DEFAULT_IMAGE, ) as container: assert container.call(["cat", "/test_mount/test_file.txt"], capture_output=True) == "1234" + + +@pytest.mark.parametrize( + ("config", "name", "create_args"), + [ + ( + "docker", + "docker", + [], + ), + ( + "docker;create_args:", + "docker", + [], + ), + ( + "docker;create_args:--abc --def", + "docker", + ["--abc", "--def"], + ), + ( + "docker; create_args: --abc --def", + "docker", + ["--abc", "--def"], + ), + ( + "name:docker; create_args: --abc --def", + "docker", + ["--abc", "--def"], + ), + ( + 'docker; create_args: --some-option="value with spaces"', + "docker", + ["--some-option=value with spaces"], + ), + ( + 'docker; create_args: --some-option="value; with; semicolons" --another-option', + "docker", + ["--some-option=value; with; semicolons", "--another-option"], + ), + ], +) +def test_parse_engine_config(config, name, create_args): + engine_config = OCIContainerEngineConfig.from_config_string(config) + assert engine_config.name == name + assert engine_config.create_args == create_args diff --git a/unit_test/options_test.py b/unit_test/options_test.py index be56b206..134db012 100644 --- a/unit_test/options_test.py +++ b/unit_test/options_test.py @@ -198,3 +198,58 @@ def test_toml_environment_quoting(tmp_path: Path, toml_assignment, result_value) ) assert environment_values["TEST_VAR"] == result_value + + +@pytest.mark.parametrize( + ("toml_assignment", "result_name", "result_create_args"), + [ + ( + 'container-engine = "podman"', + "podman", + [], + ), + ( + 'container-engine = {name = "podman"}', + "podman", + [], + ), + ( + 'container-engine = "docker; create_args: --some-option"', + "docker", + ["--some-option"], + ), + ( + 'container-engine = {name = "docker", create-args = ["--some-option"]}', + "docker", + ["--some-option"], + ), + ( + 'container-engine = {name = "docker", create-args = ["--some-option", "value that contains spaces"]}', + "docker", + ["--some-option", "value that contains spaces"], + ), + ( + 'container-engine = {name = "docker", create-args = ["--some-option", "value;that;contains;semicolons"]}', + "docker", + ["--some-option", "value;that;contains;semicolons"], + ), + ], +) +def test_container_engine_option(tmp_path: Path, toml_assignment, result_name, result_create_args): + args = CommandLineArguments.defaults() + args.package_dir = tmp_path + + tmp_path.joinpath("pyproject.toml").write_text( + textwrap.dedent( + f"""\ + [tool.cibuildwheel] + {toml_assignment} + """ + ) + ) + + options = Options(platform="linux", command_line_arguments=args, env={}) + parsed_container_engine = options.globals.container_engine + + assert parsed_container_engine.name == result_name + assert parsed_container_engine.create_args == result_create_args