Repository: scikit-build/scikit-build-core
Pull request #851 (2024-08-05 20:41:18)

feat: if.scikit-build-version
This adds an `if.scikit-build-version`, and makes sure it can be used to gate features not yet implemented. This should help with #769 by providing a way to support old scikit-build-core's on Python 3.7 even after we drop support.

Also fixes auto minimum-version to respect markers.

LecrisUT: The plain `version` naming is so ambiguous though. What about `skbuild-version` or `skbuild-core-version` to mirror the CMake variable?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/851?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 83.71%. Comparing base [(`0a8a352`)](https://app.codecov.io/gh/scikit-build/scikit-build-core/commit/0a8a352e7017067ba947b8cf69410315aa9b56c2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) to head [(`c756ae1`)](https://app.codecov.io/gh/scikit-build/scikit-build-core/commit/c756ae11cb3f1737b0d1e6423e361c0f80ac9561?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #851      +/-   ##
==========================================
- Coverage   83.87%   83.71%   -0.17%     
==========================================
  Files          74       74              
  Lines        4347     4353       +6     
==========================================
- Hits         3646     3644       -2     
- Misses        701      709       +8     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scikit-build/scikit-build-core/pull/851?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build).

henryiii: I was trying to match `minimum-version`, which also doesn't have any extra qualifier. Other versions, like `cmake.version` or `python-version` have qualifiers. That was the thinking, anyway. It's also rather important, so `scikit-build-core-version` or `scikit-build-version` seems really long, especially for something this integral. I've avoided "skbuild" in pyproject.toml.

Admittedly, `minimum-version` is `tool.scikit-build.minimum-version`, while this is `tool.scikit-build.overrides[].if.version`, which is a bit more disconnected from the "scikit-build" part.

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index dc411ba..dc2884c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -17,9 +17,10 @@ New features:
 * Adding `if.system-cmake` and `if.cmake-wheel` by @henryiii in #826
 * Add `if.from-sdist` for overrides by @henryiii in #812
 * Add `if.failed` (retry) by @henryiii in #820
+* Add `if.scikit-build-version` by @henryiii in #851
 * Packages can also be specified via a table by @henryiii in #841
 * Move `cmake.targets` and `cmake.verbose` to `build.targets` and `build.verbose` by @henryiii in #793
-* Support multipart regex by @henryiii in #818
+* Support multipart regex using `result=` by @henryiii in #818
 * Add `remove=` postprocess to regex by @henryiii in #828
 
 Fixes:
diff --git a/docs/configuration.md b/docs/configuration.md
index c06ca66..8ddcd71 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -615,10 +615,10 @@ matches from the final result. A more complex example:
 provider = "scikit_build_core.metadata.regex"
 input = "src/mypackage/version.hpp"
 regex = '''(?sx)
-\#define \w+ VERSION_MAJOR \w+ (?P<major>\d+) .*?
-\#define \w+ VERSION_MINOR \w+ (?P<minor>\d+) .*?
-\#define \w+ VERSION_PATCH \w+ (?P<patch>\d+) .*?
-\#define \w+ VERSION_DEV \w+ (?P<dev>\d+) .*?
+\#define \s+ VERSION_MAJOR \s+ (?P<major>\d+) .*?
+\#define \s+ VERSION_MINOR \s+ (?P<minor>\d+) .*?
+\#define \s+ VERSION_PATCH \s+ (?P<patch>\d+) .*?
+\#define \s+ VERSION_DEV   \s+ (?P<dev>\d+)   .*?
 '''
 result = "{major}.{minor}.{patch}dev{dev}"
 remove = "dev0"
diff --git a/docs/overrides.md b/docs/overrides.md
index d70b159..1cac13b 100644
--- a/docs/overrides.md
+++ b/docs/overrides.md
@@ -21,6 +21,16 @@ At least one must be provided. Then you can specify any collection of valid
 options, and those will override if all the items in the `if` are true. They
 will match top to bottom, overriding previous matches.
 
