Repository: scikit-build/scikit-build-core
Pull request #550 (2023-11-08 21:24:49)

feat: support cmake-less runs (for overrides)
This supports skipping the CMake build. This can be used with overrides to make packages that have a pure Python implementation (#112).



Patch:
diff --git a/README.md b/README.md
index a1c9dc2..3182092 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,15 @@
 
 <!-- SPHINX-START -->
 
-Scikit-build-core is a ground-up rewrite of the classic Scikit-build, a bridge
-between Python package build systems and CMake, the most popular compiled
-language build system. The key features of scikit-build classic (which is
-setuptools based) are also present here:
+Scikit-build-core is a build backend for Python that uses CMake to build
+extension modules. It has a simple yet powerful static configuration system in
+pyproject.toml, and supports almost unlimited flexibility via CMake. It was
+initially developed to support the demanding needs of scientific users, but can
+build any sort of package that uses CMake.
+
+Scikit-build-core is a ground-up rewrite of the classic Scikit-build. The key
+features of scikit-build classic (which is setuptools based) are also present
+here:
 
 - Great support for or by most OSs, compilers, IDEs, and libraries
 - Support for C++ features and other languages like Fortran
@@ -221,6 +226,13 @@ wheel.install-dir = ""
 # A list of license files to include in the wheel. Supports glob patterns.
 wheel.license-files = ["LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*"]
 
+# If set to True (the default), CMake will be run before building the wheel.
+wheel.cmake = true
+
+# Target the platlib or the purelib. If not set, the default is to target the
+# platlib if wheel.cmake is true, and the purelib otherwise.
+wheel.platlib = ""
+
 # If CMake is less than this value, backport a copy of FindPython. Set to 0
 # disable this, or the empty string.
 backport.find-python = "3.26.1"
@@ -291,6 +303,10 @@ backends:
 
 - [py-build-cmake][]: A different attempt at a standards compliant builder for
   CMake. Strong focus on cross-compilation. Uses Flit internals.
+- [cmeel][]: A different attempt at a standards compliant builder for CMake.
+  Focus on building an ecosystem around a special unimportable folder in
+  site-packages (similar to scikit-build's usage of `cmake.*` entrypoints, but
+  folder-based).
 - [meson-python][]: A meson-based build backend; has some maintainer overlap
   with scikit-build-core.
 - [maturin][]: A build backend for Rust projects, using Cargo.
@@ -311,6 +327,7 @@ Support for this work was provided by NSF cooperative agreement [OAC-2209877][].
 [actions-link]:             https://github.com/scikit-build/scikit-build-core/actions
 [conda-badge]:              https://img.shields.io/conda/vn/conda-forge/scikit-build-core
 [conda-link]:               https://github.com/conda-forge/scikit-build-core-feedstock
+[cmeel]:                    https://github.com/cmake-wheel/cmeel
 [github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github
 [github-discussions-link]:  https://github.com/orgs/scikit-build/discussions
 [discord-badge]:            https://img.shields.io/discord/803025117553754132?label=Discord%20chat%20%23scikit-build
diff --git a/docs/configuration.md b/docs/configuration.md
index 8205287..d48082a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -251,6 +251,16 @@ Globbing patterns are supported.
 wheel.license-files = ["LICENSE"]
 ```
 
+:::{note}
+
+There are two more settings that are primarily intended for `overrides` (see
+below). `wheel.cmake` defaults to `true`, and this enables/disables building
+with CMake. It also changes the default of `wheel.platlib` unless it's set
+explicitly; CMake builds assume `wheel.platlib = true`, and CMake-less builds
+assume `wheel.platlib = false` (purelib targeted instead).
+
+:::
+
 ## Customizing the output wheel
 
 The python API tags for your wheel will be correct assuming you are building a
diff --git a/src/scikit_build_core/build/__init__.py b/src/scikit_build_core/build/__init__.py
index af56207..1060205 100644
--- a/src/scikit_build_core/build/__init__.py
+++ b/src/scikit_build_core/build/__init__.py
@@ -123,11 +123,15 @@ def get_requires_for_build_wheel(
 
     requires = GetRequires(config_settings)
 
+    # These are only injected if cmake is required for the wheel step
+    cmake_requires = (
+        [*requires.cmake(), *requires.ninja()] if requires.settings.wheel.cmake else []
+    )
+
     return [
         "pathspec",
         "pyproject_metadata",
-        *requires.cmake(),
-        *requires.ninja(),
+        *cmake_requires,
         *requires.dynamic_metadata(),
     ]
 
@@ -139,10 +143,14 @@ def get_requires_for_build_editable(
 
     requires = GetRequires(config_settings)
 
+    # These are only injected if cmake is required for the wheel step
+    cmake_requires = (
+        [*requires.cmake(), *requires.ninja()] if requires.settings.wheel.cmake else []
+    )
+
     return [
         "pathspec",
         "pyproject_metadata",
-        *requires.cmake(),
-        *requires.ninja(),
+        *cmake_requires,
         *requires.dynamic_metadata(),
     ]
diff --git a/src/scikit_build_core/build/_editable.py b/src/scikit_build_core/build/_editable.py
index fe2fa4f..93f9b25 100644
--- a/src/scikit_build_core/build/_editable.py
+++ b/src/scikit_build_core/build/_editable.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+import typing
 from pathlib import Path
 
 from ..resources import resources
@@ -10,6 +11,9 @@ from ._pathutil import (
     scantree,
 )
 
+if typing.TYPE_CHECKING:
+    from collections.abc import Sequence
+
 __all__ = ["editable_redirect", "libdir_to_installed", "mapping_to_modules"]
 
 
@@ -24,8 +28,8 @@ def editable_redirect(
     reload_dir: Path | None,
     rebuild: bool,
     verbose: bool,
-    build_options: list[str],
-    install_options: list[str],
+    build_options: Sequence[str],
+    install_options: Sequence[str],
 ) -> str:
     """
     Prepare the contents of the _editable_redirect.py file.
diff --git a/src/scikit_build_core/build/_wheelfile.py b/src/scikit_build_core/build/_wheelfile.py
index c6d0010..53398b5 100644
--- a/src/scikit_build_core/build/_wheelfile.py
+++ b/src/scikit_build_core/build/_wheelfile.py
@@ -142,12 +142,11 @@ class WheelWriter:
         }
 
     def build(self, wheel_dirs: dict[str, Path]) -> None:
-        assert "platlib" in wheel_dirs
-        assert "purelib" not in wheel_dirs
-        assert {"platlib", "data", "headers", "scripts", "null"} >= wheel_dirs.keys()
+        (targetlib,) = {"platlib", "purelib"} & set(wheel_dirs)
+        assert {targetlib, "data", "headers", "scripts", "null"} >= wheel_dirs.keys()
 
-        # The "main" directory (platlib for us) will be handled specially below
-        plans = {"": wheel_dirs["platlib"]}
+        # The "main" directory (platlib usually for us) will be handled specially below
+        plans = {"": wheel_dirs[targetlib]}
         data_dir = f"{self.name_ver}.data"
 
         for key in sorted({"data", "headers", "scripts"} & wheel_dirs.keys()):
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 14de9d0..b3896de 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -42,7 +42,8 @@ def __dir__() -> list[str]:
 
 def _make_editable(
     *,
-    builder: Builder,
+    build_options: Sequence[str] = (),
+    install_options: Sequence[str] = (),
     libdir: Path,
     mapping: dict[str, str],
     name: str,
@@ -53,19 +54,14 @@ def _make_editable(
     modules = mapping_to_modules(mapping, libdir)
     installed = libdir_to_installed(libdir)
 
-    options = []
-    if not builder.config.single_config and builder.config.build_type:
-        options += ["--config", builder.config.build_type]
-    ext_build_opts = ["-v"] if builder.settings.cmake.verbose else []
-
     editable_txt = editable_redirect(
         modules=modules,
         installed=installed,
         reload_dir=reload_dir,
         rebuild=settings.editable.rebuild,
         verbose=settings.editable.verbose,
-        build_options=options + ext_build_opts,
-        install_options=options,
+        build_options=build_options,
+        install_options=install_options,
     )
 
     wheel.writestr(
@@ -143,11 +139,21 @@ def _build_wheel_impl(
     if exit_after_config:
         state = "sdist"
 
-    cmake = CMake.default_search(minimum_version=settings.cmake.minimum_version)
+    if settings.wheel.cmake:
+        cmake = CMake.default_search(minimum_version=settings.cmake.minimum_version)
+        cmake_msg = [f"using [blue]CMake {cmake.version}[/blue]"]
+    else:
+        cmake = None
+        cmake_msg = []
+
+    if settings.wheel.platlib is None:
+        targetlib = "platlib" if settings.wheel.cmake else "purelib"
+    else:
+        targetlib = "platlib" if settings.wheel.platlib else "purelib"
 
     rich_print(
         f"[green]***[/green] [bold][green]scikit-build-core {__version__}[/green]",
-        f"using [blue]CMake {cmake.version}[/blue]",
+        *cmake_msg,
         f"[red]({state})[/red]",
     )
 
@@ -177,7 +183,7 @@ def _build_wheel_impl(
         logger.info("Build directory: {}", build_dir.resolve())
 
         wheel_dirs = {
-            "platlib": wheel_dir / "platlib",
+            targetlib: wheel_dir / targetlib,
             "data": wheel_dir / "data",
             "headers": wheel_dir / "headers",
             "scripts": wheel_dir / "scripts",
@@ -199,7 +205,7 @@ def _build_wheel_impl(
                 raise AssertionError(msg)
             install_dir = wheel_dir / settings.wheel.install_dir[1:]
         else:
-            install_dir = wheel_dirs["platlib"] / settings.wheel.install_dir
+            install_dir = wheel_dirs[targetlib] / settings.wheel.install_dir
 
         license_files = {
             x: x.read_bytes()
@@ -217,18 +223,6 @@ def _build_wheel_impl(
                 gen.path.write_text(contents)
                 settings.sdist.include.append(str(gen.path))
 
-        config = CMaker(
-            cmake,
-            source_dir=settings.cmake.source_dir,
-            build_dir=build_dir,
-            build_type=settings.cmake.build_type,
-        )
-
-        builder = Builder(
-            settings=settings,
-            config=config,
-        )
-
         if wheel_directory is None and not exit_after_config:
             if metadata_directory is None:
                 msg = "metadata_directory must be specified if wheel_directory is None"
@@ -262,38 +256,60 @@ def _build_wheel_impl(
             path.parent.mkdir(parents=True, exist_ok=True)
             path.write_text(contents, encoding="utf-8")
 
-        rich_print("[green]***[/green] [bold]Configuring CMake...")
-        defines: dict[str, str] = {}
-        cache_entries: dict[str, str | Path] = {
-            f"SKBUILD_{k.upper()}_DIR": v for k, v in wheel_dirs.items()
-        }
-        cache_entries["SKBUILD_STATE"] = state
-        builder.configure(
-            defines=defines,
-            cache_entries=cache_entries,
-            name=metadata.name,
-            version=metadata.version,
-        )
+        build_options = []
+        install_options = []
 
-        if exit_after_config:
-            return WheelImplReturn("")
+        if cmake is not None:
+            config = CMaker(
+                cmake,
+                source_dir=settings.cmake.source_dir,
+                build_dir=build_dir,
+                build_type=settings.cmake.build_type,
+            )
 
-        assert wheel_directory is not None
+            builder = Builder(
+                settings=settings,
+                config=config,
+            )
 
-        default_gen = (
-            "MSVC"
-            if sysconfig.get_platform().startswith("win")
-            else "Default Generator"
-        )
-        generator = builder.get_generator() or default_gen
-        rich_print(
-            f"[green]***[/green] [bold]Building project with [blue]{generator}[/blue]..."
-        )
-        build_args: list[str] = []
-        builder.build(build_args=build_args)
+            rich_print("[green]***[/green] [bold]Configuring CMake...")
+            defines: dict[str, str] = {}
+            cache_entries: dict[str, str | Path] = {
+                f"SKBUILD_{k.upper()}_DIR": v for k, v in wheel_dirs.items()
+            }
+            cache_entries["SKBUILD_STATE"] = state
+            builder.configure(
+                defines=defines,
+                cache_entries=cache_entries,
+                name=metadata.name,
+                version=metadata.version,
+            )
+
+            if exit_after_config:
+                return WheelImplReturn("")
 
-        rich_print("[green]***[/green] [bold]Installing project into wheel...")
-        builder.install(install_dir)
+            default_gen = (
+                "MSVC"
+                if sysconfig.get_platform().startswith("win")
+                else "Default Generator"
+            )
+            generator = builder.get_generator() or default_gen
+            rich_print(
+                f"[green]***[/green] [bold]Building project with [blue]{generator}[/blue]..."
+            )
+            build_args: list[str] = []
+            builder.build(build_args=build_args)
+
+            rich_print("[green]***[/green] [bold]Installing project into wheel...")
+            builder.install(install_dir)
+
+            if not builder.config.single_config and builder.config.build_type:
+                build_options += ["--config", builder.config.build_type]
+                install_options += ["--config", builder.config.build_type]
+            if builder.settings.cmake.verbose:
+                build_options.append("-v")
+
+        assert wheel_directory is not None
 
         rich_print(f"[green]***[/green] [bold]Making {state}...")
         packages = _get_packages(
@@ -302,7 +318,7 @@ def _build_wheel_impl(
         )
         mapping = packages_to_file_mapping(
             packages=packages,
-            platlib_dir=wheel_dirs["platlib"],
+            platlib_dir=wheel_dirs[targetlib],
             include=settings.sdist.include,
             exclude=settings.sdist.exclude,
         )
@@ -326,10 +342,11 @@ def _build_wheel_impl(
                 reload_dir = build_dir.resolve() if settings.build_dir else None
 
                 _make_editable(
-                    libdir=wheel_dirs["platlib"],
+                    build_options=build_options,
+                    install_options=install_options,
+                    libdir=wheel_dirs[targetlib],
                     mapping=mapping,
                     reload_dir=reload_dir,
-                    builder=builder,
                     settings=settings,
                     wheel=wheel,
                     name=normalized_name,
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index f27b600..9559eec 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -157,6 +157,15 @@
             "type": "string"
           },
           "description": "A list of license files to include in the wheel. Supports glob patterns."
+        },
+        "cmake": {
+          "type": "boolean",
+          "default": true,
+          "description": "If set to True (the default), CMake will be run before building the wheel."
+        },
+        "platlib": {
+          "type": "boolean",
+          "description": "Target the platlib or the purelib. If not set, the default is to target the platlib if wheel.cmake is true, and the purelib otherwise."
         }
       }
     },
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 98d5136..1e569c4 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -166,6 +166,17 @@ class WheelSettings:
     A list of license files to include in the wheel. Supports glob patterns.
     """
 
+    cmake: bool = True
+    """
+    If set to True (the default), CMake will be run before building the wheel.
+    """
+
+    platlib: Optional[bool] = None
+    """
+    Target the platlib or the purelib. If not set, the default is to target the
+    platlib if wheel.cmake is true, and the purelib otherwise.
+    """
+
 
 @dataclasses.dataclass
 class BackportSettings:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3e87f17..b6fca6a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -294,6 +294,17 @@ def package_sdist_config(
     return package
 
 
+@pytest.fixture()
+def package_simple_purelib_package(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> PackageInfo:
+    package = PackageInfo(
+        "simple_purelib_package",
+    )
+    process_package(package, tmp_path, monkeypatch)
+    return package
+
+
 def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
     for item in items:
         # Ensure all tests using virtualenv are marked as such
diff --git a/tests/packages/simple_purelib_package/pyproject.toml b/tests/packages/simple_purelib_package/pyproject.toml
new file mode 100644
index 0000000..a023663
--- /dev/null
+++ b/tests/packages/simple_purelib_package/pyproject.toml
@@ -0,0 +1,10 @@
+[build-system]
+requires = ["scikit-build-core[pyproject]"]
+build-backend = "scikit_build_core.build"
+
+[project]
+name = "purelib_example"
+version = "0.0.1"
+
+[tool.scikit-build]
+wheel.cmake = false
diff --git a/tests/packages/simple_purelib_package/src/purelib_example/__init__.py b/tests/packages/simple_purelib_package/src/purelib_example/__init__.py
new file mode 100644
index 0000000..10aa336
--- /dev/null
+++ b/tests/packages/simple_purelib_package/src/purelib_example/__init__.py
@@ -0,0 +1,1 @@
+__version__ = "1.2.3"
diff --git a/tests/test_get_requires.py b/tests/test_get_requires.py
index a3001ac..82018a4 100644
--- a/tests/test_get_requires.py
+++ b/tests/test_get_requires.py
@@ -97,7 +97,19 @@ def test_get_requires_for_build_wheel(fp):
     assert set(get_requires_for_build_wheel({})) == expected
 
 
+def test_get_requires_for_build_wheel_pure(fp):
+    expected = {"pathspec", "pyproject_metadata"}
+    fp.register([Path("cmake/path"), "--version"], stdout="3.14.0")
+    assert set(get_requires_for_build_wheel({"wheel.cmake": "False"})) == expected
+
+
 def test_get_requires_for_build_editable(fp):
     expected = {"pathspec", "pyproject_metadata", "cmake>=3.15", *ninja}
     fp.register([Path("cmake/path"), "--version"], stdout="3.14.0")
     assert set(get_requires_for_build_editable({})) == expected
+
+
+def test_get_requires_for_build_editable_pure(fp):
+    expected = {"pathspec", "pyproject_metadata"}
+    fp.register([Path("cmake/path"), "--version"], stdout="3.14.0")
+    assert set(get_requires_for_build_editable({"wheel.cmake": "False"})) == expected
diff --git a/tests/test_pyproject_purelib.py b/tests/test_pyproject_purelib.py
new file mode 100644
index 0000000..5c1dc53
--- /dev/null
+++ b/tests/test_pyproject_purelib.py
@@ -0,0 +1,22 @@
+from pathlib import Path
+
+import pytest
+
+from scikit_build_core.build import build_wheel
+
+
+@pytest.mark.compile()
+@pytest.mark.configure()
+@pytest.mark.usefixtures("package_simple_purelib_package")
+def test_pep517_wheel(virtualenv):
+    dist = Path("dist")
+    out = build_wheel("dist", {})
+    (wheel,) = dist.glob("purelib_example-0.0.1-*.whl")
+    assert wheel == dist / out
+
+    virtualenv.install(wheel)
+
+    version = virtualenv.execute(
+        "import purelib_example; print(purelib_example.__version__)",
+    )
+    assert version.strip() == "1.2.3"