Repository: pypa/setuptools

Issue:
[BUG] PEP-660 editable installs breaks versioneer
### setuptools version

49.2.1

### Python version

3.9.2

### OS

macOS 12.5

### Additional environment information

`versioneer.py` is version 0.19
`wheel` is 0.37.1 (not sure if relevant)

### Description

Doing an editable install of our code (with `pip install -e .`) on a github actions runner is failing on macOS. We use [versioneer](https://github.com/python-versioneer/python-versioneer) for version numbers and something changed that seems to be breaking it. Do you have any ideas on what might have changed that is making versioneer unhappy?


*EDIT: This now fails on linux (Ubuntu 20.04) as well*


### Expected behavior

It does not fail, prior to this update it was fine.

### How to Reproduce

we have a repository with versioneer 0.19, and the setup.py and pyproject.toml below:

<details>
<summary>setup.py</summary>

NOTE: some details removed and replaced with `...`

```python
import os
from pathlib import Path
import sys
from setuptools import setup, find_packages
import versioneer

setup(
    name="Project",
    version=versioneer.get_version(),  # from versioneer install guide
    cmdclass=versioneer.get_cmdclass(),  # from versioneer install guide
    author="Lyle Cheatham",
    author_email="lyle@alloyenterprises.co",
    keywords="",
    packages=find_packages(exclude=["test"]),
    install_requires=[...],
    dependency_links=[...],
    include_package_data=True,
    python_requires="~=3.9.2",
    entry_points={
        "console_scripts": [...],
    },
)

```

</details>

<details>
<summary>pyproject.toml</summary>

NOTE: some details replaced with `...`

```toml
######################################### BUILD ##########################################

[build-system]
requires = [
    "setuptools>=49.2.1",
    "wheel~=0.37.1",
    "cython~=0.29.28",
    "gitpython~=3.1.26",
]

######################################### BLACK ##########################################

[tool.black]
...

######################################### PYTEST #########################################

[tool.pytest.ini_options]
...

######################################### PYLINT #########################################

[tool.pylint]
...
```

</details>



### Output

<details>
<summary>Log excerpt</summary>

```console
× Building editable for Project (pyproject.toml) did not run successfully.
  │ exit code: 1
  ╰─> [55 lines of output]
      running editable_wheel
      creating /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info
      writing /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/PKG-INFO
      writing dependency_links to /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/dependency_links.txt
      writing entry points to /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/entry_points.txt
      writing requirements to /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/requires.txt
      writing top-level names to /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/top_level.txt
      writing manifest file '/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/SOURCES.txt'
      reading manifest file '/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/SOURCES.txt'
      reading manifest template 'MANIFEST.in'
      writing manifest file '/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project.egg-info/SOURCES.txt'
      creating '/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project-0.20.1+22.gdfe7c96.dist-info'
      creating /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-wheel-55z7vnhm/tmpziqr33el/Project-0.20.1+22.gdfe7c96.dist-info/WHEEL
      /private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/command/install.py:34: SetuptoolsDeprecationWarning: setup.py install is deprecated. Use build and pip and other standards-based tools.
        warnings.warn(
      running build
      running build_py
      Traceback (most recent call last):
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/command/editable_wheel.py", line 138, in run
          self._create_wheel_file(bdist_wheel)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/command/editable_wheel.py", line 284, in _create_wheel_file
          files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/command/editable_wheel.py", line 257, in _run_build_commands
          self.run_command("build")
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/_distutils/cmd.py", line 319, in run_command
          self.distribution.run_command(command)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/dist.py", line 1217, in run_command
          super().run_command(command)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/_distutils/dist.py", line 992, in run_command
          cmd_obj.run()
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/command/build.py", line 33, in run
          super().run()
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/_distutils/command/build.py", line 132, in run
          self.run_command(cmd_name)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/_distutils/cmd.py", line 319, in run_command
          self.distribution.run_command(command)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/dist.py", line 1217, in run_command
          super().run_command(command)
        File "/private/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/pip-build-env-7altab77/overlay/lib/python3.9/site-packages/setuptools/_distutils/dist.py", line 992, in run_command
          cmd_obj.run()
        File "/Users/runner/work/project/project/versioneer.py", line 1568, in run
          write_to_version_file(target_versionfile, versions)
        File "/Users/runner/work/project/project/versioneer.py", line 1223, in write_to_version_file
          os.unlink(filename)
      FileNotFoundError: [Errno 2] No such file or directory: '/var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/tmpd49cxg_x.build-lib/project/_version.py'
      UPDATING /var/folders/24/8k48jl6d249_n_qfxwsl6xvm0000gn/T/tmpd49cxg_x.build-lib/project/_version.py
      error: Support for editable installs via PEP 660 was recently introduced
      in `setuptools`. If you are seeing this error, please report to:
      
      https://github.com/pypa/setuptools/issues
      
      Meanwhile you can try the legacy behavior by setting an
      environment variable and trying to install again:
      
      SETUPTOOLS_ENABLE_FEATURES="legacy-editable"
      [end of output]
```

</details>


Discussion:
Reported in [#3498-comment](https://github.com/pypa/setuptools/issues/3498#issuecomment-1212119770). Related to #3499 and #3500.
Is this the same thing as #3498? I don't see it going down the same code path and hitting the typo'd attribute. Thanks for being so quick with the response though!

Also update on the Linux vs MacOS thing, that was a red herring. All of our linux builds are now also failing
It was a comment in the mix of #3498, but unrelated to the original issue - added it in there for a bit more context, since a contributor replied. All of our builds are failing as well, both MacOS and Linux. 
Thank you very much guys, I am investigating both issues. I hope to get back to you soon.
Hi @Lyle-Alloy, maybe I will need a bit more detail on this one...

You reported that the problem also happens on Ubuntu 20.02 right? (Unfortunately I don't have a macOS available for testing).

I tried to use the following reproducer, but things seem to be working:

```bash
cd /tmp
rm -rf myproj
mkdir -p myproj/myproj
cd myproj
cat <<EOF > pyproject.toml
[build-system]
requires = [
    "setuptools>=49.2.1",
    "wheel~=0.37.1",
    "cython~=0.29.28",
    "gitpython~=3.1.26",
]

[tool.black]

[tool.pytest.ini_options]

[tool.pylint]
EOF

cat <<EOF > setup.py
import os
from pathlib import Path
import sys
from setuptools import setup, find_packages
import versioneer

setup(
    name="Project",
    version=versioneer.get_version(),  # from versioneer install guide
    cmdclass=versioneer.get_cmdclass(),  # from versioneer install guide
    author="Lyle Cheatham",
    author_email="lyle@alloyenterprises.co",
    keywords="",
    packages=find_packages(exclude=["test"]),
    install_requires=[],
    dependency_links=[],
    include_package_data=True,
    python_requires="~=3.9.2",
    entry_points={
        "console_scripts": [],
    },
)
EOF

echo "print(42)" > myproj/__init__.py
git init .
git add .

virtualenv -p py39 .venv
.venv/bin/python -m pip install 'versioneer==0.19' -U pip
.venv/bin/versioneer install

cat <<EOF > setup.cfg
[versioneer]
VCS = git
style = pep440
versionfile_source =
versionfile_build =
tag_prefix =
parentdir_prefix =
EOF

git add setup.cfg versioneer.py
git commit -m "Initial commit"
git tag 0.1

.venv/bin/python -m pip install -e .
# ...
# Successfully installed Project-0.1
```

I am currently trying to download a container for Python 3.9.2, but it is just taking ages... Meanwhile could you check if the reproducer above works in your machine? If it works what changes are necessary to make so it breaks with the same error you are seeing?
I have the same original error. The reproducer above runs successfully for me. I will work on finding a modification that reproduces the error.
To reproduce the issue, you must set the version files:

```
cat <<EOF > setup.cfg
[versioneer]
VCS = git
style = pep440
versionfile_source = _version.py
versionfile_build = _version.py
tag_prefix =
parentdir_prefix =
EOF
```
I am seeing this error too in Versioningit, even though @abravalheri added support for PEP660 in https://github.com/jwodder/versioningit/pull/38.

The solution will likely be similar.
Thank you very much @msarahan.

I am still trying to understand what is going on...

So far what I think is happening is the following:

- `versionner` assumes that `build_py` will always copy the project files to a temporary build directory.
- This is no longer the case (for editable installs) in v64. In this version, we don't copy the files because they are not added to the wheel... They are just executed in place (so it does not make much sense to copy).

I managed to get around the error in 2 different ways:

```diff
diff --git i/versioneer.py w/versioneer.py
index 1040c21..ec375ea 100644
--- i/versioneer.py
+++ w/versioneer.py
@@ -1220,7 +1220,8 @@ def versions_from_file(filename):

 def write_to_version_file(filename, versions):
     """Write the given version number to the given _version.py file."""
-    os.unlink(filename)
+    if os.path.exists(filename):
+        os.unlink(filename)
     contents = json.dumps(versions, sort_keys=True,
                           indent=1, separators=(",", ": "))
     with open(filename, "w") as f:
```

**OR**

```diff
diff --git i/versioneer.py w/versioneer.py
index 1040c21..5f57a8b 100644
--- i/versioneer.py
+++ w/versioneer.py
@@ -1559,6 +1559,8 @@ def get_cmdclass(cmdclass=None):
             cfg = get_config_from_root(root)
             versions = get_versions()
             _build_py.run(self)
+            if self.editable_mode:
+                return
             # now locate _version.py in the new build/ directory and replace
             # it with an updated value
             if cfg.versionfile_build:
```

I suspect the second patch makes more sense...

My conclusion is that solving this issue would require a bit of collaboration with `versioneer`. I will try to see if I can start proposing something, but I am not familiar with their code base.
@basnijholt thanks for reporting, please feel free to open a new issue with a reproducer.
I am partial to your first suggestion - it's just better defensive programming around any issue. It also then doesn't introduce a dependency on having the `editable_mode` attribute.

Unfortunately, this is probably causing issues in more places. In particular, I am not using verisoneer, but rather [miniver](https://pypi.org/project/miniver/), which is a versioneer derivative. My point is that there is an unknown number of projects that may be breaking here. Instead of forcing a fix across all of them, is there some (admittedly inherently janky) thing we can do on the setuptools side? My first thought was to treat the source dir as the build dir, but that is definitely trouble (it would then be modifying source in possibly crazy ways as "setup"). Perhaps introduce a new cmdclass that does not copy, which is the new default, but for the old cmdclass that versioneer (and others) inherit/extend, do the source copying to the temporary folder, even though it isn't used? And post a warning?
Hi @msarahan thank you very much for the in-depth analysis and suggestion.

Unfortunately things are a bit more complicated...

Previously, the `develop` command special-cased `build_py` and `build_ext` and treated each command considering the knowledge on what they do. It ignored all the other `build` subcommands.

This was problematic because custom build steps could not run during an editable install.

`editable_wheel` tries to use a more generic approach and fix the problem of custom build steps:

- all the `build` subcommands run now, *but*...
- each command has the opportunity to early return by checking if the current build is an editable install or not.

I don't know exactly, but I think `versioneer` uses both assumptions:

- `build_py` does not run during an editable installation.
- `build_py` always create a version file inside `build_lib`.

I have to think a little more to understand if there is a different alternative that can fix this problem for a larger number of projects... Please feel free to keep the suggestions coming :P 

(Any PR for fixing this problem directly in setuptools without compromising the new functionality is also very appreciated).


The relevant breaking code in miniver is: https://github.com/jbweston/miniver/blob/master/miniver/_version.py#L152-L163

They have a try/except around removing the file, but the writing the file will also fail because the temp dir is not there. Any fixes would have to account for both. One thing that might help is if setuptools at least created that temp folder. It would silence the error for miniver, at least. I don't think that's actually the right thing, though. Just a hack.

I think you've done the right thing for the long-term here. It does suck to break many things, but I don't know that there is any satisfactory workaround. Thanks for your efforts.



Resolving patch:
diff --git a/setuptools/command/build.py b/setuptools/command/build.py
--- a/setuptools/command/build.py
+++ b/setuptools/command/build.py
@@ -20,7 +20,7 @@ class build(_build):
     # copy to avoid sharing the object with parent class
     sub_commands = _build.sub_commands[:]
 
-    def run(self):
+    def get_sub_commands(self):
         subcommands = {cmd[0] for cmd in _build.sub_commands}
         if subcommands - _ORIGINAL_SUBCOMMANDS:
             msg = """
@@ -30,7 +30,7 @@ def run(self):
             """
             warnings.warn(msg, SetuptoolsDeprecationWarning)
             self.sub_commands = _build.sub_commands
-        super().run()
+        return super().get_sub_commands()
 
 
 class SubCommand(Protocol):
diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py
--- a/setuptools/command/editable_wheel.py
+++ b/setuptools/command/editable_wheel.py
@@ -37,6 +37,7 @@
 )
 
 from setuptools import Command, SetuptoolsDeprecationWarning, errors, namespaces
+from setuptools.command.build_py import build_py as build_py_cls
 from setuptools.discovery import find_package_path
 from setuptools.dist import Distribution
 
@@ -254,13 +255,55 @@ def _run_build_commands(
         self, dist_name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path
     ) -> Tuple[List[str], Dict[str, str]]:
         self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir)
-        self.run_command("build")
+        self._run_build_subcommands()
         files, mapping = self._collect_build_outputs()
         self._run_install("headers")
         self._run_install("scripts")
         self._run_install("data")
         return files, mapping
 
+    def _run_build_subcommands(self):
+        """
+        Issue #3501 indicates that some plugins/customizations might rely on:
+
+        1. ``build_py`` not running
+        2. ``build_py`` always copying files to ``build_lib``
+
+        However both these assumptions may be false in editable_wheel.
+        This method implements a temporary workaround to support the ecosystem
+        while the implementations catch up.
+        """
+        # TODO: Once plugins/customisations had the chance to catch up, replace
+        #       `self._run_build_subcommands()` with `self.run_command("build")`.
+        #       Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023.
+        build: Command = self.get_finalized_command("build")
+        for name in build.get_sub_commands():
+            cmd = self.distribution.get_command_obj(name)
+            if name == "build_py" and type(cmd) != build_py_cls:
+                self._safely_run(name)
+            else:
+                self.run_command(name)
+
+    def _safely_run(self, cmd_name: str):
+        try:
+            return self.run_command(cmd_name)
+        except Exception:
+            msg = f"""{traceback.format_exc()}\n
+            If you are seeing this warning it is very likely that a setuptools
+            plugin or customization overrides the `{cmd_name}` command, without
+            tacking into consideration how editable installs run build steps
+            starting from v64.0.0.
+
+            Plugin authors and developers relying on custom build steps are encouraged
+            to update their `{cmd_name}` implementation considering the information in
+            https://setuptools.pypa.io/en/latest/userguide/extension.html
+            about editable installs.
+
+            For the time being `setuptools` will silence this error and ignore
+            the faulty command, but this behaviour will change in future versions.\n
+            """
+            warnings.warn(msg, SetuptoolsDeprecationWarning, stacklevel=2)
+
     def _create_wheel_file(self, bdist_wheel):
         from wheel.wheelfile import WheelFile
 


Test patch:
diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py
--- a/setuptools/tests/test_editable_install.py
+++ b/setuptools/tests/test_editable_install.py
@@ -558,8 +558,9 @@ class TestOverallBehaviour:
 
     @pytest.mark.parametrize("layout", EXAMPLES.keys())
     def test_editable_install(self, tmp_path, venv, layout, editable_opts):
-        opts = editable_opts
-        project = install_project("mypkg", venv, tmp_path, self.EXAMPLES[layout], *opts)
+        project, _ = install_project(
+            "mypkg", venv, tmp_path, self.EXAMPLES[layout], *editable_opts
+        )
 
         # Ensure stray files are not importable
         cmd_import_error = """\
@@ -758,13 +759,55 @@ def test_pbr_integration(tmp_path, venv, editable_opts):
     assert b"Hello world!" in out
 
 
+class TestCustomBuildPy:
+    """
+    Issue #3501 indicates that some plugins/customizations might rely on:
+
+    1. ``build_py`` not running
+    2. ``build_py`` always copying files to ``build_lib``
+
+    During the transition period setuptools should prevent potential errors from
+    happening due to those assumptions.
+    """
+    # TODO: Remove tests after _run_build_steps is removed.
+
+    FILES = {
+        **TestOverallBehaviour.EXAMPLES["flat-layout"],
+        "setup.py": dedent("""\
+            import pathlib
+            from setuptools import setup
+            from setuptools.command.build_py import build_py as orig
+
+            class my_build_py(orig):
+                def run(self):
+                    super().run()
+                    raise ValueError("TEST_RAISE")
+
+            setup(cmdclass={"build_py": my_build_py})
+            """),
+    }
+
+    def test_safeguarded_from_errors(self, tmp_path, venv):
+        """Ensure that errors in custom build_py are reported as warnings"""
+        # Warnings should show up
+        _, out = install_project("mypkg", venv, tmp_path, self.FILES)
+        assert b"SetuptoolsDeprecationWarning" in out
+        assert b"ValueError: TEST_RAISE" in out
+        # but installation should be successful
+        out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
+        assert b"42" in out
+
+
 def install_project(name, venv, tmp_path, files, *opts):
     project = tmp_path / name
     project.mkdir()
     jaraco.path.build(files, prefix=project)
     opts = [*opts, "--no-build-isolation"]  # force current version of setuptools
-    venv.run(["python", "-m", "pip", "install", "-e", str(project), *opts])
-    return project
+    out = venv.run(
+        ["python", "-m", "pip", "-v", "install", "-e", str(project), *opts],
+        stderr=subprocess.STDOUT,
+    )
+    return project, out
 
 
 # ---- Assertion Helpers ----