Repository: pex-tool/pex

Issue:
Improve error message when interpreters cannot be found
Current:

```
pex --interpreter-constraint='CPython>=3.8.*'
Could not find compatible interpreter
```

This is a fine message for trivial constraints, especially when using Pex directly through the CLI. But, this is hard to interpret when there are complex constraints and when Pex is used programmatically, such as Pants passing often quite complex constraints to Pex.

An ideal message will
a) output all requested constraints
b) identify which interpreters were recognized by Pex
c) offer suggestions to resolve the issue

See https://github.com/pantsbuild/pants/pull/7628 for possible inspiration.


Discussion:
I don't think `c` can be satisfied without stating the obvious, but how about:
```
$ python -m pex --interpreter-constraint='>=3.9'
Examined the following interpreters:
	/opt/pypy/bin/pypy PyPy==2.7.13
	/usr/bin/python2.7 CPython==2.7.17
	/usr/bin/python3.5 CPython==3.5.9
	/usr/bin/python3.6 CPython==3.6.10
	/usr/bin/python3.7 CPython==3.7.7
	/usr/bin/python3.8 CPython==3.8.2
None were compatible with the requested constraints:
	>=3.9
Could not find compatible interpreter
```
c) is indeed less relevant with Pex than Pants. In Pants, this is the message we use:

```
Possible ways to fix this:
* Modify your Python interpreter constraints by following https://www.pantsbuild.org/python_readme.html#configure-the-python-version.
* Ensure the targeted Python version is installed and discoverable.
* Modify Pants' interpreter search paths via --pants-setup-interpreter-search-paths.
```

The only one that we could bring from this to Pex is `Ensure the targeted Python version is installed and discoverable.`. Maybe:

> Ensure that the targeted Python version is installed and discoverable, or tweak your constraints to work with one of the discovered interpreters.

Indeed, this is obvious to us Pants devs, but think about a first time user (e.g. a summer intern) using Pex/Pants for the first time. This at least gives 2 paths forward. Thinking back to my first week at Foursquare two summers ago, this sentence would have helped me.

--

I really like your suggestion. The only tweak would be moving "Could not find compatible interpreter" to the very top of the message so that you know what the issue is, and then are presented with helpful information to debug.
Since I have a branch I'm going to take this over and post for further discussion.

Resolving patch:
diff --git a/pex/bin/pex.py b/pex/bin/pex.py
--- a/pex/bin/pex.py
+++ b/pex/bin/pex.py
@@ -15,7 +15,10 @@
 
 from pex.common import die, safe_delete, safe_mkdtemp
 from pex.interpreter import PythonInterpreter
-from pex.interpreter_constraints import validate_constraints
+from pex.interpreter_constraints import (
+    UnsatisfiableInterpreterConstraintsError,
+    validate_constraints
+)
 from pex.jobs import DEFAULT_MAX_JOBS
 from pex.pex import PEX
 from pex.pex_bootstrapper import iter_compatible_interpreters
@@ -528,10 +531,10 @@ def to_python_interpreter(full_path_or_basename):
         pex_python_path = rc_variables.get('PEX_PYTHON_PATH', None)
       else:
         pex_python_path = None
-      interpreters = list(iter_compatible_interpreters(pex_python_path, constraints))
-      if not interpreters:
-        die('Could not find compatible interpreter for constraints {}'.format(
-            ' or '.join(constraints)), CANNOT_SETUP_INTERPRETER)
+      try:
+        interpreters = list(iter_compatible_interpreters(pex_python_path, constraints))
+      except UnsatisfiableInterpreterConstraintsError as e:
+        die(e.create_message('Could not find a compatible interpreter.'), CANNOT_SETUP_INTERPRETER)
 
   try:
     with open(options.preamble_file) as preamble_fd:
diff --git a/pex/interpreter_constraints.py b/pex/interpreter_constraints.py
--- a/pex/interpreter_constraints.py
+++ b/pex/interpreter_constraints.py
@@ -21,6 +21,44 @@ def validate_constraints(constraints):
       die("Compatibility requirements are not formatted properly: %s" % str(e))
 
 
+class UnsatisfiableInterpreterConstraintsError(Exception):
+  """Indicates interpreter constraints could not be satisfied."""
+
+  def __init__(self, constraints, candidates):
+    """
+    :param constraints: The constraints that could not be satisfied.
+    :type constraints: iterable of str
+    :param candidates: The python interpreters that were compared against the constraints.
+    :type candidates: iterable of :class:`pex.interpreter.PythonInterpreter`
+    """
+    self.constraints = tuple(constraints)
+    self.candidates = tuple(candidates)
+    super(UnsatisfiableInterpreterConstraintsError, self).__init__(self.create_message())
+
+  def create_message(self, preamble=None):
+    """Create a message describing  failure to find matching interpreters with an optional preamble.
+
+    :param str preamble: An optional preamble to the message that will be displayed above it
+                         separated by an empty blank line.
+    :return: A descriptive message useable for display to an end user.
+    :rtype: str
+    """
+    binary_column_width = max(len(candidate.binary) for candidate in self.candidates)
+    interpreters_format = '{{binary: >{}}} {{requirement}}'.format(binary_column_width)
+    return (
+      '{preamble}'
+      'Examined the following interpreters:\n  {interpreters}\n\n'
+      'None were compatible with the requested constraints:\n  {constraints}'
+    ).format(
+      preamble='{}\n\n'.format(preamble) if preamble else '',
+      interpreters='\n  '.join(interpreters_format.format(
+        binary=candidate.binary,
+        requirement=candidate.identity.requirement
+      ) for candidate in self.candidates),
+      constraints='\n  '.join(self.constraints)
+    )
+
+
 def matched_interpreters_iter(interpreters_iter, constraints):
   """Given some filters, yield any interpreter that matches at least one of them.
 
@@ -29,10 +67,23 @@ def matched_interpreters_iter(interpreters_iter, constraints):
     pex. Each string uses the Requirement-style format, e.g. 'CPython>=3' or '>=2.7,<3' for
     requirements agnostic to interpreter class. Multiple requirement strings may be combined
     into a list to OR the constraints, such as ['CPython>=2.7,<3', 'CPython>=3.4'].
-  :return interpreter: returns a generator that yields compatible interpreters
+  :return: returns a generator that yields compatible interpreters
+  :raises: :class:`UnsatisfiableInterpreterConstraintsError` if constraints were given and could
+           not be satisfied. The exception will only be raised when the returned generator is fully
+           consumed.
   """