+If an override does not match, it's contents are ignored, including invalid
+options. Combined with the `if.scikit-build-version` override, this allows using overrides to
+support a range of scikit-build-core versions that added settings you want to
+use.
+
+### `scikit-build-version` (version)
+
+The version of scikit-build-core itself. Takes a specifier set. If this is
+provided, unknown overrides will not be validated unless it's a match.
+
 ### `python-version` (version)
 
 The two-digit Python version. Takes a specifier set.
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index f9401d8..6cfaa49 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -635,6 +635,10 @@
       "minProperties": 1,
       "additionalProperties": false,
       "properties": {
+        "scikit-build-version": {
+          "type": "string",
+          "description": "The version of scikit-build-version. Takes a specifier set."
+        },
         "python-version": {
           "type": "string",
           "description": "The two-digit Python version. Takes a specifier set."
diff --git a/src/scikit_build_core/settings/auto_requires.py b/src/scikit_build_core/settings/auto_requires.py
index 6195abe..83436ea 100644
--- a/src/scikit_build_core/settings/auto_requires.py
+++ b/src/scikit_build_core/settings/auto_requires.py
@@ -29,13 +29,14 @@ def get_min_requires(package: str, reqlist: Iterable[str]) -> Version | None:
 
     requires = [Requirement(req) for req in reqlist]
 
-    for req in requires:
-        if canonicalize_name(req.name) == norm_package:
-            specset = req.specifier
-            versions = (min_from_spec(v) for v in specset)
-            return min((v for v in versions if v is not None), default=None)
-
-    return None
+    versions = (
+        min_from_spec(v)
+        for req in requires
+        if canonicalize_name(req.name) == norm_package
+        and (req.marker is None or req.marker.evaluate())
+        for v in req.specifier
+    )
+    return min((v for v in versions if v is not None), default=None)
 
 
 def min_from_spec(spec: Specifier) -> Version | None:
diff --git a/src/scikit_build_core/settings/skbuild_overrides.py b/src/scikit_build_core/settings/skbuild_overrides.py
index 3c73f61..5315210 100644
--- a/src/scikit_build_core/settings/skbuild_overrides.py
+++ b/src/scikit_build_core/settings/skbuild_overrides.py
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
 import packaging.tags
 from packaging.specifiers import SpecifierSet
 
+from .. import __version__
 from .._compat import tomllib
 from .._logging import logger
 from ..builder.sysconfig import get_abi_flags
@@ -78,10 +79,12 @@ def override_match(
     system_cmake: str | None = None,
     cmake_wheel: bool | None = None,
     abi_flags: str | None = None,
-) -> tuple[dict[str, str], set[str]]:
+    scikit_build_version: str | None = None,
+    **unknown: Any,
+) -> tuple[dict[str, str], set[str], dict[str, Any]]:
     """
-    Check if the current environment matches the overrides. Returns a dict
-    of passed matches, with reasons for values, and a set of non-matches.
+    Check if the current environment matches the overrides. Returns a dict of
+    passed matches, with reasons for values, and a set of non-matches.
     """
 
     passed_dict = {}
@@ -90,6 +93,16 @@ def override_match(
     if current_env is None:
         current_env = os.environ
 
+    if scikit_build_version is not None:
+        current_version = __version__
+        match_msg = version_match(
+            current_version, scikit_build_version, "scikit-build-core"
+        )
+        if match_msg:
+            passed_dict["scikit-build-version"] = match_msg
+        else:
+            failed_set.add("scikit-build-version")
+
     if python_version is not None:
         current_python_version = ".".join(str(x) for x in sys.version_info[:2])
         match_msg = version_match(current_python_version, python_version, "Python")
@@ -219,11 +232,11 @@ def override_match(
                 else:
                     failed_set.add(f"env.{key}")
 
-    if not passed_dict and not failed_set:
+    if len(passed_dict) + len(failed_set) + len(unknown) < 1:
         msg = "At least one override must be provided"
         raise ValueError(msg)
 
-    return passed_dict, failed_set
+    return passed_dict, failed_set, unknown
 
 
 def inherit_join(
@@ -261,7 +274,9 @@ def process_overides(
     for override in tool_skb.pop("overrides", []):
         passed_any: dict[str, str] | None = None
         passed_all: dict[str, str] | None = None
-        failed: set[str] = set()
+        unknown: set[str] = set()
+        failed_any: set[str] = set()
+        failed_all: set[str] = set()
         if_override = override.pop("if", None)
         if not if_override:
             msg = "At least one 'if' override must be provided"
@@ -272,13 +287,14 @@ def process_overides(
         if "any" in if_override:
             any_override = if_override.pop("any")
             select = {k.replace("-", "_"): v for k, v in any_override.items()}
-            passed_any, _ = override_match(
+            passed_any, failed_any, unknown_any = override_match(
                 current_env=env,
                 current_state=state,
                 has_dist_info=has_dist_info,
                 retry=retry,
                 **select,
             )
+            unknown |= set(unknown_any)
 
         inherit_override = override.pop("inherit", {})
         if not isinstance(inherit_override, dict):
@@ -287,20 +303,32 @@ def process_overides(
 
         select = {k.replace("-", "_"): v for k, v in if_override.items()}
         if select:
-            passed_all, failed = override_match(
+            passed_all, failed_all, unknown_all = override_match(
                 current_env=env,
                 current_state=state,
                 has_dist_info=has_dist_info,
                 retry=retry,
                 **select,
             )
+            unknown |= set(unknown_all)
+
+        # Verify no unknown options are present unless scikit-build-version is specified
+        passed_or_failed = {
+            *(passed_all or {}),
+            *(passed_any or {}),
+            *failed_all,
+            *failed_any,
+        }
+        if "scikit-build-version" not in passed_or_failed and unknown:
+            msg = f"Unknown overrides: {', '.join(unknown)}"
+            raise TypeError(msg)
 
         # If no overrides are passed, do nothing
         if passed_any is None and passed_all is None:
             continue
 
         # If normal overrides are passed and one or more fails, do nothing
-        if passed_all is not None and failed:
+        if passed_all is not None and failed_all:
             continue
 
         # If any is passed, at least one always needs to pass.
@@ -310,6 +338,10 @@ def process_overides(
         local_matched = set(passed_any or []) | set(passed_all or [])
         global_matched |= local_matched
         if local_matched:
+            if unknown:
+                msg = f"Unknown overrides: {', '.join(unknown)}"
+                raise TypeError(msg)
+
             all_str = " and ".join(
                 [
                     *(passed_all or {}).values(),
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index 081f57c..d5151f7 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -88,6 +88,10 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
         "minProperties": 1,
         "additionalProperties": False,
         "properties": {
+            "scikit-build-version": {
+                "type": "string",
+                "description": "The version of scikit-build-version. Takes a specifier set.",
+            },
             "python-version": {
                 "type": "string",
                 "description": "The two-digit Python version. Takes a specifier set.",


Test patch:
diff --git a/tests/test_auto.py b/tests/test_auto.py
index 3133a7a..f4453e8 100644
--- a/tests/test_auto.py
+++ b/tests/test_auto.py
@@ -30,6 +30,15 @@ def test_auto_requires_pkg_version(spec: str, version: Version):
     assert get_min_requires("scikit-build-core", reqlist) == version
 
 
+def test_auto_requires_with_marker():
+    reqlist = [
+        "scikit_build_core>=0.1; python_version < '3.7'",
+        "scikit_build_core>=0.2; python_version >= '3.7'",
+    ]
+
+    assert get_min_requires("scikit-build-core", reqlist) == Version("0.2")
+
+
 @pytest.mark.parametrize(
     ("expr", "answer"),
     [
diff --git a/tests/test_cmake_config.py b/tests/test_cmake_config.py
index ddc170a..73804d2 100644
--- a/tests/test_cmake_config.py
+++ b/tests/test_cmake_config.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import os
 import shutil
+import sysconfig
 from pathlib import Path
 from typing import TYPE_CHECKING
 
@@ -18,11 +19,51 @@ if TYPE_CHECKING:
 DIR = Path(__file__).parent.resolve()
 
 
-def configure_args(config: CMaker, *, init: bool = False) -> Generator[str, None, None]:
+def single_config(param: None | str) -> bool:
+    if param is None:
+        return not sysconfig.get_platform().startswith("win")
+
+    return param in {"Ninja", "Makefiles"}
+
+
+@pytest.fixture(
+    params=[
+        pytest.param(None, id="default"),
+        pytest.param("Ninja", id="ninja"),
+        pytest.param(
+            "Makefiles",
+            id="makefiles",
+            marks=pytest.mark.skipif(
+                sysconfig.get_platform().startswith("win"), reason="run on Windows only"
+            ),
+        ),
+        pytest.param(
+            "Others",
+            id="others",
+            marks=pytest.mark.skipif(
+                sysconfig.get_platform().startswith("win"), reason="run on Windows only"
+            ),
+        ),
+    ]
+)
+def generator(
+    request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
+) -> str | None:
+    if request.param is None:
+        monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
+    else:
+        monkeypatch.setenv("CMAKE_GENERATOR", request.param)
+
+    return request.param
+
+
+def configure_args(
+    config: CMaker, *, init: bool = False, single_config: bool = False
+) -> Generator[str, None, None]:
     yield f"-S{config.source_dir}"
     yield f"-B{config.build_dir}"
 
-    if config.single_config:
+    if single_config:
         yield f"-DCMAKE_BUILD_TYPE:STRING={config.build_type}"
 
     if init:
@@ -31,7 +72,11 @@ def configure_args(config: CMaker, *, init: bool = False) -> Generator[str, None
 
 
 @pytest.mark.configure()
-def test_init_cache(fp, tmp_path):
+def test_init_cache(
+    generator: str,
+    tmp_path: Path,
+    fp,
+):
     fp.register(
         [fp.program("cmake"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.14.0"}}',
@@ -51,7 +96,9 @@ def test_init_cache(fp, tmp_path):
         {"SKBUILD": True, "SKBUILD_VERSION": "1.0.0", "SKBUILD_PATH": config.source_dir}
     )
 
-    cmd = list(configure_args(config, init=True))
+    cmd = list(
+        configure_args(config, init=True, single_config=single_config(generator))
+    )
     print("Registering: cmake", *cmd)
     fp.register([fp.program("cmake"), *cmd])
     fp.register([fp.program("cmake3"), *cmd])
@@ -87,7 +134,11 @@ def test_too_old(fp, monkeypatch):
 
 
 @pytest.mark.configure()
-def test_cmake_args(tmp_path, fp):
+def test_cmake_args(
+    generator: str,
+    tmp_path: Path,
+    fp,
+):
     fp.register(
         [fp.program("cmake"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.15.0"}}',
@@ -99,24 +150,29 @@ def test_cmake_args(tmp_path, fp):
 
     config = CMaker(
         CMake.default_search(),
-        source_dir=DIR / "packages/simple_pure",
+        source_dir=DIR / "packages" / "simple_pure",
         build_dir=tmp_path / "build",
         build_type="Release",
     )
 
-    cmd = list(configure_args(config))
+    cmd = list(configure_args(config, single_config=single_config(generator)))
     cmd.append("-DSOMETHING=one")
     print("Registering: cmake", *cmd)
     fp.register([fp.program("cmake"), *cmd])
     fp.register([fp.program("cmake3"), *cmd])
 
     config.configure(cmake_args=["-DSOMETHING=one"])
-
+    # config.configure might mutate config.single_config
+    assert config.single_config == single_config(generator)
     assert len(fp.calls) == 2
 
 
 @pytest.mark.configure()
-def test_cmake_paths(tmp_path, fp):
+def test_cmake_paths(
+    generator: str,
+    tmp_path: Path,
+    fp,
+):
     fp.register(
         [fp.program("cmake"), "-E", "capabilities"],
         stdout='{"version":{"string":"3.15.0"}}',
@@ -135,7 +191,7 @@ def test_cmake_paths(tmp_path, fp):
         module_dirs=[tmp_path / "module"],
     )
 
-    cmd = list(configure_args(config))
+    cmd = list(configure_args(config, single_config=single_config(generator)))
     print("Registering: cmake", *cmd)
     fp.register([fp.program("cmake"), *cmd])
     fp.register([fp.program("cmake3"), *cmd])
diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py
index 645fd70..39f484a 100644
--- a/tests/test_settings_overrides.py
+++ b/tests/test_settings_overrides.py
@@ -7,6 +7,7 @@ from textwrap import dedent
 
 import pytest
 
+import scikit_build_core.settings.skbuild_overrides
 from scikit_build_core.settings.skbuild_overrides import regex_match
 from scikit_build_core.settings.skbuild_read_settings import SettingsReader
 
@@ -688,3 +689,167 @@ def test_free_threaded_override(tmp_path: Path):
     settings_reader = SettingsReader.from_file(pyproject_toml, state="wheel")
     settings = settings_reader.settings
     assert settings.wheel.cmake == bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
+
+
+@pytest.mark.parametrize("version", ["0.9", "0.10"])
+def test_skbuild_overrides_version(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", version
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [tool.scikit-build]
+            wheel.cmake = false
+
+            [[tool.scikit-build.overrides]]
+            if.scikit-build-version = ">=0.10"
+            wheel.cmake = true
+            """
+        )
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, state="wheel")
+    settings = settings_reader.settings
+    if version == "0.10":
+        assert settings.wheel.cmake
+    else:
+        assert not settings.wheel.cmake
+
+
+def test_skbuild_overrides_unmatched_version(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.10"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.scikit-build-version = "<0.10"
+            if.is-not-real = true
+            also-not-real = true
+            """
+        )
+    )
+
+    settings = SettingsReader.from_file(pyproject_toml)
+    settings.validate_may_exit()
+
+
+def test_skbuild_overrides_matched_version_if(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.10"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.scikit-build-version = ">=0.10"
+            if.is-not-real = true
+            """
+        )
+    )
+
+    with pytest.raises(TypeError, match="is_not_real"):
+        SettingsReader.from_file(pyproject_toml)
+
+
+def test_skbuild_overrides_matched_version_extra(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.10"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.scikit-build-version = ">=0.10"
+            not-real = true
+            """
+        )
+    )
+
+    settings = SettingsReader.from_file(pyproject_toml)
+    with pytest.raises(SystemExit):
+        settings.validate_may_exit()
+
+    assert "not-real" in capsys.readouterr().out
+
+
+def test_skbuild_overrides_matched_version_if_any(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.9"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.any.scikit-build-version = ">=0.10"
+            if.any.not-real = true
+            also-not-real = true
+            """
+        )
+    )
+
+    settings = SettingsReader.from_file(pyproject_toml)
+    settings.validate_may_exit()
+
+
+def test_skbuild_overrides_matched_version_if_any_dual(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.9"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.scikit-build-version = ">=0.10"
+            if.any.not-real = true
+            if.any.python-version = ">=3.7"
+            also-not-real = true
+            """
+        )
+    )
+
+    settings = SettingsReader.from_file(pyproject_toml)
+    settings.validate_may_exit()
+
+
+def test_skbuild_overrides_matched_version_if_any_match(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr(
+        scikit_build_core.settings.skbuild_overrides, "__version__", "0.10"
+    )
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if.any.scikit-build-version = ">=0.10"
+            if.any.not-real = true
+            if.python-version = ">=3.7"
+            experimental = true
+            """
+        )
+    )
+
+    with pytest.raises(TypeError, match="not_real"):
+        SettingsReader.from_file(pyproject_toml)