Repository: pantsbuild/pants

Issue:
Add tests and improve documentation surrounding AND and OR for interpreter constraints.
This is confusing territory that should be documented in code, by tests and with better Target and option docs.


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/python/interpreter_cache.py b/src/python/pants/backend/python/interpreter_cache.py
--- a/src/python/pants/backend/python/interpreter_cache.py
+++ b/src/python/pants/backend/python/interpreter_cache.py
@@ -30,6 +30,10 @@ def _safe_link(src, dst):
 
 
 class PythonInterpreterCache(object):
+
+  class UnsatisfiableInterpreterConstraintsError(TaskError):
+    """Indicates a python interpreter matching given constraints could not be located."""
+
   @staticmethod
   def _matches(interpreter, filters):
     return any(interpreter.identity.matches(filt) for filt in filters)
@@ -88,9 +92,10 @@ def select_interpreter_for_targets(self, targets):
       unique_compatibilities = set(tuple(t.compatibility) for t in tgts_with_compatibilities)
       unique_compatibilities_strs = [','.join(x) for x in unique_compatibilities if x]
       tgts_with_compatibilities_strs = [t.address.spec for t in tgts_with_compatibilities]
-      raise TaskError('Unable to detect a suitable interpreter for compatibilities: {} '
-                      '(Conflicting targets: {})'.format(' && '.join(unique_compatibilities_strs),
-                                                         ', '.join(tgts_with_compatibilities_strs)))
+      raise self.UnsatisfiableInterpreterConstraintsError(
+        'Unable to detect a suitable interpreter for compatibilities: {} '
+        '(Conflicting targets: {})'.format(' && '.join(unique_compatibilities_strs),
+                                           ', '.join(tgts_with_compatibilities_strs)))
     # Return the lowest compatible interpreter.
     return min(allowed_interpreters)
 
diff --git a/src/python/pants/backend/python/subsystems/python_setup.py b/src/python/pants/backend/python/subsystems/python_setup.py
--- a/src/python/pants/backend/python/subsystems/python_setup.py
+++ b/src/python/pants/backend/python/subsystems/python_setup.py
@@ -30,9 +30,10 @@ def register_options(cls, register):
     register('--interpreter-constraints', advanced=True, default=['CPython>=2.7,<3'], type=list,
              metavar='<requirement>',
              help="Constrain the selected Python interpreter.  Specify with requirement syntax, "
-                  "e.g. 'CPython>=2.7,<3' or 'PyPy'. Multiple constraints will be ORed together. "
-                  "These constraints are applied in addition to any compatibilities required by "
-                  "the relevant targets.")
+                  "e.g. 'CPython>=2.7,<3' (A CPython interpreter with version >=2.7 AND version <3)"
+                  "or 'PyPy' (A pypy interpreter of any version). Multiple constraint strings will "
+                  "be ORed together. These constraints are applied in addition to any "
+                  "compatibilities required by the relevant targets.")
     register('--setuptools-version', advanced=True, default='30.0.0',
              help='The setuptools version for this python environment.')
     register('--wheel-version', advanced=True, default='0.29.0',
diff --git a/src/python/pants/backend/python/targets/python_target.py b/src/python/pants/backend/python/targets/python_target.py
--- a/src/python/pants/backend/python/targets/python_target.py
+++ b/src/python/pants/backend/python/targets/python_target.py
@@ -43,10 +43,12 @@ def __init__(self,
     :param provides:
       The `setup_py <#setup_py>`_ to publish that represents this
       target outside the repo.
-    :param compatibility: either a string or list of strings that represents
-      interpreter compatibility for this target, using the Requirement-style
-      format, e.g. ``'CPython>=3', or just ['>=2.7','<3']`` for requirements
-      agnostic to interpreter class.
+    :param compatibility: either a string that represents interpreter compatibility for this target
+      using the Requirement-style format, e.g. ``'CPython>=2.7,<3'`` (Select a CPython interpreter
+      with version ``>=2.7`` AND version ``<3``) or a list of Requirement-style strings which will
+      be OR'ed together. If the compatibility requirement is agnostic to interpreter class, using
+      the example above, a Requirement-style compatibility constraint like '>=2.7,<3' (N.B.: not
+      prefixed with CPython) can be used.
     """
     self.address = address
     payload = payload or Payload()


Test patch:
diff --git a/tests/python/pants_test/backend/python/tasks/test_select_interpreter.py b/tests/python/pants_test/backend/python/tasks/test_select_interpreter.py
--- a/tests/python/pants_test/backend/python/tasks/test_select_interpreter.py
+++ b/tests/python/pants_test/backend/python/tasks/test_select_interpreter.py
@@ -103,3 +103,23 @@ def test_interpreter_selection_invalidation(self):
                              dependencies=[self.tgt3], sources=['foo/bar/baz.py'])
     self.assertEquals('FakePython-2.99.999',
                       self._select_interpreter([tgtb], should_invalidate=False))
+
+  def test_compatibility_AND(self):
+    tgt = self._fake_target('tgt5', compatibility=['FakePython>2.77.777,<2.99.999'])
+    self.assertEquals('FakePython-2.88.888', self._select_interpreter([tgt]))
+
+  def test_compatibility_AND_impossible(self):
+    tgt = self._fake_target('tgt5', compatibility=['FakePython>2.77.777,<2.88.888'])
+
+    with self.assertRaises(PythonInterpreterCache.UnsatisfiableInterpreterConstraintsError):
+      self._select_interpreter([tgt])
+
+  def test_compatibility_OR(self):
+    tgt = self._fake_target('tgt6', compatibility=['FakePython>2.88.888', 'FakePython<2.7'])
+    self.assertEquals('FakePython-2.99.999', self._select_interpreter([tgt]))
+
+  def test_compatibility_OR_impossible(self):
+    tgt = self._fake_target('tgt6', compatibility=['FakePython>2.99.999', 'FakePython<2.77.777'])
+
+    with self.assertRaises(PythonInterpreterCache.UnsatisfiableInterpreterConstraintsError):
+      self._select_interpreter([tgt])