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

Issue:
Support complete version specification syntax for CMake and ninja versions
Currently scikit-build-core supports specifying [minimum required CMake and ninja versions](https://scikit-build-core.readthedocs.io/en/latest/configuration.html#cmake-and-ninja-minimum-versions) in pyproject.toml. While this may be sufficient most of the time, sometimes more precise version requests are necessary. It would be good for scikit-build-core to support full version specs here.

Pull request description:
feat: cmake.version and ninja.version
Close #583.

If `tool.scikit-build.minimum-version` is set, then this will require the correct setting here. If it's unset, it will just produce a warning if the old form is used (for now).

This is also an API change, but I think that's fine for a 0.x release unless someone needs back-compat, which I can add if needed.

Patch:
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index d3e07d9..3eedbd6 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -138,7 +138,7 @@ Included modules:
 ## Basic CMake usage
 
 ```python
-cmake = CMake.default_search(minimum_version="3.15")
+cmake = CMake.default_search(version=">=3.15")
 config = CMaker(
     cmake,
     source_dir=source_dir,
@@ -208,8 +208,8 @@ from scikit_build_core.settings.skbuild_settings import SettingsReader
 
 settings_reader = SettingsReader.from_file("pyproject.toml", config_settings)
 setting = settings_reader.settings
-assert settings.cmake.minimum_version == "3.15"
-assert settings.ninja.minimum_version == "1.5"
+assert str(settings.cmake.version) == ">=3.15"
+assert str(settings.ninja.version) == ">=1.5"
 ```
 
 ## Builders
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1ec6161..7d77038 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -99,7 +99,7 @@ repos:
     rev: v2.2.6
     hooks:
       - id: codespell
-        exclude: ^(LICENSE$|src/scikit_build_core/resources/find_python)
+        exclude: ^(LICENSE$|src/scikit_build_core/resources/find_python|tests/test_skbuild_settings.py$)
 
   - repo: https://github.com/shellcheck-py/shellcheck-py
     rev: v0.9.0.6
diff --git a/README.md b/README.md
index 5e886f8..41fd32e 100644
--- a/README.md
+++ b/README.md
@@ -150,10 +150,13 @@ print("```\n")
 
 ```toml
 [tool.scikit-build]
-# The minimum version of CMake to use. If CMake is not present on the system or
-# is older than this, it will be downloaded via PyPI if possible. An empty
+# DEPRECATED in 0.8; use version instead.
+cmake.minimum-version = ""
+
+# The versions of CMake to allow. If CMake 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.
-cmake.minimum-version = "3.15"
+cmake.version = ">=3.15"
 
 # A list of args to pass to CMake when configuring the project. Setting this in
 # config or envvar will override toml. See also ``cmake.define``.
@@ -177,10 +180,13 @@ cmake.source-dir = "."
 # target.
 cmake.targets = []
 
-# The minimum version of Ninja to use. If Ninja is not present on the system or
-# is older than this, it will be downloaded via PyPI if possible. An empty
+# DEPRECATED in 0.8; use version instead.
+ninja.minimum-version = ""
+
+# 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.
-ninja.minimum-version = "1.5"
+ninja.version = ">=1.5"
 
 # If CMake is not present on the system or is older required, it will be
 # downloaded via PyPI if possible. An empty string will disable this check.
diff --git a/docs/configuration.md b/docs/configuration.md
index 957dbe1..ad292f9 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -104,6 +104,8 @@ The following behaviors are affected by `minimum-version`:
 - `minimum-version` 0.5+ (or unset) provides the original name in metadata and
   properly normalized SDist names.
 - `minimum-version` 0.5+ (or unset) strips binaries by default.
+- `minimum-version` 0.8+ (or unset) `cmake.minimum-version` and
+  `ninja.minimum-version` are replaced with `cmake.version` and `ninja.version`.
 
 :::
 
@@ -117,8 +119,8 @@ For example, to require a recent CMake and Ninja:
 
 ```toml
 [tool.scikit-build]
-cmake.minimum-version = "3.26.1"
-ninja.minimum-version = "1.11"
+cmake.version = ">=3.26.1"
+ninja.version = ">=1.11"
 ```
 
 You can also enforce ninja to be required even if make is present on Unix:
@@ -138,6 +140,12 @@ would turn it off).
 backport.find-python = "3.15"
 ```
 
+```{versionadded} 0.8
+These used to be called `cmake.minimum-version` and `ninja.minimum-version`, and
+only took a single value. Now they are full specifier sets, allowing for more
+complex version requirements, like `>=3.15,!=3.18.0`.
+```
+
 ## Configuring source file inclusion
 
 Scikit-build-core defaults to using your `.gitignore` to select what to exclude
@@ -647,7 +655,7 @@ will match top to bottom, overriding previous matches. For example:
 ```toml
 [[tool.scikit-build.overrides]]
 if.sys-platform = "darwin"
-cmake.minimum-version = "3.18"
+cmake.version = ">=3.18"
 ```
 
 If you use `if.any` instead of `if`, then the override is true if any one of the
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 4ef5e7a..12dd7f2 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -148,7 +148,7 @@ def _build_wheel_impl(
     normalized_name = metadata.name.replace("-", "_").replace(".", "_")
 
     if settings.wheel.cmake:
-        cmake = CMake.default_search(minimum_version=settings.cmake.minimum_version)
+        cmake = CMake.default_search(version=settings.cmake.version)
         cmake_msg = [f"using [blue]CMake {cmake.version}[/blue]"]
     else:
         cmake = None
diff --git a/src/scikit_build_core/builder/generator.py b/src/scikit_build_core/builder/generator.py
index 6eb934a..b3e2aa0 100644
--- a/src/scikit_build_core/builder/generator.py
+++ b/src/scikit_build_core/builder/generator.py
@@ -107,9 +107,7 @@ def set_environment_for_gen(
         return {}
 
     if (generator or "Ninja") == "Ninja":
-        ninja = best_program(
-            get_ninja_programs(), minimum_version=ninja_settings.minimum_version
-        )
+        ninja = best_program(get_ninja_programs(), version=ninja_settings.version)
 
         if ninja is not None:
             env.setdefault("CMAKE_GENERATOR", "Ninja")
diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py
index eab22c8..0de8c9b 100644
--- a/src/scikit_build_core/builder/get_requires.py
+++ b/src/scikit_build_core/builder/get_requires.py
@@ -59,19 +59,17 @@ class GetRequires:
         return self._settings
 
     def cmake(self) -> Generator[str, None, None]:
-        cmake_min = self.settings.cmake.minimum_version
+        cmake_verset = self.settings.cmake.version
 
         # If the module is already installed (via caching the build
         # environment, for example), we will use that
         if importlib.util.find_spec("cmake") is not None:
-            yield f"cmake>={cmake_min}"
+            yield f"cmake{cmake_verset}"
             return
 
-        cmake = best_program(
-            get_cmake_programs(module=False), minimum_version=cmake_min
-        )
+        cmake = best_program(get_cmake_programs(module=False), version=cmake_verset)
         if cmake is None:
-            yield f"cmake>={cmake_min}"
+            yield f"cmake{cmake_verset}"
             return
         logger.debug("Found system CMake: {} - not requiring PyPI package", cmake)
 
@@ -90,17 +88,15 @@ class GetRequires:
         if os.environ.get("CMAKE_MAKE_PROGRAM", ""):
             return
 
-        ninja_min = self.settings.ninja.minimum_version
+        ninja_verset = self.settings.ninja.version
 
         # If the module is already installed (via caching the build
         # environment, for example), we will use that
         if importlib.util.find_spec("ninja") is not None:
-            yield f"ninja>={ninja_min}"
+            yield f"ninja{ninja_verset}"
             return
 
-        ninja = best_program(
-            get_ninja_programs(module=False), minimum_version=ninja_min
-        )
+        ninja = best_program(get_ninja_programs(module=False), version=ninja_verset)
         if ninja is not None:
             logger.debug("Found system Ninja: {} - not requiring PyPI package", ninja)
             return
@@ -114,7 +110,7 @@ class GetRequires:
                 "Found system Make & not on known platform - not requiring PyPI package for Ninja"
             )
             return
-        yield f"ninja>={ninja_min}"
+        yield f"ninja{ninja_verset}"
 
     def dynamic_metadata(self) -> Generator[str, None, None]:
         for dynamic_metadata in self.settings.metadata.values():
diff --git a/src/scikit_build_core/cmake.py b/src/scikit_build_core/cmake.py
index f901fc3..e3893e3 100644
--- a/src/scikit_build_core/cmake.py
+++ b/src/scikit_build_core/cmake.py
@@ -21,6 +21,7 @@ from .program_search import best_program, get_cmake_programs
 if TYPE_CHECKING:
     from collections.abc import Mapping, Sequence
 
+    from packaging.specifiers import SpecifierSet
     from packaging.version import Version
 
     from ._compat.typing import Self
@@ -42,13 +43,13 @@ class CMake:
 
     @classmethod
     def default_search(
-        cls, *, minimum_version: Version | None = None, module: bool = True
+        cls, *, version: SpecifierSet | None = None, module: bool = True
     ) -> Self:
         candidates = get_cmake_programs(module=module)
-        cmake_program = best_program(candidates, minimum_version=minimum_version)
+        cmake_program = best_program(candidates, version=version)
 
         if cmake_program is None:
-            msg = f"Could not find CMake with version >= {minimum_version}"
+            msg = f"Could not find CMake with version {version}"
             raise CMakeNotFoundError(msg)
         if cmake_program.version is None:
             msg = "CMake version undetermined @ {program.path}"
diff --git a/src/scikit_build_core/program_search.py b/src/scikit_build_core/program_search.py
index 980d2a5..25e2210 100644
--- a/src/scikit_build_core/program_search.py
+++ b/src/scikit_build_core/program_search.py
@@ -14,6 +14,8 @@ from ._shutil import Run
 if TYPE_CHECKING:
     from collections.abc import Generator, Iterable
 
+    from packaging.specifiers import SpecifierSet
+
 __all__ = ["get_cmake_programs", "get_ninja_programs", "best_program", "Program"]
 
 
@@ -122,16 +124,16 @@ def get_make_programs() -> Generator[Path, None, None]:
 
 
 def best_program(
-    programs: Iterable[Program], *, minimum_version: Version | None
+    programs: Iterable[Program], *, version: SpecifierSet | None
 ) -> Program | None:
     """
     Select the first program entry that is of a supported version, or None if not found.
     """
 
     for program in programs:
-        if minimum_version is None:
+        if version is None:
             return program
-        if program.version is not None and program.version >= minimum_version:
+        if program.version is not None and version.contains(program.version):
             return program
 
     return None
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 568efa8..e6721e3 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -11,8 +11,13 @@
       "properties": {
         "minimum-version": {
           "type": "string",
-          "default": "3.15",
-          "description": "The minimum version of CMake to use. If CMake is not present on the system or is older than this, it will be downloaded via PyPI if possible. An empty string will disable this check."
+          "description": "DEPRECATED in 0.8; use version instead.",
+          "deprecated": true
+        },
+        "version": {
+          "type": "string",
+          "default": ">=3.15",
+          "description": "The versions of CMake to allow. If CMake 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."
         },
         "args": {
           "type": "array",
@@ -67,8 +72,13 @@
       "properties": {
         "minimum-version": {
           "type": "string",
-          "default": "1.5",
-          "description": "The minimum version of Ninja to use. If Ninja is not present on the system or is older than this, it will be downloaded via PyPI if possible. An empty string will disable this check."
+          "description": "DEPRECATED in 0.8; use version instead.",
+          "deprecated": true
+        },
+        "version": {
+          "type": "string",
+          "default": ">=1.5",
+          "description": "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."
         },
         "make-fallback": {
           "type": "boolean",
diff --git a/src/scikit_build_core/settings/documentation.py b/src/scikit_build_core/settings/documentation.py
index b978903..9535309 100644
--- a/src/scikit_build_core/settings/documentation.py
+++ b/src/scikit_build_core/settings/documentation.py
@@ -8,6 +8,7 @@ import textwrap
 from pathlib import Path
 from typing import TYPE_CHECKING
 
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 from .._compat.typing import get_args, get_origin
@@ -80,7 +81,7 @@ def mk_docs(dc: type[object], prefix: str = "") -> Generator[DCDoc, None, None]:
         if field.default is not dataclasses.MISSING and field.default is not None:
             default = repr(
                 str(field.default)
-                if isinstance(field.default, (Path, Version))
+                if isinstance(field.default, (Path, Version, SpecifierSet))
                 else field.default
             )
         elif field.default_factory is not dataclasses.MISSING:
diff --git a/src/scikit_build_core/settings/json_schema.py b/src/scikit_build_core/settings/json_schema.py
index 23d6548..22b3721 100644
--- a/src/scikit_build_core/settings/json_schema.py
+++ b/src/scikit_build_core/settings/json_schema.py
@@ -5,6 +5,7 @@ import sys
 from pathlib import Path
 from typing import Any, Union
 
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 from .._compat.builtins import ExceptionGroup
@@ -49,7 +50,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
         if field.default is not dataclasses.MISSING and field.default is not None:
             props[field.name]["default"] = (
                 str(field.default)
-                if isinstance(field.default, (Version, Path))
+                if isinstance(field.default, (Version, Path, SpecifierSet))
                 else field.default
             )
 
@@ -66,6 +67,8 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
     docs = pull_docs(dclass)
     for k, v in docs.items():
         props[k]["description"] = v
+        if "DEPRECATED" in v:
+            props[k]["deprecated"] = True
 
     if normalize_keys:
         props = {k.replace("_", "-"): v for k, v in props.items()}
@@ -84,7 +87,7 @@ def to_json_schema(dclass: type[Any], *, normalize_keys: bool) -> dict[str, Any]
 def convert_type(t: Any, *, normalize_keys: bool) -> dict[str, Any]:
     if dataclasses.is_dataclass(t):
         return to_json_schema(t, normalize_keys=normalize_keys)
-    if t is str or t is Path or t is Version:
+    if t is str or t is Path or t is Version or t is SpecifierSet:
         return {"type": "string"}
     if t is bool:
         return {"type": "boolean"}
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 4440016..51eebae 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -2,6 +2,7 @@ import dataclasses
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Union
 
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 from .._compat.typing import Literal
@@ -26,10 +27,15 @@ def __dir__() -> List[str]:
 
 @dataclasses.dataclass
 class CMakeSettings:
-    minimum_version: Version = Version("3.15")
+    minimum_version: Optional[Version] = None
+    """
+    DEPRECATED in 0.8; use version instead.
+    """
+
+    version: SpecifierSet = SpecifierSet(">=3.15")
     """
-    The minimum version of CMake to use. If CMake is not present on the system
-    or is older than this, it will be downloaded via PyPI if possible. An empty
+    The versions of CMake to allow. If CMake 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.
     """
 
@@ -71,10 +77,15 @@ class CMakeSettings:
 
 @dataclasses.dataclass
 class NinjaSettings:
-    minimum_version: Version = Version("1.5")
+    minimum_version: Optional[Version] = None
+    """
+    DEPRECATED in 0.8; use version instead.
+    """
+
+    version: SpecifierSet = SpecifierSet(">=1.5")
     """
-    The minimum version of Ninja to use. If Ninja is not present on the system
-    or is older than this, it will be downloaded via PyPI if possible. An empty
+    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/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 3b6e466..0bc8524 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import copy
+import dataclasses
 import difflib
 import os
 import platform
@@ -16,7 +17,7 @@ from .. import __version__
 from .._compat import tomllib
 from .._logging import logger, rich_print
 from ..errors import CMakeConfigError
-from .skbuild_model import ScikitBuildSettings
+from .skbuild_model import CMakeSettings, NinjaSettings, ScikitBuildSettings
 from .sources import ConfSource, EnvSource, SourceChain, TOMLSource
 
 if TYPE_CHECKING:
@@ -148,6 +149,48 @@ def override_match(
     return matched
 
 
+def _handle_minimum_version(
+    dc: CMakeSettings | NinjaSettings, minimum_version: Version | None
+) -> None:
+    """
+    Handle the minimum version option. Supports scikit-build-core < 0.8 style
+    minimum_version. Prints an error message and exits.
+    """
+    name = "cmake" if isinstance(dc, CMakeSettings) else "ninja"
+
+    version_default = next(
+        iter(f for f in dataclasses.fields(dc) if f.name == "version")
+    ).default
+
+    # Check for minimum_version < 0.8 and the modern version setting
+    if (
+        dc.version != version_default
+        and minimum_version is not None
+        and minimum_version < Version("0.8")
+    ):
+        rich_print(
+            f"[red][bold]ERROR:[/bold] Cannot set {name}.version if minimum-version is set to less than 0.8 (which is where it was introduced)"
+        )
+        raise SystemExit(7)
+
+    # Backwards compatibility for minimum_version
+    if dc.minimum_version is not None:
+        msg = f"Use {name}.version instead of {name}.minimum-version with scikit-build-core >= 0.8"
+        if minimum_version is None:
+            rich_print(f"[yellow][bold]WARNING:[/bold] {msg}")
+        elif minimum_version >= Version("0.8"):
+            rich_print(f"[red][bold]ERROR:[/bold] {msg}")
+            raise SystemExit(7)
+
+        if dc.version != version_default:
+            rich_print(
+                f"[red][bold]ERROR:[/bold] Cannot set both {name}.minimum_version and {name}.version; use version only for scikit-build-core >= 0.8."
+            )
+            raise SystemExit(7)
+
+        dc.version = SpecifierSet(f">={dc.minimum_version}")
+
+
 class SettingsReader:
     def __init__(
         self,
@@ -245,6 +288,9 @@ class SettingsReader:
         if self.settings.install.strip is None:
             self.settings.install.strip = install_policy
 
+        _handle_minimum_version(self.settings.cmake, self.settings.minimum_version)
+        _handle_minimum_version(self.settings.ninja, self.settings.minimum_version)
+
     def unrecognized_options(self) -> Generator[str, None, None]:
         return self.sources.unrecognized_options(ScikitBuildSettings)
 
diff --git a/src/scikit_build_core/setuptools/build_cmake.py b/src/scikit_build_core/setuptools/build_cmake.py
index a663f64..0cbcca5 100644
--- a/src/scikit_build_core/setuptools/build_cmake.py
+++ b/src/scikit_build_core/setuptools/build_cmake.py
@@ -114,7 +114,7 @@ class BuildCMake(setuptools.Command):
 
         settings = SettingsReader.from_file("pyproject.toml", {}).settings
 
-        cmake = CMake.default_search(minimum_version=settings.cmake.minimum_version)
+        cmake = CMake.default_search(version=settings.cmake.version)
 
         config = CMaker(
             cmake,


Test patch:
diff --git a/tests/test_cmake_config.py b/tests/test_cmake_config.py
index 8bef9d5..0b41f6c 100644
--- a/tests/test_cmake_config.py
+++ b/tests/test_cmake_config.py
@@ -5,7 +5,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 import pytest
-from packaging.version import Version
+from packaging.specifiers import SpecifierSet
 
 from scikit_build_core.cmake import CMake, CMaker
 from scikit_build_core.errors import CMakeNotFoundError
@@ -68,8 +68,8 @@ def test_too_old(fp, monkeypatch):
     fp.register([fp.program("cmake3"), "--version"], stdout="3.14.0")
 
     with pytest.raises(CMakeNotFoundError) as excinfo:
-        CMake.default_search(minimum_version=Version("3.15"))
-    assert "Could not find CMake with version >= 3.15" in excinfo.value.args[0]
+        CMake.default_search(version=SpecifierSet(">=3.15"))
+    assert "Could not find CMake with version >=3.15" in excinfo.value.args[0]
 
 
 @pytest.mark.configure()
diff --git a/tests/test_fileapi.py b/tests/test_fileapi.py
index 658f8b8..69edf81 100644
--- a/tests/test_fileapi.py
+++ b/tests/test_fileapi.py
@@ -6,7 +6,7 @@ import sysconfig
 from pathlib import Path
 
 import pytest
-from packaging.version import Version
+from packaging.specifiers import SpecifierSet
 
 from scikit_build_core.cmake import CMake, CMaker
 from scikit_build_core.file_api._cattrs_converter import (
@@ -37,7 +37,7 @@ def prepare_env_or_skip() -> None:
 def test_cattrs_comparison(tmp_path):
     build_dir = tmp_path / "build"
 
-    cmake = CMake.default_search(minimum_version=Version("3.15"))
+    cmake = CMake.default_search(version=SpecifierSet(">=3.15"))
     config = CMaker(
         cmake,
         source_dir=DIR / "packages/simple_pure",
@@ -67,7 +67,7 @@ def test_no_index(tmp_path):
 def test_simple_pure(tmp_path):
     build_dir = tmp_path / "build"
 
-    cmake = CMake.default_search(minimum_version=Version("3.15"))
+    cmake = CMake.default_search(version=SpecifierSet(">=3.15"))
     config = CMaker(
         cmake,
         source_dir=DIR / "packages/simple_pure",
diff --git a/tests/test_fortran.py b/tests/test_fortran.py
index 598b0ce..544685b 100644
--- a/tests/test_fortran.py
+++ b/tests/test_fortran.py
@@ -5,7 +5,7 @@ import zipfile
 from pathlib import Path
 
 import pytest
-from packaging.version import Version
+from packaging.specifiers import SpecifierSet
 
 from scikit_build_core.build import build_wheel
 from scikit_build_core.program_search import (
@@ -20,8 +20,8 @@ DIR = Path(__file__).parent.resolve()
 FORTRAN_EXAMPLE = DIR / "packages/fortran_example"
 
 
-cmake_info = best_program(get_cmake_programs(), minimum_version=Version("3.17.2"))
-ninja_info = best_program(get_ninja_programs(), minimum_version=Version("1.10"))
+cmake_info = best_program(get_cmake_programs(), version=SpecifierSet(">=3.17.2"))
+ninja_info = best_program(get_ninja_programs(), version=SpecifierSet(">=1.10"))
 
 
 @pytest.mark.compile()
diff --git a/tests/test_get_requires.py b/tests/test_get_requires.py
index 82018a4..f939b8a 100644
--- a/tests/test_get_requires.py
+++ b/tests/test_get_requires.py
@@ -61,7 +61,7 @@ def test_get_requires_parts_uneeded(fp):
 
 def test_get_requires_parts_settings(fp):
     fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
-    config = {"cmake.minimum-version": "3.20"}
+    config = {"cmake.version": ">=3.20"}
     assert set(GetRequires(config).cmake()) == {"cmake>=3.20"}
     assert set(GetRequires(config).ninja()) == {*ninja}
 
@@ -71,7 +71,23 @@ def test_get_requires_parts_pyproject(fp, monkeypatch, tmp_path):
     tmp_path.joinpath("pyproject.toml").write_text(
         """
         [tool.scikit-build.cmake]
-        minimum-version = "3.21"
+        version = ">=3.21"
+        """
+    )
+    fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
+
+    assert set(GetRequires().cmake()) == {"cmake>=3.21"}
+    assert set(GetRequires().ninja()) == {*ninja}
+
+
+def test_get_requires_parts_pyproject_old(fp, monkeypatch, tmp_path):
+    monkeypatch.chdir(tmp_path)
+    tmp_path.joinpath("pyproject.toml").write_text(
+        """
+        
+        [tool.scikit-build]
+        minimum-version = "0.0"
+        cmake.minimum-version = "3.21"
         """
     )
     fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
diff --git a/tests/test_program_search.py b/tests/test_program_search.py
index ca7e903..cf091de 100644
--- a/tests/test_program_search.py
+++ b/tests/test_program_search.py
@@ -4,6 +4,7 @@ import logging
 from pathlib import Path
 
 import pytest
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 from scikit_build_core.program_search import (
@@ -55,11 +56,11 @@ def test_get_cmake_programs_all(monkeypatch, fp):
     assert programs[1].path.name == "cmake"
     assert programs[1].version == Version("3.20.0")
 
-    best1 = best_program(programs, minimum_version=None)
+    best1 = best_program(programs, version=None)
     assert best1
     assert best1.path.name == "cmake3"
 
-    best2 = best_program(programs, minimum_version=Version("3.20.0"))
+    best2 = best_program(programs, version=SpecifierSet(">=3.20.0"))
     assert best2
     assert best2.path.name == "cmake"
 
@@ -77,11 +78,11 @@ def test_get_ninja_programs_all(monkeypatch, fp):
     assert programs[1].path.name == "ninja"
     assert programs[1].version == Version("1.10.1")
 
-    best1 = best_program(programs, minimum_version=None)
+    best1 = best_program(programs, version=None)
     assert best1
     assert best1.path.name == "ninja-build"
 
-    best2 = best_program(programs, minimum_version=Version("1.9"))
+    best2 = best_program(programs, version=SpecifierSet(">=1.9"))
     assert best2
     assert best2.path.name == "ninja"
 
@@ -98,13 +99,13 @@ def test_get_cmake_programs_malformed(monkeypatch, fp, caplog):
     assert "Could not determine CMake version" in str(caplog.records[0].msg)
     assert len(programs) == 2
 
-    best_none = best_program(programs, minimum_version=None)
+    best_none = best_program(programs, version=None)
     assert best_none
     assert best_none.path.name == "cmake3"
 
-    best_3_15 = best_program(programs, minimum_version=Version("3.15"))
+    best_3_15 = best_program(programs, version=SpecifierSet(">=3.15"))
     assert best_3_15
     assert best_3_15.path.name == "cmake3"
 
-    best_3_20 = best_program(programs, minimum_version=Version("3.20"))
+    best_3_20 = best_program(programs, version=SpecifierSet(">=3.20"))
     assert best_3_20 is None
diff --git a/tests/test_simple_pure.py b/tests/test_simple_pure.py
index 32307d8..fbbd8c4 100644
--- a/tests/test_simple_pure.py
+++ b/tests/test_simple_pure.py
@@ -8,7 +8,7 @@ import sysconfig
 from pathlib import Path
 
 import pytest
-from packaging.version import Version
+from packaging.specifiers import SpecifierSet
 
 from scikit_build_core.cmake import CMake, CMaker
 
@@ -39,7 +39,7 @@ def config(tmp_path_factory):
 
     build_dir = tmp_path / "build"
 
-    cmake = CMake.default_search(minimum_version=Version("3.15"))
+    cmake = CMake.default_search(version=SpecifierSet(">=3.15"))
     config = CMaker(
         cmake,
         source_dir=DIR / "packages/simple_pure",
@@ -107,7 +107,7 @@ def test_variable_defined(tmp_path, capfd):
 
     build_dir = tmp_path / "build"
 
-    cmake = CMake.default_search(minimum_version=Version("3.15"))
+    cmake = CMake.default_search(version=SpecifierSet(">=3.15"))
     config = CMaker(
         cmake,
         source_dir=DIR / "packages/simple_pure",
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 652786b..b037e8f 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -8,6 +8,7 @@ import textwrap
 from pathlib import Path
 
 import pytest
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 import scikit_build_core.settings.skbuild_read_settings
@@ -25,9 +26,11 @@ def test_skbuild_settings_default(tmp_path: Path):
     settings = settings_reader.settings
     assert list(settings_reader.unrecognized_options()) == []
 
-    assert settings.ninja.minimum_version == Version("1.5")
+    assert settings.ninja.minimum_version is None
+    assert settings.ninja.version == SpecifierSet(">=1.5")
     assert settings.ninja.make_fallback
-    assert settings.cmake.minimum_version == Version("3.15")
+    assert settings.cmake.minimum_version is None
+    assert settings.cmake.version == SpecifierSet(">=3.15")
     assert settings.cmake.args == []
     assert settings.cmake.define == {}
     assert not settings.cmake.verbose
@@ -326,8 +329,8 @@ def test_skbuild_settings_pyproject_toml_broken(
         textwrap.dedent(
             """\
             [tool.scikit-build]
-            cmake.minimum-verison = "3.18"
-            ninja.minimum-version = "1.3"
+            cmake.verison = ">=3.18"
+            ninja.version = ">=1.3"
             ninja.make-fallback = false
             logger.level = "ERROR"
             """
@@ -339,7 +342,7 @@ def test_skbuild_settings_pyproject_toml_broken(
 
     settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
     assert list(settings_reader.unrecognized_options()) == [
-        "tool.scikit-build.cmake.minimum-verison",
+        "tool.scikit-build.cmake.verison",
         "tool.scikit-build.logger",
     ]
 
@@ -352,7 +355,7 @@ def test_skbuild_settings_pyproject_toml_broken(
         ex.split()
         == """\
       ERROR: Unrecognized options in pyproject.toml:
-        tool.scikit-build.cmake.minimum-verison -> Did you mean: tool.scikit-build.cmake.minimum-version, tool.scikit-build.minimum-version, tool.scikit-build.ninja.minimum-version?
+        tool.scikit-build.cmake.verison -> Did you mean: tool.scikit-build.cmake.version, tool.scikit-build.cmake.verbose, tool.scikit-build.cmake.define?
         tool.scikit-build.logger -> Did you mean: tool.scikit-build.logging, tool.scikit-build.generate, tool.scikit-build.wheel?
       """.split()
     )
@@ -363,15 +366,15 @@ def test_skbuild_settings_pyproject_conf_broken(tmp_path, capsys):
     pyproject_toml.write_text("", encoding="utf-8")
 
     config_settings: dict[str, str | list[str]] = {
-        "cmake.minimum-verison": "3.17",
-        "ninja.minimum-version": "1.2",
+        "cmake.verison": ">=3.17",
+        "ninja.version": ">=1.2",
         "ninja.make-fallback": "False",
         "logger.level": "INFO",
     }
 
     settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
     assert list(settings_reader.unrecognized_options()) == [
-        "cmake.minimum-verison",
+        "cmake.verison",
         "logger",
     ]
 
@@ -385,13 +388,13 @@ def test_skbuild_settings_pyproject_conf_broken(tmp_path, capsys):
         ex.split()
         == """\
       ERROR: Unrecognized options in config-settings:
-        cmake.minimum-verison -> Did you mean: cmake.minimum-version, minimum-version, ninja.minimum-version?
+        cmake.verison -> Did you mean: cmake.version, cmake.verbose, cmake.define?
         logger -> Did you mean: logging?
       """.split()
     )
 
 
-def test_skbuild_settings_min_version_defaults(
+def test_skbuild_settings_min_version_defaults_strip(
     tmp_path: Path, monkeypatch: pytest.MonkeyPatch
 ):
     monkeypatch.setattr(
@@ -412,3 +415,43 @@ def test_skbuild_settings_min_version_defaults(
     )
     settings = settings_reader.settings
     assert settings.install.strip
+
+
+def test_skbuild_settings_min_version_versions(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.8.0"
+    )
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        "tool.scikit-build.cmake.minimum-version = '3.21'", encoding="utf-8"
+    )
+
+    settings_reader = SettingsReader.from_file(
+        pyproject_toml, {"minimum-version": "0.7"}
+    )
+    settings = settings_reader.settings
+    assert settings.cmake.version == SpecifierSet(">=3.21")
+
+    with pytest.raises(SystemExit):
+        SettingsReader.from_file(pyproject_toml, {"minimum-version": "0.8"})
+
+
+def test_skbuild_settings_version_too_old(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_read_settings, "__version__", "0.8.0"
+    )
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        "tool.scikit-build.cmake.version = '>=3.21'", encoding="utf-8"
+    )
+
+    SettingsReader.from_file(pyproject_toml, {})
+
+    with pytest.raises(SystemExit):
+        SettingsReader.from_file(pyproject_toml, {"minimum-version": "0.7"})