Repository: pypa/pip
Pull request #8212 (2020-05-09 10:27:04)

PEP 660 (build_editable) support
I wanted to experiment a little bit too with the `build_wheel_for_editable` concept, just to see how well it fits in the pip code base. I'm sharing in case anyone is interested. This is ~~totally~~ draft and not well tested.

- [x] install --editable 
  - [x]  backend has `build_wheel_for_editable`
  - [x] backend does not have `build_wheel_for_editable` - fallback to `setup.py develop`
  - [x] backend does not have `build_wheel_for_editable` - error if no setup.py
  - [x] update to latest PEP 660
  - [x] support get_requires_for_build_editable
  - [x] support prepare_metadata_for_editable
  - [x] fallback if hook not present
  - [x] make sure pip wheel -e does *not* use any editable hook
- [x] uninstall
- [x] update pip freeze (done in #10249)
- [x] update pip list (done in #10249)
- [x] in log message `Building wheel for * (PEP 517) ... done`, mention we are in editable mode?



sbidoul: > How does it interact with caching 

Caching should be fine when #8145 is merged. I cherry picked it here for clarity and added a few should_build/should_cache unit tests for the editable+pep517 combination.

> and out of tree builds?

That should be ok, since the part the does the `copytree` is never reached for editable requirements.
dholth: Do you have an opinion on passing the target directory to the build so that it can use relative paths to find the real editable module?
sbidoul: > Do you have an opinion on passing the target directory to the build so that it can use relative paths to find the real editable module?

I'm personally not opposed to it as I'm not too concerned with having a pure backend/frontend interface for editables. But I guess this would quickly be abused by backends to fiddle inside the target directory.

OTOH my view of editable installs is that they are for development only, so in that scenario absolute paths are probably less of an issue?

dholth: The 'editable' name or a linked install is better than the 'setup.py develop' name. Packaging provides a feature but shouldn't try to restrict what it is used for.
sbidoul: This PR is now somewhat usable. I tested it with a custom backend.
pfmoore: Personally, I still prefer the approach where the backend builds a wheel that installs just like any other wheel but the installed code links back to the source directory. This gives editable support with essentially no support code in pip.

Basic implementation is [here](https://github.com/pfmoore/editables). It needs fleshing out to make it into a library that backends can use, I plan on doing that when I have more free time.
dholth: @pfmoore this could hardly be closer to what you prefer, depending entirely on what the backend decides to put in that wheel.
pfmoore: @dholth Whoops, you're right. I think I was getting confused with another one of the proposals. I'm way too tired, and I need to stop posting now, I think.

Apologies for the confusion, and thanks @sbidoul for this! I'll have a proper look when I'm not half asleep :slee
ofek: @sbidoul I'm writing a PEP 517 backend for Hatch. Can you explain what `build_wheel_for_editable` should do?
pfmoore: @ofek If you look at [editables](https://pypi.org/project/editables/) that's the basic idea - the backend can use that to build a wheel which when installed, will act like an editable installation of the project.
ofek: @pfmoore I starred that then forgot 🤦‍♂️ I'll use that, thanks!!!
BrownTruck: Hello!

I am an automated bot and I have noticed that this pull request is not currently able to be merged. If you are able to either merge the ``master`` branch into this pull request or rebase this pull request against ``master`` then it will be eligible for code review and hopefully merging!
BrownTruck: Hello!

I am an automated bot and I have noticed that this pull request is not currently able to be merged. If you are able to either merge the ``master`` branch into this pull request or rebase this pull request against ``master`` then it will be eligible for code review and hopefully merging!
dholth: Rudimentary support: https://github.com/dholth/enscons/pull/9/files
sbidoul: @dholth cool. I can't seem to make it work though. I've tried an editable install of rsalette in a python 3.8 venv and I get (in verbose mode)

```
[...]
Getting requirements to build wheel ... done
  Created temporary directory: /tmp/pip-modern-metadata-tbktrjvt
  Running command /home/me/.virtualenvs/tempenv-46f6785219bc/bin/python /home/me/pip/src/pip/_vendor/pep517/_in_process.py prepare_metadata_for_build_wheel /tmp/tmplc9bqnce
  scons: Reading SConscript files ...
  scons: done reading SConscript files.
  scons: Building targets ...
  zip(["/tmp/pip-wheel-32z26ikb/rsalette-0.4.2-ed.py2.py3-none-any.whl"], ["build/wheel/rsalette-0.4.2.dist-info"])
  scons: *** [/tmp/pip-wheel-32z26ikb/rsalette-0.4.2-ed.py2.py3-none-any.whl] TypeError : from_file() got an unexpected keyword argument 'strict_timestamps'
  Traceback (most recent call last):
    File "/tmp/pip-build-env-mpnn1ksd/overlay/lib/python3.8/site-packages/SCons/Action.py", line 1280, in execute
      result = self.execfunction(target=target, source=rsources, env=env)
    File "/tmp/pip-build-env-mpnn1ksd/overlay/lib/python3.8/site-packages/SCons/Tool/zip.py", line 56, in zip
      zf.write(path, os.path.relpath(path, str(env.get('ZIPROOT', ''))))
    File "/usr/lib/python3.8/zipfile.py", line 1741, in write
      zinfo = ZipInfo.from_file(filename, arcname,
  TypeError: from_file() got an unexpected keyword argument 'strict_timestamps'
  scons: building terminated because of errors.
  Building wheel for rsalette (PEP 517) ... done
  Created wheel for rsalette: filename=rsalette-0.4.2-ed.py2.py3-none-any.whl size=22 sha256=8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
  Stored in directory: /tmp/pip-ephem-wheel-cache-24q5wun0/wheels/75/f8/bd/4791c7abdb70831f4b2ce742a4795ad7240c8914367e3a49c1
Successfully built rsalette
[...]
```

I don't quite understand the `unexpected keyword argument 'strict_timestamps'` error... it is as if it was running on another python version, although the log says it starts from the python in my venv. And the other issue is that this error is masked, resulting in an invalid wheel (it might be pip not detecting it, not sure yet). 
dholth: I monkey patch zipfile to enforce custom timestamps. That might be the part that fails for you.

On Sun, Dec 13, 2020, at 4:29 PM, Stéphane Bidoul wrote:
> 

> @dholth <https://github.com/dholth> cool. I can't seem to make it work though. I've tried an editable install of rsalette in a python 3.8 venv and I get (in verbose mode)

> `[...]
Getting requirements to build wheel ... done
  Created temporary directory: /tmp/pip-modern-metadata-tbktrjvt
  Running command /home/me/.virtualenvs/tempenv-46f6785219bc/bin/python /home/me/pip/src/pip/_vendor/pep517/_in_process.py prepare_metadata_for_build_wheel /tmp/tmplc9bqnce
  scons: Reading SConscript files ...
  scons: done reading SConscript files.
  scons: Building targets ...
  zip(["/tmp/pip-wheel-32z26ikb/rsalette-0.4.2-ed.py2.py3-none-any.whl"], ["build/wheel/rsalette-0.4.2.dist-info"])
  scons: *** [/tmp/pip-wheel-32z26ikb/rsalette-0.4.2-ed.py2.py3-none-any.whl] TypeError : from_file() got an unexpected keyword argument 'strict_timestamps'
  Traceback (most recent call last):
    File "/tmp/pip-build-env-mpnn1ksd/overlay/lib/python3.8/site-packages/SCons/Action.py", line 1280, in execute
      result = self.execfunction(target=target, source=rsources, env=env)
    File "/tmp/pip-build-env-mpnn1ksd/overlay/lib/python3.8/site-packages/SCons/Tool/zip.py", line 56, in zip
      zf.write(path, os.path.relpath(path, str(env.get('ZIPROOT', ''))))
    File "/usr/lib/python3.8/zipfile.py", line 1741, in write
      zinfo = ZipInfo.from_file(filename, arcname,
  TypeError: from_file() got an unexpected keyword argument 'strict_timestamps'
  scons: building terminated because of errors.
  Building wheel for rsalette (PEP 517) ... done
  Created wheel for rsalette: filename=rsalette-0.4.2-ed.py2.py3-none-any.whl size=22 sha256=8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85
  Stored in directory: /tmp/pip-ephem-wheel-cache-24q5wun0/wheels/75/f8/bd/4791c7abdb70831f4b2ce742a4795ad7240c8914367e3a49c1
Successfully built rsalette
[...]
`
> I don't quite understand the `unexpected keyword argument 'strict_timestamps'` error... it is as if it was running on another python version, although the log says it starts from the python in my venv. And the other issue is that this error is masked, resulting in an invalid wheel (it might be pip not detecting it, not sure yet).


> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub <https://github.com/pypa/pip/pull/8212#issuecomment-744071467>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AABSZETEXFHOE4UTQUDLRELSUUW45ANCNFSM4M4XW7PQ>.

dholth: Sorry about that @sbidoul . It should be fixed now! Try installing it twice if the first try fails.
sbidoul: Thanks @dholth. The error is gone. I tried with cryptacular, which did install the editable stub. It did not build `_bcrypt.abi3.so`, though.

I also took the opportunity to rebase.
dholth: You're right I didn't call the build (which in enscons land would produce a wheel after building in place)... I suppose for many editable use cases you do want the auto build. scons with no arguments will build. 

On Sun, Dec 13, 2020, at 6:38 PM, Stéphane Bidoul wrote:
> 

> Thanks @dholth <https://github.com/dholth>. The error is gone. I tried with cryptacular, which did install the editable stub. It did not build `_bcrypt.abi3.so`, though.

> I also took the opportunity to rebase.


> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub <https://github.com/pypa/pip/pull/8212#issuecomment-744088981>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AABSZEVBSDYD6V5XYBGMLH3SUVGABANCNFSM4M4XW7PQ>.

BrownTruck: Hello!

I am an automated bot and I have noticed that this pull request is not currently able to be merged. If you are able to either merge the ``master`` branch into this pull request or rebase this pull request against ``master`` then it will be eligible for code review and hopefully merging!
pradyunsg: Closing as this seems to have some quite substantial merge conflicts.

Please do feel free to resubmit an updated PR for this, whenever this whole effort is rejuvenated! ^>^
sbidoul: @pradyunsg I'd rather leave this open as it is the best solution we have and this branch is good enough to test by backend authors who would like to experiment. I'll rebase one of these days.
pradyunsg: Coolio! Sounds fine by me!

FWIW, I was in copy-pasta mode and didn't notice which PR this was. ;)
ofek: This works for me! & shows up in `pip freeze`:

```
ofek@ozone ~\Desktop\code\hatch v1-rewrite $ pip freeze
...
hatch @ file:///C:/Users/ofek/Desktop/code/hatch
...
```

is there a spec update that needs to be approved or can this be merged for people to try out?
pfmoore: The discussion on editable hooks has fizzled out: https://discuss.python.org/t/third-try-on-editable-installs/3986 and https://discuss.python.org/t/standardising-editable-mode-installs-runtime-layout-not-hooks/4098.

To make progress, what's needed is someone to pull together the discussion, write a PEP, and push it through to approval.

A lot of people seemed to be pushing for "write a proof of concept" as a gate on moving forward, so hopefully now we have this and the [editables](https://github.com/pfmoore/editables) project, we have most of that. Did you say you had a prototype of the backend side of things in hatch?

I imagine we'd need setuptools to implement the hook before this would be ready for release, though. (Otherwise someone is bound to complain that editable mode doesn't work if they remove `setup.py` and switch to `pyproject.toml`...)
ofek: I'm not finished but you can try it with `build_wheel_for_editable` support: https://pypi.org/project/hatchling/0.0.10/

It uses https://www.python.org/dev/peps/pep-0621/ for config.

It uses your [editables](https://github.com/pfmoore/editables) library on Python 3 and a `pth` file on Python 2 (or if `tool.hatch.build.editable-mode` is set to `pth` e.g. for namespaced packages)
sbidoul: I'm [volunteering](https://discuss.python.org/t/standardising-editable-mode-installs-runtime-layout-not-hooks/4098/74?u=sbidoul) to write the PEP. It's high in my oss-stuff-todo-in-free-time list, but it might take a few more weeks until I come to it.
BrownTruck: Hello!

I am an automated bot and I have noticed that this pull request is not currently able to be merged. If you are able to either merge the ``master`` branch into this pull request or rebase this pull request against ``master`` then it will be eligible for code review and hopefully merging!
ofek: FYI this branch is working well for me
sbidoul: @ofek that's great to hear. I'll publish the draft PEP for discussion very soon now. It has minor differences to this branch, but the principle is the same.
domdfcoding: I tried using this branch, but fallback the behaviour when the PEP 660 approach fails isn't quite right:


### pip 21.0.1, unsupported backend

```console
ERROR: File "setup.py" not found. Directory cannot be installed in editable mode: /home/domdf/Python//snippet-fmt
(A "pyproject.toml" file was found, but editable mode currently requires a setup.py based build.)

```

### this branch, same unsupported backend

```console
Building wheels for collected packages: snippet-fmt
  Building wheel for snippet-fmt (PEP 517) ... done
  WARNING: Cannot build wheel for editable snippet-fmt because the build backend does not have the build_wheel_for_editable hook
Failed to build snippet-fmt
Installing collected packages: Pygments, snippet-fmt
  Running setup.py develop for snippet-fmt
Successfully installed Pygments-2.9.0 snippet-fmt
```

There's no `setup.py`, however. Running `pip show snippet-fmt`:

```console
WARNING: Package(s) not found: snippet-fmt
```

It did create an ``UNKNOWN.egg-link`` file pointing to the source location, though.

With this branch it should error when the PEP 660 approach fails and there's no `setup.py` file.

-----

I couldn't explore further as it's still expecting the old hook name and not the one from the finished PEP.
sbidoul: @domdfcoding yes the fallback mechanism is a known TODO (and the hardest par of this PR I guess). I'll chime in when the PR is update to the latest PEP 660.
sbidoul: I updated this PR to the latest PEP 660.

The fallback mechanism when the build backend does not have the `build_editable` hook but there is a `setup.py` still needs work. 

Other that that it should behave correctly. I tested it with https://github.com/takluyver/flit/pull/400.

domdfcoding: Grand. I haven't tested `get_requires_for_build_editable` but everything else is working how I'd expect.
pypa-bot: Hello!

I am an automated bot and I have noticed that this pull request is not currently able to be merged. If you are able to either merge the ``master`` branch into this pull request or rebase this pull request against ``master`` then it will eligible for code review and hopefully merging!
sbidoul: This one is now complete and ready to review !
arogozhnikov: @sbidoul exciting, you probably want to remove "WIP" from name 
pradyunsg: I'll try to make time to review this PR, this week!
pradyunsg: Yup yup. The trouble is that we'll create an entire build environment, set up call a missing hook on setuptools, and then fallback to just calling setup.py develop -- basically doing an additional bunch of work, for absolutely no benefit whatsoever. 
sbidoul: Hi @pradyunsg thanks for the detailed review! I'll try dive into this over the week-end.

But first I'd like to make sure we are clear with the BuildPurpose thing. 
BuildPurpose could be a flag instead of an enum, no problem with that.

However I tried to make it a property of InstallRequirement, and it did not feel right.
Basically I had to do this, after parsing the requirements:

https://github.com/pypa/pip/pull/8212/commits/53419b6193fce6245ef5119ebfd76ddf78c9c5c2#diff-0e8a4985186a14b2deceeb18b81dc7aa90accb68480ac485e49a8f1803dd0339L124-L128

(and I now think we may need to do the same in pip download).

and I felt that InstallRequirements should not "know" a priori what we are going to do with them (i.e. installing/building wheels/or downloading them). The BuildPurpose approach felt much cleaner to me. But I anticipated discussion about that and that's why there are two commits in this PR :) Let me know if this raises further thoughts in you.

Regarding `supports_pep660` I don't understand your worries: it is there because we need to support backends that do not implement PEP 660, since it is entirely optional. If, when obtaining metadata, we detect that a backend does not support PEP 660, we record the information in  `supports_pep660`, so _should_build can say "no", to avoid attempting a useless build (that would fail) later. We would have to do that regardless of setuptools compatibility.


pradyunsg: > However I tried to make it a property of InstallRequirement, and it did not feel right (see first commit in this PR).
> Basically I had to do this, after parsing the requirements:
> 
> [53419b6#diff-0e8a4985186a14b2deceeb18b81dc7aa90accb68480ac485e49a8f1803dd0339L124-L128](https://github.com/pypa/pip/commit/53419b6193fce6245ef5119ebfd76ddf78c9c5c2#diff-0e8a4985186a14b2deceeb18b81dc7aa90accb68480ac485e49a8f1803dd0339L124-L128)

Yea, I think that's A-OK. I reviewed this commit-by-commit, so I noticed all the changes between the two commits as well. :)
pradyunsg: > I don't understand your worries:

Phrasing that in terms of `supports_pep660` was a mistake on my part. I'm thinking of a project that uses setuptools and has a pyproject.toml:

- 21.2: pip will call `setup.py develop` directly.
- 21.3: pip will try to create a build environment w/ `build-system.requires`, check for the hook on `setuptools.build_meta`, fail and fallback to the `setup.py` call (due to how the try-except fall through to the legacy case).

This is the "jumping" the boundaries of a `pyproject.toml` based build system vs `setup.py` based build system; as well as a bunch of additional work the affords absolutely no behavior change for the user except for an additional slowdown. :(

sbidoul: > Yup yup. The trouble is that we'll create an entire build environment, make a call to setuptools, note that it's missing a hook, and then fallback to just calling setup.py develop -- basically doing an additional bunch of work, for absolutely no benefit whatsoever.

> It's also a bunch of additional work that brings absolutely no behavior change for the user except for an additional slowdown.

Understood. I don't see any alternative though. We can't even do something if we detect a setup.py because we recently started supporting editables with setup.cfg only. And there is no fast mechanism to query build backend capabilities.

I guess at some point we'll have to work on build environment creation performance (with caching or something, I don't know).
dholth: If we make PEP 660 faster than the old way, maybe the problem will eventually solve itself.
sbidoul: I added a spinner message around legacy metadata preparation
- so the fallback to setup.py egg_info when the backend does not support PEP 660 shows something meaningful (before it was silent and it was confusing to have a log saying we did prepare editable metadata using pyproject.toml)
- for symmetry with pyproject.toml metadata preparation, and it will also help users understanding what happens when metadata preparation is slow
sbidoul: @pradyunsg I handled (most of) your review comments :)
pradyunsg: This is gonna need a rebase and a squashing of the fixup commits.
pradyunsg: Wait, where are my manners!? I failed to touch on all the important bits in my get-the-code-stuff-done mode. 😅 

I'm genuinely excited that after, uhm... quite a while... we're finally able to fill the hole that we had left in PEP 517 (for good reason, as the length of the discussion for PEP 660 proved!). This PR is the culmination of a *lot* of work and I'm really glad that we're here now.

@sbidoul I really appreciate you being receptive *and* responsive to all of my feedback here; even if a bunch of it has seemed a bit odd to you. :)

sbidoul: > I do think we're taking on a bunch of technical debt along with this improvement

Well, I did not have the impression to make the code *a lot* harder to read or maintain than it is.
But I remain open to improvements suggestions to make in follow-ups - short of rewriting the full build logic. :)

Rebased and squashed. Thanks again for the review. Feel free to merge when green.

Patch:
diff --git a/news/8212.feature.rst b/news/8212.feature.rst
new file mode 100644
index 000000000..dec2240e0
--- /dev/null
+++ b/news/8212.feature.rst
@@ -0,0 +1,2 @@
+Support editable installs for projects that have a ``pyproject.toml`` and use a
+build backend that supports :pep:`660`.
diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py
index a427c6c59..3d87e1af1 100644
--- a/src/pip/_internal/commands/install.py
+++ b/src/pip/_internal/commands/install.py
@@ -306,6 +306,12 @@ class InstallCommand(RequirementCommand):
         try:
             reqs = self.get_requirements(args, options, finder, session)
 
+            # Only when installing is it permitted to use PEP 660.
+            # In other circumstances (pip wheel, pip download) we generate
+            # regular (i.e. non editable) metadata and wheels.
+            for req in reqs:
+                req.permit_editable_wheels = True
+
             reject_location_related_install_options(reqs, options.install_options)
 
             preparer = self.make_requirement_preparer(
@@ -361,22 +367,22 @@ class InstallCommand(RequirementCommand):
                 global_options=[],
             )
 
-            # If we're using PEP 517, we cannot do a direct install
+            # If we're using PEP 517, we cannot do a legacy setup.py install
             # so we fail here.
             pep517_build_failure_names: List[str] = [
                 r.name for r in build_failures if r.use_pep517  # type: ignore
             ]
             if pep517_build_failure_names:
                 raise InstallationError(
-                    "Could not build wheels for {} which use"
-                    " PEP 517 and cannot be installed directly".format(
+                    "Could not build wheels for {}, which is required to "
+                    "install pyproject.toml-based projects".format(
                         ", ".join(pep517_build_failure_names)
                     )
                 )
 
             # For now, we just warn about failures building legacy
-            # requirements, as we'll fall through to a direct
-            # install for those.
+            # requirements, as we'll fall through to a setup.py install for
+            # those.
             for r in build_failures:
                 if not r.use_pep517:
                     r.legacy_install_reason = 8368
diff --git a/src/pip/_internal/distributions/sdist.py b/src/pip/_internal/distributions/sdist.py
index 573fefae7..b4e289293 100644
--- a/src/pip/_internal/distributions/sdist.py
+++ b/src/pip/_internal/distributions/sdist.py
@@ -1,5 +1,5 @@
 import logging
-from typing import Set, Tuple
+from typing import Iterable, Set, Tuple
 
 from pip._internal.build_env import BuildEnvironment
 from pip._internal.distributions.base import AbstractDistribution
@@ -37,23 +37,17 @@ class SourceDistribution(AbstractDistribution):
         self.req.prepare_metadata()
 
     def _setup_isolation(self, finder: PackageFinder) -> None:
-        def _raise_conflicts(
-            conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
-        ) -> None:
-            format_string = (
-                "Some build dependencies for {requirement} "
-                "conflict with {conflicting_with}: {description}."
-            )
-            error_message = format_string.format(
-                requirement=self.req,
-                conflicting_with=conflicting_with,
-                description=", ".join(
-                    f"{installed} is incompatible with {wanted}"
-                    for installed, wanted in sorted(conflicting)
-                ),
-            )
-            raise InstallationError(error_message)
+        self._prepare_build_backend(finder)
+        # Install any extra build dependencies that the backend requests.
+        # This must be done in a second pass, as the pyproject.toml
+        # dependencies must be installed before we can call the backend.
+        if self.req.editable and self.req.permit_editable_wheels:
+            build_reqs = self._get_build_requires_editable()
+        else:
+            build_reqs = self._get_build_requires_wheel()
+        self._install_build_reqs(finder, build_reqs)
 
+    def _prepare_build_backend(self, finder: PackageFinder) -> None:
         # Isolate in a BuildEnvironment and install the build-time
         # requirements.
         pyproject_requires = self.req.pyproject_requires
@@ -67,7 +61,7 @@ class SourceDistribution(AbstractDistribution):
             self.req.requirements_to_check
         )
         if conflicting:
-            _raise_conflicts("PEP 517/518 supported requirements", conflicting)
+            self._raise_conflicts("PEP 517/518 supported requirements", conflicting)
         if missing:
             logger.warning(
                 "Missing build requirements in pyproject.toml for %s.",
@@ -78,19 +72,46 @@ class SourceDistribution(AbstractDistribution):
                 "pip cannot fall back to setuptools without %s.",
                 " and ".join(map(repr, sorted(missing))),
             )
-        # Install any extra build dependencies that the backend requests.
-        # This must be done in a second pass, as the pyproject.toml
-        # dependencies must be installed before we can call the backend.
+
+    def _get_build_requires_wheel(self) -> Iterable[str]:
         with self.req.build_env:
             runner = runner_with_spinner_message("Getting requirements to build wheel")
             backend = self.req.pep517_backend
             assert backend is not None
             with backend.subprocess_runner(runner):
-                reqs = backend.get_requires_for_build_wheel()
+                return backend.get_requires_for_build_wheel()
 
+    def _get_build_requires_editable(self) -> Iterable[str]:
+        with self.req.build_env:
+            runner = runner_with_spinner_message(
+                "Getting requirements to build editable"
+            )
+            backend = self.req.pep517_backend
+            assert backend is not None
+            with backend.subprocess_runner(runner):
+                return backend.get_requires_for_build_editable()
+
+    def _install_build_reqs(self, finder: PackageFinder, reqs: Iterable[str]) -> None:
         conflicting, missing = self.req.build_env.check_requirements(reqs)
         if conflicting:
-            _raise_conflicts("the backend dependencies", conflicting)
+            self._raise_conflicts("the backend dependencies", conflicting)
         self.req.build_env.install_requirements(
             finder, missing, "normal", "Installing backend dependencies"
         )
+
+    def _raise_conflicts(
+        self, conflicting_with: str, conflicting_reqs: Set[Tuple[str, str]]
+    ) -> None:
+        format_string = (
+            "Some build dependencies for {requirement} "
+            "conflict with {conflicting_with}: {description}."
+        )
+        error_message = format_string.format(
+            requirement=self.req,
+            conflicting_with=conflicting_with,
+            description=", ".join(
+                f"{installed} is incompatible with {wanted}"
+                for installed, wanted in sorted(conflicting_reqs)
+            ),
+        )
+        raise InstallationError(error_message)
diff --git a/src/pip/_internal/operations/build/metadata.py b/src/pip/_internal/operations/build/metadata.py
index e76723527..e99af4697 100644
--- a/src/pip/_internal/operations/build/metadata.py
+++ b/src/pip/_internal/operations/build/metadata.py
@@ -23,7 +23,9 @@ def generate_metadata(build_env: BuildEnvironment, backend: Pep517HookCaller) ->
         # Note that Pep517HookCaller implements a fallback for
         # prepare_metadata_for_build_wheel, so we don't have to
         # consider the possibility that this hook doesn't exist.
-        runner = runner_with_spinner_message("Preparing wheel metadata")
+        runner = runner_with_spinner_message(
+            "Preparing wheel metadata (pyproject.toml)"
+        )
         with backend.subprocess_runner(runner):
             distinfo_dir = backend.prepare_metadata_for_build_wheel(metadata_dir)
 
diff --git a/src/pip/_internal/operations/build/metadata_editable.py b/src/pip/_internal/operations/build/metadata_editable.py
new file mode 100644
index 000000000..13de75f6c
--- /dev/null
+++ b/src/pip/_internal/operations/build/metadata_editable.py
@@ -0,0 +1,34 @@
+"""Metadata generation logic for source distributions.
+"""
+
+import os
+
+from pip._vendor.pep517.wrappers import Pep517HookCaller
+
+from pip._internal.build_env import BuildEnvironment
+from pip._internal.utils.subprocess import runner_with_spinner_message
+from pip._internal.utils.temp_dir import TempDirectory
+
+
+def generate_editable_metadata(
+    build_env: BuildEnvironment, backend: Pep517HookCaller
+) -> str:
+    """Generate metadata using mechanisms described in PEP 660.
+
+    Returns the generated metadata directory.
+    """
+    metadata_tmpdir = TempDirectory(kind="modern-metadata", globally_managed=True)
+
+    metadata_dir = metadata_tmpdir.path
+
+    with build_env:
+        # Note that Pep517HookCaller implements a fallback for
+        # prepare_metadata_for_build_wheel/editable, so we don't have to
+        # consider the possibility that this hook doesn't exist.
+        runner = runner_with_spinner_message(
+            "Preparing editable metadata (pyproject.toml)"
+        )
+        with backend.subprocess_runner(runner):
+            distinfo_dir = backend.prepare_metadata_for_build_editable(metadata_dir)
+
+    return os.path.join(metadata_dir, distinfo_dir)
diff --git a/src/pip/_internal/operations/build/metadata_legacy.py b/src/pip/_internal/operations/build/metadata_legacy.py
index 2013046fc..ff52de9c4 100644
--- a/src/pip/_internal/operations/build/metadata_legacy.py
+++ b/src/pip/_internal/operations/build/metadata_legacy.py
@@ -5,6 +5,7 @@ import logging
 import os
 
 from pip._internal.build_env import BuildEnvironment
+from pip._internal.cli.spinners import open_spinner
 from pip._internal.exceptions import InstallationError
 from pip._internal.utils.setuptools_build import make_setuptools_egg_info_args
 from pip._internal.utils.subprocess import call_subprocess
@@ -54,11 +55,13 @@ def generate_metadata(
     )
 
     with build_env:
-        call_subprocess(
-            args,
-            cwd=source_dir,
-            command_desc="python setup.py egg_info",
-        )
+        with open_spinner("Preparing metadata (setup.py)") as spinner:
+            call_subprocess(
+                args,
+                cwd=source_dir,
+                command_desc="python setup.py egg_info",
+                spinner=spinner,
+            )
 
     # Return the .egg-info directory.
     return _find_egg_info(egg_info_dir)
diff --git a/src/pip/_internal/operations/build/wheel.py b/src/pip/_internal/operations/build/wheel.py
index 6249a9bfd..b0d2fc9ea 100644
--- a/src/pip/_internal/operations/build/wheel.py
+++ b/src/pip/_internal/operations/build/wheel.py
@@ -23,7 +23,9 @@ def build_wheel_pep517(
     try:
         logger.debug("Destination directory: %s", tempd)
 
-        runner = runner_with_spinner_message(f"Building wheel for {name} (PEP 517)")
+        runner = runner_with_spinner_message(
+            f"Building wheel for {name} (pyproject.toml)"
+        )
         with backend.subprocess_runner(runner):
             wheel_name = backend.build_wheel(
                 tempd,
diff --git a/src/pip/_internal/operations/build/wheel_editable.py b/src/pip/_internal/operations/build/wheel_editable.py
new file mode 100644
index 000000000..cf7b01aed
--- /dev/null
+++ b/src/pip/_internal/operations/build/wheel_editable.py
@@ -0,0 +1,46 @@
+import logging
+import os
+from typing import Optional
+
+from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
+
+from pip._internal.utils.subprocess import runner_with_spinner_message
+
+logger = logging.getLogger(__name__)
+
+
+def build_wheel_editable(
+    name: str,
+    backend: Pep517HookCaller,
+    metadata_directory: str,
+    tempd: str,
+) -> Optional[str]:
+    """Build one InstallRequirement using the PEP 660 build process.
+
+    Returns path to wheel if successfully built. Otherwise, returns None.
+    """
+    assert metadata_directory is not None
+    try:
+        logger.debug("Destination directory: %s", tempd)
+
+        runner = runner_with_spinner_message(
+            f"Building editable for {name} (pyproject.toml)"
+        )
+        with backend.subprocess_runner(runner):
+            try:
+                wheel_name = backend.build_editable(
+                    tempd,
+                    metadata_directory=metadata_directory,
+                )
+            except HookMissing as e:
+                logger.error(
+                    "Cannot build editable %s because the build "
+                    "backend does not have the %s hook",
+                    name,
+                    e,
+                )
+                return None
+    except Exception:
+        logger.error("Failed building editable for %s", name)
+        return None
+    return os.path.join(tempd, wheel_name)
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
index b9b18139a..0a6b3367e 100644
--- a/src/pip/_internal/req/constructors.py
+++ b/src/pip/_internal/req/constructors.py
@@ -22,7 +22,6 @@ from pip._internal.exceptions import InstallationError
 from pip._internal.models.index import PyPI, TestPyPI
 from pip._internal.models.link import Link
 from pip._internal.models.wheel import Wheel
-from pip._internal.pyproject import make_pyproject_path
 from pip._internal.req.req_file import ParsedRequirement
 from pip._internal.req.req_install import InstallRequirement
 from pip._internal.utils.filetypes import is_archive_file
@@ -75,21 +74,6 @@ def parse_editable(editable_req: str) -> Tuple[Optional[str], str, Set[str]]:
     url_no_extras, extras = _strip_extras(url)
 
     if os.path.isdir(url_no_extras):
-        setup_py = os.path.join(url_no_extras, "setup.py")
-        setup_cfg = os.path.join(url_no_extras, "setup.cfg")
-        if not os.path.exists(setup_py) and not os.path.exists(setup_cfg):
-            msg = (
-                'File "setup.py" or "setup.cfg" not found. Directory cannot be '
-                "installed in editable mode: {}".format(os.path.abspath(url_no_extras))
-            )
-            pyproject_path = make_pyproject_path(url_no_extras)
-            if os.path.isfile(pyproject_path):
-                msg += (
-                    '\n(A "pyproject.toml" file was found, but editable '
-                    "mode currently requires a setuptools-based build.)"
-                )
-            raise InstallationError(msg)
-
         # Treating it as code that has already been checked out
         url_no_extras = path_to_url(url_no_extras)
 
@@ -197,6 +181,7 @@ def install_req_from_editable(
     options: Optional[Dict[str, Any]] = None,
     constraint: bool = False,
     user_supplied: bool = False,
+    permit_editable_wheels: bool = False,
 ) -> InstallRequirement:
 
     parts = parse_req_from_editable(editable_req)
@@ -206,6 +191,7 @@ def install_req_from_editable(
         comes_from=comes_from,
         user_supplied=user_supplied,
         editable=True,
+        permit_editable_wheels=permit_editable_wheels,
         link=parts.link,
         constraint=constraint,
         use_pep517=use_pep517,
diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py
index 0df0ff6f4..933b2ea67 100644
--- a/src/pip/_internal/req/req_install.py
+++ b/src/pip/_internal/req/req_install.py
@@ -16,7 +16,7 @@ from pip._vendor.packaging.specifiers import SpecifierSet
 from pip._vendor.packaging.utils import canonicalize_name
 from pip._vendor.packaging.version import Version
 from pip._vendor.packaging.version import parse as parse_version
-from pip._vendor.pep517.wrappers import Pep517HookCaller
+from pip._vendor.pep517.wrappers import HookMissing, Pep517HookCaller
 from pip._vendor.pkg_resources import Distribution
 
 from pip._internal.build_env import BuildEnvironment, NoOpBuildEnvironment
@@ -24,6 +24,7 @@ from pip._internal.exceptions import InstallationError
 from pip._internal.locations import get_scheme
 from pip._internal.models.link import Link
 from pip._internal.operations.build.metadata import generate_metadata
+from pip._internal.operations.build.metadata_editable import generate_editable_metadata
 from pip._internal.operations.build.metadata_legacy import (
     generate_metadata as generate_metadata_legacy,
 )
@@ -36,7 +37,10 @@ from pip._internal.operations.install.wheel import install_wheel
 from pip._internal.pyproject import load_pyproject_toml, make_pyproject_path
 from pip._internal.req.req_uninstall import UninstallPathSet
 from pip._internal.utils.deprecation import deprecated
-from pip._internal.utils.direct_url_helpers import direct_url_from_link
+from pip._internal.utils.direct_url_helpers import (
+    direct_url_for_editable,
+    direct_url_from_link,
+)
 from pip._internal.utils.hashes import Hashes
 from pip._internal.utils.logging import indent_log
 from pip._internal.utils.misc import (
@@ -105,12 +109,14 @@ class InstallRequirement:
         constraint: bool = False,
         extras: Collection[str] = (),
         user_supplied: bool = False,
+        permit_editable_wheels: bool = False,
     ) -> None:
         assert req is None or isinstance(req, Requirement), req
         self.req = req
         self.comes_from = comes_from
         self.constraint = constraint
         self.editable = editable
+        self.permit_editable_wheels = permit_editable_wheels
         self.legacy_install_reason: Optional[int] = None
 
         # source_dir is the local directory where the linked requirement is
@@ -191,6 +197,11 @@ class InstallRequirement:
         # but after loading this flag should be treated as read only.
         self.use_pep517 = use_pep517
 
+        # supports_pyproject_editable will be set to True or False when we try
+        # to prepare editable metadata or build an editable wheel. None means
+        # "we don't know yet".
+        self.supports_pyproject_editable: Optional[bool] = None
+
         # This requirement needs more preparation before it can be built
         self.needs_more_preparation = False
 
@@ -455,6 +466,13 @@ class InstallRequirement:
 
         return setup_py
 
+    @property
+    def setup_cfg_path(self) -> str:
+        assert self.source_dir, f"No source dir for {self}"
+        setup_cfg = os.path.join(self.unpacked_source_directory, "setup.cfg")
+
+        return setup_cfg
+
     @property
     def pyproject_toml_path(self) -> str:
         assert self.source_dir, f"No source dir for {self}"
@@ -486,29 +504,79 @@ class InstallRequirement:
             backend_path=backend_path,
         )
 
-    def _generate_metadata(self) -> str:
+    def _generate_editable_metadata(self) -> str:
         """Invokes metadata generator functions, with the required arguments."""
-        if not self.use_pep517:
-            assert self.unpacked_source_directory
+        if self.use_pep517:
+            assert self.pep517_backend is not None
+            try:
+                metadata_directory = generate_editable_metadata(
+                    build_env=self.build_env,
+                    backend=self.pep517_backend,
+                )
+            except HookMissing as e:
+                self.supports_pyproject_editable = False
+                if not os.path.exists(self.setup_py_path) and not os.path.exists(
+                    self.setup_cfg_path
+                ):
+                    raise InstallationError(
+                        f"Project {self} has a 'pyproject.toml' and its build "
+                        f"backend is missing the {e} hook. Since it does not "
+                        f"have a 'setup.py' nor a 'setup.cfg', "
+                        f"it cannot be installed in editable mode. "
+                        f"Consider using a build backend that supports PEP 660."
+                    )
+                # At this point we have determined that the build_editable hook
+                # is missing, and there is a setup.py or setup.cfg
+                # so we fallback to the legacy metadata generation
+                logger.info(
+                    "Build backend does not support editables, "
+                    "falling back to setup.py egg_info."
+                )
+            else:
+                self.supports_pyproject_editable = True
+                return metadata_directory
+        elif not os.path.exists(self.setup_py_path) and not os.path.exists(
+            self.setup_cfg_path
+        ):
+            raise InstallationError(
+                f"File 'setup.py' or 'setup.cfg' not found "
+                f"for legacy project {self}. "
+                f"It cannot be installed in editable mode."
+            )
+
+        return generate_metadata_legacy(
+            build_env=self.build_env,
+            setup_py_path=self.setup_py_path,
+            source_dir=self.unpacked_source_directory,
+            isolated=self.isolated,
+            details=self.name or f"from {self.link}",
+        )
 
-            if not os.path.exists(self.setup_py_path):
+    def _generate_metadata(self) -> str:
+        """Invokes metadata generator functions, with the required arguments."""
+        if self.use_pep517:
+            assert self.pep517_backend is not None
+            try:
+                return generate_metadata(
+                    build_env=self.build_env,
+                    backend=self.pep517_backend,
+                )
+            except HookMissing as e:
                 raise InstallationError(
-                    f'File "setup.py" not found for legacy project {self}.'
+                    f"Project {self} has a pyproject.toml but its build "
+                    f"backend is missing the required {e} hook."
                 )
-
-            return generate_metadata_legacy(
-                build_env=self.build_env,
-                setup_py_path=self.setup_py_path,
-                source_dir=self.unpacked_source_directory,
-                isolated=self.isolated,
-                details=self.name or f"from {self.link}",
+        elif not os.path.exists(self.setup_py_path):
+            raise InstallationError(
+                f"File 'setup.py' not found for legacy project {self}."
             )
 
-        assert self.pep517_backend is not None
-
-        return generate_metadata(
+        return generate_metadata_legacy(
             build_env=self.build_env,
-            backend=self.pep517_backend,
+            setup_py_path=self.setup_py_path,
+            source_dir=self.unpacked_source_directory,
+            isolated=self.isolated,
+            details=self.name or f"from {self.link}",
         )
 
     def prepare_metadata(self) -> None:
@@ -520,7 +588,10 @@ class InstallRequirement:
         assert self.source_dir
 
         with indent_log():
-            self.metadata_directory = self._generate_metadata()
+            if self.editable and self.permit_editable_wheels:
+                self.metadata_directory = self._generate_editable_metadata()
+            else:
+                self.metadata_directory = self._generate_metadata()
 
         # Act on the newly generated metadata, based on the name and version.
         if not self.name:
@@ -728,7 +799,7 @@ class InstallRequirement:
         )
 
         global_options = global_options if global_options is not None else []
-        if self.editable:
+        if self.editable and not self.is_wheel:
             install_editable_legacy(
                 install_options,
                 global_options,
@@ -747,7 +818,9 @@ class InstallRequirement:
         if self.is_wheel:
             assert self.local_file_path
             direct_url = None
-            if self.original_link:
+            if self.editable:
+                direct_url = direct_url_for_editable(self.unpacked_source_directory)
+            elif self.original_link:
                 direct_url = direct_url_from_link(
                     self.original_link,
                     self.source_dir,
diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py
index 5c6f9a04a..60fad55db 100644
--- a/src/pip/_internal/resolution/resolvelib/candidates.py
+++ b/src/pip/_internal/resolution/resolvelib/candidates.py
@@ -82,6 +82,7 @@ def make_install_req_from_editable(
         use_pep517=template.use_pep517,
         isolated=template.isolated,
         constraint=template.constraint,
+        permit_editable_wheels=template.permit_editable_wheels,
         options=dict(
             install_options=template.install_options,
             global_options=template.global_options,
diff --git a/src/pip/_internal/utils/direct_url_helpers.py b/src/pip/_internal/utils/direct_url_helpers.py
index 088e977b5..0e8e5e160 100644
--- a/src/pip/_internal/utils/direct_url_helpers.py
+++ b/src/pip/_internal/utils/direct_url_helpers.py
@@ -2,6 +2,7 @@ from typing import Optional
 
 from pip._internal.models.direct_url import ArchiveInfo, DirectUrl, DirInfo, VcsInfo
 from pip._internal.models.link import Link
+from pip._internal.utils.urls import path_to_url
 from pip._internal.vcs import vcs
 
 
@@ -28,6 +29,13 @@ def direct_url_as_pep440_direct_reference(direct_url: DirectUrl, name: str) -> s
     return requirement
 
 
+def direct_url_for_editable(source_dir: str) -> DirectUrl:
+    return DirectUrl(
+        url=path_to_url(source_dir),
+        info=DirInfo(editable=True),
+    )
+
+
 def direct_url_from_link(
     link: Link, source_dir: Optional[str] = None, link_is_in_wheel_cache: bool = False
 ) -> DirectUrl:
diff --git a/src/pip/_internal/wheel_builder.py b/src/pip/_internal/wheel_builder.py
index cf039fe76..b4855a9a7 100644
--- a/src/pip/_internal/wheel_builder.py
+++ b/src/pip/_internal/wheel_builder.py
@@ -16,6 +16,7 @@ from pip._internal.metadata import FilesystemWheel, get_wheel_distribution
 from pip._internal.models.link import Link
 from pip._internal.models.wheel import Wheel
 from pip._internal.operations.build.wheel import build_wheel_pep517
+from pip._internal.operations.build.wheel_editable import build_wheel_editable
 from pip._internal.operations.build.wheel_legacy import build_wheel_legacy
 from pip._internal.req.req_install import InstallRequirement
 from pip._internal.utils.logging import indent_log
@@ -66,7 +67,13 @@ def _should_build(
     # From this point, this concerns the pip install command only
     # (need_wheel=False).
 
-    if req.editable or not req.source_dir:
+    if not req.source_dir:
+        return False
+
+    if req.editable:
+        if req.use_pep517 and req.supports_pyproject_editable is not False:
+            return True
+        # we don't build legacy editable requirements
         return False
 
     if req.use_pep517:
@@ -194,16 +201,19 @@ def _build_one(
     verify: bool,
     build_options: List[str],
     global_options: List[str],
+    editable: bool,
 ) -> Optional[str]:
     """Build one wheel.
 
     :return: The filename of the built wheel, or None if the build failed.
     """
+    artifact = "editable" if editable else "wheel"
     try:
         ensure_dir(output_dir)
     except OSError as e:
         logger.warning(
-            "Building wheel for %s failed: %s",
+            "Building %s for %s failed: %s",
+            artifact,
             req.name,
             e,
         )
@@ -212,13 +222,13 @@ def _build_one(
     # Install build deps into temporary directory (PEP 518)
     with req.build_env:
         wheel_path = _build_one_inside_env(
-            req, output_dir, build_options, global_options
+            req, output_dir, build_options, global_options, editable
         )
     if wheel_path and verify:
         try:
             _verify_one(req, wheel_path)
         except (InvalidWheelFilename, UnsupportedWheel) as e:
-            logger.warning("Built wheel for %s is invalid: %s", req.name, e)
+            logger.warning("Built %s for %s is invalid: %s", artifact, req.name, e)
             return None
     return wheel_path
 
@@ -228,6 +238,7 @@ def _build_one_inside_env(
     output_dir: str,
     build_options: List[str],
     global_options: List[str],
+    editable: bool,
 ) -> Optional[str]:
     with TempDirectory(kind="wheel") as temp_dir:
         assert req.name
@@ -242,12 +253,20 @@ def _build_one_inside_env(
                 logger.warning(
                     "Ignoring --build-option when building %s using PEP 517", req.name
                 )
-            wheel_path = build_wheel_pep517(
-                name=req.name,
-                backend=req.pep517_backend,
-                metadata_directory=req.metadata_directory,
-                tempd=temp_dir.path,
-            )
+            if editable:
+                wheel_path = build_wheel_editable(
+                    name=req.name,
+                    backend=req.pep517_backend,
+                    metadata_directory=req.metadata_directory,
+                    tempd=temp_dir.path,
+                )
+            else:
+                wheel_path = build_wheel_pep517(
+                    name=req.name,
+                    backend=req.pep517_backend,
+                    metadata_directory=req.metadata_directory,
+                    tempd=temp_dir.path,
+                )
         else:
             wheel_path = build_wheel_legacy(
                 name=req.name,
@@ -324,9 +343,15 @@ def build(
     with indent_log():
         build_successes, build_failures = [], []
         for req in requirements:
+            assert req.name
             cache_dir = _get_cache_dir(req, wheel_cache)
             wheel_file = _build_one(
-                req, cache_dir, verify, build_options, global_options
+                req,
+                cache_dir,
+                verify,
+                build_options,
+                global_options,
+                req.editable and req.permit_editable_wheels,
             )
             if wheel_file:
                 # Update the link for this.


Test patch:
diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py
index 3af75cb56..368973d9b 100644
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -644,7 +644,7 @@ def test_install_from_local_directory_with_no_setup_py(script, data):
     assert "Neither 'setup.py' nor 'pyproject.toml' found." in result.stderr
 
 
-def test_editable_install__local_dir_no_setup_py(script, data, deprecated_python):
+def test_editable_install__local_dir_no_setup_py(script, data):
     """
     Test installing in editable mode from a local directory with no setup.py.
     """
@@ -652,16 +652,12 @@ def test_editable_install__local_dir_no_setup_py(script, data, deprecated_python
     assert not result.files_created
 
     msg = result.stderr
-    if deprecated_python:
-        assert 'File "setup.py" or "setup.cfg" not found. ' in msg
-    else:
-        assert msg.startswith('ERROR: File "setup.py" or "setup.cfg" not found. ')
+    assert msg.startswith("ERROR: File 'setup.py' or 'setup.cfg' not found ")
+    assert "cannot be installed in editable mode" in msg
     assert "pyproject.toml" not in msg
 
 
-def test_editable_install__local_dir_no_setup_py_with_pyproject(
-    script, deprecated_python
-):
+def test_editable_install__local_dir_no_setup_py_with_pyproject(script):
     """
     Test installing in editable mode from a local directory with no setup.py
     but that does have pyproject.toml.
@@ -675,11 +671,9 @@ def test_editable_install__local_dir_no_setup_py_with_pyproject(
     assert not result.files_created
 
     msg = result.stderr
-    if deprecated_python:
-        assert 'File "setup.py" or "setup.cfg" not found. ' in msg
-    else:
-        assert msg.startswith('ERROR: File "setup.py" or "setup.cfg" not found. ')
-    assert 'A "pyproject.toml" file was found' in msg
+    assert "has a 'pyproject.toml'" in msg
+    assert "does not have a 'setup.py' nor a 'setup.cfg'" in msg
+    assert "cannot be installed in editable mode" in msg
 
 
 @pytest.mark.network
diff --git a/tests/functional/test_pep660.py b/tests/functional/test_pep660.py
new file mode 100644
index 000000000..b7d97c8d8
--- /dev/null
+++ b/tests/functional/test_pep660.py
@@ -0,0 +1,209 @@
+import os
+
+import tomli_w
+
+from pip._internal.utils.urls import path_to_url
+
+SETUP_PY = """
+from setuptools import setup
+
+setup()
+"""
+
+SETUP_CFG = """
+[metadata]
+name = project
+version = 1.0.0
+"""
+
+BACKEND_WITHOUT_PEP660 = """
+from setuptools.build_meta import (
+    build_wheel as _build_wheel,
+    prepare_metadata_for_build_wheel as _prepare_metadata_for_build_wheel,
+    get_requires_for_build_wheel as _get_requires_for_build_wheel,
+)
+
+def get_requires_for_build_wheel(config_settings=None):
+    with open("log.txt", "a") as f:
+        print(":get_requires_for_build_wheel called", file=f)
+    return _get_requires_for_build_wheel(config_settings)
+
+def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
+    with open("log.txt", "a") as f:
+        print(":prepare_metadata_for_build_wheel called", file=f)
+    return _prepare_metadata_for_build_wheel(metadata_directory, config_settings)
+
+def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
+    with open("log.txt", "a") as f:
+        print(":build_wheel called", file=f)
+    return _build_wheel(wheel_directory, config_settings, metadata_directory)
+"""
+
+# fmt: off
+BACKEND_WITH_PEP660 = BACKEND_WITHOUT_PEP660 + """
+def get_requires_for_build_editable(config_settings=None):
+    with open("log.txt", "a") as f:
+        print(":get_requires_for_build_editable called", file=f)
+    return _get_requires_for_build_wheel(config_settings)
+
+def prepare_metadata_for_build_editable(metadata_directory, config_settings=None):
+    with open("log.txt", "a") as f:
+        print(":prepare_metadata_for_build_editable called", file=f)
+    return _prepare_metadata_for_build_wheel(metadata_directory, config_settings)
+
+def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
+    with open("log.txt", "a") as f:
+        print(":build_editable called", file=f)
+    return _build_wheel(wheel_directory, config_settings, metadata_directory)
+"""
+# fmt: on
+
+
+def _make_project(tmpdir, backend_code, with_setup_py):
+    project_dir = tmpdir / "project"
+    project_dir.mkdir()
+    project_dir.joinpath("setup.cfg").write_text(SETUP_CFG)
+    if with_setup_py:
+        project_dir.joinpath("setup.py").write_text(SETUP_PY)
+    if backend_code:
+        buildsys = {"requires": ["setuptools", "wheel"]}
+        buildsys["build-backend"] = "test_backend"
+        buildsys["backend-path"] = ["."]
+        data = tomli_w.dumps({"build-system": buildsys})
+        project_dir.joinpath("pyproject.toml").write_text(data)
+        project_dir.joinpath("test_backend.py").write_text(backend_code)
+    project_dir.joinpath("log.txt").touch()
+    return project_dir
+
+
+def _assert_hook_called(project_dir, hook):
+    log = project_dir.joinpath("log.txt").read_text()
+    assert f":{hook} called" in log, f"{hook} has not been called"
+
+
+def _assert_hook_not_called(project_dir, hook):
+    log = project_dir.joinpath("log.txt").read_text()
+    assert f":{hook} called" not in log, f"{hook} should not have been called"
+
+
+def test_install_pep517_basic(tmpdir, script, with_wheel):
+    """
+    Check that the test harness we have in this file is sane.
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=False)
+    script.pip(
+        "install",
+        "--use-feature=in-tree-build",
+        "--no-index",
+        "--no-build-isolation",
+        project_dir,
+    )
+    _assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
+    _assert_hook_called(project_dir, "build_wheel")
+
+
+def test_install_pep660_basic(tmpdir, script, with_wheel):
+    """
+    Test with backend that supports build_editable.
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
+    result = script.pip(
+        "install",
+        "--no-index",
+        "--no-build-isolation",
+        "--editable",
+        project_dir,
+    )
+    _assert_hook_called(project_dir, "prepare_metadata_for_build_editable")
+    _assert_hook_called(project_dir, "build_editable")
+    assert (
+        result.test_env.site_packages.joinpath("project.egg-link")
+        not in result.files_created
+    ), "a .egg-link file should not have been created"
+
+
+def test_install_no_pep660_setup_py_fallback(tmpdir, script, with_wheel):
+    """
+    Test that we fall back to setuptools develop when using a backend that
+    does not support build_editable .
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=True)
+    result = script.pip(
+        "install",
+        "--no-index",
+        "--no-build-isolation",
+        "--editable",
+        project_dir,
+        allow_stderr_warning=False,
+    )
+    assert (
+        result.test_env.site_packages.joinpath("project.egg-link")
+        in result.files_created
+    ), "a .egg-link file should have been created"
+
+
+def test_install_no_pep660_setup_cfg_fallback(tmpdir, script, with_wheel):
+    """
+    Test that we fall back to setuptools develop when using a backend that
+    does not support build_editable .
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITHOUT_PEP660, with_setup_py=False)
+    result = script.pip(
+        "install",
+        "--no-index",
+        "--no-build-isolation",
+        "--editable",
+        project_dir,
+        allow_stderr_warning=False,
+    )
+    print(result.stdout, result.stderr)
+    assert (
+        result.test_env.site_packages.joinpath("project.egg-link")
+        in result.files_created
+    ), ".egg-link file should have been created"
+
+
+def test_wheel_editable_pep660_basic(tmpdir, script, with_wheel):
+    """
+    Test 'pip wheel' of an editable pep 660 project.
+    It must *not* call prepare_metadata_for_build_editable.
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
+    wheel_dir = tmpdir / "dist"
+    script.pip(
+        "wheel",
+        "--no-index",
+        "--no-build-isolation",
+        "--editable",
+        project_dir,
+        "-w",
+        wheel_dir,
+    )
+    _assert_hook_not_called(project_dir, "prepare_metadata_for_build_editable")
+    _assert_hook_not_called(project_dir, "build_editable")
+    _assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
+    _assert_hook_called(project_dir, "build_wheel")
+    assert len(os.listdir(str(wheel_dir))) == 1, "a wheel should have been created"
+
+
+def test_download_editable_pep660_basic(tmpdir, script, with_wheel):
+    """
+    Test 'pip download' of an editable pep 660 project.
+    It must *not* call prepare_metadata_for_build_editable.
+    """
+    project_dir = _make_project(tmpdir, BACKEND_WITH_PEP660, with_setup_py=False)
+    reqs_file = tmpdir / "requirements.txt"
+    reqs_file.write_text(f"-e {path_to_url(project_dir)}\n")
+    download_dir = tmpdir / "download"
+    script.pip(
+        "download",
+        "--no-index",
+        "--no-build-isolation",
+        "-r",
+        reqs_file,
+        "-d",
+        download_dir,
+    )
+    _assert_hook_not_called(project_dir, "prepare_metadata_for_build_editable")
+    _assert_hook_called(project_dir, "prepare_metadata_for_build_wheel")
+    assert len(os.listdir(str(download_dir))) == 1, "a zip should have been created"
diff --git a/tests/unit/test_wheel_builder.py b/tests/unit/test_wheel_builder.py
index 30b406021..2b8ec2fa9 100644
--- a/tests/unit/test_wheel_builder.py
+++ b/tests/unit/test_wheel_builder.py
@@ -39,6 +39,7 @@ class ReqMock:
         constraint: bool = False,
         source_dir: Optional[str] = "/tmp/pip-install-123/pendulum",
         use_pep517: bool = True,
+        supports_pyproject_editable: Optional[bool] = None,
     ) -> None:
         self.name = name
         self.is_wheel = is_wheel
@@ -47,6 +48,7 @@ class ReqMock:
         self.constraint = constraint
         self.source_dir = source_dir
         self.use_pep517 = use_pep517
+        self.supports_pyproject_editable = supports_pyproject_editable
 
 
 @pytest.mark.parametrize(
@@ -63,8 +65,18 @@ class ReqMock:
         (ReqMock(constraint=True), False, False),
         # We don't build reqs that are already wheels.
         (ReqMock(is_wheel=True), False, False),
-        # We don't build editables.
-        (ReqMock(editable=True), False, False),
+        (ReqMock(editable=True, use_pep517=False), False, False),
+        (ReqMock(editable=True, use_pep517=True), False, True),
+        (
+            ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=True),
+            False,
+            True,
+        ),
+        (
+            ReqMock(editable=True, use_pep517=True, supports_pyproject_editable=False),
+            False,
+            False,
+        ),
         (ReqMock(source_dir=None), False, False),
         # By default (i.e. when binaries are allowed), VCS requirements
         # should be built in install mode.
@@ -108,7 +120,8 @@ def test_should_build_for_install_command(
         (ReqMock(), True),
         (ReqMock(constraint=True), False),
         (ReqMock(is_wheel=True), False),
-        (ReqMock(editable=True), True),
+        (ReqMock(editable=True, use_pep517=False), True),
+        (ReqMock(editable=True, use_pep517=True), True),
         (ReqMock(source_dir=None), True),
         (ReqMock(link=Link("git+https://g.c/org/repo")), True),
     ],
@@ -145,7 +158,8 @@ def test_should_build_legacy_wheel_installed(is_wheel_installed: mock.Mock) -> N
 @pytest.mark.parametrize(
     "req, expected",
     [
-        (ReqMock(editable=True), False),
+        (ReqMock(editable=True, use_pep517=False), False),
+        (ReqMock(editable=True, use_pep517=True), False),
         (ReqMock(source_dir=None), False),
         (ReqMock(link=Link("git+https://g.c/org/repo")), False),
         (ReqMock(link=Link("https://g.c/dist.tgz")), False),