Repository: pypa/cibuildwheel

Issue:
Empty MANYLINUX env variable produces invalid result
If the MANYLINUX environment variables are empty, then cibuildwheel fails, rather than falling back to either the value in the config or the default value. This means code like this is broken:

```yaml
  build_wheels:
    strategy:
      fail-fast: false
      matrix:
        os: [windows-latest, macos-latest, ubuntu-latest]
        arch: [auto64]
        build: ["*"]

        include:
          - os: ubuntu-latest
            arch: auto32
            build: "*"

          - os: ubuntu-latest
            type: ManyLinux1
            arch: auto
            build: "cp{36,37,38}-*"
            CIBW_MANYLINUX_X86_64_IMAGE: skhep/manylinuxgcc-x86_64
            CIBW_MANYLINUX_I686_IMAGE: skhep/manylinuxgcc-i686

    steps:
    - uses: actions/checkout@v2

    - uses: pypa/cibuildwheel@v2.1.2
      env:
        CIBW_BUILD: ${{ matrix.build }}
        CIBW_MANYLINUX_I686_IMAGE: ${{ matrix.CIBW_MANYLINUX_I686_IMAGE }}
        CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.CIBW_MANYLINUX_X86_64_IMAGE }}
        CIBW_ARCHS: ${{ matrix.arch }}
```

This sets `CIBW_MANYLINUX_I686_IMAGE` to an empty string on most jobs, which is then an invalid docker container. This should either be the default, or better yet, the pyproject.toml file value or the default.

I don't think this generalizes to all parameters, though; setting things like the repair command to an empty string is valid.


Discussion:
I also think this used to work, so it might be a regression. Pretty sure we discussed it in the past somewhere, and I think fixed it back then.

Resolving patch:
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -291,9 +291,9 @@ def main() -> None:
         for build_platform in MANYLINUX_ARCHS:
             pinned_images = all_pinned_docker_images[build_platform]
 
-            config_value = options(f"manylinux-{build_platform}-image")
+            config_value = options(f"manylinux-{build_platform}-image", ignore_empty=True)
 
-            if config_value is None:
+            if not config_value:
                 # default to manylinux2010 if it's available, otherwise manylinux2014
                 image = pinned_images.get("manylinux2010") or pinned_images.get("manylinux2014")
             elif config_value in pinned_images:
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -19,15 +19,26 @@ class ConfigOptionError(KeyError):
     pass
 
 
-def _dig_first(*pairs: Tuple[Mapping[str, Any], str]) -> Setting:
+def _dig_first(*pairs: Tuple[Mapping[str, Setting], str], ignore_empty: bool = False) -> Setting:
     """
     Return the first dict item that matches from pairs of dicts and keys.
-    Final result is will throw a KeyError if missing.
+    Will throw a KeyError if missing.
 
     _dig_first((dict1, "key1"), (dict2, "key2"), ...)
     """
-    (dict_like, key), *others = pairs
-    return dict_like.get(key, _dig_first(*others)) if others else dict_like[key]
+    if not pairs:
+        raise ValueError("pairs cannot be empty")
+
+    for dict_like, key in pairs:
+        if key in dict_like:
+            value = dict_like[key]
+
+            if ignore_empty and value == "":
+                continue
+
+            return value
+
+    raise KeyError(key)
 
 
 class ConfigOptions:
