Repository: pantsbuild/pants

Issue:
Only python_binary's constraint should be included in a built pex
https://github.com/pantsbuild/pex/issues/655 changed the default operator for lists of constraints to `OR`.

But for a long time now `PythonBinaryCreate` has been using something like the following snippet to collect constraints from _all_ targets in the closure:
https://github.com/pantsbuild/pants/blob/58706c1025f3a32bc7a55b487d724028ae51f8e0/src/python/pants/backend/python/tasks/python_binary_create.py#L131-L145

----

For a usecase like:
```
python_library(
  compatibility=['CPython>=2.7,<4'],
  ..
)

python_binary(
  compatibility=['CPython>=3.6'],
  ..
)
```
... the resulting pex will have constraints:
```
['CPython>=2.7,<4', 'CPython>=3.6']
```
...which when ORd, do not have the intended effect.

----

It looks like what this code wants to be doing is:
1. Including only the `python_binary` target's constraint,
2. ... but still validating that the context is compatible.

Doing 1 is straightforward, but I'll need a pointer on 2. cc @jsirois , @Eric-Arellano 


Discussion:
Agreed that the desired behavior is to only use the `python_binary`'s constraints for the PEX's interpreter constraints. If those constraints are missing, then we should fallback to the global `--python-setup-interpreter-constraints`. In the above example, it should be `['CPython>=3.6']` and nothing else.

As pitched over Slack, I think your idea to do an interpreter cache lookup with _all_ constraints first to ensure is a good one. I recommend using this function: https://github.com/pantsbuild/pants/blob/58706c1025f3a32bc7a55b487d724028ae51f8e0/src/python/pants/backend/python/interpreter_cache.py#L79-L80

Otherwise, we would have to build a Pex twice—the first time with all constraints and the second with just the `binary_target` constraints—and then execute the first Pex to see if it fails at runtime.

--

It would be great to see a test added for this. I think we want to test where a binary's `compatibility` conflicts with one of its library's `compatibility`, to ensure that the build fails.
Although `select_interpreter_for_targets` sort of does the right thing, its not completely correct. I think using it may introduce no breaks over current broken-ness though. In particular, the broken cases are those where we have no local interpreter appropriate, but we have all needed wheels. For example, building a 2.7 pex on a 3.6 only machine. Should work, but won't. As noted, this is already broken, but the correct future would probably parse constraints using `packaging` and form the intersection directly from math on components of the parsed constraint never involving any real local interpreter.
Good call, John. Making this resolution not depend on the local interpreters available also reduces the risk of Pex interpreter resolution diverging from Pants interpreter resolution. Finally, I suspect it will make it easier for us to port `./pants binary` to V2 and remoting.

Stu, feel free to assign this ticket to me.
The necessary bits:
```
$ pex packaging
Python 3.7.3 (default, Mar 26 2019, 21:43:19) 
[GCC 8.2.1 20181127] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from packaging.requirements import Requirement
>>> req1 = Requirement('CPython>=3.6')
>>> req2 = Requirement('CPython>=2.7,<4')
>>> req1.specifier
<SpecifierSet('>=3.6')>
>>> req2.specifier
<SpecifierSet('<4,>=2.7')>
>>> all_specs = list(req1.specifier) + list(req2.specifier)
>>> all_specs
[<Specifier('>=3.6')>, <Specifier('<4')>, <Specifier('>=2.7')>]
>>> components = [(spec.operator, spec.version) for spec in all_specs]
>>> components
[('>=', '3.6'), ('<', '4'), ('>=', '2.7')]
>>> 
```
@stuhood Here's an initial diff:

