Repository: jazzband/pip-tools

Issue:
pip-sync 1.10: TypeError: '<' not supported between instances of 'InstallRequirement' and 'InstallRequirement'
Describe the issue briefly here.

After upgrading to 1.10, `pip-sync` fails with the error:

```
$ venv/bin/pip-sync dev-requirements.txt
Traceback (most recent call last):
  File "venv/bin/pip-sync", line 11, in <module>
    sys.exit(cli())
  File "venv/lib64/python3.6/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 697, in main
    rv = self.invoke(ctx)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 895, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "venv/lib64/python3.6/site-packages/piptools/scripts/sync.py", line 74, in cli
    install_flags=install_flags))
  File "venv/lib64/python3.6/site-packages/piptools/sync.py", line 159, in sync
    for ireq in sorted(to_install):
TypeError: '<' not supported between instances of 'InstallRequirement' and 'InstallRequirement'
```

##### Environment Versions

1. OS Type: Linux
1. Python version: `$ python -V`: Python 3.6.2
1. pip version:  `$ pip --version`: pip 9.0.1 from venv/lib64/python3.6/site-packages (python 3.6)
1. pip-tools version: `$ pip-compile --version`: pip-compile, version 1.10.0

##### Steps to replicate

```
$ cat requirements.in
Django
```

```
$ cat requirements.txt
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --generate-hashes --output-file requirements.txt requirements.in
#
django==1.11.5 \
    --hash=sha256:1836878162dfdf865492bacfdff0321e4ee8f1e7d51d93192546000b54982b29 \
    --hash=sha256:89162f70a74aac62a53f975128faba6099a7ef2c9d8140a41ae9d6210bda05cd
pytz==2017.2 \
    --hash=sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67 \
    --hash=sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589 \
    # via django
```

To reproduce:

```
$ rm -rf venv
$ python3 -m venv venv
$ venv/bin/pip install --upgrade pip setuptools
$ venv/bin/pip install pip-tools
$ venv/bin/pip-sync requirements.txt
Traceback (most recent call last):
  File "venv/bin/pip-sync", line 11, in <module>
    sys.exit(cli())
  File "venv/lib64/python3.6/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 697, in main
    rv = self.invoke(ctx)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 895, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "venv/lib64/python3.6/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "venv/lib64/python3.6/site-packages/piptools/scripts/sync.py", line 74, in cli
    install_flags=install_flags))
  File "venv/lib64/python3.6/site-packages/piptools/sync.py", line 159, in sync
    for ireq in sorted(to_install):
TypeError: '<' not supported between instances of 'InstallRequirement' and 'InstallRequirement'
```
##### Expected result

Packages installed without error.

##### Actual result

Receive unhandled exception:

`TypeError: '<' not supported between instances of 'InstallRequirement' and 'InstallRequirement'`



Discussion:
We can replicate this on Amazon Linux 2016.03 running on Python 3.4.4, pip-tools 1.10.0.
Seem to be related to #566 there is also a note around the code in that section that regarding equality

```
# NOTE: We need to compare RequirementSummary objects, since
# InstallRequirement does not define equality
```

Edit: Not 100% sure on the above, just reading at a surface level & seems to be the around the right area
The error occurs only in Python 3+.
I do not think this is related to #566. The error occurs in sync.py, the traceback is pretty explicit here.
InstallRequirement does not define `<`, so it fails.
I seems like Py2 has no issue with that and falls back to a default '<', while Py3 raises a TypeError instead (I don't know a lot about Py2 vs Py3).
It's pretty bad the tests did not catch that. I'll try to fix that ASAP.

> I seems like Py2 has no issue with that and falls back to a default '<', while Py3 raises a TypeError instead (I don't know a lot about Py2 vs Py3).

FYI, This change in Python3 comparison behavior is described here: https://docs.python.org/dev/whatsnew/3.0.html#ordering-comparisons

Resolving patch:
diff --git a/piptools/resolver.py b/piptools/resolver.py
--- a/piptools/resolver.py
+++ b/piptools/resolver.py
@@ -15,19 +15,12 @@
 from .exceptions import UnsupportedConstraint
 from .logging import log
 from .utils import (format_requirement, format_specifier, full_groupby,
-                    is_pinned_requirement, key_from_req, UNSAFE_PACKAGES)
+                    is_pinned_requirement, key_from_ireq, key_from_req, UNSAFE_PACKAGES)
 
 green = partial(click.style, fg='green')
 magenta = partial(click.style, fg='magenta')
 
 
-def _dep_key(ireq):
-    if ireq.req is None and ireq.link is not None:
-        return str(ireq.link)
-    else:
-        return key_from_req(ireq.req)
-
-
 class RequirementSummary(object):
     """
     Summary of a requirement's properties for comparison purposes.
@@ -147,7 +140,7 @@ def _group_constraints(self, constraints):
             flask~=0.7
 
         """
-        for _, ireqs in full_groupby(constraints, key=_dep_key):
+        for _, ireqs in full_groupby(constraints, key=key_from_ireq):
             ireqs = list(ireqs)
             editable_ireq = first(ireqs, key=lambda ireq: ireq.editable)
             if editable_ireq:
@@ -178,7 +171,7 @@ def _resolve_one_round(self):
         configuration.
         """
         # Sort this list for readability of terminal output
-        constraints = sorted(self.constraints, key=_dep_key)
+        constraints = sorted(self.constraints, key=key_from_ireq)
         unsafe_constraints = []
         original_constraints = copy.copy(constraints)
         if not self.allow_unsafe:
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, format_requirement, key_from_req
+from .utils import flat_map, format_requirement, key_from_ireq, key_from_req
 
 PACKAGES_TO_IGNORE = [
     'pip',
@@ -156,7 +156,7 @@ def sync(to_install, to_uninstall, verbose=False, dry_run=False, pip_flags=None,
                 click.echo("  {}".format(format_requirement(ireq)))
         else:
             package_args = []
-            for ireq in sorted(to_install):
+            for ireq in sorted(to_install, key=key_from_ireq):
                 if ireq.editable:
                     package_args.extend(['-e', str(ireq.link or ireq.req)])
                 else:
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -34,6 +34,14 @@ def assert_compatible_pip_version():
         sys.exit(4)
 
 
+def key_from_ireq(ireq):
+    """Get a standardized key for an InstallRequirement."""
+    if ireq.req is None and ireq.link is not None:
+        return str(ireq.link)
+    else:
+        return key_from_req(ireq.req)
+
+
 def key_from_req(req):
     """Get an all-lowercase version of the requirement's name."""
     if hasattr(req, 'key'):


Test patch:
diff --git a/tests/test_sync.py b/tests/test_sync.py
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -192,6 +192,21 @@ def test_diff_with_editable(fake_dist, from_editable):
     assert str(package.link) == _get_file_url(path_to_package)
 
 
+@pytest.mark.parametrize(
+    'lines',
+    [
+        ['django==1.8'],
+        ['django==1.8', 'click==4.0'],
+    ]
+)
+def test_sync_install(from_line, lines):
+    with mock.patch('piptools.sync.check_call') as check_call:
+        to_install = {from_line(line) for line in lines}
+
+        sync(to_install, set())
+        check_call.assert_called_once_with(['pip', 'install', '-q'] + sorted(lines))
+
+
 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')