Repository: scikit-build/scikit-build-core
Pull request #1055 (2025-04-26 05:08:57)

feat: add a couple of build commands
Adding a couple of potentially useful build commands:

```
python -m scikit_build_core.build requires
python -m scikit_build_core.build metadata
```

"requires" prints out the full build requirements. "metadata" reads the project table, runs dynamic metadata, and then prints the new project table in json form; it may need the requirements installed (which is why I added both commands). Maybe it needs another name, actually, I could see someone wanting the METADATA contents, perhaps.

Like the other commands, these are somewhat experimental and subject to change.

Warning/error messages now print to stderr.


henryiii: Any thoughts on a better name for "metadata"? I don't like that one. It evaluates the dynamic metadata then returns the updated project table in JSON form.
LecrisUT: > Any thoughts on a better name for "metadata"? I don't like that one. It evaluates the dynamic metadata then returns the updated project table in JSON form.

For me Metadata would be the `PKG-INFO` or equivalent. How about `pyproject-toml` or `pyproject-table`?

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 62c17cb..761f28a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -314,6 +314,7 @@ known-local-folder = ["pathutils"]
 "docs/conf.py" = ["TID251"]
 "docs/examples/**" = ["ANN"]
 "src/scikit_build_core/file_api/model/*.py" = ["N"]
+"**/__main__.py" = ["T20"]
 
 
 [tool.check-sdist]
diff --git a/src/scikit_build_core/_logging.py b/src/scikit_build_core/_logging.py
index cfa6bf6..ebe2a25 100644
--- a/src/scikit_build_core/_logging.py
+++ b/src/scikit_build_core/_logging.py
@@ -345,9 +345,16 @@ def rich_warning(
     color: Literal[
         "", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
     ] = "yellow",
+    file: object = None,
     **kwargs: object,
 ) -> None:
