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 805dfa8a589..8031f808c7e 100644 --- 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: diff --git a/testprojects/src/python/interpreter_selection/BUILD b/testprojects/src/python/interpreter_selection/BUILD index 183ebed1245..ae1f5244242 100644 --- a/testprojects/src/python/interpreter_selection/BUILD +++ b/testprojects/src/python/interpreter_selection/BUILD @@ -41,7 +41,7 @@ python_binary( # Note: Used by tests, but also useful for manual testing. python_binary( - name = 'deliberately_conficting_compatibility', + name = 'deliberately_conflicting_compatibility', dependencies = [ ':echo_interpreter_version_lib', ], 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 index 8aa029eeb92..f1ac3a03bb9 100644 --- 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)