Repository: jazzband/pip-tools (python)

Issue:
Showing provenance of conflicting constraints in pip-compile
Thanks for the great tool. I use pip-compile to manage a large production Python environment, and one of the main pain-points I've found is that when an upstream change leads to a conflict, it can often be quite difficult to ascertain the cause of the failure.

For example, consider the following requirements.in file, which in my case can have several dozen listed dependencies:
```bash
# requirements.in
# ... several dozen packages
google-cloud-bigquery~=1.14.0
# ... several dozen more packages
tensorflow-data-validation~=0.13.1
# ... several dozen more packages
```
If you run this through pip compile, it finds a conflict:
```
$ python -m piptools compile requirements.in
Could not find a version that matches google-cloud-bigquery<1.7.0,>=1.6.0,~=1.14.0
Tried: 0.20.0, 0.20.0, 0.21.0, 0.21.0, 0.22.0, 0.22.0, 0.22.1, 0.22.1, 0.23.0, 0.23.0, 0.24.0, 0.24.0, 0.25.0, 0.25.0, 0.26.0, 0.26.0, 0.27.0, 0.27.0, 0.28.0, 0.28.0, 0.29.0, 0.29.0, 0.30.0, 0.30.0, 0.31.0, 0.31.0, 0.32.0, 0.32.0, 1.0.0, 1.0.0, 1.1.0, 1.1.0, 1.2.0, 1.2.0, 1.3.0, 1.3.0, 1.4.0, 1.4.0, 1.5.0, 1.5.0, 1.5.1, 1.5.1, 1.6.0, 1.6.0, 1.6.1, 1.6.1, 1.7.0, 1.7.0, 1.8.0, 1.8.0, 1.8.1, 1.8.1, 1.9.0, 1.9.0, 1.10.0, 1.10.0, 1.11.1, 1.11.1, 1.11.2, 1.11.2, 1.11.3, 1.11.3, 1.12.0, 1.12.0, 1.12.1, 1.12.1, 1.13.0, 1.13.0, 1.14.0, 1.14.0, 1.15.0, 1.15.0
There are incompatible versions in the resolved dependencies.
```
but this error contains no information about the provenance of the conflicting constraints, and so it is not much help in ascertaining the cause of the conflict.

I have a partial solution to this in https://github.com/jakevdp/pip-tools/commit/c33cafc69552dfaef1b730e864897b26ea74068a; it works by propagating combined ``comes_from`` metadata as a string in the combined constraints. The result for the above requirements looks like this:
```
$ python -m piptools compile requirements.in
Could not find a version that matches google-cloud-bigquery<1.7.0,>=1.6.0,~=1.14.0 (from apache-beam[gcp]==2.13.0->tensorflow-data-validation==0.13.1->-r requirements.in (line 5), -r requirements.in (line 3))
Tried: 0.20.0, 0.20.0, 0.21.0, 0.21.0, 0.22.0, 0.22.0, 0.22.1, 0.22.1, 0.23.0, 0.23.0, 0.24.0, 0.24.0, 0.25.0, 0.25.0, 0.26.0, 0.26.0, 0.27.0, 0.27.0, 0.28.0, 0.28.0, 0.29.0, 0.29.0, 0.30.0, 0.30.0, 0.31.0, 0.31.0, 0.32.0, 0.32.0, 1.0.0, 1.0.0, 1.1.0, 1.1.0, 1.2.0, 1.2.0, 1.3.0, 1.3.0, 1.4.0, 1.4.0, 1.5.0, 1.5.0, 1.5.1, 1.5.1, 1.6.0, 1.6.0, 1.6.1, 1.6.1, 1.7.0, 1.7.0, 1.8.0, 1.8.0, 1.8.1, 1.8.1, 1.9.0, 1.9.0, 1.10.0, 1.10.0, 1.11.1, 1.11.1, 1.11.2, 1.11.2, 1.11.3, 1.11.3, 1.12.0, 1.12.0, 1.12.1, 1.12.1, 1.13.0, 1.13.0, 1.14.0, 1.14.0, 1.15.0, 1.15.0
There are incompatible versions in the resolved dependencies.

```
It's not perfect or pretty, but it makes debugging these kinds of dependency conflicts *much* easier. 

My solution is admittedly a bit of a hack (which is why this is an issue rather than a pull request) but I was wondering if there are better solutions I am overlooking, or if you'd be open to a contribution along these lines if I put in some effort to clean up my approach.

Thanks!

Pull request:
Print provenance information when pip-compile fails
For motivation, see #836