```diff
diff --git a/src/python/pants/backend/python/subsystems/python_setup.py b/src/python/pants/backend/python/subsystems/python_setup.py
index 695b1def7..2e41aa07d 100644
--- a/src/python/pants/backend/python/subsystems/python_setup.py
+++ b/src/python/pants/backend/python/subsystems/python_setup.py
@@ -133,6 +133,22 @@ class PythonSetup(Subsystem):
       return tuple(self.interpreter_constraints)
     return tuple(compatibility or self.interpreter_constraints)
 
+  # TODO: this function should probably directly take interpreter constraints, and not call
+  # self.compatibility_or_constraints(), because it would be a simpler function that makes less
+  # assumptions and it would also become a pure function. Would make it much easier to test.
+  def has_no_compatibility_constraint_conflicts(self, *compatibility_entries):
+    """Confirm that all targets have compatible constraints.
+
+    :param compatibility_entries: List[Optional[List[str]]], e.g. *[None, ['CPython>3']].
+    """
+    resolved_constraints = {
+      constraint
+      for compatibility_entry in compatibility_entries
+      for constraint in self.compatibility_or_constraints(compatibility_entry)
+    }
+    # TODO: somehow calculate if any of the constraints fail, likely using packaging.requirements.
+    # TODO: add various unit tests for this this function.
+
   @classmethod
   def expand_interpreter_search_paths(cls, interpreter_search_paths, pyenv_root_func=None):
     special_strings = {
diff --git a/src/python/pants/backend/python/tasks/python_binary_create.py b/src/python/pants/backend/python/tasks/python_binary_create.py
index 805dfa8a5..2b98a550d 100644
--- a/src/python/pants/backend/python/tasks/python_binary_create.py
+++ b/src/python/pants/backend/python/tasks/python_binary_create.py
@@ -140,9 +140,19 @@ class PythonBinaryCreate(Task):
         if is_python_target(tgt):
           constraint_tgts.append(tgt)
 
-      # Add interpreter compatibility constraints to pex info. This will first check the targets for any
-      # constraints, and if they do not have any will resort to the global constraints.
-      pex_builder.add_interpreter_constraints_from(constraint_tgts)
+      # Check that all dependencies have valid compatibility constraints that do not conflict
+      # with the binary's constraints.
+      # TODO: somehow reference global instance of PythonSetup
+      # TODO: fail if not compatible
+      PythonSetup().has_no_compatibility_constraint_conflicts(*[
+        getattr(tgt, 'compatibility', None) for tgt in constraint_tgts
+      ])
+
+
+      # Add interpreter compatibility constraints to pex info from the binary target. This will
+      # first check if the target has `compatibility` defined, then will resort to the global
+      # constraints.
+      pex_builder.add_interpreter_constraints_from([binary_tgt])
 
       # Dump everything into the builder's chroot.
       for tgt in source_tgts:

```

I'm going to move on to other work for now, but can pick back up tomorrow if you'd like.
So... while I agree that not requiring a matching local interpreter would be good, we're currently trying to unblock the `1.16.x` release, which unblocks deleting python 2 (among other things), which unblocks Eric dying a happy man.

I'd like to avoid doing anything algorithmically fiddly in that path, and hopefully hand off a working patch to @illicitonion  in a few hours that he can land, cherry-pick, and ship.

So I'm leaning toward just doing the `select_interpreter_for_targets` thing right now.
> So I'm leaning toward just doing the select_interpreter_for_targets thing right now.

So long as we have a good TODO to make this more robust, I'm fine with that given the context.

> which unblocks Eric dying a happy man.

😀 

Resolving patch:
diff --git a/src/python/pants/backend/python/tasks/python_binary_create.py b/src/python/pants/backend/python/tasks/python_binary_create.py
--- a/src/python/pants/backend/python/tasks/python_binary_create.py
+++ b/src/python/pants/backend/python/tasks/python_binary_create.py
@@ -10,6 +10,7 @@
 from pex.pex_builder import PEXBuilder
 from pex.pex_info import PexInfo
 
