Repository: pex-tool/pex

Issue:
Pex builds wheels with bad platform tags on some mac machines.
This was seen over in Pants in https://github.com/pantsbuild/pants/issues/11061. In that case, the mac system interpreter was built with a `MACOSX_DEPLOYMENT_TARGET` of `10.14.6` leading to wheels built with that interpreter having an invalid platform tag of `macosx_10_14_6_x86_64`. Pex should either detect these badly configured / behaving interpreters and exclude them from discovery / fail fast when directly selected or else find a way to un-break these interpreters.


Discussion:

Resolving patch:
diff --git a/pex/interpreter.py b/pex/interpreter.py
--- a/pex/interpreter.py
+++ b/pex/interpreter.py
@@ -12,11 +12,12 @@
 import re
 import subprocess
 import sys
+import sysconfig
 from collections import OrderedDict
 from textwrap import dedent
 
 from pex import third_party
-from pex.common import is_exe, safe_mkdtemp, safe_rmtree, temporary_dir
+from pex.common import is_exe, safe_mkdtemp, safe_rmtree
 from pex.compatibility import string
 from pex.executor import Executor
 from pex.jobs import ErrorHandler, Job, Retain, SpawnedJob, execute_parallel
@@ -31,11 +32,14 @@
 
 if TYPE_CHECKING:
     from typing import (
+        Any,
+        AnyStr,
         Callable,
         Dict,
         Iterable,
         Iterator,
         List,
+        Mapping,
         MutableMapping,
         Optional,
         Sequence,
@@ -86,6 +90,8 @@ def get(cls, binary=None):
 
         supported_tags = tuple(tags.sys_tags())
         preferred_tag = supported_tags[0]
+        configured_macosx_deployment_target = sysconfig.get_config_var("MACOSX_DEPLOYMENT_TARGET")
+
         return cls(
             binary=binary or sys.executable,
             prefix=sys.prefix,
@@ -102,13 +108,14 @@ def get(cls, binary=None):
             version=sys.version_info[:3],
             supported_tags=supported_tags,
             env_markers=markers.default_environment(),
+            configured_macosx_deployment_target=configured_macosx_deployment_target or None,
         )
 
     @classmethod
     def decode(cls, encoded):
         TRACER.log("creating PythonIdentity from encoded: %s" % encoded, V=9)
         values = json.loads(encoded)
-        if len(values) != 9:
+        if len(values) != 10:
             raise cls.InvalidError("Invalid interpreter identity: %s" % encoded)
 
         supported_tags = values.pop("supported_tags")
@@ -137,10 +144,11 @@ def __init__(
         version,  # type: Iterable[int]
         supported_tags,  # type: Iterable[tags.Tag]
         env_markers,  # type: Dict[str, str]
+        configured_macosx_deployment_target,  # type: Optional[str]
     ):
         # type: (...) -> None
-        # N.B.: We keep this mapping to support historical values for `distribution` and `requirement`
-        # properties.
+        # N.B.: We keep this mapping to support historical values for `distribution` and
+        # `requirement` properties.
         self._interpreter_name = self._find_interpreter_name(python_tag)
 
         self._binary = binary
@@ -152,6 +160,7 @@ def __init__(
         self._version = tuple(version)
         self._supported_tags = tuple(supported_tags)
         self._env_markers = dict(env_markers)
+        self._configured_macosx_deployment_target = configured_macosx_deployment_target
 
     def encode(self):
         values = dict(
@@ -166,6 +175,7 @@ def encode(self):
                 (tag.interpreter, tag.abi, tag.platform) for tag in self._supported_tags
             ],
             env_markers=self._env_markers,
+            configured_macosx_deployment_target=self._configured_macosx_deployment_target,
         )
         return json.dumps(values, sort_keys=True)
 
@@ -218,6 +228,11 @@ def supported_tags(self):
     def env_markers(self):
         return dict(self._env_markers)
 
+    @property
+    def configured_macosx_deployment_target(self):
+        # type: () -> Optional[str]
+        return self._configured_macosx_deployment_target
+
     @property
     def interpreter(self):
         return self._interpreter_name
@@ -537,7 +552,14 @@ def all(cls, paths=None):
         return list(cls.iter(paths=paths))
 
     @classmethod
-    def _create_isolated_cmd(cls, binary, args=None, pythonpath=None, env=None):
+    def _create_isolated_cmd(
+        cls,
+        binary,  # type: str
+        args=None,  # type: Optional[Iterable[str]]
+        pythonpath=None,  # type: Optional[Iterable[str]]
+        env=None,  # type: Optional[Mapping[str, str]]
+    ):
+        # type: (...) -> Tuple[Iterable[str], Mapping[str, str]]
         cmd = [binary]
 
         # Don't add the user site directory to `sys.path`.
@@ -564,12 +586,6 @@ def _create_isolated_cmd(cls, binary, args=None, pythonpath=None, env=None):
 
         return cmd, env
 
-    @classmethod
-    def _execute(cls, binary, args=None, pythonpath=None, env=None, stdin_payload=None, **kwargs):
-        cmd, env = cls._create_isolated_cmd(binary, args=args, pythonpath=pythonpath, env=env)
-        stdout, stderr = Executor.execute(cmd, stdin_payload=stdin_payload, env=env, **kwargs)
-        return cmd, stdout, stderr
-
     INTERP_INFO_FILE = "INTERP-INFO"
 
     @classmethod
@@ -825,9 +841,10 @@ def version_filter(version):
 
     @classmethod
     def _sanitized_environment(cls, env=None):
+        # type: (Optional[Mapping[str, str]]) -> Dict[str, str]
         # N.B. This is merely a hack because sysconfig.py on the default OS X
-        # installation of 2.7 breaks.
-        env_copy = (env or os.environ).copy()
+        # installation of 2.7 breaks. See: https://bugs.python.org/issue9516
+        env_copy = dict(env or os.environ)
         env_copy.pop("MACOSX_DEPLOYMENT_TARGET", None)
         return env_copy
 
@@ -985,18 +1002,84 @@ def supported_platforms(self):
             self._supported_platforms = frozenset(self._identity.iter_supported_platforms())
         return self._supported_platforms
 
-    def execute(self, args=None, stdin_payload=None, pythonpath=None, env=None, **kwargs):
-        return self._execute(
-            self.binary,
-            args=args,
-            stdin_payload=stdin_payload,
-            pythonpath=pythonpath,
-            env=env,
-            **kwargs
+    def create_isolated_cmd(
+        self,
+        args=None,  # type: Optional[Iterable[str]]
+        pythonpath=None,  # type: Optional[Iterable[str]]
+        env=None,  # type: Optional[Mapping[str, str]]
+    ):
+        # type: (...) -> Tuple[Iterable[str], Mapping[str, str]]
+        env_copy = dict(env or os.environ)
+
+        if self._identity.configured_macosx_deployment_target:
+            # System interpreters on mac have a history of bad configuration from one source or
+            # another. See `cls._sanitized_environment` for one example of this.
+            #
+            # When a Python interpreter is used to build platform specific wheels on a mac, it needs
+            # to report a platform of `macosx-X.Y-<machine>` to conform to PEP-425 & PyPAs
+            # `packaging` tags library. The X.Y release is derived from the MACOSX_DEPLOYMENT_TARGET
+            # sysconfig (Makefile) variable. Sometimes the configuration is provided by a user
+            # building a custom Python. See https://github.com/pypa/wheel/issues/385 for an example
+            # where MACOSX_DEPLOYMENT_TARGET is set to 11. Other times the configuration is provided
+            # by the system maintainer (Apple). See https://github.com/pantsbuild/pants/issues/11061
+            # for an example of this via XCode 12s system Python 3.8 interpreter which reports
+            # 10.14.6.
+            release = self._identity.configured_macosx_deployment_target
+            version = release.split(".")
+            if len(version) == 1:
+                release = "{}.0".format(version)
+            elif len(version) > 2:
+                release = ".".join(version[:2])
+
+            if release != self._identity.configured_macosx_deployment_target:
+                osname, _, machine = sysconfig.get_platform().split("-")
+                pep425_compatible_platform = "{osname}-{release}-{machine}".format(
+                    osname=osname, release=release, machine=machine
+                )
+                # An undocumented feature of `sysconfig.get_platform()` is respect for the
+                # _PYTHON_HOST_PLATFORM environment variable. We can fix up badly configured macOS
+                # interpreters by influencing the platform this way, which is enough to get wheels
+                # building with proper platform tags. This is supported for the CPythons we support:
+                # + https://github.com/python/cpython/blob/v2.7.18/Lib/sysconfig.py#L567-L569
+                # ... through ...
+                # + https://github.com/python/cpython/blob/v3.9.2/Lib/sysconfig.py#L652-L654
+                TRACER.log(
+                    "Correcting mis-configured MACOSX_DEPLOYMENT_TARGET of {} to {} corresponding "
+                    "to a valid PEP-425 platform of {} for {}.".format(
+                        self._identity.configured_macosx_deployment_target,
+                        release,
+                        pep425_compatible_platform,
+                        self,
+                    )
+                )
+                env_copy.update(_PYTHON_HOST_PLATFORM=pep425_compatible_platform)
+
+        return self._create_isolated_cmd(
+            self.binary, args=args, pythonpath=pythonpath, env=env_copy
         )
 
-    def open_process(self, args=None, pythonpath=None, env=None, **kwargs):
-        cmd, env = self._create_isolated_cmd(self.binary, args=args, pythonpath=pythonpath, env=env)
+    def execute(
+        self,
+        args=None,  # type: Optional[Iterable[str]]
+        stdin_payload=None,  # type: Optional[AnyStr]
+        pythonpath=None,  # type: Optional[Iterable[str]]
+        env=None,  # type: Optional[Mapping[str, str]]
+        **kwargs  # type: Any
+    ):
+        # type: (...) -> Tuple[Iterable[str], str, str]
+        cmd, env = self.create_isolated_cmd(args=args, pythonpath=pythonpath, env=env)
+        stdout, stderr = Executor.execute(cmd, stdin_payload=stdin_payload, env=env, **kwargs)
+        return cmd, stdout, stderr
+
+    def open_process(
+        self,
+        args=None,  # type: Optional[Iterable[str]]
+        pythonpath=None,  # type: Optional[Iterable[str]]
+        env=None,  # type: Optional[Mapping[str, str]]
+        **kwargs  # type: Any
+    ):
+        # type: (...) -> Tuple[Iterable[str], subprocess.Popen]
+        cmd, env = self.create_isolated_cmd(args=args, pythonpath=pythonpath, env=env)
         process = Executor.open_process(cmd, env=env, **kwargs)
         return cmd, process
 
diff --git a/pex/pex.py b/pex/pex.py
--- a/pex/pex.py
+++ b/pex/pex.py
@@ -55,12 +55,6 @@ class InvalidEntryPoint(Error):
     @classmethod
     def _clean_environment(cls, env=None, strip_pex_env=True):
         env = env or os.environ
-
-        try:
-            del env["MACOSX_DEPLOYMENT_TARGET"]
-        except KeyError:
-            pass
-
         if strip_pex_env:
             for key in list(env):
                 if key.startswith("PEX_"):
@@ -698,10 +692,8 @@ def cmdline(self, args=()):
         :keyword args: Additional arguments to be passed to the application being invoked by the
           environment.
         """
-        cmds = [self._interpreter.binary]
-        cmds.append(self._pex)
-        cmds.extend(args)
-        return cmds
+        cmd, _ = self._interpreter.create_isolated_cmd([self._pex] + list(args))
+        return cmd
 
     def run(self, args=(), with_chroot=False, blocking=True, setsid=False, env=None, **kwargs):
         """Run the PythonEnvironment in an interpreter in a subprocess.
@@ -717,16 +709,15 @@ def run(self, args=(), with_chroot=False, blocking=True, setsid=False, env=None,
         Remaining keyword arguments are passed directly to subprocess.Popen.
         """
         if env is not None:
-            # If explicit env vars are passed, we don't want clean any of these.
+            # If explicit env vars are passed, we don't want to clean any of these.
             env = env.copy()
         else:
             env = os.environ.copy()
             self._clean_environment(env=env)
 
-        cmdline = self.cmdline(args)
-        TRACER.log("PEX.run invoking %s" % " ".join(cmdline))
-        process = Executor.open_process(
-            cmdline,
+        TRACER.log("PEX.run invoking {}".format(" ".join(self.cmdline(args))))
+        _, process = self._interpreter.open_process(
+            [self._pex] + list(args),
             cwd=self._pex if with_chroot else os.getcwd(),
             preexec_fn=os.setsid if setsid else None,
             stdin=kwargs.pop("stdin", None),
diff --git a/pex/pip.py b/pex/pip.py
--- a/pex/pip.py
+++ b/pex/pip.py
@@ -207,7 +207,7 @@ def create(cls, path):
                             # it needs to perform.
                             os.environ['__PEX_UNVENDORED__'] = '1'
                             os.environ['PYTHONPATH'] = os.pathsep.join(sys.path)
-                            
+
                             runpy.run_module('pip', run_name='__main__')
                             """
                         )


Test patch:
diff --git a/pex/testing.py b/pex/testing.py
--- a/pex/testing.py
+++ b/pex/testing.py
@@ -49,6 +49,7 @@
 NOT_CPYTHON27 = IS_PYPY or PY_VER != (2, 7)
 NOT_CPYTHON36 = IS_PYPY or PY_VER != (3, 6)
 IS_LINUX = platform.system() == "Linux"
+IS_MAC = platform.system() == "Darwin"
 IS_NOT_LINUX = not IS_LINUX
 NOT_CPYTHON27_OR_OSX = NOT_CPYTHON27 or IS_NOT_LINUX
 NOT_CPYTHON36_OR_LINUX = NOT_CPYTHON36 or IS_LINUX
diff --git a/tests/test_integration.py b/tests/test_integration.py
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -41,6 +41,7 @@
 from pex.pip import get_pip
 from pex.requirements import LogicalLine, PyPIRequirement, URLFetcher, parse_requirement_file
 from pex.testing import (
+    IS_MAC,
     IS_PYPY,
     IS_PYPY2,
     NOT_CPYTHON27,
@@ -3305,3 +3306,24 @@ def main():
         args=["-D", src_dir, "-m", "issues_1018", "-o", with_module_venv_pex, "--venv"]
     ).assert_success()
     assert expected_output == subprocess.check_output(args=[with_module_venv_pex])
+
+
+@pytest.mark.skipif(
+    not IS_MAC, reason="This is a test of a problem specific to macOS interpreters."
+)
+def test_invalid_macosx_platform_tag(tmpdir):
+    # type: (Any) -> None
+    if not any((3, 8) == pi.version[:2] for pi in PythonInterpreter.iter()):
+        pytest.skip("Test requires a system Python 3.8 interpreter.")
+
+    repository_pex = os.path.join(str(tmpdir), "repository.pex")
+    ic_args = ["--interpreter-constraint", "==3.8.*"]
+    args = ic_args + ["setproctitle==1.2", "-o", repository_pex]
+    run_pex_command(args=args).assert_success()
+
+    setproctitle_pex = os.path.join(str(tmpdir), "setproctitle.pex")
+    run_pex_command(
+        args=ic_args + ["setproctitle", "--pex-repository", repository_pex, "-o", setproctitle_pex]
+    ).assert_success()
+
+    subprocess.check_call(args=[setproctitle_pex, "-c", "import setproctitle"])