From d07f6499d9e62e69479683a88f9764001c03e4ae Mon Sep 17 00:00:00 2001
From: Cristian Le <git@lecris.dev>
Date: Mon, 17 Feb 2025 19:05:58 +0100
Subject: [PATCH] Initial support for `cmake.preset`

Signed-off-by: Cristian Le <git@lecris.dev>
---
 README.md                                     |  8 ++++++
 src/scikit_build_core/builder/builder.py      |  1 +
 src/scikit_build_core/builder/generator.py    |  1 +
 src/scikit_build_core/cmake.py                | 17 ++++++++++--
 .../resources/scikit-build.schema.json        |  4 +++
 .../settings/skbuild_model.py                 |  9 +++++++
 .../settings/skbuild_read_settings.py         |  6 ++++-
 tests/packages/cmake_defines/CMakeLists.txt   |  9 +++++++
 .../packages/cmake_defines/CMakePresets.json  | 14 ++++++++++
 tests/packages/cmake_defines/pyproject.toml   |  5 ++++
 tests/test_cmake_config.py                    | 26 ++++++++++++++++++-
 tests/test_skbuild_settings.py                |  1 +
 12 files changed, 97 insertions(+), 4 deletions(-)
 create mode 100644 tests/packages/cmake_defines/CMakePresets.json

diff --git a/README.md b/README.md
index b6a97886..3bacc704 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,14 @@ cmake.source-dir = "."
 # DEPRECATED in 0.10; use build.targets instead.
 cmake.targets = ""
 
+# Configure preset to use. ``cmake.source-dir`` must still be appropriately
+# defined and it must contain a ``CMake(User)Presets.json``. The preset's
+# ``binaryDir`` is ignored and is always overwritten by the ``build-dir``
+# defined by scikit-build-core. ``cmake.define``, generator values are still
+# passed if defined and take precedence over preset's value according to CMake
+# logic.
+cmake.preset = ""
+
 # The versions of Ninja to allow. If Ninja is not present on the system or does
 # not pass this specifier, it will be downloaded via PyPI if possible. An empty
 # string will disable this check.
diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py
index b19173fd..88e89d9b 100644
--- a/src/scikit_build_core/builder/builder.py
+++ b/src/scikit_build_core/builder/builder.py
@@ -256,6 +256,7 @@ def configure(
         cmake_defines.update(self.settings.cmake.define)
 
         self.config.configure(
+            preset=self.settings.cmake.preset,
             defines=cmake_defines,
             cmake_args=[*self.get_cmake_args(), *configure_args],
         )
diff --git a/src/scikit_build_core/builder/generator.py b/src/scikit_build_core/builder/generator.py
index 9c914864..fcfaf875 100644
--- a/src/scikit_build_core/builder/generator.py
+++ b/src/scikit_build_core/builder/generator.py
@@ -90,6 +90,7 @@ def set_environment_for_gen(
 
     If gen is not None, then that will be the target generator.
     """
+    # TODO: How does make_fallback interact when `preset` is set?
     allow_make_fallback = ninja_settings.make_fallback
 
     if generator:
diff --git a/src/scikit_build_core/cmake.py b/src/scikit_build_core/cmake.py
index aac19f0d..7d6c0bd2 100644
--- a/src/scikit_build_core/cmake.py
+++ b/src/scikit_build_core/cmake.py
@@ -12,17 +12,23 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
+from packaging.version import Version
+
 from . import __version__
 from ._logging import logger
 from ._shutil import Run
-from .errors import CMakeConfigError, CMakeNotFoundError, FailedLiveProcessError
+from .errors import (
+    CMakeConfigError,
+    CMakeNotFoundError,
+    CMakeVersionError,
+    FailedLiveProcessError,
+)
 from .program_search import Program, best_program, get_cmake_program, get_cmake_programs
 
 if TYPE_CHECKING:
     from collections.abc import Generator, Iterable, Mapping, Sequence
 
     from packaging.specifiers import SpecifierSet
-    from packaging.version import Version
 
     from ._compat.typing import Self
 
@@ -222,12 +228,19 @@ def get_generator(self, *args: str) -> str | None:
     def configure(
         self,
         *,
+        preset: str | None = None,
         defines: Mapping[str, str | os.PathLike[str] | bool] | None = None,
         cmake_args: Sequence[str] = (),
     ) -> None:
         _cmake_args = self._compute_cmake_args(defines or {})
         all_args = [*_cmake_args, *cmake_args]
 
+        if preset:
+            if self.cmake.version < Version("3.19"):
+                msg = f"CMake version ({self.cmake.version}) is too old to support presets."
+                raise CMakeVersionError(msg)
+            all_args.append(f"--preset={preset}")
+
         gen = self.get_generator(*all_args)
         if gen:
             self.single_config = gen == "Ninja" or "Makefiles" in gen
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index d8e87df8..feb43e9c 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -102,6 +102,10 @@
           },
           "description": "DEPRECATED in 0.10; use build.targets instead.",
           "deprecated": true
+        },
+        "preset": {
+          "type": "string",
+          "description": "Configure preset to use. ``cmake.source-dir`` must still be appropriately defined and it must contain a ``CMake(User)Presets.json``. The preset's ``binaryDir`` is ignored and is always overwritten by the ``build-dir`` defined by scikit-build-core. ``cmake.define``, generator values are still passed if defined and take precedence over preset's value according to CMake logic."
         }
       }
     },
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index af1c7c4f..fb4c03a3 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -102,6 +102,15 @@ class CMakeSettings:
     DEPRECATED in 0.10; use build.targets instead.
     """
 
+    preset: Optional[str] = None
+    """
+    Configure preset to use. ``cmake.source-dir`` must still be appropriately defined
+    and it must contain a ``CMake(User)Presets.json``. The preset's ``binaryDir`` is
+    ignored and is always overwritten by the ``build-dir`` defined by scikit-build-core.
+    ``cmake.define``, generator values are still passed if defined and take precedence
+    over preset's value according to CMake logic.
+    """
+
 
 @dataclasses.dataclass
 class NinjaSettings:
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index a3bd624f..eb6a07c1 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -273,8 +273,12 @@ def __init__(
                 new_min_cmake = "3.15"
             self.settings.cmake.version = SpecifierSet(f">={new_min_cmake}")
 
+        default_cmake_minimum = "3.15"
+        if self.settings.cmake.preset:
+            default_cmake_minimum = "3.19"
+
         _handle_minimum_version(
-            self.settings.cmake, self.settings.minimum_version, "3.15"
+            self.settings.cmake, self.settings.minimum_version, default_cmake_minimum
         )
         _handle_minimum_version(self.settings.ninja, self.settings.minimum_version)
 
diff --git a/tests/packages/cmake_defines/CMakeLists.txt b/tests/packages/cmake_defines/CMakeLists.txt
index e4867404..a0a593e7 100644
--- a/tests/packages/cmake_defines/CMakeLists.txt
+++ b/tests/packages/cmake_defines/CMakeLists.txt
@@ -7,10 +7,19 @@ set(ONE_LEVEL_LIST
 set(NESTED_LIST
     ""
     CACHE STRING "")
+set(PRESET_ONLY_VAR
+    ""
+    CACHE STRING "")
+set(OVERWRITTEN_VAR
+    ""
+    CACHE STRING "")
 
 set(out_file "${CMAKE_CURRENT_BINARY_DIR}/log.txt")
 file(WRITE "${out_file}" "")
 
+file(APPEND "${out_file}" "PRESET_ONLY_VAR=${PRESET_ONLY_VAR}\n")
+file(APPEND "${out_file}" "OVERWRITTEN_VAR=${OVERWRITTEN_VAR}\n")
+
 foreach(list IN ITEMS ONE_LEVEL_LIST NESTED_LIST)
   list(LENGTH ${list} length)
   file(APPEND "${out_file}" "${list}.LENGTH = ${length}\n")
diff --git a/tests/packages/cmake_defines/CMakePresets.json b/tests/packages/cmake_defines/CMakePresets.json
new file mode 100644
index 00000000..fe37b6b2
--- /dev/null
+++ b/tests/packages/cmake_defines/CMakePresets.json
@@ -0,0 +1,14 @@
+{
+  "version": 1,
+  "configurePresets": [
+    {
+      "name": "scikit",
+      "cacheVariables": {
+        "PRESET_ONLY_VAR": "defined",
+        "OVERWRITTEN_VAR": "original"
+      },
+      "binaryDir": "/dev/null",
+      "generator": "Ninja"
+    }
+  ]
+}
diff --git a/tests/packages/cmake_defines/pyproject.toml b/tests/packages/cmake_defines/pyproject.toml
index 32e9bc15..ab3f0ce6 100644
--- a/tests/packages/cmake_defines/pyproject.toml
+++ b/tests/packages/cmake_defines/pyproject.toml
@@ -9,3 +9,8 @@ ONE_LEVEL_LIST = [
     "Baz",
 ]
 NESTED_LIST = [ "Apple", "Lemon;Lime", "Banana" ]
+OVERWRITTEN_VAR = "overwritten"
+
+[[tool.scikit-build.overrides]]
+if.env.WITH_PRESET = true
+cmake.preset = "scikit"
diff --git a/tests/test_cmake_config.py b/tests/test_cmake_config.py
index 6a86e1ba..19b40561 100644
--- a/tests/test_cmake_config.py
+++ b/tests/test_cmake_config.py
@@ -14,12 +14,14 @@
 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.program_search import best_program, get_cmake_programs
 from scikit_build_core.settings.skbuild_read_settings import SettingsReader
 
 if TYPE_CHECKING:
     from collections.abc import Generator
 
 DIR = Path(__file__).parent.resolve()
+cmake_preset_info = best_program(get_cmake_programs(), version=SpecifierSet(">=3.19"))
 
 
 def single_config(param: None | str) -> bool:
@@ -204,10 +206,26 @@ def test_cmake_paths(
     assert len(fp.calls) == 2
 
 
+@pytest.mark.parametrize(
+    "with_preset",
+    [
+        pytest.param(
+            True,
+            marks=pytest.mark.skipif(
+                cmake_preset_info is None,
+                reason="CMake version does not support presets.",
+            ),
+        ),
+        False,
+    ],
+)
 @pytest.mark.configure
 def test_cmake_defines(
+    monkeypatch,
     tmp_path: Path,
+    with_preset: bool,
 ):
+    monkeypatch.setenv("WITH_PRESET", f"{with_preset}")
     source_dir = DIR / "packages" / "cmake_defines"
     binary_dir = tmp_path / "build"
 
@@ -224,8 +242,14 @@ def test_cmake_defines(
     builder.configure(defines={})
 
     configure_log = Path.read_text(binary_dir / "log.txt")
+
+    # This var is always overwritten
+    overwritten_var = "overwritten"
+    preset_only_var = "defined" if with_preset else ""
     assert configure_log == dedent(
-        """\
+        f"""\
+        PRESET_ONLY_VAR={preset_only_var}
+        OVERWRITTEN_VAR={overwritten_var}
         ONE_LEVEL_LIST.LENGTH = 4
         Foo
         Bar
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 90d4e429..86957ece 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -764,4 +764,5 @@ def test_skbuild_settings_cmake_define_list():
     assert settings.cmake.define == {
         "NESTED_LIST": r"Apple;Lemon\;Lime;Banana",
         "ONE_LEVEL_LIST": "Foo;Bar;ExceptionallyLargeListEntryThatWouldOverflowTheLine;Baz",
+        "OVERWRITTEN_VAR": "overwritten",
     }