+from pants.backend.python.interpreter_cache import PythonInterpreterCache
 from pants.backend.python.subsystems.pex_build_util import (PexBuilderWrapper,
                                                             has_python_requirements,
                                                             has_python_sources, has_resources,
@@ -35,6 +36,7 @@ def subsystem_dependencies(cls):
     return super(PythonBinaryCreate, cls).subsystem_dependencies() + (
       PexBuilderWrapper.Factory,
       PythonNativeCode.scoped(cls),
+      PythonInterpreterCache,
     )
 
   @memoized_property
@@ -102,6 +104,18 @@ def execute(self):
         atomic_copy(pex_path, pex_copy)
         self.context.log.info('created pex {}'.format(os.path.relpath(pex_copy, get_buildroot())))
 
+  def _validate_interpreter_constraints(self, constraint_tgts):
+    """Validate that the transitive constraints of the given PythonBinary target are compatible.
+
+    If no (local) interpreter can satisfy all of the given targets, raises
+    PythonInterpreterCache.UnsatisfiableInterpreterConstraintsError.
+
+    TODO: This currently does so by finding a concrete local interpreter that matches all of the
+    constraints, but it is possible to do this in memory instead.
+      see https://github.com/pantsbuild/pants/issues/7775
+    """
+    PythonInterpreterCache.global_instance().select_interpreter_for_targets(constraint_tgts)
+
   def _create_binary(self, binary_tgt, results_dir):
     """Create a .pex file for the specified binary target."""
     # Note that we rebuild a chroot from scratch, instead of using the REQUIREMENTS_PEX
@@ -142,7 +156,8 @@ def _create_binary(self, binary_tgt, results_dir):
 
       # Add interpreter compatibility constraints to pex info. This will first check the targets for any
       # constraints, and if they do not have any will resort to the global constraints.
-      pex_builder.add_interpreter_constraints_from(constraint_tgts)
+      self._validate_interpreter_constraints(constraint_tgts)
+      pex_builder.add_interpreter_constraints_from([binary_tgt])
 
       # Dump everything into the builder's chroot.
       for tgt in source_tgts:


Test patch:
diff --git a/tests/python/pants_test/backend/python/tasks/test_interpreter_selection_integration.py b/tests/python/pants_test/backend/python/tasks/test_interpreter_selection_integration.py
--- a/tests/python/pants_test/backend/python/tasks/test_interpreter_selection_integration.py
+++ b/tests/python/pants_test/backend/python/tasks/test_interpreter_selection_integration.py
@@ -14,6 +14,7 @@
 from pants.util.process_handler import subprocess
 from pants_test.backend.python.interpreter_selection_utils import (PY_3, PY_27,
                                                                    skip_unless_python3_present,
+                                                                   skip_unless_python27_and_python3_present,
                                                                    skip_unless_python27_present)
 from pants_test.pants_run_integration_test import PantsRunIntegrationTest
 
@@ -53,20 +54,25 @@ def _echo_version(self, version):
 
       # Run the built pex.
       exe = os.path.join(distdir, binary_name + '.pex')
-      proc = subprocess.Popen([exe], stdout=subprocess.PIPE)
-      (stdout_data, _) = proc.communicate()
-      return stdout_data.decode('utf-8')
+      return self._popen_stdout(exe)
+
+  def _popen_stdout(self, exe):
+    proc = subprocess.Popen([exe], stdout=subprocess.PIPE)
+    (stdout_data, _) = proc.communicate()
+    return stdout_data.decode('utf-8')
 
   def _test_version(self, version):
-    echo = self._echo_version(version)
-    v = echo.split('.')  # E.g., 2.7.13.
+    self._assert_version_matches(self._echo_version(version), version)
+
+  def _assert_version_matches(self, actual, expected):
+    v = actual.strip().split('.')  # E.g., 2.7.13.
     self.assertTrue(len(v) > 2, 'Not a valid version string: {}'.format(v))
-    expected_components = version.split('.')
+    expected_components = expected.split('.')
     self.assertEqual(expected_components, v[:len(expected_components)])
 
   def test_cli_option_wins_compatibility_conflict(self):
     # Tests that targets with compatibility conflicts collide.
-    binary_target = '{}:deliberately_conficting_compatibility'.format(self.testproject)
+    binary_target = '{}:deliberately_conflicting_compatibility'.format(self.testproject)
     pants_run = self._build_pex(binary_target)
     self.assert_success(pants_run, 'Failed to build {binary}.'.format(binary=binary_target))
 
@@ -103,6 +109,32 @@ def test_conflict_via_config(self):
       "Did not output interpreters discoved by Pants."
     )
 
+  @skip_unless_python27_and_python3_present
+  def test_binary_uses_own_compatibility(self):
+    """Tests that a binary target uses its own compatiblity, rather than including that of its
+    transitive dependencies.
+    """
+    # This target depends on a 2.7 minimum library, but does not declare its own compatibility.
+    # By specifying a version on the CLI, we ensure that the binary target will use that, and then
+    # test that it ends up with the version we request (and not the lower version specified on its
+    # dependency).
+    with temporary_dir() as distdir:
+      config = {
+        'GLOBAL': {
+          'pants_distdir': distdir
+        }
+      }
+      args = [
+          '--python-setup-interpreter-constraints=["CPython>=3.6,<4"]',
+        ]
+      binary_name = 'echo_interpreter_version'
+      binary_target = '{}:{}'.format(self.testproject, binary_name)
+      pants_run = self._build_pex(binary_target, config=config, args=args)
+      self.assert_success(pants_run, 'Failed to build {binary}.'.format(binary=binary_target))
+
+      actual = self._popen_stdout(os.path.join(distdir, binary_name + '.pex'))
+      self._assert_version_matches(actual, '3')
+
   @skip_unless_python3_present
   def test_select_3(self):
     self._test_version(PY_3)