-    rich_print("{bold.yellow}WARNING:", *args, color=color, **kwargs)  # type: ignore[arg-type]
+    rich_print(
+        "{bold.yellow}WARNING:",
+        *args,
+        color=color,
+        file=file or sys.stderr,
+        **kwargs,  # type: ignore[arg-type]
+    )
 
 
 def rich_error(
@@ -355,7 +362,14 @@ def rich_error(
     color: Literal[
         "", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
     ] = "red",
+    file: object = None,
     **kwargs: object,
 ) -> NoReturn:
-    rich_print("{bold.red}ERROR:", *args, color=color, **kwargs)  # type: ignore[arg-type]
+    rich_print(
+        "{bold.red}ERROR:",
+        *args,
+        color=color,
+        file=file or sys.stderr,
+        **kwargs,  # type: ignore[arg-type]
+    )
     raise SystemExit(7)
diff --git a/src/scikit_build_core/build/__main__.py b/src/scikit_build_core/build/__main__.py
new file mode 100644
index 0000000..0fa28fd
--- /dev/null
+++ b/src/scikit_build_core/build/__main__.py
@@ -0,0 +1,81 @@
+import argparse
+import json
+from pathlib import Path
+from typing import Literal
+
+from .._compat import tomllib
+from .._logging import rich_warning
+from ..builder._load_provider import process_dynamic_metadata
+from . import (
+    get_requires_for_build_editable,
+    get_requires_for_build_sdist,
+    get_requires_for_build_wheel,
+)
+
+
+def main_project_table(_args: argparse.Namespace, /) -> None:
+    """Get the full project table, including dynamic metadata."""
+    with Path("pyproject.toml").open("rb") as f:
+        pyproject = tomllib.load(f)
+
+    project = pyproject.get("project", {})
+    metadata = pyproject.get("tool", {}).get("scikit-build", {}).get("metadata", {})
+    new_project = process_dynamic_metadata(project, metadata)
+    print(json.dumps(new_project, indent=2))
+
+
+def main_requires(args: argparse.Namespace, /) -> None:
+    get_requires(args.mode)
+
+
+def get_requires(mode: Literal["sdist", "wheel", "editable"]) -> None:
+    """Get the build requirements."""
+
+    with Path("pyproject.toml").open("rb") as f:
+        pyproject = tomllib.load(f)
+
+    requires = pyproject.get("build-system", {}).get("requires", [])
+    backend = pyproject.get("build-system", {}).get("build-backend", "")
+    if backend != "scikit_build_core.build":
+        rich_warning("Might not be a scikit-build-core project.")
+    if mode == "sdist":
+        requires += get_requires_for_build_sdist({})
+    elif mode == "wheel":
+        requires += get_requires_for_build_wheel({})
+    elif mode == "editable":
+        requires += get_requires_for_build_editable({})
+    print(json.dumps(sorted(set(requires)), indent=2))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Build backend utilities",
+    )
+
+    subparsers = parser.add_subparsers(help="Commands")
+    requires = subparsers.add_parser(
+        "requires",
+        help="Get the build requirements",
+        description="Includes the static build requirements, the dynamically generated ones, and dynamic-metadata ones.",
+    )
+    requires.set_defaults(func=main_requires)
+    requires.add_argument(
+        "--mode",
+        choices=["sdist", "wheel", "editable"],
+        default="wheel",
+        help="The build mode to get the requirements for",
+    )
+
+    project_table = subparsers.add_parser(
+        "project-table",
+        help="Get the full project table, including dynamic metadata",
+        description="Processes static and dynamic metadata without triggering the backend, only handles scikit-build-core's dynamic metadata.",
+    )
+    project_table.set_defaults(func=main_project_table)
+
+    args = parser.parse_args()
+    args.func(args)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/tests/test_broken_fallback.py b/tests/test_broken_fallback.py
index 0c7b6d6..3730037 100644
--- a/tests/test_broken_fallback.py
+++ b/tests/test_broken_fallback.py
@@ -50,8 +50,8 @@ def test_fail_setting(
         build_wheel("dist")
 
     assert exc.value.code == 7
-    out, _ = capsys.readouterr()
-    assert "fail setting was enabled" in out
+    _, err = capsys.readouterr()
+    assert "fail setting was enabled" in err
 
 
 @pytest.mark.usefixtures("broken_fallback")
diff --git a/tests/test_build_cli.py b/tests/test_build_cli.py
new file mode 100644
index 0000000..ad5ee6c
--- /dev/null
+++ b/tests/test_build_cli.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+import sysconfig
+from typing import TYPE_CHECKING
+
+import pytest
+
+from scikit_build_core._logging import rich_warning
+from scikit_build_core.build.__main__ import main
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+PYPROJECT_1 = """
+[build-system]
+requires = ["scikit-build-core"]
+build-backend = "scikit_build_core.build"
+[project]
+name = "test"
+dynamic = ["version"]
+
+[tool.scikit-build.metadata.version]
+provider = "scikit_build_core.metadata.setuptools_scm"
+"""
+
+
+@pytest.mark.parametrize("mode", ["sdist", "wheel", "editable"])
+def test_requires_command(
+    capsys: pytest.CaptureFixture[str],
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+    mode: str,
+) -> None:
+    monkeypatch.setattr(
+        sys, "argv", ["scikit_build_core.build", "requires", f"--mode={mode}"]
+    )
+    monkeypatch.setattr(shutil, "which", lambda _: None)
+    (tmp_path / "pyproject.toml").write_text(PYPROJECT_1)
+    monkeypatch.chdir(tmp_path)
+
+    main()
+    rich_warning.cache_clear()
+    out, err = capsys.readouterr()
+    assert "CMakeLists.txt not found" in err
+    jout = json.loads(out)
+    if mode == "sdist":
+        assert frozenset(jout) == {"scikit-build-core", "setuptools-scm"}
+    elif sysconfig.get_platform().startswith("win-"):
+        assert frozenset(jout) == {
+            "cmake>=3.15",
+            "scikit-build-core",
+            "setuptools-scm",
+        }
+    else:
+        assert frozenset(jout) == {
+            "cmake>=3.15",
+            "ninja>=1.5",
+            "scikit-build-core",
+            "setuptools-scm",
+        }
+
+
+PYPROJECT_2 = """
+[build-system]
+requires = ["scikit-build-core"]
+build-backend = "scikit_build_core.build"
+[project]
+name = "test"
+dynamic = ["version", "dependencies"]
+
+[tool.scikit-build.metadata.version]
+provider = "scikit_build_core.metadata.regex"
+input = "version.py"
+
+[tool.scikit-build.metadata.dependencies]
+provider = "scikit_build_core.metadata.template"
+result = ["self=={project[version]}"]
+"""
+
+
+def test_metadata_command(
+    capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+    monkeypatch.setattr(sys, "argv", ["scikit_build_core.build", "project-table"])
+    monkeypatch.setattr(shutil, "which", lambda _: None)
+    (tmp_path / "pyproject.toml").write_text(PYPROJECT_2)
+    (tmp_path / "version.py").write_text("version = '0.1.3'")
+    monkeypatch.chdir(tmp_path)
+
+    main()
+    out, _ = capsys.readouterr()
+    jout = json.loads(out)
+    assert jout == {
+        "name": "test",
+        "version": "0.1.3",
+        "dynamic": [],
+        "dependencies": ["self==0.1.3"],
+    }
diff --git a/tests/test_printouts.py b/tests/test_printouts.py
index cac8e5c..0e3b6da 100644
--- a/tests/test_printouts.py
+++ b/tests/test_printouts.py
@@ -1,7 +1,14 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
 from scikit_build_core.builder.__main__ import main
 
+if TYPE_CHECKING:
+    import pytest
+
 
-def test_builder_printout(capsys):
+def test_builder_printout(capsys: pytest.CaptureFixture[str]) -> None:
     main()
     out, err = capsys.readouterr()
     assert "Detected Python Library" in out
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 5da9199..2aff167 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -746,7 +746,7 @@ def test_skbuild_settings_auto_cmake_warning(
 
     assert settings_reader.settings.cmake.version == SpecifierSet(">=3.15")
 
-    ex = capsys.readouterr().out
+    ex = capsys.readouterr().err
     ex = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", ex)
     print(ex)
     assert ex.split() == [