+  candidates = []
+  found = False
+
   for interpreter in interpreters_iter:
     if any(interpreter.identity.matches(filt) for filt in constraints):
       TRACER.log("Constraints on interpreters: %s, Matching Interpreter: %s"
                  % (constraints, interpreter.binary), V=3)
+      found = True
       yield interpreter
+
+    if not found:
+      candidates.append(interpreter)
+
+  if not found:
+    raise UnsatisfiableInterpreterConstraintsError(constraints, candidates)
diff --git a/pex/pex_bootstrapper.py b/pex/pex_bootstrapper.py
--- a/pex/pex_bootstrapper.py
+++ b/pex/pex_bootstrapper.py
@@ -10,7 +10,10 @@
 from pex.common import die
 from pex.executor import Executor
 from pex.interpreter import PythonInterpreter
-from pex.interpreter_constraints import matched_interpreters_iter
+from pex.interpreter_constraints import (
+    UnsatisfiableInterpreterConstraintsError,
+    matched_interpreters_iter
+)
 from pex.orderedset import OrderedSet
 from pex.tracer import TRACER
 from pex.variables import ENV
@@ -96,11 +99,11 @@ def _select_pex_python_interpreter(pex_python, compatibility_constraints=None):
     _iter_pex_python(pex_python),
     compatibility_constraints=compatibility_constraints
   )
-  selected = _select_interpreter(compatible_interpreters_iter)
-  if not selected:
-    die('Failed to find a compatible PEX_PYTHON={} for constraints: {}'
-        .format(pex_python, compatibility_constraints))
-  return selected
+  try:
+    return _select_interpreter(compatible_interpreters_iter)
+  except UnsatisfiableInterpreterConstraintsError as e:
+    die(e.create_message('Failed to find a compatible PEX_PYTHON={pex_python}.'
+                         .format(pex_python=pex_python)))
 
 
 def _select_path_interpreter(path=None, compatibility_constraints=None):
@@ -108,11 +111,11 @@ def _select_path_interpreter(path=None, compatibility_constraints=None):
     path=path,
     compatibility_constraints=compatibility_constraints
   )
-  selected = _select_interpreter(compatible_interpreters_iter)
-  if not selected:
-    die('Failed to find compatible interpreter on path {} for constraints: {}'
-        .format(path or os.getenv('PATH'), compatibility_constraints))
-  return selected
+  try:
+    return _select_interpreter(compatible_interpreters_iter)
+  except UnsatisfiableInterpreterConstraintsError as e:
+    die(e.create_message('Failed to find compatible interpreter on path {path}.'
+                         .format(path=path or os.getenv('PATH'))))
 
 
 def _select_interpreter(candidate_interpreters_iter):


Test patch:
diff --git a/tests/test_integration.py b/tests/test_integration.py
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -599,8 +599,7 @@ def test_pex_python():
     stdin_payload = b'import sys; print(sys.executable); sys.exit(0)'
     stdout, rc = run_simple_pex(pex_out_path, stdin=stdin_payload, env=env)
     assert rc == 1
-    fail_str = ('Failed to find a compatible PEX_PYTHON={} for constraints'
-                .format(pex_python)).encode()
+    fail_str = ('Failed to find a compatible PEX_PYTHON={}.'.format(pex_python)).encode()
     assert fail_str in stdout
 
     # test PEX_PYTHON with no constraints
diff --git a/tests/test_pex_bootstrapper.py b/tests/test_pex_bootstrapper.py
--- a/tests/test_pex_bootstrapper.py
+++ b/tests/test_pex_bootstrapper.py
@@ -5,7 +5,10 @@
 import sys
 from textwrap import dedent
 
+import pytest
+
 from pex.interpreter import PythonInterpreter
+from pex.interpreter_constraints import UnsatisfiableInterpreterConstraintsError
 from pex.pex_bootstrapper import iter_compatible_interpreters
 from pex.testing import PY27, PY35, PY36, ensure_python_interpreter
 
@@ -29,9 +32,14 @@ def test_find_compatible_interpreters():
   assert [py35] == find_interpreters(path, '>{}, <{}'.format(PY27, PY36))
   assert [py36] == find_interpreters(path, '>=3.6')
 
-  assert [] == find_interpreters(path, '<2')
-  assert [] == find_interpreters(path, '>4')
-  assert [] == find_interpreters(path, '>{}, <{}'.format(PY27, PY35))
+  with pytest.raises(UnsatisfiableInterpreterConstraintsError):
+    find_interpreters(path, '<2')
+
+  with pytest.raises(UnsatisfiableInterpreterConstraintsError):
+    find_interpreters(path, '>4')
+
+  with pytest.raises(UnsatisfiableInterpreterConstraintsError):
+    find_interpreters(path, '>{}, <{}'.format(PY27, PY35))
 
   # All interpreters on PATH including whatever interpreter is currently running.
   all_known_interpreters = set(PythonInterpreter.all())