Repository: scikit-build/scikit-build-core
Pull request #212 (2023-03-01 20:59:28)

feat: experimental support for editable installs
This is a very early implementation of editable mode. One mode is supported: redirect, which pulls the auto-discovered or manually specified Python package files from the source locations, and the binary files from the installed wheel. This method respects includes and ignores, but doesn't adjust automatically if new files are added.

A few planned changes:
- [x] `_editable.py` will be renamed and moved to resources.
- [x] configure return changes will be reverted (at least for now)
- [x] utils in wheel will be cleaned up a bit
- [x] dataclass will be removed from Finder (minimal import impact important)
- [x] A few unrelated changes will be pulled out into separate PR, #213
- [x] Consider name (rebuild might be an option, so "static" not ideal)
- [x] Add rebuild (opt in)
- [x] Check coverage support on editable installs (working with IMinuit)

For later:

- Include file extensions when converting modules
- Check datafile support


codecov[bot]: # [Codecov](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) Report
> Merging [#212](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) (3f90e88) into [main](https://codecov.io/gh/scikit-build/scikit-build-core/commit/c4469f6c600cd45aa5ef62a534f453184c63098d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) (c4469f6) will **decrease** coverage by `0.66%`.
> The diff coverage is `77.10%`.

```diff
@@            Coverage Diff             @@
##             main     #212      +/-   ##
==========================================
- Coverage   89.33%   88.68%   -0.66%     
==========================================
  Files          45       46       +1     
  Lines        2063     2138      +75     
==========================================
+ Hits         1843     1896      +53     
- Misses        220      242      +22     
```


| [Impacted Files](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build) | Coverage Δ | |
|---|---|---|
| [...cikit\_build\_core/settings/skbuild\_read\_settings.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL3NldHRpbmdzL3NrYnVpbGRfcmVhZF9zZXR0aW5ncy5weQ==) | `91.80% <20.00%> (-6.42%)` | :arrow_down: |
| [src/scikit\_build\_core/build/\_\_init\_\_.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkL19faW5pdF9fLnB5) | `65.00% <58.82%> (-10.00%)` | :arrow_down: |
| [src/scikit\_build\_core/build/\_editable.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkL19lZGl0YWJsZS5weQ==) | `70.37% <70.37%> (ø)` | |
| [src/scikit\_build\_core/build/wheel.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkL3doZWVsLnB5) | `83.56% <100.00%> (+2.76%)` | :arrow_up: |
| [src/scikit\_build\_core/builder/builder.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkZXIvYnVpbGRlci5weQ==) | `93.75% <100.00%> (ø)` | |
| [src/scikit\_build\_core/cmake.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2NtYWtlLnB5) | `82.51% <100.00%> (+0.24%)` | :arrow_up: |
| [src/scikit\_build\_core/settings/skbuild\_model.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL3NldHRpbmdzL3NrYnVpbGRfbW9kZWwucHk=) | `100.00% <100.00%> (ø)` | |
| [src/scikit\_build\_core/builder/sysconfig.py](https://codecov.io/gh/scikit-build/scikit-build-core/pull/212?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build#diff-c3JjL3NjaWtpdF9idWlsZF9jb3JlL2J1aWxkZXIvc3lzY29uZmlnLnB5) | `78.37% <0.00%> (-2.71%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-build)

LecrisUT: > One mode is supported: static, which pulls the auto-discovered or manually specified Python package files from the source locations, and the binary files from the installed wheel.

> and the binary files from the installed wheel.

It doesn't handle libs yet right?

> manually specified Python package files 

Does this mean anything from the current source package folder? This does not link the additional library files like the python binding library file?

I think it would be helpful to have a pre-defined cmake build/install directory where we can run the cmake (e.g. from the ide) to rebuild from the original source, i.e. `cmake -B ./path/to/.scikit-build`. Maybe also create a `CMakePreset-scikit-build.json` so that it can be linked from a `CMakeUserPresets.json`.
henryiii: > It doesn't handle libs yet right?

I think it depends on how you load the lib. If you use `importlib.resources`, yes, if you manipulate file paths, no. But that's just an educated guess, not at all tested.

> Does this mean anything from the current source package folder? This does not link the additional library files like the python binding library file?

The things that CMake produces are put into the ephemeral wheel which is then installed into site packages. So the dir looks like:

```
<local>/src/mypackage/__init__.py
<env>/site-packages/mypackage/mylib.so
```

And `import mypackage` gets the first line, and `from mypackage import mylib` gets the second one. The import system combines the two physically different locations.

> I think it would be helpful to have a pre-defined cmake build/install directory where we can run the cmake

That's set with `tool.cmake.build-dir`, and the next step will be providing a way for the loader to rerun it as well. I hadn't thought about manually rerunning it, but that probably would work too. Might try that. The install target might be a little tricky, but I'm assuming meson-python has the final site-packages install target, so it should be doable.

I'd like to eventually have several methods, possibly including inplace builds & simple pth files, and certainly including meson-python's automatic rebuild (as an option).

I haven't worked with presets at all yet, will look into it eventually, also open to suggestions & info.

LecrisUT: > and `from mypackage import mylib` gets the second one.

Hmm, that would handle the python-C binder, but I wonder if it will also handle the linked library (assuming that the main C library is built as shared in this case), without manually specifying `LD_LIBRARY_PATH`. My suspicion is that as long as we instruct `cmake --install` to not strip `RPATH` and point it to the correct install path, it will automatically pick the correct library.

For the cmake preset, from my playing around with it, I think the minimum we need to define are:
- `configurePreset.binaryDir`: Point it to `tool.cmake.build-dir`. Might be tricky keeping `tool.cmake.build-dir` synchronized with this, but assuming these are always editted via `pip` commands, we could just re-create `CMakePreset-scikit-build.json` each time a `pip` command is passed, right?
- `configurePreset.cacheVariables.CMAKE_INSTALL_PREFIX`: Point it to the relevant site-package location. And similarly for any paths that need to be defined, `SKBUILD`, etc. This is where most of the magic happens. We can also add environment variables and so on.
- `buildPreset.targets`: Make sure it includes the relevant python target.
- Unfortunately there is [no `installPreset`](https://gitlab.kitware.com/cmake/cmake/-/issues/23208) where we can minimize to a specific component, so instead we can add `install` to `buildPreset.targets`. Not sure how to manipulate `RPath` in this case though.
- `workflowPreset`: Combine the `configure` and `build` steps
- If the user wants to update these, they should inherit the relevant presets and/or add a new `workflowPreset` (e.g. to add `ctest`) in their final `CMakeUserPresets.json` or the main `CMakePresets.json` that `include`s `CMakePreset-scikit-build.json`
henryiii: Okay, for an experimental first round of support, I think something like this will work. You have to opt-into the experimental live reloading explicitly, and live reloading probably won't work very well if you are relying on the backports and things.

I'll probably try to add a few tests of the live reloading, maybe some unit tests, and some docs, and clarify it's experimental (probably won't require the opt-in experimental flag, as it's a developer facing tool, not a user one, and that would be a lot of settings to have to pass on the command line).

The one thing I'm still looking into is the strange case were coverage isn't computed in CI but it is locally when I tried this with IMinuit.
henryiii: rpm-build caught that I missed `cmake3`. :)

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ba05728..b0346da 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -54,13 +54,13 @@ repos:
         exclude: "^tests"
 
   - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: v0.0.260
+    rev: v0.0.261
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
 
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.1.1
+    rev: v1.2.0
     hooks:
       - id: mypy
         exclude: |
diff --git a/README.md b/README.md
index 07d8ff4..12ddfa6 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,8 @@ Features over classic Scikit-build:
 - Dedicated entrypoints for module and prefix directories
 - Several integrated dynamic metadata plugins (proposing standardized support
   soon)
+- Experimental editable mode support, with optional experimental auto rebuilds
+  on import.
 
 The following limitations are present compared to classic scikit-build:
 
@@ -44,7 +46,6 @@ The following limitations are present compared to classic scikit-build:
 
 Some known missing features that will be developed soon:
 
-- No editable mode support
 - No support for other targets besides install
 - Wheels are not fully reproducible yet
 
@@ -170,6 +171,15 @@ wheel.install-dir = "."
 # sync with the version of FindPython stored in scikit-build-core.
 backport.find-python = "3.26.1"
 
+# This is the only editable mode currently
+editable.mode = "redirect"
+
+# Enable auto rebuilds on import (experimental)
+editable.rebuild = false
+
+# Display output on stderr while rebuilding on import
+editable.verbose = false
+
 # Enable experimental features if any are available
 experimental = false
 
diff --git a/docs/configuration.md b/docs/configuration.md
index cddc243..cb898fd 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -433,6 +433,36 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
 
 :::
 
+## Editable installs
+
+Experimental support for editable installs is provided, with some caveats and
+configuration. Recommendations:
+
+- Use `--no-build-isolation` when doing an editable install is recommended; you
+  should preinstall your dependencies.
+- Automatic rebuilds do not have the original isolated build dir (pip deletes
+  it).
+- Select a `build-dir` when using editable installs, especially if you also
+  enable automatic rebuilds.
+- Resources (via `importlib.resources`) are not properly supported (yet).
+- You need to reinstall to pick up new files.
+
+```toml
+[tool.scikit-build]
+# Very expiremental rebuild on initial import feature
+editable.rebuild = true
+
+# Display stdout on stderr when rebuilding
+# (Also available as SKBUILD_EDITABLE_VERBOSE envvar when importing)
+editable.verbose = true
+```
+
+Currently one `editable.mode` is provided, `"redirect"`, which uses a custom
+redirecting finder to combine the static CMake install dir with the original
+source code. Python code added via scikit-build-core's package discovery will be
+found in the original location, so changes there are picked up on import,
+regardless of the `editable.rebuild` setting..
+
 ## Other options
 
 You can select a custom build dir; by default scikit-build-core will use a
diff --git a/pyproject.toml b/pyproject.toml
index 8024717..a778b91 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -211,6 +211,8 @@ exclude = []
 [tool.ruff.per-file-ignores]
 "tests/**" = ["T20"]
 "noxfile.py" = ["T20"]
+"src/scikit_build_core/resources/*.py" = ["PTH", "ARG002"]
+
 
 [tool.check-sdist]
 sdist-only = ["src/scikit_build_core/_version.py"]
diff --git a/src/scikit_build_core/_logging.py b/src/scikit_build_core/_logging.py
index 1b201c1..f996f0a 100644
--- a/src/scikit_build_core/_logging.py
+++ b/src/scikit_build_core/_logging.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import logging
+import os
 import re
 import sys
 import typing
@@ -78,19 +79,57 @@ class ScikitBuildLogger:
 logger = ScikitBuildLogger(raw_logger)
 
 
-def _strip_rich(msg: object) -> object:
-    if isinstance(msg, str):
-        return re.sub(r"\[.*?\]", "", msg)
-    return msg
+ANY_ESCAPE = re.compile(r"\[([\w\s/]*)\]")
+
+
+_COLORS = {
+    "red": "\33[91m",
+    "green": "\33[92m",
+    "yellow": "\33[93m",
+    "blue": "\33[94m",
+    "magenta": "\33[95m",
+    "cyan": "\33[96m",
+    "bold": "\33[1m",
+    "/red": "\33[0m",
+    "/green": "\33[0m",
+    "/blue": "\33[0m",
+    "/yellow": "\33[0m",
+    "/magenta": "\33[0m",
+    "/cyan": "\33[0m",
+    "/bold": "\33[22m",
+    "reset": "\33[0m",
+}
+_NO_COLORS = {color: "" for color in _COLORS}
+
+
+def colors() -> dict[str, str]:
+    if "NO_COLOR" in os.environ:
+        return _NO_COLORS
+    if os.environ.get("FORCE_COLOR", ""):
+        return _COLORS
+    if sys.stdout.isatty() and not sys.platform.startswith("win"):
+        return _COLORS
+    return _NO_COLORS
+
+
+def _process_rich(msg: object) -> str:
+    return ANY_ESCAPE.sub(
+        lambda m: "".join(colors()[x] for x in m.group(1).split()),
+        str(msg),
+    )
 
 
 def fake_rich_print(*args: object, **kwargs: object) -> None:
-    args_2 = (_strip_rich(arg) for arg in args)
+    args_2 = tuple(_process_rich(arg) for arg in args)
+    if args != args_2:
+        args_2 = (*args_2[:-1], args_2[-1] + colors()["reset"])
     print(*args_2, **kwargs)  # type: ignore[call-overload] # noqa: T201
 
 
-try:
-    from rich import print as rich_print
-except ModuleNotFoundError:
-    if not typing.TYPE_CHECKING:
+if typing.TYPE_CHECKING:
+    rich_print = fake_rich_print
+else:
+    try:
+        from rich import print as rich_print
+    except ModuleNotFoundError:
         rich_print = fake_rich_print
diff --git a/src/scikit_build_core/build/__init__.py b/src/scikit_build_core/build/__init__.py
index d4a403a..4429778 100644
--- a/src/scikit_build_core/build/__init__.py
+++ b/src/scikit_build_core/build/__init__.py
@@ -13,6 +13,9 @@ __all__ = [
     "get_requires_for_build_sdist",
     "get_requires_for_build_wheel",
     "prepare_metadata_for_build_wheel",
+    "build_editable",
+    "get_requires_for_build_editable",
+    "prepare_metadata_for_build_editable",
 ]
 
 
@@ -27,7 +30,32 @@ def build_wheel(
 
     try:
         return _build_wheel_impl(
-            wheel_directory, config_settings, metadata_directory
+            wheel_directory,
+            config_settings,
+            metadata_directory,
+            editable=False,
+        ).wheel_filename
+    except FailedLiveProcessError as err:
+        sys.stdout.flush()
+        rich_print(f"\n[red bold]*** {' '.join(err.args)}", file=sys.stderr)
+        raise SystemExit(1) from None
+
+
+def build_editable(
+    wheel_directory: str,
+    config_settings: dict[str, list[str] | str] | None = None,
+    metadata_directory: str | None = None,
+) -> str:
+    from .._logging import rich_print
+    from ..errors import FailedLiveProcessError
+    from .wheel import _build_wheel_impl
+
+    try:
+        return _build_wheel_impl(
+            wheel_directory,
+            config_settings,
+            metadata_directory,
+            editable=True,
         ).wheel_filename
     except FailedLiveProcessError as err:
         sys.stdout.flush()
@@ -39,9 +67,25 @@ def prepare_metadata_for_build_wheel(
     metadata_directory: str,
     config_settings: dict[str, list[str] | str] | None = None,
 ) -> str:
+    """Prepare metadata for building a wheel. Does not build the wheel. Returns the dist-info directory."""
+    from .wheel import _build_wheel_impl
+
+    return _build_wheel_impl(
+        None, config_settings, metadata_directory, editable=False
+    ).wheel_filename  # actually returns the dist-info directory
+
+
+def prepare_metadata_for_build_editable(
+    metadata_directory: str,
+    config_settings: dict[str, list[str] | str] | None = None,
+) -> str:
+    """Prepare metadata for building a wheel. Does not build the wheel. Returns the dist-info directory."""
+
     from .wheel import _build_wheel_impl
 
-    return _build_wheel_impl(None, config_settings, metadata_directory).wheel_filename
+    return _build_wheel_impl(
+        None, config_settings, metadata_directory, editable=True
+    ).wheel_filename  # actually returns the dist-info directory
 
 
 def build_sdist(
@@ -81,3 +125,19 @@ def get_requires_for_build_wheel(
         *requires.ninja(),
         *requires.dynamic_metadata(),
     ]
+
+
+def get_requires_for_build_editable(
+    config_settings: dict[str, str | list[str]] | None = None,
+) -> list[str]:
+    from ..builder.get_requires import GetRequires
+
+    requires = GetRequires(config_settings)
+
+    return [
+        "pathspec",
+        "pyproject_metadata",
+        *requires.cmake(),
+        *requires.ninja(),
+        *requires.dynamic_metadata(),
+    ]
diff --git a/src/scikit_build_core/build/_pathutil.py b/src/scikit_build_core/build/_pathutil.py
index feb1751..6dd8c79 100644
--- a/src/scikit_build_core/build/_pathutil.py
+++ b/src/scikit_build_core/build/_pathutil.py
@@ -1,17 +1,34 @@
 from __future__ import annotations
 
-from collections.abc import Sequence
+import os
+from collections.abc import Generator, Sequence
 from pathlib import Path
 
 from ._file_processor import each_unignored_file
 
-__all__ = ["packages_to_file_mapping"]
+__all__ = ["scantree", "path_to_module", "packages_to_file_mapping"]
 
 
 def __dir__() -> list[str]:
     return __all__
 
 
+def scantree(path: Path) -> Generator[Path, None, None]:
+    """Recursively yield Path objects for given directory."""
+    for entry in os.scandir(path):
+        if entry.is_dir(follow_symlinks=False):
+            yield from scantree(Path(entry))
+        else:
+            yield Path(entry)
+
+
+def path_to_module(path: Path) -> str:
+    path = path.with_name(path.name.split(".", 1)[0])
+    if path.name == "__init__":
+        path = path.parent
+    return ".".join(path.parts)
+
+
 def packages_to_file_mapping(
     *,
     packages: Sequence[str],
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index d38054d..8870abd 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -16,10 +16,11 @@ from .._logging import logger, rich_print
 from ..builder.builder import Builder, archs_to_tags, get_archs
 from ..builder.wheel_tag import WheelTag
 from ..cmake import CMake, CMaker
+from ..resources import resources
 from ..settings.metadata import get_standard_metadata
 from ..settings.skbuild_read_settings import SettingsReader
 from ._init import setup_logging
-from ._pathutil import packages_to_file_mapping
+from ._pathutil import packages_to_file_mapping, path_to_module, scantree
 from ._scripts import process_script_dir
 from ._wheelfile import WheelWriter
 
@@ -30,7 +31,11 @@ def __dir__() -> list[str]:
     return __all__
 
 
-def _get_packages(*, packages: Sequence[str] | None, name: str) -> list[str]:
+def _get_packages(
+    *,
+    packages: Sequence[str] | None,
+    name: str,
+) -> list[str]:
     if packages is not None:
         return list(packages)
 
@@ -60,9 +65,11 @@ def _build_wheel_impl(
     wheel_directory: str | None,
     config_settings: dict[str, list[str] | str] | None,
     metadata_directory: str | None,
+    *,
+    editable: bool,
 ) -> WheelImplReturn:
     """
-    Build a wheel or just prepare metadata (if wheel dir is None).
+    Build a wheel or just prepare metadata (if wheel dir is None). Can be editable.
     """
     pyproject_path = Path("pyproject.toml")
     with pyproject_path.open("rb") as ft:
@@ -153,15 +160,13 @@ def _build_wheel_impl(
             if metadata_directory is None:
                 msg = "metadata_directory must be specified if wheel_directory is None"
                 raise AssertionError(msg)
-            with WheelWriter(
-                metadata, Path(metadata_directory), tags.as_tags_set()
-            ) as wheel:
-                dist_info_contents = wheel.dist_info_contents()
-                dist_info = Path(metadata_directory) / f"{wheel.name_ver}.dist-info"
-                dist_info.mkdir(parents=True)
-                for key, data in dist_info_contents.items():
-                    path = dist_info / key
-                    path.write_bytes(data)
+            wheel = WheelWriter(metadata, Path(metadata_directory), tags.as_tags_set())
+            dist_info_contents = wheel.dist_info_contents()
+            dist_info = Path(metadata_directory) / f"{wheel.name_ver}.dist-info"
+            dist_info.mkdir(parents=True)
+            for key, data in dist_info_contents.items():
+                path = dist_info / key
+                path.write_bytes(data)
             return WheelImplReturn(wheel_filename=dist_info.name)
 
         rich_print("[green]***[/green] [bold]Configurating CMake...")
@@ -198,15 +203,43 @@ def _build_wheel_impl(
             exclude=settings.sdist.exclude,
         )
 
-        for filepath, package_dir in mapping.items():
-            Path(package_dir).parent.mkdir(exist_ok=True, parents=True)
-            shutil.copyfile(filepath, package_dir)
+        if not editable:
+            for filepath, package_dir in mapping.items():
+                Path(package_dir).parent.mkdir(exist_ok=True, parents=True)
+                shutil.copyfile(filepath, package_dir)
 
-        process_script_dir(wheel_dirs["scripts"])
+            process_script_dir(wheel_dirs["scripts"])
 
         with WheelWriter(metadata, Path(wheel_directory), tags.as_tags_set()) as wheel:
             wheel.build(wheel_dirs)
 
+            if editable:
+                modules = {
+                    path_to_module(Path(v).relative_to(wheel_dirs["platlib"])): str(
+                        Path(k).resolve()
+                    )
+                    for k, v in mapping.items()
+                }
+                installed = {
+                    path_to_module(v.relative_to(wheel_dirs["platlib"])): str(
+                        v.relative_to(wheel_dirs["platlib"])
+                    )
+                    for v in scantree(wheel_dirs["platlib"])
+                }
+                editable_py = resources / "_editable_redirect.py"
+                editable_txt = editable_py.read_text(encoding="utf-8")
+                reload_dir = os.fspath(build_dir) if settings.build_dir else None
+                editable_txt += f"\n\ninstall({modules!r}, {installed!r}, {reload_dir!r}, {settings.editable.rebuild!r}, {settings.editable.verbose!r})\n"
+
+                wheel.writestr(
+                    f"_{normalized_name}_editable.py",
+                    editable_txt.encode("utf-8"),
+                )
+                wheel.writestr(
+                    f"_{normalized_name}_editable.pth",
+                    f"import _{normalized_name}_editable\n".encode(),
+                )
+
     if metadata_directory is not None:
         dist_info_contents = wheel.dist_info_contents()
         dist_info = Path(metadata_directory)
diff --git a/src/scikit_build_core/resources/_editable_redirect.py b/src/scikit_build_core/resources/_editable_redirect.py
new file mode 100644
index 0000000..3fcc262
--- /dev/null
+++ b/src/scikit_build_core/resources/_editable_redirect.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import importlib.abc
+import importlib.machinery
+import importlib.util
+import os
+import subprocess
+import sys
+
+DIR = os.path.abspath(os.path.dirname(__file__))
+MARKER = "SKBUILD_EDITABLE_SKIP"
+VERBOSE = "SKBUILD_EDITABLE_VERBOSE"
+
+__all__ = ["install"]
+
+
+def __dir__() -> list[str]:
+    return __all__
+
+
+class ScikitBuildRedirectingFinder(importlib.abc.MetaPathFinder):
+    def __init__(
+        self,
+        known_source_files: dict[str, str],
+        known_wheel_files: dict[str, str],
+        path: str | None,
+        rebuild: bool,
+        verbose: bool,
+    ):
+        self.known_source_files = known_source_files
+        self.known_wheel_files = known_wheel_files
+        self.path = path
+        self.rebuild_flag = rebuild
+        self.verbose = verbose
+
+    def find_spec(
+        self,
+        fullname: str,
+        path: object = None,
+        target: object = None,
+    ) -> importlib.machinery.ModuleSpec | None:
+        if fullname in self.known_source_files:
+            redir = self.known_source_files[fullname]
+            return importlib.util.spec_from_file_location(fullname, redir)
+        if fullname in self.known_wheel_files:
+            redir = self.known_wheel_files[fullname]
+            if self.rebuild_flag:
+                self.rebuild()
+            return importlib.util.spec_from_file_location(
+                fullname, os.path.join(DIR, redir)
+            )
+
+        return None
+
+    def rebuild(self) -> None:
+        # Don't rebuild if not set to a local path
+        if not self.path:
+            return
+
+        env = os.environ.copy()
+        # Protect against recursion
+        if self.path in env.get(MARKER, "").split(os.pathsep):
+            return
+
+        env[MARKER] = os.pathsep.join((env.get(MARKER, ""), self.path))
+
+        verbose = self.verbose or bool(env.get(VERBOSE, ""))
+        if verbose:
+            print(f"Running cmake --build & --install in {self.path}")  # noqa: T201
+
+        result = subprocess.run(
+            ["cmake", "--build", "."],
+            cwd=self.path,
+            stdout=sys.stderr if verbose else subprocess.PIPE,
+            env=env,
+            check=False,
+            text=True,
+        )
+        if result.returncode and verbose:
+            print(  # noqa: T201
+                f"ERROR: {result.stdout}",
+                file=sys.stderr,
+            )
+        result.check_returncode()
+
+        result = subprocess.run(
+            ["cmake", "--install", ".", "--prefix", DIR],
+            cwd=self.path,
+            stdout=sys.stderr if verbose else subprocess.PIPE,
+            env=env,
+            check=False,
+            text=True,
+        )
+        if result.returncode and verbose:
+            print(  # noqa: T201
+                f"ERROR: {result.stdout}",
+                file=sys.stderr,
+            )
+        result.check_returncode()
+
+
+def install(
+    known_source_files: dict[str, str],
+    known_wheel_files: dict[str, str],
+    path: str | None,
+    rebuild: bool = False,
+    verbose: bool = False,
+) -> None:
+    """
+    Install a meta path finder that redirects imports to the source files, and
+    optionally rebuilds if path is given.
+
+    :param known_source_files: A mapping of module names to source files
+    :param known_wheel_files: A mapping of module names to wheel files
+    :param path: The path to the build directory, or None
+    :param verbose: Whether to print the cmake commands (also controlled by the
+                    SKBUILD_EDITABLE_VERBOSE environment variable)
+    """
+    sys.meta_path.insert(
+        0,
+        ScikitBuildRedirectingFinder(
+            known_source_files, known_wheel_files, path, rebuild, verbose
+        ),
+    )
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 613d20d..4026edf 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -102,6 +102,19 @@ class BackportSettings:
     find_python: str = "3.26.1"
 
 
+@dataclasses.dataclass
+class Editable:
+    #: Select the editable mode to use. Currently only "redirect" is supported.
+    mode: str = "redirect"
+
+    #: Turn on verbose output for the editable mode.
+    verbose: bool = False
+
+    #: Rebuild the project when the package is imported.
+    #: This will do nothing if the build-directory is not set.
+    rebuild: Optional[bool] = None
+
+
 @dataclasses.dataclass
 class ScikitBuildSettings:
     cmake: CMakeSettings = dataclasses.field(default_factory=CMakeSettings)
@@ -111,6 +124,7 @@ class ScikitBuildSettings:
     wheel: WheelSettings = dataclasses.field(default_factory=WheelSettings)
     backport: BackportSettings = dataclasses.field(default_factory=BackportSettings)
     metadata: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict)
+    editable: Editable = dataclasses.field(default_factory=Editable)
 
     #: Strictly check all config options. If False, warnings will be
     #: printed for unknown options. If True, an error will be raised.
diff --git a/src/scikit_build_core/settings/skbuild_read_settings.py b/src/scikit_build_core/settings/skbuild_read_settings.py
index bb04053..d17fa93 100644
--- a/src/scikit_build_core/settings/skbuild_read_settings.py
+++ b/src/scikit_build_core/settings/skbuild_read_settings.py
@@ -49,6 +49,18 @@ class SettingsReader:
                 )
                 raise CMakeConfigError(msg)
 
+        if self.settings.editable.mode != "redirect":
+            if self.settings.strict_config:
+                rich_print("[red][bold]ERROR:[/bold] editable mode must be 'redirect'")
+                raise SystemExit(7)
+            logger.warning("editable mode must be 'redirect', ignoring")
+
+        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)
+
     def unrecognized_options(self) -> Generator[str, None, None]:
         return self.sources.unrecognized_options(ScikitBuildSettings)
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 84b1b2d..d1f80f1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -45,6 +45,7 @@ def pep518_wheelhouse(tmp_path_factory: pytest.TempPathFactory) -> Path:
     packages = [
         "build",
         "hatchling",
+        "pip>=23",
         "pybind11",
         "rich",
         "setuptools",
@@ -77,6 +78,7 @@ def pep518_wheelhouse(tmp_path_factory: pytest.TempPathFactory) -> Path:
 class VEnv(EnvBuilder):
     executable: Path
     env_dir: Path
+    wheelhouse: Path | None
 
     def __init__(self, env_dir: Path, *, wheelhouse: Path | None = None) -> None:
         super().__init__(with_pip=True)
@@ -90,6 +92,8 @@ class VEnv(EnvBuilder):
                     DeprecationWarning,
                 )
             self.create(env_dir)
+        self.wheelhouse = None
+        self.install("pip>=21.3.1")
         self.wheelhouse = wheelhouse
 
     def ensure_directories(
@@ -254,6 +258,15 @@ def package_dynamic_metadata(
     return package
 
 
+@pytest.fixture()
+def package_simplest_c(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> PackageInfo:
+    package = PackageInfo(
+        "simplest_c",
+    )
+    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/test_get_requires.py b/tests/test_get_requires.py
index 663a55c..960d76c 100644
--- a/tests/test_get_requires.py
+++ b/tests/test_get_requires.py
@@ -4,6 +4,13 @@ import shutil
 import sys
 from pathlib import Path
 
+import pytest
+
+from scikit_build_core.build import (
+    get_requires_for_build_editable,
+    get_requires_for_build_sdist,
+    get_requires_for_build_wheel,
+)
 from scikit_build_core.builder.get_requires import GetRequires
 
 ninja = [] if sys.platform.startswith("win") else ["ninja>=1.5"]
@@ -17,40 +24,37 @@ def which_mock(name: str) -> str | None:
     return None
 
 
-def test_get_requires_for_build_wheel(fp, monkeypatch):
+@pytest.fixture(autouse=True)
+def protect_get_requires(fp, monkeypatch):
+    """
+    Protect get_requires from actually calling anything variable during tests.
+    """
     # This needs to be passed due to packaging.tags 22 extra checks if macos 10.16 is reported
     fp.pass_command([sys.executable, fp.any()])
-    cmake = Path("cmake/path")
     monkeypatch.setattr(shutil, "which", which_mock)
     monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
-    fp.register([cmake, "--version"], stdout="3.14.0")
+
+
+def test_get_requires_parts(fp):
+    fp.register([Path("cmake/path"), "--version"], stdout="3.14.0")
     assert set(GetRequires().cmake()) == {"cmake>=3.15"}
     assert set(GetRequires().ninja()) == {*ninja}
 
 
-def test_get_requires_for_build_wheel_uneeded(fp, monkeypatch):
-    fp.pass_command([sys.executable, fp.any()])
-    cmake = Path("cmake/path")
-    monkeypatch.setattr(shutil, "which", which_mock)
-    monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
-    fp.register([cmake, "--version"], stdout="3.18.0")
+def test_get_requires_parts_uneeded(fp):
+    fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
     assert set(GetRequires().cmake()) == set()
     assert set(GetRequires().ninja()) == {*ninja}
 
 
-def test_get_requires_for_build_wheel_settings(fp, monkeypatch):
-    fp.pass_command([sys.executable, fp.any()])
-    cmake = Path("cmake/path")
-    monkeypatch.setattr(shutil, "which", which_mock)
-    monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
-    fp.register([cmake, "--version"], stdout="3.18.0")
+def test_get_requires_parts_settings(fp):
+    fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
     config = {"cmake.minimum-version": "3.20"}
     assert set(GetRequires(config).cmake()) == {"cmake>=3.20"}
     assert set(GetRequires(config).ninja()) == {*ninja}
 
 
-def test_get_requires_for_build_wheel_pyproject(fp, monkeypatch, tmp_path):
-    fp.pass_command([sys.executable, fp.any()])
+def test_get_requires_parts_pyproject(fp, monkeypatch, tmp_path):
     monkeypatch.chdir(tmp_path)
     tmp_path.joinpath("pyproject.toml").write_text(
         """
@@ -58,10 +62,24 @@ def test_get_requires_for_build_wheel_pyproject(fp, monkeypatch, tmp_path):
         minimum-version = "3.21"
         """
     )
-    cmake = Path("cmake/path")
-    monkeypatch.setattr(shutil, "which", which_mock)
-    monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
-    fp.register([cmake, "--version"], stdout="3.18.0")
+    fp.register([Path("cmake/path"), "--version"], stdout="3.18.0")
 
     assert set(GetRequires().cmake()) == {"cmake>=3.21"}
     assert set(GetRequires().ninja()) == {*ninja}
+
+
+def test_get_requires_for_build_sdist(fp):
+    fp.register([Path("cmake/path"), "--version"], stdout="3.14.0")
+    assert set(get_requires_for_build_sdist({})) == {"pathspec", "pyproject_metadata"}
+
+
+def test_get_requires_for_build_wheel(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_wheel({})) == 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
diff --git a/tests/test_logging.py b/tests/test_logging.py
index 97c79b3..4b37378 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -1,6 +1,13 @@
 from scikit_build_core._logging import fake_rich_print
 
 
-def test_fake_rich_print(capsys):
+def test_fake_rich_print_nocolor(capsys, monkeypatch):
+    monkeypatch.setenv("NO_COLOR", "1")
     fake_rich_print("[red]hello[/red] world", end="")
     assert capsys.readouterr().out == "hello world"
+
+
+def test_fake_rich_print_forcecolor(capsys, monkeypatch):
+    monkeypatch.setenv("FORCE_COLOR", "1")
+    fake_rich_print("[red bold]hello[/bold] world", end="")
+    assert capsys.readouterr().out == "\33[91m\33[1mhello\33[22m world\33[0m"
diff --git a/tests/test_prepare_metadata.py b/tests/test_prepare_metadata.py
new file mode 100644
index 0000000..3361c4f
--- /dev/null
+++ b/tests/test_prepare_metadata.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from scikit_build_core.build import (
+    prepare_metadata_for_build_editable,
+    prepare_metadata_for_build_wheel,
+)
+
+
+@pytest.mark.usefixtures("package_simplest_c")
+@pytest.mark.parametrize("editable", [True, False], ids=["editable", "wheel"])
+def test_prepare_metadata_for_build(fp, editable):
+    # Old versions of packaging call mac_ver via subprocess
+    fp.pass_command([sys.executable, fp.any()])
+    fp.pass_command([fp.program("cmake"), "--version"])
+    fp.pass_command([fp.program("cmake3"), "--version"])
+
+    if editable:
+        assert (
+            prepare_metadata_for_build_editable("simple") == "simplest-0.0.1.dist-info"
+        )
+    else:
+        assert prepare_metadata_for_build_wheel("simple") == "simplest-0.0.1.dist-info"
+
+    with Path("simple/simplest-0.0.1.dist-info").joinpath("METADATA").open() as f:
+        metadata = f.read()
+    assert (
+        textwrap.dedent(
+            """\
+        Metadata-Version: 2.1
+        Name: simplest
+        Version: 0.0.1"""
+        )
+        == metadata.strip()
+    )
+
+    assert len(list(Path("simple/simplest-0.0.1.dist-info").iterdir())) == 3
+    assert len(list(Path("simple").iterdir())) == 1
diff --git a/tests/test_pyproject_pep660.py b/tests/test_pyproject_pep660.py
new file mode 100644
index 0000000..80123bf
--- /dev/null
+++ b/tests/test_pyproject_pep660.py
@@ -0,0 +1,64 @@
+import sys
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from scikit_build_core.build import build_editable
+
+
+# TODO: figure out why gmake is reporting no rule to make simple_pure.cpp
+@pytest.mark.compile()
+@pytest.mark.configure()
+@pytest.mark.xfail(
+    sys.platform.startswith("cygwin"),
+    strict=False,
+    reason="No idea why this fails on Cygwin",
+)
+@pytest.mark.usefixtures("package_simplest_c")
+def test_pep660_wheel():
+    dist = Path("dist")
+    out = build_editable("dist")
+    (wheel,) = dist.glob("simplest-0.0.1-*.whl")
+    assert wheel == dist / out
+
+    if sys.version_info >= (3, 8):
+        with wheel.open("rb") as f:
+            p = zipfile.Path(f)
+            file_names = [p.name for p in p.iterdir()]
+            metadata = p.joinpath("simplest-0.0.1.dist-info/METADATA").read_text()
+
+        assert len(file_names) == 4
+        assert "simplest-0.0.1.dist-info" in file_names
+        assert "simplest" in file_names
+        assert "_simplest_editable.py" in file_names
+        assert "_simplest_editable.pth" in file_names
+
+        assert "Metadata-Version: 2.1" in metadata
+        assert "Name: simplest" in metadata
+        assert "Version: 0.0.1" in metadata
+
+
+@pytest.mark.compile()
+@pytest.mark.configure()
+@pytest.mark.integration()
+@pytest.mark.parametrize("isolate", [True, False])
+@pytest.mark.usefixtures("package_simplest_c")
+def test_pep660_pip_isolated(isolated, isolate):
+    isolate_args = ["--no-build-isolation"] if not isolate else []
+    isolated.install("pip>=23")
+    if not isolate:
+        isolated.install("scikit-build-core[pyproject]")
+
+    isolated.install(
+        "-v", "--config-settings=build-dir=build/{wheel_tag}", *isolate_args, "-e", "."
+    )
+
+    value = isolated.execute("import simplest; print(simplest.square(2))")
+    assert value == "4.0"
+
+    location = isolated.execute("import simplest; print(*simplest.__path__)")
+    assert location == str(Path.cwd() / "src/simplest")
+
+    location = isolated.execute("import simplest; print(simplest.__file__)")
+    assert location == str(Path.cwd() / "src/simplest/__init__.py")
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index 093d6c3..3aa31fa 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -42,6 +42,9 @@ def test_skbuild_settings_default(tmp_path):
     assert settings.minimum_version is None
     assert settings.build_dir == ""
     assert settings.metadata == {}
+    assert settings.editable.mode == "redirect"
+    assert settings.editable.rebuild is None
+    assert not settings.editable.verbose
 
 
 def test_skbuild_settings_envvar(tmp_path, monkeypatch):
@@ -68,6 +71,8 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch):
     monkeypatch.setenv("SKBUILD_MINIMUM_VERSION", "0.1")
     monkeypatch.setenv("SKBUILD_CMAKE_VERBOSE", "TRUE")
     monkeypatch.setenv("SKBUILD_BUILD_DIR", "a/b/c")
+    monkeypatch.setenv("SKBUILD_EDITABLE_REBUILD", "True")
+    monkeypatch.setenv("SKBUILD_EDITABLE_VERBOSE", "True")
 
     pyproject_toml = tmp_path / "pyproject.toml"
     pyproject_toml.write_text("", encoding="utf-8")
@@ -98,6 +103,9 @@ def test_skbuild_settings_envvar(tmp_path, monkeypatch):
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
     assert settings.metadata == {}
+    assert settings.editable.mode == "redirect"
+    assert settings.editable.rebuild
+    assert settings.editable.verbose
 
 
 def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
@@ -129,6 +137,9 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
         "experimental": "1",
         "minimum-version": "0.1",
         "build-dir": "a/b/c",
+        "editable.mode": "redirect",
+        "editable.rebuild": "True",
+        "editable.verbose": "True",
     }
 
     settings_reader = SettingsReader.from_file(pyproject_toml, config_settings)
@@ -155,6 +166,9 @@ def test_skbuild_settings_config_settings(tmp_path, monkeypatch):
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
     assert settings.metadata == {}
+    assert settings.editable.mode == "redirect"
+    assert settings.editable.rebuild
+    assert settings.editable.verbose
 
 
 def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
@@ -186,6 +200,9 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
             minimum-version = "0.1"
             build-dir = "a/b/c"
             metadata.version.provider = "a"
+            editable.mode = "redirect"
+            editable.rebuild = true
+            editable.verbose = true
             """
         ),
         encoding="utf-8",
@@ -217,6 +234,9 @@ def test_skbuild_settings_pyproject_toml(tmp_path, monkeypatch):
     assert settings.minimum_version == "0.1"
     assert settings.build_dir == "a/b/c"
     assert settings.metadata == {"version": {"provider": "a"}}
+    assert settings.editable.mode == "redirect"
+    assert settings.editable.rebuild
+    assert settings.editable.verbose
 
 
 def test_skbuild_settings_pyproject_toml_broken(tmp_path, capsys):