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

Issue:
feat: Overrides mechanism
This is an initial idea for the overrides system.

The overrides array would requires the `select` key set:

```toml
[[tool.scikit-build.overides]]
select = {python-version=">=3.8", implementation-name="cpython"}
```

The keys would be based on [PEP 508](https://peps.python.org/pep-0508/#environment-markers):

* `python-version`: a SpecifierSet that matches the Python version (note: PEP 508 splits this into `python_full_version`, maybe we should too, and make them mutually exclusive)
* `implementation-name`
* `implementation-version`
* `platform-system`
* `platform-machine`

The other values (besides `python-version`) would support regular expressions.

All other values in that array entry are then used over the original values if the select matches. Table/array merging should be considered, but might not make it in the first version. At least one value is required.

Pull request description:
feat: adding overrides
Closes #332. Needs docs and a few more tests (like regex test, unrecognized key).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 264d82d..dbbfbf4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -37,7 +37,7 @@ repos:
     rev: 1.16.0
     hooks:
       - id: blacken-docs
-        additional_dependencies: [black==23.3.0]
+        additional_dependencies: [black==23.9.1]
 
   - repo: https://github.com/cheshirekow/cmake-format-precommit
     rev: v0.6.13
diff --git a/README.md b/README.md
index 0f1c1d4..9359b45 100644
--- a/README.md
+++ b/README.md
@@ -281,6 +281,9 @@ Most CMake environment variables should be supported, and `CMAKE_ARGS` can be
 used to set extra CMake args. `ARCHFLAGS` is used to specify macOS universal2 or
 cross-compiles, just like setuptools.
 
+You can also specify `[[tool.scikit-build.overrides]]`` to customize values for
+different systems. See the docs for details.
+
 ## Other projects for building
 
 Scikit-build-core is a binary build backend. There are also other binary build
diff --git a/docs/configuration.md b/docs/configuration.md
index b5eeac2..013e183 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -577,6 +577,29 @@ only be used if you enable them:
 experimental = true
 ```
 
+## Overrides
+
+Scikit-build-core has an override system, similar to cibuildwheel and mypy. You
+specify a `tool.scikit-build.overrides` array with an `if` key. That if key can
+take several values, based on [PEP 508][]:
+
+- `python_version`: The two-digit Python version. Takes a specifier set.
+- `sys_platform`: The value of `sys.platform`. Takes a regex.
+- `platform_machine`: The value of `platform.machine()`. Takes a regex.
+- `implementation_name`: The value of `sys.implementation.name`. Takes a regex.
+- `implementation_version`: Derived from `sys.implementation.version`, following
+  PEP 508. Takes a specifier set.
+
+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. For example:
+
+```toml
+[[tool.scikit-build.overrides]]
+if.sys_platform = "darwin"
+cmake.minimum-version = "3.18"
+```
+
 ## Full schema
 
 The full schema for the `tool.scikit-build` table is below:
@@ -584,3 +607,5 @@ The full schema for the `tool.scikit-build` table is below:
 ```{jsonschema} ../src/scikit_build_core/resources/scikit-build.schema.json
 
 ```
+
+[pep 508]: https://peps.python.org/pep-0508/#environment-markers
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index 77bfcfa..bef0f0e 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -303,6 +303,83 @@
       "type": "string",
       "default": "",
       "description": "The build directory. Defaults to a temporary directory, but can be set."
+    },
+    "overrides": {
+      "type": "array",
+      "items": {
+        "type: ": "object",
+        "required": [
+          "if"
+        ],
+        "minProperties": 2,
+        "additionalProperties": false,
+        "properties": {
+          "if": {
+            "type": "object",
+            "minProperties": 1,
+            "additionalProperties": false,
+            "properties": {
+              "python-version": {
+                "type": "string"
+              },
+              "implementation-name": {
+                "type": "string"
+              },
+              "implementation-version": {
+                "type": "string"
+              },
+              "platform-system": {
+                "type": "string"
+              },
+              "platform-machine": {
+                "type": "string"
+              }
+            }
+          },
+          "cmake": {
+            "$ref": "#/properties/cmake"
+          },
+          "ninja": {
+            "$ref": "#/properties/ninja"
+          },
+          "logging": {
+            "$ref": "#/properties/logging"
+          },
+          "sdist": {
+            "$ref": "#/properties/sdist"
+          },
+          "wheel": {
+            "$ref": "#/properties/wheel"
+          },
+          "backport": {
+            "$ref": "#/properties/backport"
+          },
+          "editable": {
+            "$ref": "#/properties/editable"
+          },
+          "install": {
+            "$ref": "#/properties/install"
+          },
+          "generate": {
+            "$ref": "#/properties/generate"
+          },
+          "metadata": {
+            "$ref": "#/properties/metadata"
+          },
+          "strict-config": {
+            "$ref": "#/properties/strict-config"
+          },
+          "experimental": {
+            "$ref": "#/properties/experimental"
+          },
+          "minimum-version": {
+            "$ref": "#/properties/minimum-version"
+          },
+          "build-dir": {
+            "$ref": "#/properties/build-dir"
+          }
+        }
+      }
     }
   }
 }
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 4fd1a01..4d23c4c 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -1,10 +1,14 @@
 from __future__ import annotations
 
