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

Issue:
Allow toml lists in tool.scikit-build.cmake.define
The following entry leads to a cryptic error:

```
[tool.scikit-build.cmake.define]
SOME_LIST = ["A", "B", "C"]
```

However, I believe it should be allowed. Simply join the entries with a `;` and issue a warning if any entry already contains a `;` (which would split the "item" in CMake-land).

I think the tangible UX improvement comes when the list entries are many or long:

```
[tool.scikit-build.cmake.define]
MY_PROJECT_ENABLE_COMPONENTS = [
    "FancyComponent",
    "OptionalDependency",
    "ObtuseComponentWithAReallyLongNameWhyDidAnyoneThinkThisWasAGoodIdea",
    "Foo",
]
```

Pull request:
Support TOML lists in cmake.define
Adds support for specifying CMake lists in `cmake.define` using TOML lists:

```
[tool.scikit-build.cmake.define]
ONE_LEVEL_LIST = [
    "Foo",
    "Bar",
    "ExceptionallyLargeListEntryThatWouldOverflowTheLine",
    "Baz",
]
NESTED_LIST = [ "Apple", "Lemon;Lime", "Banana" ]
```

Escapes semicolons in list items so as to allow nested lists.

Fixes #874

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c3984e..c1afada 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,9 +27,9 @@ jobs:
       - uses: actions/setup-python@v5
         with:
           python-version: "3.12"
-      - uses: yezz123/setup-uv@v4
+      - uses: astral-sh/setup-uv@v3
       - name: Install nox
-        run: uv pip install --system nox
+        run: uv tool install nox
       - uses: pre-commit/action@v3.0.1
         with:
           extra_args: --all-files
@@ -37,7 +37,7 @@ jobs:
         run: nox -s pylint -- --output-format=github
       - name: Run nox generator
         run: |
-          nox -s generate_schema -s readme
+          nox -t gen
           git diff --exit-code
 
   checks:
@@ -105,7 +105,7 @@ jobs:
           python-version: ${{ matrix.python-version }}
           allow-prereleases: true
 
-      - uses: yezz123/setup-uv@v4
+      - uses: astral-sh/setup-uv@v3
         if:
           matrix.python-version != '3.7' && matrix.python-version != 'pypy-3.8'
           && matrix.python-version != 'pypy-3.7'
@@ -168,7 +168,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
 
-      - uses: yezz123/setup-uv@v4
+      - uses: astral-sh/setup-uv@v3
 
       # The min requirements are not compatible with some of the extra test
       # deps, so limit to just built-in deps.
@@ -332,7 +332,7 @@ jobs:
         with:
           fetch-depth: 0
 
-      - uses: yezz123/setup-uv@v4
+      - uses: astral-sh/setup-uv@v3
 
       - uses: wntrblm/nox@2024.04.15
         with:
diff --git a/docs/configuration.md b/docs/configuration.md
index 50ee673..f20729b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -413,17 +413,44 @@ You can select a different build type, such as `Debug`:
 
 ```
 
-You can specify CMake defines:
+You can specify CMake defines as strings or bools:
 
 ````{tab} pyproject.toml
 
 ```toml
 [tool.scikit-build.cmake.define]
-SOME_DEFINE = "ON"
+SOME_DEFINE = "Foo"
+SOME_OPTION = true
 ```
 
 ````
 
+You can even specify a CMake define as a list of strings:
+
+````{tab} pyproject.toml
+
+```toml
+[tool.scikit-build.cmake.define]
+FOOD_GROUPS = [
+    "Apple",
+    "Lemon;Lime",
+    "Banana",
+    "Pineapple;Mango",
+]
+```
+
+````
+
+Semicolons inside the list elements will be escaped with a backslash (`\`) and
+the resulting list elements will be joined together with semicolons (`;`) before
+being converted to command-line arguments.
+
+:::{versionchanged} 0.11
+
+Support for list of strings.
+
+:::
+
 `````{tab} config-settings
 
 
diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py
index 437265f..5aecd1d 100644
--- a/src/scikit_build_core/builder/builder.py
+++ b/src/scikit_build_core/builder/builder.py
@@ -245,12 +245,7 @@ class Builder:
                 cmake_defines["CMAKE_OSX_ARCHITECTURES"] = ";".join(archs)
 
         # Add the pre-defined or passed CMake defines
