Repository: scikit-build/scikit-build-core
Pull request #992 (2025-02-17 15:41:03)

feat: Add new field `build.build-requires`
TODO:
- [x] Define a new field to be inserted in `build-system.requires`
- [x] Document new feature
- [x] Add support for `{root:uri}`
    - should we copy hatchling's implementation or resolve it only if `hatchling` is added to dependency?
- [ ] ~~Do we add a special case for `sdist` building or document that the user should define it?~~ Postponed because it would require a more involved process to validate.

henryiii: Do we need `{root:uri}` if we have overrides? I thought that was a way to control the dependencies based on how it was being built?
LecrisUT: > And for sdist-building, I think we already have an override selector for that case?

Yes. The question for this PR, is if we should provide a sane default if the user did not provide one, or we leave it completely to the user

> Do we need `{root:uri}` if we have overrides? I thought that was a way to control the dependencies based on how it was being built?

Kind-of. The issue with `file:` is that relative paths are expanded relative to where `pip install` is called, so you cannot make a consistent `pip install` for the users without a more specialized handling. Although, I guess the user could do
```toml
[[tool.scikit-build.overrides]]
if.env.BUILD_ROOT = true
build.build-requires = ["foo @ file:${BUILD_ROOT}/foo"]
```
and make sure `BUILD_ROOT=$(pwd)`. Not as convenient as having a `{root:uri}` setup and relying on default `CI` environment variable, but maybe it's a better initial format for it.
henryiii: I guess my question is, in what cases would this be incorrect?

```toml
[[tool.scikit-build.overrides]]
if.from-sdist = false
build.build-requires = ["foo @ ./foo"]

[[tool.scikit-build.overrides]]
if.from-sdist = true
build.build-requires = ["foo"]
```
henryiii: Okay, since we already use Python formatting in `build-dir`, we could do it here too. And implementing `{root:uri}` should be easy. Not so sure about `{root:parent:uri}`. Hmm, it seems `__format__` passes the colon in. Okay, guess we could do it:

```Python
class DispPath(Path):
    def __format__(self, fmt):
        command, _, rest = fmt.partition(":")
        if command == "parent":
            return self.parent.__format__(rest)
        if command == "uri" and rest == "":
            return self.as_uri()
        if command == "" and rest == "":
            return str(self)
        raise ...
```

Patch:
diff --git a/README.md b/README.md
index b6a9788..cdd561a 100644
--- a/README.md
+++ b/README.md
@@ -281,6 +281,10 @@ build.targets = []
 # Verbose printout when building.
 build.verbose = false
 
+# Additional ``build-system.requires``. Intended to be used in combination with
+# ``overrides``.
+build.requires = []
+
 # The components to install. If empty, all default components are installed.
 install.components = []
 