+import copy
 import difflib
+import platform
+import re
 import sys
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
+from packaging.specifiers import SpecifierSet
 from packaging.version import Version
 
 from .. import __version__
@@ -25,6 +29,73 @@ def __dir__() -> list[str]:
     return __all__
 
 
+def version_match(value: str, match: str, name: str) -> str:
+    """
+    Returns a non-empty string if a version matches a specifier.
+    """
+    matcher = SpecifierSet(match)
+    did_match = matcher.contains(value)
+    return f"{match!r} matched {name} {value}" if did_match else ""
+
+
+def regex_match(value: str, match: str) -> str:
+    """
+    Returns a non-empty string if a value matches a regex.
+    """
+    did_match = re.compile(match).match(value) is not None
+    return f"{match!r} matched {value}" if did_match else ""
+
+
+def override_match(
+    python_version: str | None = None,
+    implementation_name: str | None = None,
+    implementation_version: str | None = None,
+    platform_system: str | None = None,
+    platform_machine: str | None = None,
+) -> bool:
+    matches = []
+
+    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")
+        matches.append(match_msg)
+
+    if implementation_name is not None:
+        current_impementation_name = sys.implementation.name
+        match_msg = regex_match(current_impementation_name, implementation_name)
+        matches.append(match_msg)
+
+    if implementation_version is not None:
+        info = sys.implementation.version
+        version = f"{info.major}.{info.minor}.{info.micro}"
+        kind = info.releaselevel
+        if kind != "final":
+            version += f"{kind[0]}{info.serial}"
+        match_msg = version_match(
+            version, implementation_version, "Python implementation"
+        )
+        matches.append(match_msg)
+
+    if platform_system is not None:
+        current_platform_system = sys.platform
+        match_msg = regex_match(current_platform_system, platform_system)
+        matches.append(match_msg)
+
+    if platform_machine is not None:
+        current_platform_machine = platform.machine()
+        match_msg = regex_match(current_platform_machine, platform_machine)
+        matches.append(match_msg)
+
+    if not matches:
+        msg = "At least one override must be provided"
+        raise ValueError(msg)
+
+    matched = all(matches)
+    if matched:
+        logger.info("Overrides {}", " and ".join(matches))
+    return matched
+
+
 class SettingsReader:
     def __init__(
         self,
@@ -33,6 +104,22 @@ class SettingsReader:
         *,
         verify_conf: bool = True,
     ) -> None:
+        pyproject = copy.deepcopy(pyproject)
+
+        # Process overrides into the main dictionary if they match
+        tool_skb = pyproject.get("tool", {}).get("scikit-build", {})
+        for override in tool_skb.pop("overrides", []):
+            select = {k.replace("-", "_"): v for k, v in override.pop("if").items()}
+            if override_match(**select):
+                for key, value in override.items():
+                    if isinstance(value, dict):
+                        for key2, value2 in value.items():
+                            inner = tool_skb.get(key, {})
+                            inner[key2] = value2
+                            tool_skb[key] = inner
+                    else:
+                        tool_skb[key] = value
+
         self.sources = SourceChain(
             EnvSource("SKBUILD"),
             ConfSource(settings=config_settings, verify=verify_conf),
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index 4bae10c..77899b9 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -47,6 +47,32 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
         "oneOf": [generate_tmpl, generate_path]
     }
 
+    props = {k: {"$ref": f"#/properties/{k}"} for k in schema["properties"]}
+    schema["properties"]["overrides"] = {
+        "type": "array",
+        "items": {
+            "type: ": "object",
+            "required": ["if"],
+            "minProperties": 2,
+            "additionalProperties": False,
+            "properties": {
+                "if": {
+                    "type": "object",
+                    "minProperties": 1,
+                    "additionalProperties": False,
+                    "properties": {
+                        "python-version": {"type": "string"},
+                        "implementation-name": {"type": "string"},
+                        "implementation-version": {"type": "string"},
+                        "platform-system": {"type": "string"},
+                        "platform-machine": {"type": "string"},
+                    },
+                },
+                **props,
+            },
+        },
+    }
+
     return schema
 
 


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 23291a2..d05b797 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -48,6 +48,16 @@ def test_valid_schemas_files(filepath: Path) -> None:
         {"generate": [{"path": "CMakeLists.txt"}]},
         {"generate": [{"path": "me.py", "template": "hi", "template-path": "hello"}]},
         {"generate": [{"path": "me.py", "template": ""}]},