-        cmake_defines.update(
-            {
-                k: ("TRUE" if v else "FALSE") if isinstance(v, bool) else v
-                for k, v in self.settings.cmake.define.items()
-            }
-        )
+        cmake_defines.update(self.settings.cmake.define)
 
         self.config.configure(
             defines=cmake_defines,
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 2f77d20..57b3548 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -37,6 +37,12 @@
                     },
                     {
                       "type": "boolean"
+                    },
+                    {
+                      "type": "array",
+                      "items": {
+                        "type": "string"
+                      }
                     }
                   ]
                 },
@@ -58,6 +64,12 @@
                         },
                         {
                           "type": "boolean"
+                        },
+                        {
+                          "type": "array",
+                          "items": {
+                            "type": "string"
+                          }
                         }
                       ]
                     }
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
index dfb7da1..2510bea 100644
--- a/src/scikit_build_core/settings/json_schema.py
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -151,6 +151,8 @@ def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]:
         }
     if origin is Literal:
         return {"enum": list(args)}
+    if hasattr(t, "json_schema"):
+        return convert_type(t.json_schema, normalize_keys=normalize_keys)
 
     msg = f"Cannot convert type {t} to JSON Schema"
     raise FailedConversionError(msg)
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 3251dc5..77ced4b 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -11,6 +11,7 @@ __all__ = [
     "BackportSettings",
     "BuildSettings",
     "CMakeSettings",
+    "CMakeSettingsDefine",
     "EditableSettings",
     "GenerateSettings",
     "InstallSettings",
@@ -27,6 +28,28 @@ def __dir__() -> List[str]:
     return __all__
 
 
+class CMakeSettingsDefine(str):
+    """
+    A str subtype for automatically normalizing bool and list values
+    to the CMake representation in the `cmake.define` settings key.
+    """
+
+    json_schema = Union[str, bool, List[str]]
+
+    def __new__(cls, raw: Union[str, bool, List[str]]) -> "CMakeSettingsDefine":
+        def escape_semicolons(item: str) -> str:
+            return item.replace(";", r"\;")
+
+        if isinstance(raw, bool):
+            value = "TRUE" if raw else "FALSE"
+        elif isinstance(raw, list):
+            value = ";".join(map(escape_semicolons, raw))
+        else:
+            value = raw
+
+        return super().__new__(cls, value)
+
+
 @dataclasses.dataclass
 class CMakeSettings:
     minimum_version: Optional[Version] = None
@@ -49,7 +72,7 @@ class CMakeSettings:
     in config or envvar will override toml. See also ``cmake.define``.
     """
 
-    define: Annotated[Dict[str, Union[str, bool]], "EnvVar"] = dataclasses.field(
+    define: Annotated[Dict[str, CMakeSettingsDefine], "EnvVar"] = dataclasses.field(
         default_factory=dict
     )
     """


Test patch:
diff --git a/tests/packages/cmake_defines/CMakeLists.txt b/tests/packages/cmake_defines/CMakeLists.txt
new file mode 100644
index 0000000..e486740
--- /dev/null
+++ b/tests/packages/cmake_defines/CMakeLists.txt
@@ -0,0 +1,20 @@
+cmake_minimum_required(VERSION 3.15)
+project(cmake_defines LANGUAGES NONE)
+
+set(ONE_LEVEL_LIST
+    ""
+    CACHE STRING "")
+set(NESTED_LIST
+    ""
+    CACHE STRING "")
+
+set(out_file "${CMAKE_CURRENT_BINARY_DIR}/log.txt")
+file(WRITE "${out_file}" "")
+
+foreach(list IN ITEMS ONE_LEVEL_LIST NESTED_LIST)
+  list(LENGTH ${list} length)
+  file(APPEND "${out_file}" "${list}.LENGTH = ${length}\n")
+  foreach(item IN LISTS ${list})
+    file(APPEND "${out_file}" "${item}\n")
+  endforeach()
+endforeach()
diff --git a/tests/packages/cmake_defines/pyproject.toml b/tests/packages/cmake_defines/pyproject.toml
new file mode 100644
index 0000000..32e9bc1
--- /dev/null
+++ b/tests/packages/cmake_defines/pyproject.toml
@@ -0,0 +1,11 @@
+[tool.scikit-build]
+cmake.version = '>=3.15'
+
+[tool.scikit-build.cmake.define]
+ONE_LEVEL_LIST = [
+    "Foo",
+    "Bar",
+    "ExceptionallyLargeListEntryThatWouldOverflowTheLine",
+    "Baz",
+]
+NESTED_LIST = [ "Apple", "Lemon;Lime", "Banana" ]
diff --git a/tests/test_cmake_config.py b/tests/test_cmake_config.py
index 42ca42a..600c29d 100644
--- a/tests/test_cmake_config.py
+++ b/tests/test_cmake_config.py
@@ -4,14 +4,17 @@ import os
 import shutil
 import sysconfig
 from pathlib import Path
+from textwrap import dedent
 from typing import TYPE_CHECKING
 
 import pytest
 from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
+from scikit_build_core.builder.builder import Builder
 from scikit_build_core.cmake import CMake, CMaker
 from scikit_build_core.errors import CMakeNotFoundError
+from scikit_build_core.settings.skbuild_read_settings import SettingsReader
 
 if TYPE_CHECKING:
     from collections.abc import Generator
@@ -201,6 +204,41 @@ def test_cmake_paths(
     assert len(fp.calls) == 2
 
 
+@pytest.mark.configure
+def test_cmake_defines(
+    tmp_path: Path,
+):
+    source_dir = DIR / "packages" / "cmake_defines"
+    binary_dir = tmp_path / "build"
+
+    config = CMaker(
+        CMake.default_search(),
+        source_dir=source_dir,
+        build_dir=binary_dir,
+        build_type="Release",
+    )
+
+    reader = SettingsReader.from_file(source_dir / "pyproject.toml")
+
+    builder = Builder(reader.settings, config)
+    builder.configure(defines={})
+
+    configure_log = Path.read_text(binary_dir / "log.txt")
+    assert configure_log == dedent(
+        """\
+        ONE_LEVEL_LIST.LENGTH = 4
+        Foo
+        Bar
+        ExceptionallyLargeListEntryThatWouldOverflowTheLine
+        Baz
+        NESTED_LIST.LENGTH = 3
+        Apple
+        Lemon;Lime
+        Banana
+        """
+    )
+
+
 def test_get_cmake_via_envvar(monkeypatch: pytest.MonkeyPatch, fp):
     monkeypatch.setattr("shutil.which", lambda x: x)
     cmake_path = Path("some-prog")
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 442eaa8..d7eb14d 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -541,8 +541,8 @@ def test_skbuild_settings_pyproject_toml_envvar_defines(
         "a": "1",
         "b": "2",
         "c": "empty",
-        "d": False,
-        "e": False,
+        "d": "FALSE",
+        "e": "FALSE",
     }
 
     monkeypatch.setenv("DEFAULT", "3")
@@ -552,8 +552,8 @@ def test_skbuild_settings_pyproject_toml_envvar_defines(
         "a": "1",
         "b": "2",
         "c": "3",
-        "d": False,
-        "e": True,
+        "d": "FALSE",
+        "e": "TRUE",
     }
 
 
@@ -754,3 +754,19 @@ def test_skbuild_settings_auto_cmake_warning(
             Report this or (and) set manually to avoid this warning. Using 3.15 as a fall-back.
       """.split()
     )
+
+
+def test_skbuild_settings_cmake_define_list():
+    pyproject_toml = (
+        Path(__file__).parent / "packages" / "cmake_defines" / "pyproject.toml"
+    )
+
+    config_settings: dict[str, list[str] | str] = {}
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
+    settings = settings_reader.settings
+
+    assert settings.cmake.define == {
+        "NESTED_LIST": r"Apple;Lemon\;Lime;Banana",
+        "ONE_LEVEL_LIST": "Foo;Bar;ExceptionallyLargeListEntryThatWouldOverflowTheLine;Baz",
+    }