diff --git a/docs/configuration/dynamic.md b/docs/configuration/dynamic.md
index 49c8a49..f46d44b 100644
--- a/docs/configuration/dynamic.md
+++ b/docs/configuration/dynamic.md
@@ -112,6 +112,45 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 # tool.hatch.metadata.hooks.fancy-pypi-readme options here
 ```
 
+## `build-system.requires`: Scikit-build-core's `build.requires`
+
+If you need to inject and manipulate additional `build-system.requires`, you can
+use the `build.requires`. This is intended to be used in combination with
+[](./overrides.md).
+
+This is not technically a dynamic metadata and thus does not have to have the
+`dynamic` field defined, and it is not defined under the `metadata` table, but
+similar to the other dynamic metadata it injects the additional
+`build-system.requires`.
+
+```toml
+[package]
+name = "mypackage"
+
+[tool.scikit-build]
+build.requires = ["foo"]
+
+[[tool.scikit-build.overrides]]
+if.from-sdist = false
+build.requires = ["foo @ {root:uri}/foo"]
+```
+
+This example shows a common use-case where the package has a default
+`build-system.requires` pointing to the package `foo` in the PyPI index, but
+when built from the original git checkout or equivalent, the local folder is
+used as dependency instead by resolving the `{root:uri}` to a file uri pointing
+to the folder where the `pyproject.toml` is located.
+
+```{note}
+In order to be compliant with the package index, when building from `sdist`, the
+`build.requires` **MUST NOT** have any `@` redirects. This rule may be later
+enforced explicitly.
+```
+
+```{versionadded} 0.11
+
+```
+
 ## Generate files with dynamic metadata
 
 You can write out metadata to file(s) as well. Other info might become available
diff --git a/src/scikit_build_core/builder/get_requires.py b/src/scikit_build_core/builder/get_requires.py
index be22448..26b6d7c 100644
--- a/src/scikit_build_core/builder/get_requires.py
+++ b/src/scikit_build_core/builder/get_requires.py
@@ -5,6 +5,7 @@ import functools
 import importlib.util
 import os
 import sysconfig
+from pathlib import Path
 from typing import TYPE_CHECKING, Literal
 
 from packaging.tags import sys_tags
@@ -66,6 +67,28 @@ def _load_scikit_build_settings(
     return SettingsReader.from_file("pyproject.toml", config_settings).settings
 
 
+@dataclasses.dataclass()
+class RootPathResolver:
+    """Handle ``{root:uri}`` like formatting similar to ``hatchling``."""
+
+    path: Path = dataclasses.field(default_factory=Path)
+
+    def __post_init__(self) -> None:
+        self.path = self.path.resolve()
+
+    def __format__(self, fmt: str) -> str:
+        command, _, rest = fmt.partition(":")
+        if command == "parent":
+            parent = RootPathResolver(self.path.parent)
+            return parent.__format__(rest)
+        if command == "uri" and rest == "":
+            return self.path.as_uri()
+        if command == "" and rest == "":
+            return str(self)
+        msg = f"Could not handle format: {fmt}"
+        raise ValueError(msg)
+
+
 @dataclasses.dataclass(frozen=True)
 class GetRequires:
     settings: ScikitBuildSettings = dataclasses.field(
@@ -140,6 +163,9 @@ class GetRequires:
         if self.settings.fail:
             return
 
+        for build_require in self.settings.build.requires:
+            yield build_require.format(root=RootPathResolver())
+
         for dynamic_metadata in self.settings.metadata.values():
             if "provider" in dynamic_metadata:
                 config = dynamic_metadata.copy()
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index d8e87df..4518f03 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -299,6 +299,13 @@
           "type": "boolean",
           "default": false,
           "description": "Verbose printout when building."
+        },
+        "requires": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "Additional ``build-system.requires``. Intended to be used in combination with ``overrides``."
         }
       }
     },
@@ -560,6 +567,9 @@
                   },
                   "targets": {
                     "$ref": "#/$defs/inherit"
+                  },
+                  "requires": {
+                    "$ref": "#/$defs/inherit"
                   }
                 }
               },
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index af1c7c4..9f8c8dd 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -277,6 +277,12 @@ class BuildSettings:
     Verbose printout when building.
     """
 
+    requires: List[str] = dataclasses.field(default_factory=list)
+    """
+    Additional ``build-system.requires``. Intended to be used in combination
+    with ``overrides``.
+    """
+
 
 @dataclasses.dataclass
 class InstallSettings:


Test patch:
diff --git a/tests/packages/dynamic_metadata/build_requires_project.toml b/tests/packages/dynamic_metadata/build_requires_project.toml
new file mode 100644
index 0000000..9bf3623
--- /dev/null
+++ b/tests/packages/dynamic_metadata/build_requires_project.toml
@@ -0,0 +1,13 @@
+[build-system]
+requires = ["scikit-build-core"]
+build-backend = "scikit_build_core.build"
+
+[project]
+name = "more_build_requires"
+
+[tool.scikit-build]
+build.requires = ["foo"]
+
+[[tool.scikit-build.overrides]]
+if.env.LOCAL_FOO = true
+build.requires = ["foo @ {root:parent:uri}/foo"]
diff --git a/tests/test_dynamic_metadata.py b/tests/test_dynamic_metadata.py
index de0f005..842c148 100644
--- a/tests/test_dynamic_metadata.py
+++ b/tests/test_dynamic_metadata.py
@@ -7,7 +7,7 @@ import textwrap
 import types
 import zipfile
 from pathlib import Path
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
 import pytest
 from packaging.version import Version
@@ -22,6 +22,9 @@ from scikit_build_core.settings.skbuild_read_settings import SettingsReader
 
 from pathutils import contained
 
+if TYPE_CHECKING:
+    from typing import Literal
+
 
 # these are mock plugins returning known results
 # it turns out to be easier to create EntryPoint objects pointing to real
@@ -345,3 +348,41 @@ def test_regex_remove(
     )
 
     assert version == ("1.2.3dev1" if dev else "1.2.3")
+
+
+@pytest.mark.usefixtures("package_dynamic_metadata")
+@pytest.mark.parametrize("override", [None, "env", "sdist"])
+def test_build_requires_field(override, monkeypatch) -> None:
+    shutil.copy("build_requires_project.toml", "pyproject.toml")
+
+    if override == "env":
+        monkeypatch.setenv("LOCAL_FOO", "True")
+    else:
+        monkeypatch.delenv("LOCAL_FOO", raising=False)
+
+    pyproject_path = Path("pyproject.toml")
+    with pyproject_path.open("rb") as ft:
+        pyproject = tomllib.load(ft)
+    state: Literal["sdist", "metadata_wheel"] = (
+        "sdist" if override == "sdist" else "metadata_wheel"
+    )
+    settings_reader = SettingsReader(pyproject, {}, state=state)
+
+    settings_reader.validate_may_exit()
+
+    if override is None:
+        assert set(GetRequires().dynamic_metadata()) == {
+            "foo",
+        }
+    elif override == "env":
+        # evaluate ../foo as uri
+        foo_path = pyproject_path.absolute().parent.parent / "foo"
+        foo_path = foo_path.absolute()
+        assert set(GetRequires().dynamic_metadata()) == {
+            f"foo @ {foo_path.as_uri()}",
+        }
+    elif override == "sdist":
+        assert set(GetRequires().dynamic_metadata()) == {
+            # TODO: Check if special handling should be done for sdist
+            "foo",
+        }