Repository: pypa/pip

Issue:
Regression: pip 7 can't install scripts if the package can't be built as a wheel
If pip can't build a package as a wheel, it falls back to running setup.py install instead. However, in this case, it doesn't seem to fix the shebang line for scripts.  Here's an example:

script.py:

``` python
#!/usr/bin/env python

print("hi")
```

setup.py:

``` python
from setuptools import setup

setup(
    name='demo-script-bug',
    version='1.0',
    author='Thomas Grenfell Smith',
    author_email='smithtg@ncbi.nlm.nih.gov',
    description='',
    tests_require=[''],
    scripts=['script.py'],
)
```

(The `tests_require` line simulates what happens when you use open('test_requirements.txt').readlines() but there's an extra blank line in the file.  I'm using it as a way to make bdist_wheel fail.)

Then here are the steps to reproduce:

``` bash
python setup.py sdist

virtualenv testenv
testenv/bin/pip install pip==7.1.0 wheel==0.24.0
testenv/bin/pip install dist/demo-script-bug-1.0.tar.gz

testenv/bin/script.py
```

The last line returns "bad interpreter" because script.py says "#!python".  With pip 6.1.1, on the other hand, the script is installed correctly.



Discussion:
pip 1.5.6 works
pip 6.1.1 works
pip 7.0.0 doesn't work
pip 7.0.3 doesn't work
pip 7.1.0 doesn't work

pip 7.1.1 doesn't work

Any thoughts?  Is this a distlib bug instead of a pip bug?

I think I found the problem.  If the wheel build fails, pip falls back on pip.req.req_install, which runs (the equivalent of) setup.py install.  This is the same between 6.1.1 and 7.0.0.  However, the difference is the contents of the temporary directories where pip has extracted the package .tar.gz.  In 7+, pip has already tried to build a wheel in that directory.  Consequently, there's already a `build/` directory, and in fact a `build/scripts-3.4/script.py` file - _with the broken shebang._

I guess that, in the wheel format, we expect to rewrite the shebang during the file copying process, but when running setup.py install, the rewriting is part of the _build_ process.  But when setup.py install sees the build dir already there, it decides it doesn't need to run build_scripts.  It uses the copy of the script that's already there, which is broken.

I suppose that most parts of the wheel build process really are not worth repeating - maybe there was a lengthy compilation or something.  But maybe pip can remove `build/scripts-*` after a failed attempt at building a wheel?

Or the build.build-scripts directory could be set differently for wheels, or pip could run build_scripts --force.

If someone in the project could suggest which of these possible fixes would be the best for them, I would be willing to make a patch.
- Modify `pip.wheel.WheelBuilder.__build_one` to remove `build/scripts-*` after a failed build.
- Modify the same function to run `build --build-scripts build/wheel-scripts-PYVERSION bdist_wheel`, so that the wheel versions of the scripts go in a different directory.
- Modify `pip.req.req_install.InstallRequirement.install` to run `build_scripts --force install`, so that the scripts are definitely regenerated.
- Something else?

Nice analysis, thanks!

However, `tests_require` shouldn't contain blank values. It's documented as a string or list of strings, each of which specifies a dependency (i.e. is a valid requirement). Your code is simulating `open('requirements.txt').readlines()`, with a blank line in the requirements file - but your code is wrong. It should strip empty values - in fact it ignores a lot of other valid content in a requirements file (comments, for example). If your text file isn't actually a requirements-format file, you should still strip blank lines.

Really, `bdist_wheel` shouldn't fail when `install` will succeed - but with invalid input all bets are off. It looks like setuptools is more forgiving than bdist_wheel over invalid values - maybe (given that setuptools defined the argument in the first place) there's an argument to say that the setuptools behaviour is correct, but in that case this is a bug in `bdist_wheel` as it's rejecting input that setuptools' `install` command allows.

I can see a point in removing the build directory if `bdist_wheel` fails and we intend to try `install` - running install with a potentially invalid build directory is risky at best. But just cleaning out the scripts feels wrong to me. And even though it would fix this case, it's still a case of invalid input, so I'd rather the input was corrected.

So, I'm +0 on a PR to remove the build directory at the point where `bdist_wheel` fails and we intend to try `install`. But I won't be writing it myself.

I'd also suggest you fix your original code to avoid passing an invalid `tests_require` argument - that will likely help you to avoid other issues in the future (as well as being a quicker solution than waiting for a fix to be added to pip :-)).

