Repository: pypa/setuptools

Issue:
[BUG] Installing in editable mode fails when an optional extension fails to build
### setuptools version

setuptools==60.0.1

### Python version

Python 3.10

### OS

osx-arm64

### Additional environment information

_No response_

### Description

If an optional extension fails to build in editable mode, the whole install aborts because `setuptools` is trying to copy the (non-present) library file. Works fine when not using editable mode.

### Expected behavior

`pip install -e .` should succeed and simply exclude the optional extension.

### How to Reproduce

`setup.py`:

```python
from setuptools import setup
from setuptools.extension import Extension

extensions = [
    Extension(name="foo", sources=["foo.c"], optional=True),
]

setup(
    name="foobar",
    ext_modules=extensions
)
```

`foo.c`:

```
#include missingheader.h
```

### Output

```console
$ pip install -e . -v
Using pip 22.1.1 from /Users/dion/.virtualenvs/tempenv-47702011277da/lib/python3.9/site-packages/pip (python 3.9)
Obtaining file:///private/tmp/foo
  Running command python setup.py egg_info
  running egg_info
  creating /private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info
  writing /private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/PKG-INFO
  writing dependency_links to /private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/dependency_links.txt
  writing top-level names to /private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/top_level.txt
  writing manifest file '/private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/SOURCES.txt'
  reading manifest file '/private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/SOURCES.txt'
  writing manifest file '/private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-pip-egg-info-ybrkr3zs/foobar.egg-info/SOURCES.txt'
  Preparing metadata (setup.py) ... done
Installing collected packages: foobar
  Running setup.py develop for foobar
    Running command python setup.py develop
    running develop
    /Users/dion/.virtualenvs/tempenv-47702011277da/lib/python3.9/site-packages/setuptools/command/easy_install.py:144: EasyInstallDeprecationWarning: easy_install command is deprecated. Use build and pip and other standards-based tools.
      warnings.warn(
    /Users/dion/.virtualenvs/tempenv-47702011277da/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 egg_info
    writing foobar.egg-info/PKG-INFO
    writing dependency_links to foobar.egg-info/dependency_links.txt
    writing top-level names to foobar.egg-info/top_level.txt
    reading manifest file 'foobar.egg-info/SOURCES.txt'
    writing manifest file 'foobar.egg-info/SOURCES.txt'
    running build_ext
    building 'foo' extension
    clang -Wno-unused-result -Wsign-compare -Wunreachable-code -fno-common -dynamic -DNDEBUG -g -fwrapv -O3 -Wall -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX12.sdk -I/Users/dion/.virtualenvs/tempenv-47702011277da/include -I/usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/include/python3.9 -c foo.c -o build/temp.macosx-12-x86_64-cpython-39/foo.o
    foo.c:1:10: error: expected "FILENAME" or <FILENAME>
    #include missingheader.h
             ^
    1 error generated.
    warning: build_ext: building extension "foo" failed: command '/usr/local/opt/llvm/bin/clang' failed with exit code 1

    error: can't copy 'build/lib.macosx-12-x86_64-cpython-39/foo.cpython-39-darwin.so': doesn't exist or not a regular file
    error: subprocess-exited-with-error

    × python setup.py develop did not run successfully.
    │ exit code: 1
    ╰─> See above for output.

    note: This error originates from a subprocess, and is likely not a problem with pip.
    full command: /Users/dion/.virtualenvs/tempenv-47702011277da/bin/python -c '
    exec(compile('"'"''"'"''"'"'
    # This is <pip-setuptools-caller> -- a caller that pip uses to run setup.py
    #
    # - It imports setuptools before invoking setup.py, to enable projects that directly
    #   import from `distutils.core` to work with newer packaging standards.
    # - It provides a clear error message when setuptools is not installed.
    # - It sets `sys.argv[0]` to the underlying `setup.py`, when invoking `setup.py` so
    #   setuptools doesn'"'"'t think the script is `-c`. This avoids the following warning:
    #     manifest_maker: standard file '"'"'-c'"'"' not found".
    # - It generates a shim setup.py, for handling setup.cfg-only projects.
    import os, sys, tokenize

    try:
        import setuptools
    except ImportError as error:
        print(
            "ERROR: Can not execute `setup.py` since setuptools is not available in "
            "the build environment.",
            file=sys.stderr,
        )
        sys.exit(1)

    __file__ = %r
    sys.argv[0] = __file__

    if os.path.exists(__file__):
        filename = __file__
        with tokenize.open(__file__) as f:
            setup_py_code = f.read()
    else:
        filename = "<auto-generated setuptools caller>"
        setup_py_code = "from setuptools import setup; setup()"

    exec(compile(setup_py_code, filename, "exec"))
    '"'"''"'"''"'"' % ('"'"'/private/tmp/foo/setup.py'"'"',), "<pip-setuptools-caller>", "exec"))' develop --no-deps
    cwd: /private/tmp/foo/
error: subprocess-exited-with-error

× python setup.py develop did not run successfully.
│ exit code: 1
╰─> See above for output.

note: This error originates from a subprocess, and is likely not a problem with pip.
WARNING: There was an error checking the latest version of pip.
```

