Repository: pypa/cibuildwheel

Issue:
`CIBW_CONTAINER_ENGINE` splits on `:` when it shouldn't
### Description

`CIBW_CONTAINER_ENGINE="docker; create_args: -p=8080:8080"`

gets parsed into

```
  container_engine: 
    name: docker
    create_args: ['-p=8080', ':', '8080']
```

problem is here https://github.com/pypa/cibuildwheel/blob/e5d0cc0b0d37d20a053b85112e4e1f137079c85e/cibuildwheel/util.py#L710

potential fix from [here](https://stackoverflow.com/a/61737902/9045206):
```python
shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=True)
shlexer.wordchars += ':'
```

with this change, `CIBW_CONTAINER_ENGINE="docker; create_args : -p=8080:8080"` (note the space after `create_args`) parses to `fields=[['docker'], ['create_args', ':', '-p=8080:8080']]`.


Discussion:
Ah, that's unfortunate. A workaround would be to quote the create_args, e.g. `CIBW_CONTAINER_ENGINE="docker; create_args: '-p=8080:8080'"`. However, I'll work on a fix too.

Resolving patch:
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -731,23 +731,26 @@ def parse_key_value_string(
 
     all_field_names = [*positional_arg_names, *kw_arg_names]
 
-    shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";:")
+    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']
+    # ['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:]
+        # check to see if the option name is specified
+        field_name, sep, first_value = field[0].partition(":")
+        if sep:
             if field_name not in all_field_names:
                 msg = f"Failed to parse {key_value_string!r}. Unknown field name {field_name!r}"
                 raise ValueError(msg)
+
+            values = ([first_value] if first_value else []) + field[1:]
         else:
             try:
                 field_name = positional_arg_names[field_i]


Test patch:
diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py
--- a/unit_test/utils_test.py
+++ b/unit_test/utils_test.py
@@ -9,6 +9,7 @@
     find_compatible_wheel,
     fix_ansi_codes_for_github_actions,
     format_safe,
+    parse_key_value_string,
     prepare_command,
 )
 
@@ -124,3 +125,84 @@ def test_fix_ansi_codes_for_github_actions():
     output = fix_ansi_codes_for_github_actions(input)
 
     assert output == expected
+
+
+def test_parse_key_value_string():
+    assert parse_key_value_string("bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
+    assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
+    with pytest.raises(ValueError, match="Too many positional arguments"):
+        parse_key_value_string("bar")
+    with pytest.raises(ValueError, match="Unknown field name"):
+        parse_key_value_string("foo:bar")
+    assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
+    assert parse_key_value_string("foo:bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
+    assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
+    assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
+    assert parse_key_value_string("foo: bar; baz: qux", kw_arg_names=["foo", "baz"]) == {
+        "foo": ["bar"],
+        "baz": ["qux"],
+    }
+
+    # some common options
+    assert parse_key_value_string(
+        "docker; create_args: --some-option --another-option=foo",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["--some-option", "--another-option=foo"],
+    }
+    # semicolon in value
+    assert parse_key_value_string(
+        "docker; create_args: --some-option='this; that'",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["--some-option=this; that"],
+    }
+    # colon in value
+    assert parse_key_value_string(
+        "docker; create_args: --mount a:b",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["--mount", "a:b"],
+    }
+    assert parse_key_value_string(
+        "docker;create_args:--mount a:b",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["--mount", "a:b"],
+    }
+    # quoted value with spaces
+    assert parse_key_value_string(
+        "docker;create_args:'some string with spaces'",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["some string with spaces"],
+    }
+
+    # colon in positional value
+    assert parse_key_value_string(
+        "docker; --mount a:b",
+        positional_arg_names=["name", "create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": ["--mount", "a:b"],
+    }
+
+    # empty option gives empty array
+    assert parse_key_value_string(
+        "docker;create_args:",
+        positional_arg_names=["name"],
+        kw_arg_names=["create_args"],
+    ) == {
+        "name": ["docker"],
+        "create_args": [],
+    }