Repository: jazzband/pip-tools

Issue:
Not getting correct package version under "prefer binary" mode
<!-- Describe the issue briefly here. -->

#### Environment Versions

1. OS Type: Ubuntu Linux 18.04 LTS
1. Python version: 3.6.9
1. pip version:  20.0.2
1. pip-tools version: 5.0.0

#### Steps to replicate

Sample requirement.txt file which has only one line for package "entrypoints"
```
$ cat requirements.txt
entrypoints
```

Without prefer binary it gets latest version entrypoints==0.3, which is expected. 
```
$ pip-compile -U requirements.txt -o requirements.txt.out
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --output-file=requirements.txt.out requirements.txt
#
--index-url http://mirrors.aliyun.com/pypi/simple
--trusted-host mirrors.aliyun.com

entrypoints==0.3          # via -r requirements.txt
```

However, with prefer binary mode, it gets entrypoints==0.2.2

```
$ PIP_PREFER_BINARY=1 pip-compile -U requirements.txt -o requirements.txt.out
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --output-file=requirements.txt.out requirements.txt
#
--index-url http://mirrors.aliyun.com/pypi/simple
--trusted-host mirrors.aliyun.com

entrypoints==0.2.2        # via -r requirements.txt
```

But if we look at the package on PyPI, entrypoints==0.3 actually has a wheel https://pypi.org/project/entrypoints/0.3/#files 

"pip install" behaves correct in that it always picks up the latest version. 

```
$ PIP_PREFER_BINARY=1 pip install entrypoints
Looking in indexes: http://mirrors.aliyun.com/pypi/simple
Collecting entrypoints
  Downloading http://mirrors.aliyun.com/pypi/packages/ac/c6/44694103f8c221443ee6b0041f69e2740d89a25641e62fb4f2ee568f2f9c/entrypoints-0.3-py2.py3-none-any.whl (11 kB)
Installing collected packages: entrypoints
Successfully installed entrypoints-0.3
```


Discussion:
Hello @stphnlyd,

Thanks for the bug report! Good catch 👍 
As a workaround you can use `--only-binary :entrypoints:` option in `requirements.in` while bug is not fixed.

Resolving patch:
diff --git a/piptools/repositories/pypi.py b/piptools/repositories/pypi.py
--- a/piptools/repositories/pypi.py
+++ b/piptools/repositories/pypi.py
@@ -123,15 +123,16 @@ def find_best_match(self, ireq, prereleases=None):
             return ireq  # return itself as the best match
 
         all_candidates = self.find_all_candidates(ireq.name)
-        candidates_by_version = lookup_table(
-            all_candidates, key=lambda c: c.version, unique=True
-        )
+        candidates_by_version = lookup_table(all_candidates, key=lambda c: c.version)
         matching_versions = ireq.specifier.filter(
             (candidate.version for candidate in all_candidates), prereleases=prereleases
         )
 
-        # Reuses pip's internal candidate sort key to sort
-        matching_candidates = [candidates_by_version[ver] for ver in matching_versions]
+        matching_candidates = list(
+            itertools.chain.from_iterable(
+                candidates_by_version[ver] for ver in matching_versions
+            )
+        )
         if not matching_candidates:
             raise NoCandidateFound(ireq, all_candidates, self.finder)
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,9 @@
 import json
 import os
+import sys
 from contextlib import contextmanager
 from functools import partial
+from subprocess import check_call
 from textwrap import dedent
 
 import pytest
@@ -221,3 +223,82 @@ def pip_with_index_conf(make_pip_conf):
             )
         )
     )
