Repository: jazzband/pip-tools

Issue:
pip-sync appears to get confused about editable packages
When a requirements.txt file includes editable local packages, pip-sync seems to want to uninstall them and reinstall them.

Also, the pip install command that pip-sync attempts to perform is missing the `-e` editable flags, resulting in an error.
##### Steps to replicate
- Create a new virtualenv for testing

``` bash
virtualenv --no-site-packages testvenv
source ./testvenv/bin/activate
pip list
```

returns:

```
pip (8.1.2)
setuptools (28.3.0)
wheel (0.30.0a0)
```
- Generate a requirements.txt file (obfuscated from our actual names):

```
-e file:///vagrant/projects/someproject
```
- Install the editable project into the virtualenv

``` bash
pip install -r requirements.txt --no-deps
```
- Install pip-tools into the venv (because the globally installed pip-tools doesn't seem to deal with virtualenvironents well (another bug?) )

``` bash
pip install pip-tools
deactivate
source ./testvenv/bin/activate
```
- See what pip-sync thinks

``` bash
pip-sync -n requirements.txt
```

returns _unexpected results_:

```
Would uninstall:
  our.package.name
Would install:
  file:///vagrant/projects/packagename
```
- Try to do the sync

```
pip-sync requirements.txt
```

returns

```
Uninstalling our.package.name-1.2.3+dirty:
  Successfully uninstalled our.package.name-1.2.3+dirty
Processing /vagrant/projects/packagename
    Complete output from command python setup.py egg_info:

*** Bunch of errors we generate related to our package's assumptions about being a git repo ***

    ----------------------------------------
Command "python setup.py egg_info" failed with error code 1 in /tmp/pip-ls2yuR-build/
Traceback (most recent call last):
  File "/home/vagrant/testvenv/bin/pip-sync", line 11, in <module>
    sys.exit(cli())
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/click/core.py", line 716, in __call__
    return self.main(*args, **kwargs)
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/click/core.py", line 696, in main
    rv = self.invoke(ctx)
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/click/core.py", line 889, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/click/core.py", line 534, in invoke
    return callback(*args, **kwargs)
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/piptools/scripts/sync.py", line 72, in cli
    install_flags=install_flags))
  File "/home/vagrant/testvenv/local/lib/python2.7/site-packages/piptools/sync.py", line 157, in sync
    check_call([pip, 'install'] + pip_flags + install_flags + sorted(to_install))
  File "/usr/lib/python2.7/subprocess.py", line 511, in check_call
    raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '['pip', 'install', 'file:///vagrant/projects/packagename']' returned non-zero exit status 1
```
##### Expected result

A) The tool shouldn't be attempting to uninstall the editable package and then reinstall it
B) Installing editable packages should have a `-e` option



Discussion:
Indeed this makes it really difficult to use pip-sync while developing packages.


Resolving patch:
diff --git a/piptools/sync.py b/piptools/sync.py
--- a/piptools/sync.py
+++ b/piptools/sync.py
@@ -5,7 +5,7 @@
 
 from . import click
 from .exceptions import IncompatibleRequirements, UnsupportedConstraint
-from .utils import flat_map, key_from_req
+from .utils import flat_map, format_requirement, key_from_req
 
 PACKAGES_TO_IGNORE = [
     'pip',
@@ -97,7 +97,7 @@ def diff(compiled_requirements, installed_dists):
     requirements_lut = {r.link or key_from_req(r.req): r for r in compiled_requirements}
 
     satisfied = set()  # holds keys
-    to_install = set()  # holds keys-and-versions
+    to_install = set()  # holds InstallRequirement objects
     to_uninstall = set()  # holds keys
 
     pkgs_to_ignore = get_dists_to_ignore(installed_dists)
@@ -110,7 +110,7 @@ def diff(compiled_requirements, installed_dists):
 
     for key, requirement in requirements_lut.items():
         if key not in satisfied:
-            to_install.add(str(requirement.link or requirement.req))
+            to_install.add(requirement)
 
     # Make sure to not uninstall any packages that should be ignored
     to_uninstall -= set(pkgs_to_ignore)
@@ -151,8 +151,14 @@ def sync(to_install, to_uninstall, verbose=False, dry_run=False, pip_flags=None,
             install_flags = []
         if dry_run:
             click.echo("Would install:")
-            for pkg in to_install:
-                click.echo("  {}".format(pkg))
+            for ireq in to_install:
+                click.echo("  {}".format(format_requirement(ireq)))
         else:
-            check_call([pip, 'install'] + pip_flags + install_flags + sorted(to_install))
+            package_args = []
+            for ireq in sorted(to_install):
+                if ireq.editable:
+                    package_args.extend(['-e', str(ireq.link or ireq.req)])
+                else:
+                    package_args.append(str(ireq.req))
+            check_call([pip, 'install'] + pip_flags + install_flags + package_args)
     return 0


Test patch:
diff --git a/tests/test_sync.py b/tests/test_sync.py
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -1,8 +1,12 @@
 from collections import Counter
+import os
+import platform
 
+import mock
 import pytest
+
 from piptools.exceptions import IncompatibleRequirements
-from piptools.sync import dependency_tree, diff, merge
+from piptools.sync import dependency_tree, diff, merge, sync
 
 
 @pytest.mark.parametrize(
@@ -83,7 +87,7 @@ def test_diff_should_install(from_line):
     reqs = [from_line('django==1.8')]
 
     to_install, to_uninstall = diff(reqs, installed)
-    assert to_install == {'django==1.8'}
+    assert {str(x.req) for x in to_install} == {'django==1.8'}
     assert to_uninstall == set()
 
 
@@ -101,7 +105,7 @@ def test_diff_should_update(fake_dist, from_line):
     reqs = [from_line('django==1.8')]
 
     to_install, to_uninstall = diff(reqs, installed)
-    assert to_install == {'django==1.8'}
+    assert {str(x.req) for x in to_install} == {'django==1.8'}
     assert to_uninstall == set()
 
 
@@ -149,3 +153,39 @@ def test_diff_leave_piptools_alone(fake_dist, from_line):
     to_install, to_uninstall = diff(reqs, installed)
     assert to_install == set()
     assert to_uninstall == {'foobar'}
+
+
+def _get_file_url(local_path):
+    if platform.system() == 'Windows':
+        local_path = '/%s' % local_path.replace('\\', '/')
+    return 'file://%s' % local_path
+
+
+def test_diff_with_editable(fake_dist, from_editable):
+    installed = [
+        fake_dist('small-fake-with-deps==0.0.1'),
+        fake_dist('six==1.10.0'),
+    ]
+    path_to_package = os.path.join(os.path.dirname(__file__), 'fixtures', 'small_fake_package')
+    reqs = [
+        from_editable(path_to_package),
+    ]
+    to_install, to_uninstall = diff(reqs, installed)
+
+    # FIXME: The editable package is uninstalled and reinstalled, including all its dependencies,
+    # even if the version numbers match.
+    assert to_uninstall == {'six', 'small-fake-with-deps'}
+
+    assert len(to_install) == 1
+    package = list(to_install)[0]
+    assert package.editable
+    assert str(package.link) == _get_file_url(path_to_package)
+
+
+def test_sync_with_editable(from_editable):
+    with mock.patch('piptools.sync.check_call') as check_call:
+        path_to_package = os.path.join(os.path.dirname(__file__), 'fixtures', 'small_fake_package')
+        to_install = {from_editable(path_to_package)}
+
+        sync(to_install, set())
+        check_call.assert_called_once_with(['pip', 'install', '-q', '-e', _get_file_url(path_to_package)])