For the following ``requirements.in`` file:
```
tensorflow-data-validation
google-cloud-bigquery~=1.14.0
```
Before this change, the output is this:
```bash
$ python -m piptools compile requirements.in
Could not find a version that matches google-cloud-bigquery<1.7.0,>=1.6.0,~=1.14.0
Tried: 0.20.0, 0.20.0, 0.21.0, 0.21.0, 0.22.0, 0.22.0, 0.22.1, 0.22.1, 0.23.0, 0.23.0, 0.24.0, 0.24.0, 0.25.0, 0.25.0, 0.26.0, 0.26.0, 0.27.0, 0.27.0, 0.28.0, 0.28.0, 0.29.0, 0.29.0, 0.30.0, 0.30.0, 0.31.0, 0.31.0, 0.32.0, 0.32.0, 1.0.0, 1.0.0, 1.1.0, 1.1.0, 1.2.0, 1.2.0, 1.3.0, 1.3.0, 1.4.0, 1.4.0, 1.5.0, 1.5.0, 1.5.1, 1.5.1, 1.6.0, 1.6.0, 1.6.1, 1.6.1, 1.7.0, 1.7.0, 1.8.0, 1.8.0, 1.8.1, 1.8.1, 1.9.0, 1.9.0, 1.10.0, 1.10.0, 1.11.1, 1.11.1, 1.11.2, 1.11.2, 1.11.3, 1.11.3, 1.12.0, 1.12.0, 1.12.1, 1.12.1, 1.13.0, 1.13.0, 1.14.0, 1.14.0, 1.15.0, 1.15.0
There are incompatible versions in the resolved dependencies.
```
With this change, the output is this:
```bash
$ python -m piptools compile requirements.in
Could not find a version that matches google-cloud-bigquery<1.7.0,>=1.6.0,~=1.14.0 (from -r requirements.in (line 2))
Tried: 0.20.0, 0.20.0, 0.21.0, 0.21.0, 0.22.0, 0.22.0, 0.22.1, 0.22.1, 0.23.0, 0.23.0, 0.24.0, 0.24.0, 0.25.0, 0.25.0, 0.26.0, 0.26.0, 0.27.0, 0.27.0, 0.28.0, 0.28.0, 0.29.0, 0.29.0, 0.30.0, 0.30.0, 0.31.0, 0.31.0, 0.32.0, 0.32.0, 1.0.0, 1.0.0, 1.1.0, 1.1.0, 1.2.0, 1.2.0, 1.3.0, 1.3.0, 1.4.0, 1.4.0, 1.5.0, 1.5.0, 1.5.1, 1.5.1, 1.6.0, 1.6.0, 1.6.1, 1.6.1, 1.7.0, 1.7.0, 1.8.0, 1.8.0, 1.8.1, 1.8.1, 1.9.0, 1.9.0, 1.10.0, 1.10.0, 1.11.1, 1.11.1, 1.11.2, 1.11.2, 1.11.3, 1.11.3, 1.12.0, 1.12.0, 1.12.1, 1.12.1, 1.13.0, 1.13.0, 1.14.0, 1.14.0, 1.15.0, 1.15.0
There are incompatible versions in the resolved dependencies:
  google-cloud-bigquery~=1.14.0 (from -r requirements.in (line 2))
  google-cloud-bigquery<1.7.0,>=1.6.0 (from apache-beam[gcp]==2.13.0->tensorflow-data-validation==0.13.1->-r requirements.in (line 1))
```

This involves adding a piptools-specific attribute to combined ``InstallRequirements`` objects. This might be cleaner if we used a class-based approach (defining, e.g. a ``CombinedInstallRequirements`` class) but this is difficult to do cleanly because pip's ``InstallRequirements`` class is an internal implementation that changes often between pip versions.