+        {"overrides": [{"cmake": {"args": ["-DFOO=BAR"]}}]},
+        {"overrides": [{"select": {"python-version": ">=3.10"}}]},
+        {
+            "overrides": [
+                {
+                    "if": {"python-version": ">=3.10"},
+                    "cmake.nonexist": ["-DFOO=BAR"],
+                }
+            ]
+        },
     ],
 )
 def test_invalid_schemas(addition: dict[str, Any]) -> None:
@@ -76,6 +86,14 @@ def test_invalid_schemas(addition: dict[str, Any]) -> None:
     [
         {"generate": [{"path": "CMakeLists.txt", "template": "hi"}]},
         {"generate": [{"path": "me.py", "template-path": "hello"}]},
+        {
+            "overrides": [
+                {
+                    "if": {"python-version": ">=3.10"},
+                    "cmake": {"args": ["-DFOO=BAR"]},
+                }
+            ]
+        },
     ],
 )
 def test_valid_schemas(addition: dict[str, Any]) -> None:
diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py
new file mode 100644
index 0000000..a11de4a
--- /dev/null
+++ b/tests/test_settings_overrides.py
@@ -0,0 +1,181 @@
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+
+from scikit_build_core.settings.skbuild_read_settings import SettingsReader
+
+
+@pytest.mark.parametrize("python_version", ["3.9", "3.10"])
+def test_skbuild_overrides_pyver(
+    python_version: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    monkeypatch.setattr("sys.version_info", (*map(int, python_version.split(".")), 0))
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if = {python-version = ">=3.10"}
+            cmake.args = ["-DFOO=BAR"]
+            experimental = true
+            cmake.define.SPAM = "EGGS"
+            sdist.cmake = true
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if python_version == "3.10":
+        assert settings.cmake.args == ["-DFOO=BAR"]
+        assert settings.cmake.define == {"SPAM": "EGGS"}
+        assert settings.experimental
+        assert settings.sdist.cmake
+    else:
+        assert not settings.cmake.args
+        assert not settings.cmake.define
+        assert not settings.experimental
+        assert not settings.sdist.cmake
+
+
+@pytest.mark.parametrize("implementation_name", ["cpython", "pypy"])
+@pytest.mark.parametrize("platform_system", ["darwin", "linux"])
+def test_skbuild_overrides_dual(
+    implementation_name: str,
+    platform_system: str,
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+):
+    monkeypatch.setattr(
+        "sys.implementation", type("Mock", (), {"name": implementation_name})
+    )
+    monkeypatch.setattr("sys.platform", platform_system)
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if = {implementation-name = "pypy", platform-system = "darwin"}
+            editable.verbose = false
+            install.components = ["headers"]
+
+            [[tool.scikit-build.overrides]]
+            if.implementation-name = "cpython"
+            if.platform-system = "darwin"
+            install.components = ["bindings"]
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if implementation_name == "pypy" and platform_system == "darwin":
+        assert not settings.editable.verbose
+        assert settings.install.components == ["headers"]
+    elif implementation_name == "cpython" and platform_system == "darwin":
+        assert settings.editable.verbose
+        assert settings.install.components == ["bindings"]
+    else:
+        assert settings.editable.verbose
+        assert not settings.install.components
+
+
+@pytest.mark.parametrize("platform_machine", ["x86_64", "x86_32", "other"])
+def test_skbuild_overrides_regex(
+    platform_machine: str,
+    tmp_path: Path,
+    monkeypatch: pytest.MonkeyPatch,
+):
+    monkeypatch.setattr("platform.machine", lambda: platform_machine)
+
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [tool.scikit-build]
+            install.components = ["default"]
+
+            [[tool.scikit-build.overrides]]
+            if = {platform_machine = "x86_.*"}
+            install.components = ["headers"]
+
+            [[tool.scikit-build.overrides]]
+            if = {platform_machine = "x86_32"}
+            install.components = ["headers_32"]
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {})
+    settings = settings_reader.settings
+
+    if platform_machine == "x86_64":
+        assert settings.install.components == ["headers"]
+    elif platform_machine == "x86_32":
+        assert settings.install.components == ["headers_32"]
+    else:
+        assert settings.install.components == ["default"]
+
+
+def test_skbuild_overrides_no_if(
+    tmp_path: Path,
+):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            minimum-version="0.1"
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    with pytest.raises(KeyError):
+        SettingsReader.from_file(pyproject_toml, {})
+
+
+def test_skbuild_overrides_empty_if(
+    tmp_path: Path,
+):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if = {}
+            minimum-version="0.1"
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    with pytest.raises(ValueError, match="At least one override must be provided"):
+        SettingsReader.from_file(pyproject_toml, {})
+
+
+def test_skbuild_overrides_invalid_key(
+    tmp_path: Path,
+):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            """\
+            [[tool.scikit-build.overrides]]
+            if = {python_version = ">=3"}
+            invalid-key = "Hi"
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings = SettingsReader.from_file(pyproject_toml, {})
+    with pytest.raises(SystemExit):
+        settings.validate_may_exit()