Repository: pypa/cibuildwheel

Issue:
Consider auto-correcting underscores to dashes in TOML options
From https://github.com/pypa/cibuildwheel/pull/759#discussion_r670922746

While TOML prefers dashes, as a Python programmer, it's easy to accidentally write underscores for option names. 

I wonder if it would be nice to do autofix this a la `option_name.replace("_","-")`, after we read these. pip does something similar - if you do e.g. `pip install python_dateutil`, it will still find `python-dateutil`, because underscores are invalid.


Discussion:
I'm not really a fan of this - I'd rather force "one correct" way to do it, rather than making it arbitrary. You'll immediately get an error if you put something in incorrectly, so I think it's fairly low friction. And then what do you do if `a_b` and `a-b` are both present? Normally the TOML parser should handle the errors if you duplicate a key (or eventually a linter), but this is not a TOML error, so we'd have to handle it.
Hmm. yes. Perhaps just a nicer error message, like `Unknown option "before_all". Perhaps you meant "before-all"?`
I really like it when Python 3.10 does this, and it's pretty easy to implement with `difflib.get_close_matches`, so I like this idea. Also would help for near misspellings. 

Resolving patch:
diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py
--- a/cibuildwheel/options.py
+++ b/cibuildwheel/options.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import difflib
 import functools
 import os
 import sys
@@ -188,14 +189,10 @@ def __init__(
 
         # 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')
+            self._validate_global_option(option_name)
 
         for option_name in config_platform_options:
-            if not self._is_valid_platform_option(option_name):
-                raise ConfigOptionError(
-                    f'Option "{option_name}" not supported in the "{self.platform}" section'
-                )
+            self._validate_platform_option(option_name)
 
         self.config_options = config_options
         self.config_platform_options = config_platform_options
@@ -207,40 +204,51 @@ def __init__(
 
         if config_overrides is not None:
             if not isinstance(config_overrides, list):
-                raise ConfigOptionError('"tool.cibuildwheel.overrides" must be a list')
+                raise ConfigOptionError("'tool.cibuildwheel.overrides' must be a list")
 
             for config_override in config_overrides:
                 select = config_override.pop("select", None)
 
                 if not select:
-                    raise ConfigOptionError('"select" must be set in an override')
+                    raise ConfigOptionError("'select' must be set in an override")
 
                 if isinstance(select, list):
                     select = " ".join(select)
 
                 self.overrides.append(Override(select, config_override))
 
-    def _is_valid_global_option(self, name: str) -> bool:
+    def _validate_global_option(self, name: str) -> None:
         """
-        Returns True if an option with this name is allowed in the
+        Raises an error if an option with this name is not allowed in the
         [tool.cibuildwheel] section of a config file.
         """
         allowed_option_names = self.default_options.keys() | PLATFORMS | {"overrides"}
 
-        return name in allowed_option_names
+        if name not in allowed_option_names:
+            msg = f"Option {name!r} not supported in a config file."
+            matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7)
+            if matches:
+                msg += f" Perhaps you meant {matches[0]!r}?"
+            raise ConfigOptionError(msg)
 
-    def _is_valid_platform_option(self, name: str) -> bool:
+    def _validate_platform_option(self, name: str) -> None:
         """
-        Returns True if an option with this name is allowed in the
+        Raises an error if an option with this name is not allowed in the
         [tool.cibuildwheel.<current-platform>] section of a config file.
         """
         disallowed_platform_options = self.disallow.get(self.platform, set())
         if name in disallowed_platform_options:
-            return False
+            msg = f"{name!r} is not allowed in {disallowed_platform_options}"
+            raise ConfigOptionError(msg)
 
         allowed_option_names = self.default_options.keys() | self.default_platform_options.keys()
 
-        return name in allowed_option_names
+        if name not in allowed_option_names:
+            msg = f"Option {name!r} not supported in the {self.platform!r} section"
+            matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7)
+            if matches:
+                msg += f" Perhaps you meant {matches[0]!r}?"
+            raise ConfigOptionError(msg)
 
     def _load_file(self, filename: Path) -> tuple[dict[str, Any], dict[str, Any]]:
         """
@@ -290,7 +298,8 @@ def get(
         """
 
         if name not in self.default_options and name not in self.default_platform_options:
-            raise ConfigOptionError(f"{name} must be in cibuildwheel/resources/defaults.toml file")
+            msg = f"{name!r} must be in cibuildwheel/resources/defaults.toml file to be accessed."
+            raise ConfigOptionError(msg)
 
         # Environment variable form
         envvar = f"CIBW_{name.upper().replace('-', '_')}"
@@ -314,12 +323,12 @@ def get(
 
         if isinstance(result, dict):
             if table is None:
-                raise ConfigOptionError(f"{name} does not accept a table")
+                raise ConfigOptionError(f"{name!r} does not accept a table")
             return table["sep"].join(table["item"].format(k=k, v=v) for k, v in result.items())
 
         if isinstance(result, list):
             if sep is None:
-                raise ConfigOptionError(f"{name} does not accept a list")
+                raise ConfigOptionError(f"{name!r} does not accept a list")
             return sep.join(result)
 
         if isinstance(result, int):
@@ -393,7 +402,7 @@ def globals(self) -> GlobalOptions:
         container_engine_str = self.reader.get("container-engine")
 
         if container_engine_str not in ["docker", "podman"]:
-            msg = f"cibuildwheel: Unrecognised container_engine '{container_engine_str}', only 'docker' and 'podman' are supported"
+            msg = f"cibuildwheel: Unrecognised container_engine {container_engine_str!r}, only 'docker' and 'podman' are supported"
             print(msg, file=sys.stderr)
             sys.exit(2)
 
@@ -437,7 +446,7 @@ def build_options(self, identifier: str | None) -> BuildOptions:
             elif build_frontend_str == "pip":
                 build_frontend = "pip"
             else:
-                msg = f"cibuildwheel: Unrecognised build frontend '{build_frontend_str}', only 'pip' and 'build' are supported"
+                msg = f"cibuildwheel: Unrecognised build frontend {build_frontend_str!r}, only 'pip' and 'build' are supported"
                 print(msg, file=sys.stderr)
                 sys.exit(2)
 
@@ -445,7 +454,7 @@ def build_options(self, identifier: str | None) -> BuildOptions:
                 environment = parse_environment(environment_config)
             except (EnvironmentParseError, ValueError):
                 print(
-                    f'cibuildwheel: Malformed environment option "{environment_config}"',
+                    f"cibuildwheel: Malformed environment option {environment_config!r}",
                     file=sys.stderr,
                 )
                 traceback.print_exc(None, sys.stderr)


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
@@ -160,9 +160,28 @@ def test_unexpected_key(tmp_path):
 """
     )
 
-    with pytest.raises(ConfigOptionError):
+    with pytest.raises(ConfigOptionError) as excinfo:
         OptionsReader(pyproject_toml, platform="linux")
 
+    assert "repair-wheel-command" in str(excinfo.value)
+
+
+def test_underscores_in_key(tmp_path):
+    # Note that platform contents are only checked when running
+    # for that platform.
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        """
+[tool.cibuildwheel]
+repair_wheel_command = "repair-project-linux"
+"""
+    )
+
+    with pytest.raises(ConfigOptionError) as excinfo:
+        OptionsReader(pyproject_toml, platform="linux")
+
+    assert "repair-wheel-command" in str(excinfo.value)
+
 
 def test_unexpected_table(tmp_path):
     pyproject_toml = tmp_path / "pyproject.toml"