Repository: scikit-build/scikit-build-core (python)

Issue:
Boolean fields in `config_settings` cause traceback if passed booleans
According to [the documentation](https://scikit-build-core.readthedocs.io/en/latest/configuration.html), `cmake.verbose` and `install.strip` are of *boolean* type. However, if I pass an actual boolean value rather than a string, I get a traceback:

```pytb
$ pip install -q gpep517 'scikit-build-core[pyproject]'
$ python -m gpep517 build-wheel --config-json '{"install.strip": false}' --output-fd 1 --wheel-dir dist
2024-04-16 17:11:46,348 gpep517 INFO Building wheel via backend scikit_build_core.build
  + Exception Group Traceback (most recent call last):
  |   File "<frozen runpy>", line 198, in _run_module_as_main
  |   File "<frozen runpy>", line 88, in _run_code
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/gpep517/__main__.py", line 429, in <module>
  |     sys.exit(main())
  |              ^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/gpep517/__main__.py", line 425, in main
  |     return func(args)
  |            ^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/gpep517/__main__.py", line 215, in build_wheel
  |     print(build_wheel_impl(args, args.wheel_dir), file=out)
  |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/gpep517/__main__.py", line 207, in build_wheel_impl
  |     wheel_name = backend.build_wheel(str(wheel_dir), args.config_json)
  |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/build/__init__.py", line 31, in build_wheel
  |     return _build_wheel_impl(
  |            ^^^^^^^^^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/build/wheel.py", line 136, in _build_wheel_impl
  |     settings_reader = SettingsReader(pyproject, config_settings or {}, state=state)
  |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/skbuild_read_settings.py", line 253, in __init__
  |     self.settings = self.sources.convert_target(ScikitBuildSettings)
  |                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 581, in convert_target
  |     raise ExceptionGroup(msg, errors)
  | ExceptionGroup: Failed converting tool.scikit-build (1 sub-exception)
  +-+---------------- 1 ----------------
    | Exception Group Traceback (most recent call last):
    |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 535, in convert_target
    |     prep[field.name] = self.convert_target(
    |                        ^^^^^^^^^^^^^^^^^^^^
    |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 581, in convert_target
    |     raise ExceptionGroup(msg, errors)
    | ExceptionGroup: Failed converting tool.scikit-build.install (1 sub-exception)
    | Field: tool.scikit-build.install
    +-+---------------- 1 ----------------
      | Traceback (most recent call last):
      |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 550, in convert_target
      |     tmp = source.convert(simple, field.type)
      |           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 386, in convert
      |     return _process_bool(item)
      |            ^^^^^^^^^^^^^^^^^^^
      |   File "/tmp/pyzmq/.venv/lib/python3.11/site-packages/scikit_build_core/settings/sources.py", line 76, in _process_bool
      |     return value.strip().lower() not in {"0", "false", "off", "no", ""}
      |            ^^^^^^^^^^^
      | AttributeError: 'bool' object has no attribute 'strip'
      | Field tool.scikit-build.install.strip
      +------------------------------------
```

Pull request description:
fix: support bools in config settings
Fix #707.


Patch:
diff --git a/src/scikit_build_core/settings/sources.py b/src/scikit_build_core/settings/sources.py
index 13eb3d5..ffc147b 100644
--- a/src/scikit_build_core/settings/sources.py
+++ b/src/scikit_build_core/settings/sources.py
@@ -314,12 +314,15 @@ class ConfSource:
     nested dicts. "verify" is a boolean that determines whether unrecognized
     options should be checked for. Only set this to false if this might be sharing
     config options at the same level.
+
+    While most mechanisms (pip, uv, build) only support text, gpep517 allows an
+    arbitrary json input, so this currently also handles bools.
     """
 
     def __init__(
         self,
         *prefixes: str,
-        settings: Mapping[str, str | list[str]],
+        settings: Mapping[str, str | list[str] | bool],
         verify: bool = True,
     ) -> None:
         self.prefixes = prefixes
@@ -339,7 +342,9 @@ class ConfSource:
 
         return name in self.settings
 
-    def get_item(self, *fields: str, is_dict: bool) -> str | list[str] | dict[str, str]:
+    def get_item(
+        self, *fields: str, is_dict: bool
+    ) -> str | list[str] | dict[str, str] | bool:
         names = self._get_name(*fields)
         name = ".".join(names)
         if is_dict:
@@ -360,7 +365,7 @@ class ConfSource:
 
     @classmethod
     def convert(
-        cls, item: str | list[str] | dict[str, str], target: type[Any]
+        cls, item: str | list[str] | dict[str, str] | bool, target: type[Any]
     ) -> object:
         target, _ = _process_annotated(target)
         raw_target = _get_target_raw_type(target)
@@ -370,20 +375,20 @@ class ConfSource:
         if raw_target is list:
             if isinstance(item, list):
                 return [cls.convert(i, _get_inner_type(target)) for i in item]
-            if isinstance(item, dict):
+            if isinstance(item, (dict, bool)):
                 msg = f"Expected {target}, got {type(item).__name__}"
                 raise TypeError(msg)
             return [
                 cls.convert(i.strip(), _get_inner_type(target)) for i in item.split(";")
             ]
         if raw_target is dict:
-            assert not isinstance(item, (str, list))
+            assert not isinstance(item, (str, list, bool))
             return {k: cls.convert(v, _get_inner_type(target)) for k, v in item.items()}
         if isinstance(item, (list, dict)):
             msg = f"Expected {target}, got {type(item).__name__}"
             raise TypeError(msg)
         if raw_target is bool:
-            return _process_bool(item)
+            return item if isinstance(item, bool) else _process_bool(item)
         if raw_target is Union and str in get_args(target):
             return item
         if raw_target is Literal:


Test patch:
diff --git a/tests/test_settings.py b/tests/test_settings.py
index d04887c..bee7bfe 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -345,6 +345,16 @@ def test_env_var_bools(monkeypatch, truthy, falsey):
     assert not settings.true
 
 
+def test_conf_settings_bools():
+    sources = SourceChain(
+        ConfSource(settings={"false": True, "true": False}),
+    )
+    settings = sources.convert_target(SettingBools)
+
+    assert settings.false
+    assert not settings.true
+
+
 @dataclasses.dataclass
 class SettingLists:
     list0: List[str] = dataclasses.field(default_factory=list)