**Changelog-friendly one-liner**: Print provenance information when pip-compile fails (Fixes #836)

##### Contributor checklist

- [X] Provided the tests for the changes.
- [x] Requested a review from another contributor.
- [X] Gave a clear one-line description in the PR (that the maintainers can add to CHANGELOG.md on release).
- [X] Assign the PR to an existing or new milestone for the target version (following [Semantic Versioning](https://blog.versioneye.com/2014/01/16/semantic-versioning/)).

Patch:
diff --git a/piptools/_compat/__init__.py b/piptools/_compat/__init__.py
index acdf3db..51a3aac 100644
--- a/piptools/_compat/__init__.py
+++ b/piptools/_compat/__init__.py
@@ -9,6 +9,7 @@ from .pip_compat import (
     FAVORITE_HASH,
     Command,
     FormatControl,
+    InstallationCandidate,
     InstallRequirement,
     Link,
     PackageFinder,
diff --git a/piptools/_compat/pip_compat.py b/piptools/_compat/pip_compat.py
index 0a199c5..9093d9e 100644
--- a/piptools/_compat/pip_compat.py
+++ b/piptools/_compat/pip_compat.py
@@ -25,6 +25,9 @@ def do_import(module_path, subimport=None, old_path=None):
 
 
 InstallRequirement = do_import("req.req_install", "InstallRequirement")
+InstallationCandidate = do_import(
+    "models.candidate", "InstallationCandidate", old_path="index"
+)
 parse_requirements = do_import("req.req_file", "parse_requirements")
 RequirementSet = do_import("req.req_set", "RequirementSet")
 user_cache_dir = do_import("utils.appdirs", "user_cache_dir")
diff --git a/piptools/exceptions.py b/piptools/exceptions.py
index 76a409e..5aac84b 100644
--- a/piptools/exceptions.py
+++ b/piptools/exceptions.py
@@ -35,8 +35,10 @@ class NoCandidateFound(PipToolsError):
 
         if versions or pre_versions:
             lines.append(
-                "There are incompatible versions in the resolved dependencies."
+                "There are incompatible versions in the resolved dependencies:"
             )
+            source_ireqs = getattr(self.ireq, "_source_ireqs", [])
+            lines.extend("  {}".format(ireq) for ireq in source_ireqs)
         else:
             lines.append("No versions found")
             lines.append(
diff --git a/piptools/resolver.py b/piptools/resolver.py
index fe7b302..42d299a 100644
--- a/piptools/resolver.py
+++ b/piptools/resolver.py
@@ -46,6 +46,51 @@ class RequirementSummary(object):
         return repr([self.key, self.specifier, self.extras])
 
 
+def combine_install_requirements(ireqs):
+    """
+    Return a single install requirement that reflects a combination of
+    all the inputs.
+    """
+    # We will store the source ireqs in a _source_ireqs attribute;
+    # if any of the inputs have this, then use those sources directly.
+    source_ireqs = []
+    for ireq in ireqs:
+        source_ireqs.extend(getattr(ireq, "_source_ireqs", [ireq]))
+    source_ireqs.sort(key=str)
+
+    # deepcopy the accumulator so as to not modify the inputs
+    combined_ireq = copy.deepcopy(source_ireqs[0])
+    for ireq in source_ireqs[1:]:
+        # NOTE we may be losing some info on dropped reqs here
+        combined_ireq.req.specifier &= ireq.req.specifier
+        combined_ireq.constraint &= ireq.constraint
+        # Return a sorted, de-duped tuple of extras
+        combined_ireq.extras = tuple(
+            sorted(set(tuple(combined_ireq.extras) + tuple(ireq.extras)))
+        )
+
+    # InstallRequirements objects are assumed to come from only one source, and
+    # so they support only a single comes_from entry. This function breaks this
+    # model. As a workaround, we deterministically choose a single source for
+    # the comes_from entry, and add an extra _source_ireqs attribute to keep
+    # track of multiple sources for use within pip-tools.
+    if len(source_ireqs) > 1:
+        if any(ireq.comes_from is None for ireq in source_ireqs):
+            # None indicates package was directly specified.
+            combined_ireq.comes_from = None
+        else:
+            # Populate the comes_from field from one of the sources.
+            # Requirement input order is not stable, so we need to sort:
+            # We choose the shortest entry in order to keep the printed
+            # representation as concise as possible.
+            combined_ireq.comes_from = min(
+                (ireq.comes_from for ireq in source_ireqs),
+                key=lambda x: (len(str(x)), str(x)),
+            )
+        combined_ireq._source_ireqs = source_ireqs
+    return combined_ireq
+
+
 class Resolver(object):
     def __init__(
         self,
@@ -181,20 +226,7 @@ class Resolver(object):
                 yield editable_ireq
                 continue
 
-            ireqs = iter(ireqs)
-            # deepcopy the accumulator so as to not modify
-            # the self.our_constraints invariant
-            combined_ireq = copy.deepcopy(next(ireqs))
-            combined_ireq.comes_from = None
-            for ireq in ireqs:
-                # NOTE we may be losing some info on dropped reqs here
-                combined_ireq.req.specifier &= ireq.req.specifier
-                combined_ireq.constraint &= ireq.constraint
-                # Return a sorted, de-duped tuple of extras
-                combined_ireq.extras = tuple(
-                    sorted(set(tuple(combined_ireq.extras) + tuple(ireq.extras)))
-                )
-            yield combined_ireq
+            yield combine_install_requirements(ireqs)
 
     def _resolve_one_round(self):
         """
@@ -287,6 +319,7 @@ class Resolver(object):
                 format_requirement(best_match), format_specifier(ireq)
             )
         )
+        best_match.comes_from = ireq.comes_from
         return best_match
 
     def _iter_dependencies(self, ireq):
@@ -330,7 +363,9 @@ class Resolver(object):
             )
         )
         for dependency_string in dependency_strings:
-            yield install_req_from_line(dependency_string, constraint=ireq.constraint)
+            yield install_req_from_line(
+                dependency_string, constraint=ireq.constraint, comes_from=ireq
+            )
 
     def reverse_dependencies(self, ireqs):
         non_editable = [


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e4d11bf..07835a8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -7,7 +7,11 @@ from pip._vendor.packaging.version import Version
 from pip._vendor.pkg_resources import Requirement
 from pytest import fixture
 
-from piptools._compat import install_req_from_editable, install_req_from_line
+from piptools._compat import (
+    InstallationCandidate,
+    install_req_from_editable,
+    install_req_from_line,
+)
 from piptools.cache import DependencyCache
 from piptools.exceptions import NoCandidateFound
 from piptools.repositories.base import BaseRepository
@@ -40,9 +44,11 @@ class FakeRepository(BaseRepository):
             )
         )
         if not versions:
-            raise NoCandidateFound(
-                ireq, self.index[key_from_req(ireq.req)], ["https://fake.url.foo"]
-            )
+            tried_versions = [
+                InstallationCandidate(ireq.name, version, "https://fake.url.foo")
+                for version in self.index[key_from_req(ireq.req)]
+            ]
+            raise NoCandidateFound(ireq, tried_versions, ["https://fake.url.foo"])
         best_version = max(versions, key=Version)
         return make_install_requirement(
             key_from_req(ireq.req),
diff --git a/tests/test_minimal_upgrade.py b/tests/test_minimal_upgrade.py
index ec5eb62..e2b5f73 100644
--- a/tests/test_minimal_upgrade.py
+++ b/tests/test_minimal_upgrade.py
@@ -21,12 +21,12 @@ from piptools.utils import name_from_req
                 [
                     # Add flask and upgrade werkzeug from incompatible 0.6
                     "flask==0.10.1",
-                    "itsdangerous==0.24",
+                    "itsdangerous==0.24 (from flask==0.10.1)",
                     "werkzeug==0.10.4",
                     # Other requirements are unchanged from
                     # the original requirements.txt
                     "jinja2==2.7.3",
-                    "markupsafe==0.23",
+                    "markupsafe==0.23 (from jinja2==2.7.3)",
                 ],
             )
         ]
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
index a4678c3..5fb6cc7 100644
--- a/tests/test_resolver.py
+++ b/tests/test_resolver.py
@@ -1,5 +1,8 @@
 import pytest
 
+from piptools.exceptions import NoCandidateFound
+from piptools.resolver import combine_install_requirements
+
 
 @pytest.mark.parametrize(
     ("input", "expected", "prereleases", "unsafe_constraints"),
@@ -11,10 +14,10 @@ import pytest
                 ["Flask"],
                 [
                     "flask==0.10.1",
-                    "itsdangerous==0.24",
-                    "markupsafe==0.23",
-                    "jinja2==2.7.3",
-                    "werkzeug==0.10.4",
+                    "itsdangerous==0.24 (from flask==0.10.1)",
+                    "markupsafe==0.23 (from jinja2==2.7.3->flask==0.10.1)",
+                    "jinja2==2.7.3 (from flask==0.10.1)",
+                    "werkzeug==0.10.4 (from flask==0.10.1)",
                 ],
             ),
             (["Jinja2", "markupsafe"], ["jinja2==2.7.3", "markupsafe==0.23"]),
@@ -23,17 +26,20 @@ import pytest
             # We should return the prerelease version if prereleases is True
             (["SQLAlchemy"], ["sqlalchemy==1.0.0b5"], True),
             # Ipython has extras available, but we don't require them in this test
-            (["ipython"], ["ipython==2.1.0", "gnureadline==6.3.3"]),
+            (
+                ["ipython"],
+                ["ipython==2.1.0", "gnureadline==6.3.3 (from ipython==2.1.0)"],
+            ),
             # We should get dependencies for extras
             (
                 ["ipython[notebook]"],
                 [
                     "ipython[notebook]==2.1.0",
-                    "pyzmq==2.1.12",
-                    "jinja2==2.7.3",
-                    "tornado==3.2.2",
-                    "markupsafe==0.23",
-                    "gnureadline==6.3.3",
+                    "pyzmq==2.1.12 (from ipython[notebook]==2.1.0)",
+                    "jinja2==2.7.3 (from ipython[notebook]==2.1.0)",
+                    "tornado==3.2.2 (from ipython[notebook]==2.1.0)",
+                    "markupsafe==0.23 (from jinja2==2.7.3->ipython[notebook]==2.1.0)",
+                    "gnureadline==6.3.3 (from ipython[notebook]==2.1.0)",
                 ],
             ),
             # We should get dependencies for multiple extras
@@ -42,13 +48,16 @@ import pytest
                 [
                     # Note that the extras should be sorted
                     "ipython[nbconvert,notebook]==2.1.0",
-                    "pyzmq==2.1.12",
-                    "jinja2==2.7.3",
-                    "tornado==3.2.2",
-                    "markupsafe==0.23",
-                    "gnureadline==6.3.3",
-                    "pygments==1.5",
-                    "sphinx==0.3",
+                    "pyzmq==2.1.12 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "jinja2==2.7.3 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "tornado==3.2.2 (from ipython[nbconvert,notebook]==2.1.0)",
+                    (
+                        "markupsafe==0.23 "
+                        "(from jinja2==2.7.3->ipython[nbconvert,notebook]==2.1.0)"
+                    ),
+                    "gnureadline==6.3.3 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "pygments==1.5 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "sphinx==0.3 (from ipython[nbconvert,notebook]==2.1.0)",
                 ],
             ),
             # We must take the union of all extras
@@ -57,13 +66,16 @@ import pytest
                 [
                     # Note that the extras should be sorted
                     "ipython[nbconvert,notebook]==2.1.0",
-                    "pyzmq==2.1.12",
-                    "jinja2==2.7.3",
-                    "tornado==3.2.2",
-                    "markupsafe==0.23",
-                    "gnureadline==6.3.3",
-                    "pygments==1.5",
-                    "sphinx==0.3",
+                    "pyzmq==2.1.12 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "jinja2==2.7.3 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "tornado==3.2.2 (from ipython[nbconvert,notebook]==2.1.0)",
+                    (
+                        "markupsafe==0.23 "
+                        "(from jinja2==2.7.3->ipython[nbconvert,notebook]==2.1.0)"
+                    ),
+                    "gnureadline==6.3.3 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "pygments==1.5 (from ipython[nbconvert,notebook]==2.1.0)",
+                    "sphinx==0.3 (from ipython[nbconvert,notebook]==2.1.0)",
                 ],
             ),
             # We must remove child dependencies from result if parent
@@ -75,13 +87,13 @@ import pytest
             (
                 ["celery<=3.1.23", "librabbitmq"],
                 [
-                    "amqp==1.4.9",
-                    "anyjson==0.3.3",
-                    "billiard==3.5.0.2",
+                    "amqp==1.4.9 (from librabbitmq==1.6.1)",
+                    "anyjson==0.3.3 (from kombu==3.0.35->celery==3.1.23)",
+                    "billiard==3.5.0.2 (from celery==3.1.23)",
                     "celery==3.1.23",
-                    "kombu==3.0.35",
+                    "kombu==3.0.35 (from celery==3.1.23)",
                     "librabbitmq==1.6.1",
-                    "pytz==2016.4",
+                    "pytz==2016.4 (from celery==3.1.23)",
                 ],
             ),
             # Support specifying loose top-level requirements that could also appear as
@@ -89,17 +101,22 @@ import pytest
             (
                 ["billiard", "celery", "fake-piptools-test-with-pinned-deps"],
                 [
-                    "amqp==1.4.9",
-                    "anyjson==0.3.3",
+                    "amqp==1.4.9 (from kombu==3.0.35->celery==3.1.18)",
+                    "anyjson==0.3.3 (from kombu==3.0.35->celery==3.1.18)",
                     "billiard==3.3.0.23",
                     "celery==3.1.18",  # this is pinned from test subdependency
                     "fake-piptools-test-with-pinned-deps==0.1",
-                    "kombu==3.0.35",
-                    "pytz==2016.4",
+                    "kombu==3.0.35 (from celery==3.1.18)",
+                    "pytz==2016.4 (from celery==3.1.18)",
                 ],
             ),
             # Exclude package dependcy of setuptools as it is unsafe.
-            (["html5lib"], ["html5lib==0.999999999"], False, {"setuptools==35.0.0"}),
+            (
+                ["html5lib"],
+                ["html5lib==0.999999999"],
+                False,
+                {"setuptools==35.0.0 (from html5lib==0.999999999)"},
+            ),
             # We shouldn't include irrelevant pip constraints
             # See: GH-471
             (
@@ -107,9 +124,9 @@ import pytest
                 [
                     "flask==0.10.1",
                     "itsdangerous==0.24",
-                    "markupsafe==0.23",
-                    "jinja2==2.7.3",
-                    "werkzeug==0.10.4",
+                    "markupsafe==0.23 (from jinja2==2.7.3->flask==0.10.1)",
+                    "jinja2==2.7.3 (from flask==0.10.1)",
+                    "werkzeug==0.10.4 (from flask==0.10.1)",
                 ],
             ),
             # Unsafe dependencies should be filtered
@@ -123,7 +140,20 @@ import pytest
                 ["fake-piptools-test-with-unsafe-deps==0.1"],
                 ["fake-piptools-test-with-unsafe-deps==0.1"],
                 False,
-                {"setuptools==34.0.0", "appdirs==1.4.9", "packaging==16.8"},
+                {
+                    (
+                        "setuptools==34.0.0 (from "
+                        "fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
+                    (
+                        "appdirs==1.4.9 (from "
+                        "setuptools==34.0.0->fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
+                    (
+                        "packaging==16.8 (from "
+                        "setuptools==34.0.0->fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
+                },
             ),
         ]
     ),
@@ -147,14 +177,27 @@ def test_resolver(
         for tup in [
             (
                 ["setuptools==34.0.0"],
-                ["appdirs==1.4.9", "setuptools==34.0.0", "packaging==16.8"],
+                [
+                    "appdirs==1.4.9 (from setuptools==34.0.0)",
+                    "packaging==16.8 (from setuptools==34.0.0)",
+                    "setuptools==34.0.0",
+                ],
             ),
             (
                 ["fake-piptools-test-with-unsafe-deps==0.1"],
                 [
-                    "appdirs==1.4.9",
-                    "setuptools==34.0.0",
-                    "packaging==16.8",
+                    (
+                        "appdirs==1.4.9 (from "
+                        "setuptools==34.0.0->fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
+                    (
+                        "setuptools==34.0.0 "
+                        "(from fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
+                    (
+                        "packaging==16.8 (from "
+                        "setuptools==34.0.0->fake-piptools-test-with-unsafe-deps==0.1)"
+                    ),
                     "fake-piptools-test-with-unsafe-deps==0.1",
                 ],
             ),
@@ -191,3 +234,38 @@ def test_iter_dependencies(resolver, from_line):
         TypeError, match="Expected pinned or editable requirement, got django>=1.8"
     ):
         next(res._iter_dependencies(ireq))
+
+
+def test_combine_install_requirements(from_line):
+    celery30 = from_line("celery>3.0", comes_from="-r requirements.in")
+    celery31 = from_line("celery==3.1.1", comes_from=from_line("fake-package"))
+    celery32 = from_line("celery<3.2")
+
+    combined = combine_install_requirements([celery30, celery31])
+    assert combined.comes_from == celery31.comes_from  # shortest string
+    assert set(combined._source_ireqs) == {celery30, celery31}
+    assert str(combined.req.specifier) == "==3.1.1,>3.0"
+
+    combined_all = combine_install_requirements([celery32, combined])
+    assert combined_all.comes_from is None
+    assert set(combined_all._source_ireqs) == {celery30, celery31, celery32}
+    assert str(combined_all.req.specifier) == "<3.2,==3.1.1,>3.0"
+
+
+def test_compile_failure_shows_provenance(resolver, from_line):
+    """
+    Provenance of conflicting dependencies should be printed on failure.
+    """
+    requirements = [
+        from_line("fake-piptools-test-with-pinned-deps==0.1"),
+        from_line("celery>3.2"),
+    ]
+
+    with pytest.raises(NoCandidateFound) as err:
+        resolver(requirements).resolve()
+    lines = str(err.value).splitlines()
+    assert (
+        lines[-2].strip()
+        == "celery==3.1.18 (from fake-piptools-test-with-pinned-deps==0.1)"
+    )
+    assert lines[-1].strip() == "celery>3.2"