Repository: pex-tool/pex

Issue:
If sys.executable is not on PATH a pex will re-exec itself forever.
This can happen via:
```
/not/on/PATH/python a.pex
```

If `a.pex` in this example has compatibility constraints that rule out all other interpreters on `PATH` (or there are no intepreters on `PATH`!, c.f.: #53), then, even if sys.executable would be compatible to execute `a.pex` with, it is not considered useable:
https://github.com/pantsbuild/pex/blob/442ce7952674cb8a367ecded7d138661194d4f12/pex/pex_bootstrapper.py#L45-L50
https://github.com/pantsbuild/pex/blob/442ce7952674cb8a367ecded7d138661194d4f12/pex/interpreter.py#L283-L287

And so the pex_bootstrapper re-execs the pex ad infinitum:
https://github.com/pantsbuild/pex/blob/442ce7952674cb8a367ecded7d138661194d4f12/pex/pex_bootstrapper.py#L122-L137


Discussion:

Resolving patch:
diff --git a/pex/pex_bootstrapper.py b/pex/pex_bootstrapper.py
--- a/pex/pex_bootstrapper.py
+++ b/pex/pex_bootstrapper.py
@@ -11,6 +11,7 @@
 from pex.executor import Executor
 from pex.interpreter import PythonInterpreter
 from pex.interpreter_constraints import matched_interpreters
+from pex.orderedset import OrderedSet
 from pex.tracer import TRACER
 from pex.variables import ENV
 
@@ -27,7 +28,7 @@ def find_in_path(target_interpreter):
       return try_path
 
 
-def find_compatible_interpreters(pex_python_path, compatibility_constraints):
+def find_compatible_interpreters(pex_python_path=None, compatibility_constraints=None):
   """Find all compatible interpreters on the system within the supplied constraints and use
      PEX_PYTHON_PATH if it is set. If not, fall back to interpreters on $PATH.
   """
@@ -42,12 +43,12 @@ def find_compatible_interpreters(pex_python_path, compatibility_constraints):
     if not interpreters:
       die('PEX_PYTHON_PATH was defined, but no valid interpreters could be identified. Exiting.')
   else:
-    if not os.getenv('PATH', ''):
-      # no $PATH, use sys.executable
-      interpreters = [PythonInterpreter.get()]
-    else:
-      # get all qualifying interpreters found in $PATH
-      interpreters = PythonInterpreter.all()
+    # We may have been invoked with a specific interpreter not on the $PATH, make sure our
+    # sys.executable is included as a candidate in this case.
+    interpreters = OrderedSet([PythonInterpreter.get()])
+
+    # Add all qualifying interpreters found in $PATH.
+    interpreters.update(PythonInterpreter.all())
 
   return list(
     matched_interpreters(interpreters, compatibility_constraints)
@@ -56,7 +57,7 @@ def find_compatible_interpreters(pex_python_path, compatibility_constraints):
   )
 
 
-def _select_pex_python_interpreter(target_python, compatibility_constraints):
+def _select_pex_python_interpreter(target_python, compatibility_constraints=None):
   target = find_in_path(target_python)
 
   if not target:
@@ -71,9 +72,9 @@ def _select_pex_python_interpreter(target_python, compatibility_constraints):
   return target
 
 
-def _select_interpreter(pex_python_path, compatibility_constraints):
+def _select_interpreter(pex_python_path=None, compatibility_constraints=None):
   compatible_interpreters = find_compatible_interpreters(
-    pex_python_path, compatibility_constraints)
+    pex_python_path=pex_python_path, compatibility_constraints=compatibility_constraints)
 
   if not compatible_interpreters:
     die('Failed to find compatible interpreter for constraints: %s'
@@ -114,16 +115,14 @@ def maybe_reexec_pex(compatibility_constraints):
       # TODO: Kill this off completely in favor of PEX_PYTHON_PATH
       # https://github.com/pantsbuild/pex/issues/431
       target = _select_pex_python_interpreter(ENV.PEX_PYTHON,
-                                              compatibility_constraints)
+                                              compatibility_constraints=compatibility_constraints)
     elif ENV.PEX_PYTHON_PATH:
-      target = _select_interpreter(ENV.PEX_PYTHON_PATH, compatibility_constraints)
+      target = _select_interpreter(pex_python_path=ENV.PEX_PYTHON_PATH,
+                                   compatibility_constraints=compatibility_constraints)
 
     elif compatibility_constraints:
       # Apply constraints to target using regular PATH
-      target = _select_interpreter(
-        pex_python_path=None,
-        compatibility_constraints=compatibility_constraints
-      )
+      target = _select_interpreter(compatibility_constraints=compatibility_constraints)
 
   if target and os.path.realpath(target) != os.path.realpath(sys.executable):
     cmdline = [target] + sys.argv


Test patch:
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
@@ -16,7 +16,9 @@ def test_find_compatible_interpreters():
   pex_python_path = ':'.join([py27, py35, py36])
 
   def find_interpreters(*constraints):
-    return [interp.binary for interp in find_compatible_interpreters(pex_python_path, constraints)]
+    return [interp.binary for interp in
+            find_compatible_interpreters(pex_python_path=pex_python_path,
+                                         compatibility_constraints=constraints)]
 
   assert [py35, py36] == find_interpreters('>3')
   assert [py27] == find_interpreters('<3')
@@ -29,6 +31,9 @@ def find_interpreters(*constraints):
   assert [] == find_interpreters('>4')
   assert [] == find_interpreters('>{}, <{}'.format(PY27, PY35))
 
-  # All interpreters on PATH.
-  interpreters = find_compatible_interpreters(pex_python_path='', compatibility_constraints=['<3'])
-  assert set(interpreters).issubset(set(PythonInterpreter.all()))
+  # All interpreters on PATH including whatever interpreter is currently running.
+  all_known_interpreters = set(PythonInterpreter.all())
+  all_known_interpreters.add(PythonInterpreter.get())
+
+  interpreters = find_compatible_interpreters(compatibility_constraints=['<3'])
+  assert set(interpreters).issubset(all_known_interpreters)