Repository: pex-tool/pex

Issue:
Resolve error checking does not account for environment markers.
#828 introduced build-time detection of resolution errors but this new facility can trip erroneously when environment markers are in-play. For example, as discovered upgrading Pants to Pex 2.1.0 in https://github.com/pantsbuild/pants/pull/8881:
```
$ python2 -mpex --disable-cache --interpreter-constraint "CPython==3.6.*" pytest pytest-cov pytest-rerunfailures pytest-timeout setuptools==44.0.0
Failed to resolve compatible distributions:
1: importlib-metadata==1.4.0 requires configparser>=3.5; python_version < "3" but None was resolved
2: importlib-metadata==1.4.0 requires contextlib2; python_version < "3" but None was resolved
3: importlib-metadata==1.4.0 requires pathlib2; python_version < "3" but None was resolved
4: pytest==5.3.3 requires pathlib2>=2.2.0; python_version < "3.6" but None was resolved
```

Here it is correct to not resolve any of the 4 mentioned items since they all only apply for python <3.6 whereas the pex is being built for 3.6.


Discussion:

Resolving patch:
diff --git a/pex/distribution_target.py b/pex/distribution_target.py
--- a/pex/distribution_target.py
+++ b/pex/distribution_target.py
@@ -38,6 +38,21 @@ def get_interpreter(self):
   def get_platform(self):
     return self._platform or Platform.current()
 
+  def requirement_applies(self, requirement):
+    """Determines if the given requirement applies to this distribution target.
+
+    :param requirement: The requirement to evaluate.
+    :type requirement: :class:`pex.third_party.pkg_resources.Requirement`
+    :rtype: bool
+    """
+    if not requirement.marker:
+      return True
+
+    if self._interpreter is None:
+      return True
+
+    return requirement.marker.evaluate(self._interpreter.identity.env_markers)
+
   @property
   def id(self):
     """A unique id for a resolve target suitable as a path name component.
diff --git a/pex/resolver.py b/pex/resolver.py
--- a/pex/resolver.py
+++ b/pex/resolver.py
@@ -33,13 +33,15 @@ class Unsatisfiable(Exception):
   pass
 
 
-class ResolvedDistribution(namedtuple('ResolvedDistribution', ['requirement', 'distribution'])):
-  """A requirement and the resolved distribution that satisfies it."""
+class ResolvedDistribution(namedtuple('ResolvedDistribution',
+                                      ['target', 'requirement', 'distribution'])):
+  """A distribution target, requirement and the resolved distribution that satisfies them both."""
 
-  def __new__(cls, requirement, distribution):
+  def __new__(cls, target, requirement, distribution):
+    assert isinstance(target, DistributionTarget)
     assert isinstance(requirement, Requirement)
     assert isinstance(distribution, Distribution)
-    return super(ResolvedDistribution, cls).__new__(cls, requirement, distribution)
+    return super(ResolvedDistribution, cls).__new__(cls, target, requirement, distribution)
 
 
 class DistributionRequirements(object):
@@ -565,6 +567,7 @@ def add_requirements_requests(install_result):
       for distribution in requirements_request.distributions:
         resolved_distributions.add(
           ResolvedDistribution(
+            target=requirements_request.target,
             requirement=distribution_requirements.to_requirement(distribution),
             distribution=distribution
           )
@@ -575,23 +578,37 @@ def add_requirements_requests(install_result):
     return resolved_distributions
 
   def _check_resolve(self, resolved_distributions):
-    dist_by_key = OrderedDict(
-      (resolved_distribution.requirement.key, resolved_distribution.distribution)
+    resolved_distribution_by_key = OrderedDict(
+      (resolved_distribution.requirement.key, resolved_distribution)
       for resolved_distribution in resolved_distributions
     )
 
     unsatisfied = []
-    for dist in dist_by_key.values():
+    for resolved_distribution in resolved_distribution_by_key.values():
+      dist = resolved_distribution.distribution
+      target = resolved_distribution.target
       for requirement in dist.requires():
-        resolved_dist = dist_by_key.get(requirement.key)
-        if not resolved_dist or resolved_dist not in requirement:
+        if not target.requirement_applies(requirement):
+          continue
+
+        resolved_requirement_dist = resolved_distribution_by_key.get(requirement.key)
+        if not resolved_requirement_dist:
           unsatisfied.append(
-            '{dist} requires {requirement} but {resolved_dist} was resolved'.format(
+            '{dist} requires {requirement} but no version was resolved'.format(
               dist=dist.as_requirement(),
-              requirement=requirement,
-              resolved_dist=resolved_dist.as_requirement() if resolved_dist else None
+              requirement=requirement
             )
           )
+        else:
+          resolved_dist = resolved_requirement_dist.distribution
+          if resolved_dist not in requirement:
+            unsatisfied.append(
+              '{dist} requires {requirement} but {resolved_dist} was resolved'.format(
+                dist=dist.as_requirement(),
+                requirement=requirement,
+                resolved_dist=resolved_dist
+              )
+            )
 
     if unsatisfied:
       raise Unsatisfiable(


Test patch:
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
--- a/tests/test_resolver.py
+++ b/tests/test_resolver.py
@@ -13,6 +13,7 @@
 from pex.testing import (
     IS_LINUX,
     IS_PYPY,
+    PY27,
     PY35,
     PY36,
     PY_VER,
@@ -282,3 +283,28 @@ def test_resolve_foreign_abi3():
     'cryptography-2.8-cp34-abi3-manylinux1_x86_64.whl',
     'cryptography-2.8-cp34-abi3-macosx_10_6_intel.whl'
   } == set(wheel_names)
+
+
+def test_issues_851():
+  # Previously, the PY36 resolve would fail post-resolution checks for configparser, pathlib2 and
+  # contextlib2 which are only required for python_version<3.
+
+  def resolve_pytest(python_version, pytest_version):
+    interpreter = PythonInterpreter.from_binary(ensure_python_interpreter(python_version))
+    resolved_dists = resolve_multi(interpreters=[interpreter],
+                                           requirements=['pytest=={}'.format(pytest_version)])
+    project_to_version = {rd.requirement.key: rd.distribution.version for rd in resolved_dists}
+    assert project_to_version['pytest'] == pytest_version
+    return project_to_version
+
+  resolved_project_to_version = resolve_pytest(python_version=PY36, pytest_version='5.3.4')
+  assert 'importlib-metadata' in resolved_project_to_version
+  assert 'configparser' not in resolved_project_to_version
+  assert 'pathlib2' not in resolved_project_to_version
+  assert 'contextlib2' not in resolved_project_to_version
+
+  resolved_project_to_version = resolve_pytest(python_version=PY27, pytest_version='4.6.9')
+  assert 'importlib-metadata' in resolved_project_to_version
+  assert 'configparser' in resolved_project_to_version
+  assert 'pathlib2' in resolved_project_to_version
+  assert 'contextlib2' in resolved_project_to_version