@@ -62,7 +73,7 @@ def __init__(
         defaults_path = resources_dir / "defaults.toml"
         self.default_options, self.default_platform_options = self._load_file(defaults_path)
 
-        # load the project config file
+        # Load the project config file
         config_options: Dict[str, Any] = {}
         config_platform_options: Dict[str, Any] = {}
 
@@ -75,7 +86,7 @@ def __init__(
             if pyproject_toml_path.exists():
                 config_options, config_platform_options = self._load_file(pyproject_toml_path)
 
-        # validate project config
+        # Validate project config
         for option_name in config_options:
             if not self._is_valid_global_option(option_name):
                 raise ConfigOptionError(f'Option "{option_name}" not supported in a config file')
@@ -129,6 +140,7 @@ def __call__(
         env_plat: bool = True,
         sep: Optional[str] = None,
         table: Optional[TableFmt] = None,
+        ignore_empty: bool = False,
     ) -> str:
         """
         Get and return the value for the named option from environment,
@@ -136,7 +148,8 @@ def __call__(
         accept platform versions of the environment variable. If this is an
         array it will be merged with "sep" before returning. If it is a table,
         it will be formatted with "table['item']" using {k} and {v} and merged
-        with "table['sep']".
+        with "table['sep']". Empty variables will not override if ignore_empty
+        is True.
         """
 
         if name not in self.default_options and name not in self.default_platform_options:
@@ -155,6 +168,7 @@ def __call__(
             (self.config_options, name),
             (self.default_platform_options, name),
             (self.default_options, name),
+            ignore_empty=ignore_empty,
         )
 
         if isinstance(result, dict):


Test patch:
diff --git a/unit_test/options_toml_test.py b/unit_test/options_toml_test.py
--- a/unit_test/options_toml_test.py
+++ b/unit_test/options_toml_test.py
@@ -1,6 +1,6 @@
 import pytest
 
-from cibuildwheel.options import ConfigOptionError, ConfigOptions
+from cibuildwheel.options import ConfigOptionError, ConfigOptions, _dig_first
 
 PYPROJECT_1 = """
 [tool.cibuildwheel]
@@ -181,10 +181,68 @@ def test_disallowed_a(tmp_path):
     tmp_path.joinpath("pyproject.toml").write_text(
         """
 [tool.cibuildwheel.windows]
-manylinux-x64_86-image = "manylinux1"
+manylinux-x86_64-image = "manylinux1"
 """
     )
-    disallow = {"windows": {"manylinux-x64_86-image"}}
+    disallow = {"windows": {"manylinux-x86_64-image"}}
     ConfigOptions(tmp_path, platform="linux", disallow=disallow)
     with pytest.raises(ConfigOptionError):
         ConfigOptions(tmp_path, platform="windows", disallow=disallow)
+
+
+def test_environment_override_empty(tmp_path, monkeypatch):
+    tmp_path.joinpath("pyproject.toml").write_text(
+        """
+[tool.cibuildwheel]
+manylinux-i686-image = "manylinux1"
+manylinux-x86_64-image = ""
+"""
+    )
+
+    monkeypatch.setenv("CIBW_MANYLINUX_I686_IMAGE", "")
+    monkeypatch.setenv("CIBW_MANYLINUX_AARCH64_IMAGE", "manylinux1")
+
+    options = ConfigOptions(tmp_path, platform="linux")
+
+    assert options("manylinux-x86_64-image") == ""
+    assert options("manylinux-i686-image") == ""
+    assert options("manylinux-aarch64-image") == "manylinux1"
+
+    assert options("manylinux-x86_64-image", ignore_empty=True) == "manylinux2010"
+    assert options("manylinux-i686-image", ignore_empty=True) == "manylinux1"
+    assert options("manylinux-aarch64-image", ignore_empty=True) == "manylinux1"
+
+
+@pytest.mark.parametrize("ignore_empty", (True, False))
+def test_dig_first(ignore_empty):
+    d1 = {"random": "thing"}
+    d2 = {"this": "that", "empty": ""}
+    d3 = {"other": "hi"}
+    d4 = {"this": "d4", "empty": "not"}
+
+    answer = _dig_first(
+        (d1, "empty"),
+        (d2, "empty"),
+        (d3, "empty"),
+        (d4, "empty"),
+        ignore_empty=ignore_empty,
+    )
+    assert answer == ("not" if ignore_empty else "")
+
+    answer = _dig_first(
+        (d1, "this"),
+        (d2, "this"),
+        (d3, "this"),
+        (d4, "this"),
+        ignore_empty=ignore_empty,
+    )
+    assert answer == "that"
+
+    with pytest.raises(KeyError):
+        _dig_first(
+            (d1, "this"),
+            (d2, "other"),
+            (d3, "this"),
+            (d4, "other"),
+            ignore_empty=ignore_empty,
+        )