On the other hand, this works: (non-editable)

```console
$ pip install .
Processing /private/tmp/foo
  Preparing metadata (setup.py) ... done
Building wheels for collected packages: foobar
  Building wheel for foobar (setup.py) ... done
  Created wheel for foobar: filename=foobar-0.0.0-cp39-cp39-macosx_12_0_x86_64.whl size=1403 sha256=fff7224662fda52c35254d8f973e393c846435eeae83643fc4eb5f48d1eb8ca6
  Stored in directory: /private/var/folders/ld/ylk19q555951g5k6t0vhz6740000gp/T/pip-ephem-wheel-cache-23u2txbg/wheels/59/f4/b7/5f9d2738a29be3df9fdb7bc6a9abe672e51db7ebca9ee63898
Successfully built foobar
Installing collected packages: foobar
Successfully installed foobar-0.0.0
```



Discussion:

Resolving patch:
diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py
--- a/setuptools/command/build_ext.py
+++ b/setuptools/command/build_ext.py
@@ -104,7 +104,8 @@ def copy_extensions_to_source(self):
             # Always copy, even if source is older than destination, to ensure
             # that the right extensions for the current Python/platform are
             # used.
-            self.copy_file(regular_file, inplace_file, level=self.verbose)
+            if os.path.exists(regular_file) or not ext.optional:
+                self.copy_file(regular_file, inplace_file, level=self.verbose)
 
             if ext._needs_stub:
                 inplace_stub = self._get_equivalent_stub(ext, inplace_file)


Test patch:
diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py
--- a/setuptools/tests/test_build_ext.py
+++ b/setuptools/tests/test_build_ext.py
@@ -9,10 +9,13 @@
 from setuptools.command.build_ext import build_ext, get_abi3_suffix
 from setuptools.dist import Distribution
 from setuptools.extension import Extension
+from setuptools.errors import CompileError
 
 from . import environment
 from .textwrap import DALS
 
+import pytest
+
 
 IS_PYPY = '__pypy__' in sys.builtin_module_names
 
@@ -176,6 +179,40 @@ def C(file):
         assert example_stub.endswith(".pyc")
 
 
+class TestBuildExtInplace:
+    def get_build_ext_cmd(self, optional: bool, **opts):
+        files = {
+            "eggs.c": "#include missingheader.h\n",
+            ".build": {"lib": {}, "tmp": {}},
+        }
+        path.build(files)
+        extension = Extension('spam.eggs', ['eggs.c'], optional=optional)
+        dist = Distribution(dict(ext_modules=[extension]))
+        dist.script_name = 'setup.py'
+        cmd = build_ext(dist)
+        vars(cmd).update(build_lib=".build/lib", build_temp=".build/tmp", **opts)
+        cmd.ensure_finalized()
+        return cmd
+
+    def test_optional(self, tmpdir_cwd, capsys):
+        """
+        If optional extensions fail to build, setuptools should show the error
+        in the logs but not fail to build
+        """
+        cmd = self.get_build_ext_cmd(optional=True, inplace=True)
+        cmd.run()
+        logs = capsys.readouterr()
+        messages = (logs.out + logs.err)
+        assert 'build_ext: building extension "spam.eggs" failed' in messages
+        # No compile error exception should be raised
+
+    def test_non_optional(self, tmpdir_cwd):
+        # Non-optional extensions should raise an exception
+        cmd = self.get_build_ext_cmd(optional=False, inplace=True)
+        with pytest.raises(CompileError):
+            cmd.run()
+
+
 def test_build_ext_config_handling(tmpdir_cwd):
     files = {
         'setup.py': DALS(