Repository: scikit-build/scikit-build-core
Pull request #564 (2023-12-04 09:28:29)

feat: Preserve additivity of `cmake.define` across `overrides` tables
This light-weight PR adds a special rule when overriding `cmake.define`, so that additivity is retained and orthogonal CMake defines are accommodated. Consider the following TOML excerpt:

```toml
[[tool.scikit-build.overrides]]
if.env.SET_FOO = "ON"
cmake.define.FOO = "1"

[[tool.scikit-build.overrides]]
if.env.SET_BAR = "ON"
cmake.define.BAR = "1"
```

As of now, this would result in `cmake.define` being `{"BAR": "1"}`. With the PR, `cmake.define` will be `{"FOO": 1, "BAR": "1"}`.

henryiii: I've been thinking about this (also in cibuildwheel) for quite a while, but since there's no "null" option in TOML, there would be no way to un-set an environment variable once set. It's also inconsistent with how other options work. What I've thought of is having a special character/name to indicate that it's going to be additive. Probably "cmake.define+`?
henryiii: (Haven't thought through this at all yet, but how about something like `{inherit = true}` as an item in the list filling in the old version when it's encountered?)
stubbiali: What about `'+cmake.define.FOO' = "1"`? Of course, parsing of the keys will be needed. But this approach could be applied to `cmake.args` too, to append items to the list.
stubbiali: The logic to support the syntax `'+cmake.args'` and `'+cmake.define'` has been implemented.
henryiii: (This is moving forward slowly, I'll get back to you once we are happy on a design! I've brought it up with the rest of the cibuildwheel devs at https://github.com/pypa/cibuildwheel/discussions/1716)
henryiii: If people like https://github.com/pypa/cibuildwheel/pull/1730, we can do the same thing here.
stubbiali: Speaking on behalf of the team behind https://github.com/ghex-org/GHEX: we like the approach pursued in https://github.com/pypa/cibuildwheel/pull/1730, and the end result will perfectly serve our needs, so green light from our side. If needed, I can gladly offer some help.
henryiii: There's some question about the final term ("inherit" vs. "extend", opinions welcome on that issue), but it's mostly finalized. I can look at adding it here too.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6308c44..222c33a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ jobs:
       - uses: actions/setup-python@v5
         with:
           python-version: "3.11"
-      - uses: pre-commit/action@v3.0.0
+      - uses: pre-commit/action@v3.0.1
         with:
           extra_args: --hook-stage manual --all-files
       - name: Run PyLint
@@ -106,7 +106,7 @@ jobs:
           --durations=20
 
       - name: Upload coverage report
-        uses: codecov/codecov-action@v3
+        uses: codecov/codecov-action@v4
         with:
           name:
             ${{ runner.os }}-${{ matrix.python-version }}-${{
@@ -137,7 +137,7 @@ jobs:
         run: pip install --constraint tests/constraints.txt .[test] ninja
 
       - name: Setup CMake ${{ matrix.cmake-version }}
-        uses: jwlawson/actions-setup-cmake@v1.14
+        uses: jwlawson/actions-setup-cmake@v2.0
 
       - name: Test min package
         run: pytest -ra --showlocals -Wdefault
@@ -255,7 +255,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
 
-      - uses: wntrblm/nox@2023.04.22
+      - uses: wntrblm/nox@2024.03.02
         with:
           python-versions: "3.11"
 
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index da81ef4..d66a775 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
         exclude: "^tests"
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.2.0
+    rev: v0.2.2
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
diff --git a/README.md b/README.md
index 1237f65..478bb3a 100644
--- a/README.md
+++ b/README.md
@@ -86,10 +86,12 @@ The recommended interface is the native pyproject builder. There is also a WIP
 setuptools-based interface that is being developed to provide a transition path
 for classic scikit-build.
 
-**WARNING**: Only the pyproject-based builder should be used; the setuptools
-backend is experimental and likely to move to a separate package before being
-declared stable, and internal API is still being solidified. A future version of
-this package will support creating new build extensions.
+> [!WARNING]
+>
+> Only the pyproject-based builder should be used; the setuptools backend is
+> experimental and likely to move to a separate package before being declared
+> stable, and internal API is still being solidified. A future version of this
+> package will support creating new build extensions.
 
 ## Example
 
diff --git a/docs/changelog.md b/docs/changelog.md
index 733fe4f..6304862 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,42 @@
 # Changelog
 
+## Version 0.8.2
+
+This version fixes a few small issues related to configuration. The wheel tag is
+fixed when `wheel.platlib` is False, inplace editable installs ignore
+`build-dir`, and `install-dir` no longer affects the generate path.
+
+Fixes:
+
+- Ensure `wheel.platlib` being false sets `<abi>-<platform>` as `"none-any"` by
+  @jcfr in #655
+- Inplace editable install should ignore `build-dir` by @henryiii in #644
+- `install-dir` shouldn't affect generate path by @henryiii in #654
+- Expand info printing by @henryiii in #651
+
+Documentation:
+
+- Clarify requirement for using "inplace" editable mode by @jcfr in #643
+- Update README to use modern GitHub admonition by @jcfr in #641
+- be explicit about what it means to "use SKBUILD_SOABI" by @minrk in #646
+
+## Version 0.8.1
+
+This version fixes two small issues with specific situations: if ninja/cmake is
+present but not executable, and ninja on Windows not respecting the build type.
+
+Fixes:
+
+- Do not exit with exec permission error in ninja check by @thewtex in #626
+- Include `CMAKE_BUILD_TYPE` on Windows Ninja by @henryiii in #640
+- Use `-` vs `_` in error message by @nbelakovski in #638
+- Make run command logged at info by @LecrisUT in #639
+
+Documentation:
+
+- Warning about using `cmake.args` for defines by @LecrisUT in #620
+- Correct API usage example by @henryiii in #627
+
 ## Version 0.8.0
 
 This version replaces the `cmake`/`ninja` minimum-version config option with a
diff --git a/docs/cmakelists.md b/docs/cmakelists.md
index 0681186..69b208c 100644
--- a/docs/cmakelists.md
+++ b/docs/cmakelists.md
@@ -38,11 +38,33 @@ If you are making a Limited ABI / Stable API package, you'll need the
 `Development.SABIModule` component instead. You can use the
 `SKBUILD_LIMITED_API` variable to check to see if it was requested.
 
+<!-- prettier-ignore-start -->
 :::{warning}
+:name: soabi
 
 If you want to cross-compile to Windows ARM, you'll need to use
 `${SKBUILD_SOABI}`, which is always correct, instead of trusting FindPython's
-`Python_SOABI` value.
+`Python_SOABI` value. You can manually set the extension suffix after making a
+target:
+
+```cmake
+if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
+    set_property (TARGET ${name} PROPERTY SUFFIX "$.{SKBUILD_SOSABI}.pyd")
+else()
+    set_property (TARGET ${name} PROPERTY SUFFIX ".${SKBUILD_SOSABI}${CMAKE_SHARED_MODULE_SUFFIX}")
+endif()
+```
+<!-- prettier-ignore-end -->
+
+A quicker way to do this would be to instead override `Python_SOABI` after
+`find_package(Python)`:
+
+```cmake
+set(Python_SOABI ${SKBUILD_SOABI})
+```
+
+However, this isn't officially supported upstream, and only works due to the way
+this variable is used when creating targets.
 
 :::
 
diff --git a/docs/configuration.md b/docs/configuration.md
index 15983c2..e47c6de 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -5,6 +5,8 @@ in scikit-build-core can be specified in one of three ways: as a
 `pyproject.toml` option (preferred if static), as a config-settings options
 (preferred if dynamic), or as an environment variable.
 
+(verbosity)=
+
 ## Verbosity
 
 You can increase the verbosity of the build with two settings - `cmake.verbose`
@@ -566,7 +568,7 @@ Known limitations:
 
 ```console
 # Very experimental rebuild on initial import feature
-$ pip install --no-build-isolation --config-settings=editable.rebuild=true -ve.
+$ pip install --no-build-isolation --config-settings=editable.rebuild=true -Cbuild-dir=build -ve.
 ```
 
 Due to the length of this line already being long, you do not need to set the
@@ -595,8 +597,9 @@ artifacts, etc. Also, to make your binaries importable, you should set
 all possible `*_<CONFIG>` variations) to make sure they are placed inside your
 source directory inside the Python packages; this will be run from the build
 directory, rather than installed. This will also not support automatic rebuilds.
-The build directory setting must be empty/unset to use this. You can detect this
-mode by checking for an in-place build and checking `SKBUILD` being set.
+The build directory setting will be ignored if you use this and perform an
+editable install. You can detect this mode by checking for an in-place build and
+checking `SKBUILD` being set.
 
 With all the caveats, this is very logically simple (one directory) and a near
 identical replacement for `python setup.py build_ext --inplace`. Some third
diff --git a/docs/faqs.md b/docs/faqs.md
index d9cd1e7..b858701 100644
--- a/docs/faqs.md
+++ b/docs/faqs.md
@@ -51,10 +51,28 @@ What's more, the manylinux image (which is used to make redistributable Linux
 wheels) does not have the Python libraries, both to avoid this mistake, and to
 reduce size.
 
+## Cross compiling
+
+When cross compiling, FindPython may not get the correct SOABI extension.
+Scikit-build-core does know the correct extension, however, and sets it as
+`SKBUILD_SOABI`. See [the SOABI docs](#soabi).
+
+## Things to try
+
+If you want to debug a scikit-build-core build, you have several options. If you
+are using `pip`, make sure you are passing the `-v` flag, otherwise `pip`
+suppresses all output. You can
+[increase scikit-build-core's logging verbosity](#verbosity). You can also get a
+printout of the current settings using:
+
+```bash
+python -m scikit_build_core.builder
+```
+
 <!-- prettier-ignore-start -->
 
 [scientific python cookie]: https://github.com/scientific-python/cookie
-[scientific python development guide]: https://learn.scientific-python.org/development
+[scientific python development guidelines]: https://learn.scientific-python.org/development
 [pybind11 example]: https://github.com/pybind/scikit_build_example
 
 <!-- prettier-ignore-end -->
diff --git a/pyproject.toml b/pyproject.toml
index 1319a8e..4e244da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -170,6 +170,7 @@ py-version = "3.7"
 jobs = "0"
 reports.output-format = "colorized"
 good-names = ["f"]
+ignore-patterns = ['.*\.pyi']
 messages_control.disable = [
     "design",
     "fixme",
diff --git a/src/scikit_build_core/_logging.py b/src/scikit_build_core/_logging.py
index 2c6fa25..b70c451 100644
--- a/src/scikit_build_core/_logging.py
+++ b/src/scikit_build_core/_logging.py
@@ -79,7 +79,7 @@ class ScikitBuildLogger:
 logger = ScikitBuildLogger(raw_logger)
 
 
-ANY_ESCAPE = re.compile(r"\[([\w\s/]*)\]")
+ANY_ESCAPE = re.compile(r"\[([\w\s/]+)\]")
 
 
 _COLORS = {
diff --git a/src/scikit_build_core/_shutil.py b/src/scikit_build_core/_shutil.py
index 9c67bed..46fce89 100644
--- a/src/scikit_build_core/_shutil.py
+++ b/src/scikit_build_core/_shutil.py
@@ -66,7 +66,7 @@ class Run:
                 logger.debug("RUNENV - changes since last run only:\n  {}", msg)
                 type(self)._prev_env = self.env.copy()
 
-        logger.debug("RUN: {}", " ".join(options))
+        logger.info("RUN: {}", " ".join(options))
 
         return subprocess.run(
             options,
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index d562ecf..974d71c 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -173,23 +173,27 @@ def _build_wheel_impl(
             archs_to_tags(get_archs(os.environ)),
             settings.wheel.py_api,
             expand_macos=settings.wheel.expand_macos_universal_tags,
+            root_is_purelib=targetlib == "purelib",
             build_tag=settings.wheel.build_tag,
         )
 
         # A build dir can be specified, otherwise use a temporary directory
-        build_dir = (
-            Path(
-                settings.build_dir.format(
-                    cache_tag=sys.implementation.cache_tag,
-                    wheel_tag=str(tags),
-                    build_type=settings.cmake.build_type,
-                    state=state,
+        if cmake is not None and editable and settings.editable.mode == "inplace":
+            build_dir = settings.cmake.source_dir
+        else:
+            build_dir = (
+                Path(
+                    settings.build_dir.format(
+                        cache_tag=sys.implementation.cache_tag,
+                        wheel_tag=str(tags),
+                        build_type=settings.cmake.build_type,
+                        state=state,
+                    )
                 )
+                if settings.build_dir
+                else build_tmp_folder / "build"
             )
-            if settings.build_dir
-            else build_tmp_folder / "build"
-        )
-        logger.info("Build directory: {}", build_dir.resolve())
+            logger.info("Build directory: {}", build_dir.resolve())
 
         wheel_dirs = {
             targetlib: wheel_dir / targetlib,
@@ -269,7 +273,7 @@ def _build_wheel_impl(
             if gen.location == "build":
                 path = build_dir / gen.path
             elif gen.location == "install":
-                path = install_dir / gen.path
+                path = wheel_dirs[targetlib] / gen.path
             else:
                 assert_never(gen.location)
             path.parent.mkdir(parents=True, exist_ok=True)
@@ -279,8 +283,6 @@ def _build_wheel_impl(
         install_options = []
 
         if cmake is not None:
-            if editable and settings.editable.mode == "inplace":
-                build_dir = settings.cmake.source_dir
             config = CMaker(
                 cmake,
                 source_dir=settings.cmake.source_dir,
diff --git a/src/scikit_build_core/builder/__main__.py b/src/scikit_build_core/builder/__main__.py
new file mode 100644
index 0000000..d416b71
--- /dev/null
+++ b/src/scikit_build_core/builder/__main__.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from .. import __version__
+from .._logging import rich_print
+from ..program_search import info_print as ip_program_search
+from .get_requires import GetRequires
+from .sysconfig import info_print as ip_sysconfig
+from .wheel_tag import WheelTag
+
+__all__ = ["main"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+def main() -> None:
+    rich_print(f"[bold]Scikit-build-core {__version__}[/bold] on Python {sys.version}")
+
+    ip_sysconfig(color="green")
+
+    rich_print(f"[bold blue]Default Wheel Tag:[/bold] {WheelTag.compute_best([])}")
+    rich_print(
+        "[blue] - Note: use [bold]python -m scikit_build_core.builder.wheel_tag -h[/bold] for further options[/blue]"
+    )
+
+    if Path("pyproject.toml").is_file():
+        req = GetRequires()
+        all_req = [*req.cmake(), *req.ninja(), *req.dynamic_metadata()]
+        rich_print(f"[bold red]Get Requires:[/bold] {all_req!r}")
+
+    ip_program_search(color="magenta")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/scikit_build_core/builder/sysconfig.py b/src/scikit_build_core/builder/sysconfig.py
index e8ad537..98e14c6 100644
--- a/src/scikit_build_core/builder/sysconfig.py
+++ b/src/scikit_build_core/builder/sysconfig.py
@@ -7,7 +7,7 @@ import sysconfig
 from pathlib import Path
 from typing import TYPE_CHECKING
 
-from .._logging import logger
+from .._logging import logger, rich_print
 
 if TYPE_CHECKING:
     from collections.abc import Mapping
@@ -18,6 +18,7 @@ __all__ = [
     "get_python_include_dir",
     "get_python_library",
     "get_soabi",
+    "info_print",
 ]
 
 
@@ -169,3 +170,33 @@ def get_numpy_include_dir() -> Path | None:
         return None
 
     return Path(np.get_include())
+
+
+def info_print(*, color: str = "") -> None:
+    """
+    Print information about the Python environment.
+    """
+
+    rich_print(
+        f"[bold {color}]Detected Python Library:[/bold] {get_python_library(os.environ, abi3=False)}"
+    )
+    rich_print(
+        f"[bold {color}]Detected ABI3 Python Library:[/bold] {get_python_library(os.environ, abi3=True)}"
+    )
+    rich_print(
+        f"[bold {color}]Detected Python Include Directory:[/bold] {get_python_include_dir()}"
+    )
+    rich_print(
+        f"[bold {color}]Detected NumPy Include Directory:[/bold] {get_numpy_include_dir()}"
+    )
+    rich_print(f"[bold {color}]Detected Platform:[/bold] {get_platform()}")
+    rich_print(
+        f"[bold {color}]Detected SOABI:[/bold] {get_soabi(os.environ, abi3=False)}"
+    )
+    rich_print(
+        f"[bold {color}]Detected ABI3 SOABI:[/bold] {get_soabi(os.environ, abi3=True)}"
+    )
+
+
+if __name__ == "__main__":
+    info_print()
diff --git a/src/scikit_build_core/builder/wheel_tag.py b/src/scikit_build_core/builder/wheel_tag.py
index 3e6a006..ac66764 100644
--- a/src/scikit_build_core/builder/wheel_tag.py
+++ b/src/scikit_build_core/builder/wheel_tag.py
@@ -37,6 +37,7 @@ class WheelTag:
         py_api: str = "",
         *,
         expand_macos: bool = False,
+        root_is_purelib: bool = False,
         build_tag: str = "",
     ) -> Self:
         if build_tag:
@@ -77,12 +78,21 @@ class WheelTag:
             # Remove duplicates (e.g. universal2 if macOS > 11.0 and expanded)
             plats = list(dict.fromkeys(plats))
 
+        if root_is_purelib:
+            plats = ["any"]
+            abi = "none"
+            pyvers = ["py3"]
+
         if py_api:
             pyvers_new = py_api.split(".")
             if all(x.startswith("cp3") and x[3:].isdecimal() for x in pyvers_new):
                 if len(pyvers_new) != 1:
                     msg = "Unexpected py-api, must be a single cp version (e.g. cp39), not {py_api}"
                     raise AssertionError(msg)
+                if root_is_purelib:
+                    msg = f"Unexpected py-api, since platlib is set to false, must be Pythonless (e.g. py2.py3), not {py_api}"
+                    raise AssertionError(msg)
+
                 minor = int(pyvers_new[0][3:])
                 if (
                     sys.implementation.name == "cpython"
@@ -147,6 +157,11 @@ if __name__ == "__main__":
         default="",
         help="Specify py-api, like 'cp37' or 'py3'",
     )
+    parser.add_argument(
+        "--purelib",
+        action="store_true",
+        help="Specify a non-platlib (pure) tag",
+    )
     args = parser.parse_args()
-    tag = WheelTag.compute_best(args.archs, args.abi)
+    tag = WheelTag.compute_best(args.archs, args.abi, root_is_purelib=args.purelib)
     print(tag)  # noqa: T201
diff --git a/src/scikit_build_core/cmake.py b/src/scikit_build_core/cmake.py
index 1f73a8d..762b5ae 100644
--- a/src/scikit_build_core/cmake.py
+++ b/src/scikit_build_core/cmake.py
@@ -178,9 +178,6 @@ class CMaker:
         if self.init_cache_file.is_file():
             yield f"-C{self.init_cache_file}"
 
-        if self.single_config and self.build_type:
-            yield f"-DCMAKE_BUILD_TYPE:STRING={self.build_type}"
-
         for key, value in defines.items():
             if isinstance(value, bool):
                 str_value = "ON" if value else "OFF"
@@ -214,6 +211,9 @@ class CMaker:
         if gen:
             self.single_config = gen == "Ninja" or "Makefiles" in gen
 
+        if self.single_config and self.build_type:
+            all_args.insert(2, f"-DCMAKE_BUILD_TYPE:STRING={self.build_type}")
+
         try:
             Run(env=self.env).live(self.cmake, *all_args)
         except subprocess.CalledProcessError:
diff --git a/src/scikit_build_core/program_search.py b/src/scikit_build_core/program_search.py
index 8cc1803..8208b24 100644
--- a/src/scikit_build_core/program_search.py
+++ b/src/scikit_build_core/program_search.py
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, NamedTuple
 
 from packaging.version import InvalidVersion, Version
 
-from ._logging import logger
+from ._logging import logger, rich_print
 from ._shutil import Run
 
 if TYPE_CHECKING:
@@ -149,3 +149,20 @@ def best_program(
             return program
 
     return None
+
+
+def info_print(*, color: str = "") -> None:
+    """
+    Print information about the program search.
+    """
+    rich_print(f"[bold {color}]Detected CMake and Ninja[/bold] (all versions):")
+    for n, prog in enumerate(get_cmake_programs()):
+        s = " " if n else "*"
+        rich_print(f"{s} [bold {color}]CMake:[/bold] {prog.path} {prog.version!r}")
+    for n, prog in enumerate(get_ninja_programs()):
+        s = " " if n else "*"
+        rich_print(f"{s} [bold {color}]Ninja:[/bold] {prog.path} {prog.version!r}")
+
+
+if __name__ == "__main__":
+    info_print()
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index e286954..c02871e 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -436,6 +436,63 @@
               }
             ]
           },
+          "inherit": {
+            "type": "object",
+            "properties": {
+              "cmake": {
+                "type": "object",
+                "additionalProperties": false,
+                "properties": {
+                  "args": {
+                    "$ref": "#/$defs/inherit"
+                  },
+                  "define": {
+                    "$ref": "#/$defs/inherit"
+                  },
+                  "targets": {
+                    "$ref": "#/$defs/inherit"
+                  }
+                }
+              },
+              "sdist": {
+                "type": "object",
+                "additionalProperties": false,
+                "properties": {
+                  "include": {
+                    "$ref": "#/$defs/inherit"
+                  },
+                  "exclude": {
+                    "$ref": "#/$defs/inherit"
+                  }
+                }
+              },
+              "wheel": {
+                "type": "object",
+                "additionalProperties": false,
+                "properties": {
+                  "packages": {
+                    "$ref": "#/$defs/inherit"
+                  },
+                  "license-files": {
+                    "$ref": "#/$defs/inherit"
+                  },
+                  "exclude": {
+                    "$ref": "#/$defs/inherit"
+                  }
+                }
+              },
+              "install": {
+                "type": "object",
+                "additionalProperties": false,
+                "properties": {
+                  "components": {
+                    "$ref": "#/$defs/inherit"
+                  }
+                }
+              }
+            },
+            "additionalProperties": false
+          },
           "cmake": {
             "$ref": "#/properties/cmake"
           },
@@ -546,6 +603,14 @@
           "description": "A table of environment variables mapped to either string regexs, or booleans. Valid 'truthy' environment variables are case insensitive `true`, `on`, `yes`, `y`, `t`, or a number more than 0."
         }
       }
+    },
+    "inherit": {
+      "enum": [
+        "none",
+        "append",
+        "prepend"
+      ],
+      "default": "none"
     }
   }
 }
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index 0bc8524..be434b7 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -191,6 +191,24 @@ def _handle_minimum_version(
         dc.version = SpecifierSet(f">={dc.minimum_version}")
 
 
+def inherit_join(
+    value: list[str] | dict[str, str] | str | int | bool,
+    previous: list[str] | dict[str, str] | str | int | bool | None,
+    mode: str,
+) -> list[str] | dict[str, str] | str | int | bool:
+    if mode not in {"none", "append", "prepend"}:
+        msg = "Only 'none', 'append', and 'prepend' supported for inherit"
+        raise TypeError(msg)
+    if mode == "none" or previous is None:
+        return value
+    if isinstance(previous, list) and isinstance(value, list):
+        return [*previous, *value] if mode == "append" else [*value, *previous]
+    if isinstance(previous, dict) and isinstance(value, dict):
+        return {**previous, **value} if mode == "append" else {**value, **previous}
+    msg = "Append/prepend modes can only be used on lists or dicts"
+    raise TypeError(msg)
+
+
 class SettingsReader:
     def __init__(
         self,
@@ -222,6 +240,12 @@ class SettingsReader:
                 matched = override_match(
                     match_all=False, current_env=env, current_state=state, **select
                 )
+
+            inherit_override = override.pop("inherit", {})
+            if not isinstance(inherit_override, dict):
+                msg = "'inherit' override must be a table"
+                raise TypeError(msg)
+
             select = {k.replace("-", "_"): v for k, v in if_override.items()}
             if select:
                 matched = matched and override_match(
@@ -229,13 +253,22 @@ class SettingsReader:
                 )
             if matched:
                 for key, value in override.items():
+                    inherit1 = inherit_override.get(key, {})
                     if isinstance(value, dict):
                         for key2, value2 in value.items():
+                            inherit2 = inherit1.get(key2, "none")
                             inner = tool_skb.get(key, {})
-                            inner[key2] = value2
+                            inner[key2] = inherit_join(
+                                value2, inner.get(key2, None), inherit2
+                            )
                             tool_skb[key] = inner
                     else:
-                        tool_skb[key] = value
+                        inherit_override_tmp = inherit_override or "none"
+                        if isinstance(inherit_override_tmp, dict):
+                            assert not inherit_override_tmp
+                        tool_skb[key] = inherit_join(
+                            value, tool_skb.get(key, None), inherit_override_tmp
+                        )
 
         prefixed = {
             k: v for k, v in config_settings.items() if k.startswith("skbuild.")
@@ -262,25 +295,19 @@ class SettingsReader:
                 )
                 raise CMakeConfigError(msg)
 
-        if self.settings.editable.mode == "inplace":
-            if self.settings.editable.rebuild:
+        if self.settings.editable.rebuild:
+            if self.settings.editable.mode == "inplace":
                 rich_print(
                     "[red][bold]ERROR:[/bold] editable rebuild is incompatible with inplace mode"
                 )
                 raise SystemExit(7)
 
-            if self.settings.build_dir:
+            if not self.settings.build_dir:
                 rich_print(
-                    "[red][bold]ERROR:[/bold] build-dir must be empty for editable inplace mode"
+                    "[red][bold]ERROR:[/bold] editable mode with rebuild requires build-dir"
                 )
                 raise SystemExit(7)
 
-        if self.settings.editable.rebuild and not self.settings.build_dir:
-            rich_print(
-                "[red][bold]ERROR:[/bold] editable mode with rebuild requires build_dir"
-            )
-            raise SystemExit(7)
-
         install_policy = (
             self.settings.minimum_version is None
             or self.settings.minimum_version >= Version("0.5")
diff --git a/src/scikit_build_core/settings/skbuild_schema.py b/src/scikit_build_core/settings/skbuild_schema.py
index 0206424..2c86a1c 100644
--- a/src/scikit_build_core/settings/skbuild_schema.py
+++ b/src/scikit_build_core/settings/skbuild_schema.py
@@ -127,6 +127,26 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
             },
         },
     }
+    schema["$defs"]["inherit"] = {
+        "enum": ["none", "append", "prepend"],
+        "default": "none",
+    }
+
+    inherit_props: dict[str, Any] = {
+        k: {
+            kk: {"$ref": "#/$defs/inherit"}
+            for kk, vv in v["properties"].items()
+            if vv.get("type", "") in {"object", "array"}
+        }
+        for k, v in schema["properties"].items()
+        if v.get("type", "") == "object"
+    }
+    inherit_props = {
+        k: {"type": "object", "additionalProperties": False, "properties": v}
+        for k, v in inherit_props.items()
+        if v
+    }
+
     schema["properties"]["overrides"] = {
         "type": "array",
         "description": "A list of overrides to apply to the settings, based on the `if` selector.",
@@ -147,6 +167,11 @@ def generate_skbuild_schema(tool_name: str = "scikit-build") -> dict[str, Any]:
                         },
                     ]
                 },
+                "inherit": {
+                    "type": "object",
+                    "properties": inherit_props,
+                    "additionalProperties": False,
+                },
                 **props,
             },
         },


Test patch:
diff --git a/tests/test_cmake_config.py b/tests/test_cmake_config.py
index d08f45e..3f8e25f 100644
--- a/tests/test_cmake_config.py
+++ b/tests/test_cmake_config.py
@@ -22,13 +22,13 @@ def configure_args(config: CMaker, *, init: bool = False) -> Generator[str, None
     yield f"-S{config.source_dir}"
     yield f"-B{config.build_dir}"
 
+    if config.single_config:
+        yield f"-DCMAKE_BUILD_TYPE:STRING={config.build_type}"
+
     if init:
         cmake_init = config.build_dir / "CMakeInit.txt"
         yield f"-C{cmake_init}"
 
-    if config.single_config:
-        yield f"-DCMAKE_BUILD_TYPE:STRING={config.build_type}"
-
 
 @pytest.mark.configure()
 def test_init_cache(fp, tmp_path):
diff --git a/tests/test_printouts.py b/tests/test_printouts.py
new file mode 100644
index 0000000..cac8e5c
--- /dev/null
+++ b/tests/test_printouts.py
@@ -0,0 +1,7 @@
+from scikit_build_core.builder.__main__ import main
+
+
+def test_builder_printout(capsys):
+    main()
+    out, err = capsys.readouterr()
+    assert "Detected Python Library" in out
diff --git a/tests/test_pyproject_purelib.py b/tests/test_pyproject_purelib.py
index 5c1dc53..e726215 100644
--- a/tests/test_pyproject_purelib.py
+++ b/tests/test_pyproject_purelib.py
@@ -11,7 +11,7 @@ from scikit_build_core.build import build_wheel
 def test_pep517_wheel(virtualenv):
     dist = Path("dist")
     out = build_wheel("dist", {})
-    (wheel,) = dist.glob("purelib_example-0.0.1-*.whl")
+    (wheel,) = dist.glob("purelib_example-0.0.1-*-none-any.whl")
     assert wheel == dist / out
 
     virtualenv.install(wheel)
diff --git a/tests/test_settings_overrides.py b/tests/test_settings_overrides.py
index da0106e..ea19216 100644
--- a/tests/test_settings_overrides.py
+++ b/tests/test_settings_overrides.py
@@ -425,3 +425,68 @@ def test_skbuild_overrides_state(state: str, tmp_path: Path):
         assert settings.experimental
     else:
         assert not settings.experimental
+
+
+@pytest.mark.parametrize("inherit", ["none", "append", "prepend"])
+def test_skbuild_overrides_inherit(inherit: str, tmp_path: Path):
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        dedent(
+            f"""\
+            [tool.scikit-build]
+            cmake.args = ["a", "b"]
+            cmake.targets = ["a", "b"]
+            wheel.packages = ["a", "b"]
+            wheel.license-files = ["a.txt", "b.txt"]
+            wheel.exclude = ["x", "y"]
+            install.components = ["a", "b"]
+            cmake.define = {{a="A", b="B"}}
+
+            [[tool.scikit-build.overrides]]
+            if.state = "wheel"
+            inherit.cmake.args = "{inherit}"
+            inherit.cmake.targets = "{inherit}"
+            inherit.wheel.packages = "{inherit}"
+            inherit.wheel.license-files = "{inherit}"
+            inherit.wheel.exclude = "{inherit}"
+            inherit.install.components = "{inherit}"
+            inherit.cmake.define = "{inherit}"
+            cmake.args = ["c", "d"]
+            cmake.targets = ["c", "d"]
+            wheel.packages = ["c", "d"]
+            wheel.license-files = ["c.txt", "d.txt"]
+            wheel.exclude = ["xx", "yy"]
+            install.components = ["c", "d"]
+            cmake.define = {{b="X", c="C"}}
+            """
+        ),
+        encoding="utf-8",
+    )
+
+    settings_reader = SettingsReader.from_file(pyproject_toml, {}, state="wheel")
+    settings = settings_reader.settings
+
+    if inherit == "none":
+        assert settings.cmake.args == ["c", "d"]
+        assert settings.cmake.targets == ["c", "d"]
+        assert settings.wheel.packages == ["c", "d"]
+        assert settings.wheel.license_files == ["c.txt", "d.txt"]
+        assert settings.wheel.exclude == ["xx", "yy"]
+        assert settings.install.components == ["c", "d"]
+        assert settings.cmake.define == {"b": "X", "c": "C"}
+    elif inherit == "append":
+        assert settings.cmake.args == ["a", "b", "c", "d"]
+        assert settings.cmake.targets == ["a", "b", "c", "d"]
+        assert settings.wheel.packages == ["a", "b", "c", "d"]
+        assert settings.wheel.license_files == ["a.txt", "b.txt", "c.txt", "d.txt"]
+        assert settings.wheel.exclude == ["x", "y", "xx", "yy"]
+        assert settings.install.components == ["a", "b", "c", "d"]
+        assert settings.cmake.define == {"a": "A", "b": "X", "c": "C"}
+    elif inherit == "prepend":
+        assert settings.cmake.args == ["c", "d", "a", "b"]
+        assert settings.cmake.targets == ["c", "d", "a", "b"]
+        assert settings.wheel.packages == ["c", "d", "a", "b"]
+        assert settings.wheel.license_files == ["c.txt", "d.txt", "a.txt", "b.txt"]
+        assert settings.wheel.exclude == ["xx", "yy", "x", "y"]
+        assert settings.install.components == ["c", "d", "a", "b"]
+        assert settings.cmake.define == {"a": "A", "b": "B", "c": "C"}