+
+
+@pytest.fixture
+def make_package(tmp_path):
+    """
+    Make a package from a given name, version and list of required packages.
+    """
+
+    def _make_package(name, version="0.1", install_requires=None):
+        if install_requires is None:
+            install_requires = []
+
+        install_requires_str = "[{}]".format(
+            ",".join("{!r}".format(package) for package in install_requires)
+        )
+
+        package_dir = tmp_path / "packages" / name / version
+        package_dir.mkdir(parents=True)
+
+        setup_file = str(package_dir / "setup.py")
+        with open(setup_file, "w") as fp:
+            fp.write(
+                dedent(
+                    """\
+                    from setuptools import setup
+                    setup(
+                        name={name!r},
+                        version={version!r},
+                        install_requires={install_requires_str},
+                    )
+                    """.format(
+                        name=name,
+                        version=version,
+                        install_requires_str=install_requires_str,
+                    )
+                )
+            )
+        return package_dir
+
+    return _make_package
+
+
+@pytest.fixture
+def run_setup_file():
+    """
+    Run a setup.py file from a given package dir.
+    """
+
+    def _make_wheel(package_dir_path, *args):
+        setup_file = str(package_dir_path / "setup.py")
+        return check_call((sys.executable, setup_file) + args)  # nosec
+
+    return _make_wheel
+
+
+@pytest.fixture
+def make_wheel(run_setup_file):
+    """
+    Make a wheel distribution from a given package dir.
+    """
+
+    def _make_wheel(package_dir, dist_dir, *args):
+        return run_setup_file(
+            package_dir, "bdist_wheel", "--dist-dir", str(dist_dir), *args
+        )
+
+    return _make_wheel
+
+
+@pytest.fixture
+def make_sdist(run_setup_file):
+    """
+    Make a source distribution from a given package dir.
+    """
+
+    def _make_sdist(package_dir, dist_dir, *args):
+        return run_setup_file(package_dir, "sdist", "--dist-dir", str(dist_dir), *args)
+
+    return _make_sdist
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -1146,3 +1146,75 @@ def test_preserve_compiled_prerelease_version(pip_conf, runner):
 
     assert out.exit_code == 0, out
     assert "small-fake-a==0.3b1" in out.stderr.splitlines()
+
+
+def test_prefer_binary_dist(
+    pip_conf, make_package, make_sdist, make_wheel, tmpdir, runner
+):
+    """
+    Test pip-compile chooses a correct version of a package with
+    a binary distribution when PIP_PREFER_BINARY environment variable is on.
+    """
+    dists_dir = tmpdir / "dists"
+
+    # Make first-package==1.0 and wheels
+    first_package_v1 = make_package(name="first-package", version="1.0")
+    make_wheel(first_package_v1, dists_dir)
+
+    # Make first-package==2.0 and sdists
+    first_package_v2 = make_package(name="first-package", version="2.0")
+    make_sdist(first_package_v2, dists_dir)
+
+    # Make second-package==1.0 which depends on first-package, and wheels
+    second_package_v1 = make_package(
+        name="second-package", version="1.0", install_requires=["first-package"]
+    )
+    make_wheel(second_package_v1, dists_dir)
+
+    with open("requirements.in", "w") as req_in:
+        req_in.write("second-package")
+
+    out = runner.invoke(
+        cli,
+        ["--no-annotate", "--find-links", str(dists_dir)],
+        env={"PIP_PREFER_BINARY": "1"},
+    )
+
+    assert out.exit_code == 0, out
+    assert "first-package==1.0" in out.stderr.splitlines(), out.stderr
+    assert "second-package==1.0" in out.stderr.splitlines(), out.stderr
+
+
+@pytest.mark.parametrize("prefer_binary", (True, False))
+def test_prefer_binary_dist_even_there_is_source_dists(
+    pip_conf, make_package, make_sdist, make_wheel, tmpdir, runner, prefer_binary
+):
+    """
+    Test pip-compile chooses a correct version of a package with a binary distribution
+    (despite a source dist existing) when PIP_PREFER_BINARY environment variable is on
+    or off.
+
+    Regression test for issue GH-1118.
+    """
+    dists_dir = tmpdir / "dists"
+
+    # Make first version of package with only wheels
+    package_v1 = make_package(name="test-package", version="1.0")
+    make_wheel(package_v1, dists_dir)
+
+    # Make seconds version with wheels and sdists
+    package_v2 = make_package(name="test-package", version="2.0")
+    make_wheel(package_v2, dists_dir)
+    make_sdist(package_v2, dists_dir)
+
+    with open("requirements.in", "w") as req_in:
+        req_in.write("test-package")
+
+    out = runner.invoke(
+        cli,
+        ["--no-annotate", "--find-links", str(dists_dir)],
+        env={"PIP_PREFER_BINARY": str(int(prefer_binary))},
+    )
+
+    assert out.exit_code == 0, out
+    assert "test-package==2.0" in out.stderr.splitlines(), out.stderr