I've just seen your update. Regarding your proposals:
- I don't like the 3 specific proposals, as they all involve modifying the install process to work around invalid user input.
- Something else:
  - Report this as a bug in wheel, for failing to accept undocumented but de-facto (because setuptools alllows it) valid input. I don't know whether such a report would be accepted, but if it were, and were fixed, there wouldn't be a difference in behaviour between `bdist_wheel` and `install` and the problem goes away.
  - Locate the code that tries to build the wheel, and falls back to install on failure (neither of the locations you mention is that place, and I couldn't find the correct place on a quick search) and insert a cleanup step that clears out the whole build directory if the wheel build fails.
  - Just avoid passing invalid `tests_require` arguments to setup in the first place.

Regardless of the above, thanks for all the work you put into the analysis of the issue. It made it much easier to review and make meaningful comments.

Thanks for getting back to me!

I take your point about fixing the invalid input, but this is just one way that `bdist_wheel` can fail when `install` succeeds - if there weren't any such cases, then nobody would have bothered to put in the fallback mechanism.  And the point is that the fallback mechanism is broken if the package has any scripts.

You're probably right that the whole build directory should get blown away - it's reasonable to suspect that there are other bugs lurking in build differences, and it's also cleaner and more straightforward than dealing with this particular script issue.

Shall I go ahead and make a patch like that?  I think the place to put the check + `rmtree` would be either in the error handling code in `wheel`, or in `pip.commands.install.InstallCommand.run` (where it says "Ignore the result", stop ignoring the result).  Do either of those sound good?

> I'm using it as a way to make bdist_wheel fail.

I thinks he knows about the empty `test_requires`. Personally, I'd rather see setuptools progressively refusing invalid `*_requires` but that is an other topic ^^.

Cleaning on a failed build seems like the sanest and simplest thing to do. If the wheel caching fails, it should reset the directory to its initial state so that the coming install runs as if nothing happened.
`python setup.py clean --all` seems to fit the bill.
https://github.com/pypa/pip/blob/develop/pip/wheel.py#L692-L694 seems like the right place to me.

I made a pull request to fix this - maybe let's take discussion over there?  #3047 


Resolving patch:
diff --git a/pip/wheel.py b/pip/wheel.py
--- a/pip/wheel.py
+++ b/pip/wheel.py
@@ -668,19 +668,24 @@ def _build_one(self, req, output_dir):
                     logger.info('Stored in directory: %s', output_dir)
                     return wheel_path
                 except:
-                    return None
+                    pass
+            # Ignore return, we can't do anything else useful.
+            self._clean_one(req)
             return None
         finally:
             rmtree(tempd)
 
-    def __build_one(self, req, tempd):
-        base_args = [
+    def _base_setup_args(self, req):
+        return [
             sys.executable, '-c',
             "import setuptools;__file__=%r;"
             "exec(compile(open(__file__).read().replace('\\r\\n', '\\n'), "
             "__file__, 'exec'))" % req.setup_py
         ] + list(self.global_options)
 
+    def __build_one(self, req, tempd):
+        base_args = self._base_setup_args(req)
+
         logger.info('Running setup.py bdist_wheel for %s', req.name)
         logger.debug('Destination directory: %s', tempd)
         wheel_args = base_args + ['bdist_wheel', '-d', tempd] \
@@ -692,6 +697,18 @@ def __build_one(self, req, tempd):
             logger.error('Failed building wheel for %s', req.name)
             return False
 
+    def _clean_one(self, req):
+        base_args = self._base_setup_args(req)
+
+        logger.info('Running setup.py clean for %s', req.name)
+        clean_args = base_args + ['clean', '--all']
+        try:
+            call_subprocess(clean_args, cwd=req.source_dir, show_stdout=False)
+            return True
+        except:
+            logger.error('Failed cleaning build dir for %s', req.name)
+            return False
+
     def build(self, autobuilding=False):
         """Build wheels.
 


Test patch:
diff --git a/tests/data/packages/wheelbrokenafter-0.1.tar.gz b/tests/data/packages/wheelbrokenafter-0.1.tar.gz
new file mode 100644
Binary files /dev/null and b/tests/data/packages/wheelbrokenafter-0.1.tar.gz differ
diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py
--- a/tests/functional/test_install.py
+++ b/tests/functional/test_install.py
@@ -677,6 +677,20 @@ def test_install_wheel_broken(script, data):
     assert "Successfully installed wheelbroken-0.1" in str(res), str(res)
 
 
+def test_cleanup_after_failed_wheel(script, data):
+    script.pip('install', 'wheel')
+    res = script.pip(
+        'install', '--no-index', '-f', data.find_links, 'wheelbrokenafter',
+        expect_stderr=True)
+    # One of the effects of not cleaning up is broken scripts:
+    script_py = script.bin_path / "script.py"
+    assert script_py.exists, script_py
+    shebang = open(script_py, 'r').readline().strip()
+    assert shebang != '#!python', shebang
+    # OK, assert that we *said* we were cleaning up:
+    assert "Running setup.py clean for wheelbrokenafter" in str(res), str(res)
+
+
 def test_install_builds_wheels(script, data):
     # NB This incidentally tests a local tree + tarball inputs
     # see test_install_editable_from_git_autobuild_wheel for editable