Repository: pex-tool/pex

Issue:
Published pex on github no longer works with PyPy since 2.1.109
If I download the pex on github for 2.1.108:
```
$ wget https://github.com/pantsbuild/pex/releases/download/v2.1.108/pex
$ PEX_PYTHON=/usr/bin/pypy3.8 /usr/bin/pypy3.8 ./pex -V              
2.1.108
```

For 2.1.109 or newer:
```
$ wget https://github.com/pantsbuild/pex/releases/download/v2.1.109/pex
$ PEX_PYTHON=/usr/bin/pypy3.8 /usr/bin/pypy3.8 ./pex -V
Failed to find a compatible PEX_PYTHON=/usr/bin/pypy3.8.

Examined the following interpreters:
1.) /usr/bin/pypy3.8 PyPy==3.8.13

No interpreter compatible with the requested constraints was found:

  Version matches CPython!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,<3.12,>=2.7
```

Not a big deal because one can still create their own pex that doesn't have the interpreter constraint.


Discussion:
Possible cause of the change in behaviour is 1a1b9aa63f2b5464d1b1ce8d2479eafd0006d209.

Resolving patch:
diff --git a/pex/interpreter_constraints.py b/pex/interpreter_constraints.py
--- a/pex/interpreter_constraints.py
+++ b/pex/interpreter_constraints.py
@@ -31,20 +31,22 @@ class InterpreterConstraint(object):
     def parse(
         cls,
         constraint,  # type: str
-        default_interpreter="CPython",  # type: str
+        default_interpreter=None,  # type: Optional[str]
     ):
         # type: (...) -> InterpreterConstraint
         try:
-            return cls(Requirement.parse(constraint))
+            requirement = Requirement.parse(constraint)
+            return cls(specifier=requirement.specifier, name=requirement.name)
         except RequirementParseError:
-            try:
-                return cls(
-                    Requirement.parse(
-                        "{interpreter}{specifier}".format(
-                            interpreter=default_interpreter, specifier=constraint
-                        )
-                    )
+            if default_interpreter is not None:
+                return cls.parse(
+                    constraint="{interpreter}{specifier}".format(
+                        interpreter=default_interpreter, specifier=constraint
+                    ),
+                    default_interpreter=None,
                 )
+            try:
+                return cls(specifier=SpecifierSet(constraint))
             except RequirementParseError as e:
                 raise ValueError(
                     "Unparseable interpreter constraint {constraint}: {err}".format(
@@ -70,7 +72,8 @@ def exact_version(cls, interpreter=None):
             )
         )
 
-    requirement = attr.ib()  # type: Requirement
+    specifier = attr.ib()  # type: SpecifierSet
+    name = attr.ib(default=None)  # type: Optional[str]
 
     def iter_matching(self, paths=None):
         # type: (Optional[Iterable[str]]) -> Iterator[PythonInterpreter]
@@ -81,21 +84,18 @@ def iter_matching(self, paths=None):
     @property
     def requires_python(self):
         # type: () -> SpecifierSet
-        return self.requirement.specifier
+        return self.specifier
 
     def __str__(self):
         # type: () -> str
-        return str(self.requirement)
+        return "{name}{specifier}".format(name=self.name or "", specifier=self.specifier)
 
     def __contains__(self, interpreter):
         # type: (PythonInterpreter) -> bool
         python_identity = interpreter.identity
-        return (
-            ProjectNameAndVersion(
-                project_name=python_identity.interpreter, version=python_identity.version_str
-            )
-            in self.requirement
-        )
+        if self.name and self.name != python_identity.interpreter:
+            return False
+        return python_identity.version_str in self.specifier
 
 
 @attr.s(frozen=True)
@@ -119,7 +119,7 @@ def merged(self, other):
 
     def __str__(self):
         # type: () -> str
-        return " or ".join(str(constraint.requirement) for constraint in self.constraints)
+        return " or ".join(str(constraint) for constraint in self.constraints)
 
     def __contains__(self, interpreter):
         if not self.constraints:
@@ -316,11 +316,12 @@ def iter_compatible_versions(
     # N.B.: Pex does not support the missing 3.x versions here.
     PythonVersion(Lifecycle.EOL, 3, 5, 10),
     PythonVersion(Lifecycle.EOL, 3, 6, 15),
-    PythonVersion(Lifecycle.STABLE, 3, 7, 14),
-    PythonVersion(Lifecycle.STABLE, 3, 8, 14),
-    PythonVersion(Lifecycle.STABLE, 3, 9, 14),
-    PythonVersion(Lifecycle.STABLE, 3, 10, 7),
-    PythonVersion(Lifecycle.DEV, 3, 11, 0),
+    PythonVersion(Lifecycle.STABLE, 3, 7, 15),
+    PythonVersion(Lifecycle.STABLE, 3, 8, 15),
+    PythonVersion(Lifecycle.STABLE, 3, 9, 15),
+    PythonVersion(Lifecycle.STABLE, 3, 10, 8),
+    PythonVersion(Lifecycle.STABLE, 3, 11, 0),
+    PythonVersion(Lifecycle.DEV, 3, 12, 0),
 )
 
 
diff --git a/pex/sh_boot.py b/pex/sh_boot.py
--- a/pex/sh_boot.py
+++ b/pex/sh_boot.py
@@ -53,12 +53,17 @@ def _calculate_applicable_binary_names(
     if interpreter_constraints:
         ic_majors_minors.update(
             PythonBinaryName(
-                name=calculate_binary_name(interpreter_constraint.requirement.name), version=version
+                name=calculate_binary_name(platform_python_implementation=name), version=version
             )
             for interpreter_constraint in interpreter_constraints
             for version in iter_compatible_versions(
                 requires_python=[str(interpreter_constraint.requires_python)]
             )
+            for name in (
+                (interpreter_constraint.name,)
+                if interpreter_constraint.name
+                else ("CPython", "PyPy")
+            )
         )
     # If we get targets from ICs, we only want explicitly specified local interpreter targets;
     # otherwise, if there are none, we want the implicit current target interpreter.


Test patch:
diff --git a/tests/bin/test_sh_boot.py b/tests/bin/test_sh_boot.py
--- a/tests/bin/test_sh_boot.py
+++ b/tests/bin/test_sh_boot.py
@@ -136,11 +136,14 @@ def test_calculate_no_targets_ics():
     assert (
         expected(
             PythonBinaryName(name="python", version=(3, 7)),
+            PythonBinaryName(name="pypy", version=(3, 7)),
             PythonBinaryName(name="python", version=(3, 8)),
+            PythonBinaryName(name="pypy", version=(3, 8)),
             PythonBinaryName(name="python", version=(3, 9)),
-            PythonBinaryName(name="pypy", version=(3, 7)),
+            PythonBinaryName(name="pypy", version=(3, 9)),
+            PythonBinaryName(name="pypy", version=(3, 6)),
         )
-        == calculate_binary_names(interpreter_constraints=[">=3.7,<3.10", "PyPy==3.7.*"])
+        == calculate_binary_names(interpreter_constraints=[">=3.7,<3.10", "PyPy==3.6.*"])
     )
 
 
diff --git a/tests/integration/test_issue_1995.py b/tests/integration/test_issue_1995.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_issue_1995.py
@@ -0,0 +1,40 @@
+# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+import os.path
+import subprocess
+import sys
+
+from pex.testing import PY38, ensure_python_interpreter, make_env, run_pex_command
+from pex.typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Any
+
+
+def test_packaging(
+    tmpdir,  # type: Any
+    pex_project_dir,  # type: str
+):
+    # type: (...) -> None
+    pex = os.path.join(str(tmpdir), "pex.pex")
+    package_script = os.path.join(pex_project_dir, "scripts", "package.py")
+    run_pex_command(
+        args=[
+            "toml",
+            pex_project_dir,
+            "--",
+            package_script,
+            "-v",
+            "--pex-output-file",
+            pex,
+        ],
+        # The package script requires Python 3.
+        python=sys.executable if sys.version_info[0] >= 3 else ensure_python_interpreter(PY38),
+    ).assert_success()
+    assert os.path.exists(pex), "Expected {pex} to be created by {package_script}.".format(
+        pex=pex, package_script=package_script
+    )
+    # The packaged Pex PEX should work with all Pythons we support, including the current test
+    # interpreter.
+    subprocess.check_call(args=[sys.executable, pex, "-V"], env=make_env(PEX_PYTHON=sys.executable))
diff --git a/tests/test_interpreter_constraints.py b/tests/test_interpreter_constraints.py
--- a/tests/test_interpreter_constraints.py
+++ b/tests/test_interpreter_constraints.py
@@ -5,13 +5,25 @@
 import sys
 
 from pex import interpreter_constraints
-from pex.interpreter_constraints import COMPATIBLE_PYTHON_VERSIONS, Lifecycle
+from pex.interpreter import PythonInterpreter
+from pex.interpreter_constraints import COMPATIBLE_PYTHON_VERSIONS, InterpreterConstraint, Lifecycle
+from pex.testing import PY27, PY38, PY310, ensure_python_interpreter
 from pex.typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
     from typing import List, Tuple
 
 
+def test_parse():
+    py38 = PythonInterpreter.from_binary(ensure_python_interpreter(PY38))
+
+    assert py38 in InterpreterConstraint.parse("==3.8.*")
+    assert py38 in InterpreterConstraint.parse("CPython==3.8.*")
+    assert py38 in InterpreterConstraint.parse("==3.8.*", default_interpreter="CPython")
+    assert py38 not in InterpreterConstraint.parse("==3.8.*", default_interpreter="PyPy")
+    assert py38 not in InterpreterConstraint.parse("PyPy==3.8.*")
+
+
 def iter_compatible_versions(*requires_python):
     # type: (*str) -> List[Tuple[int, int, int]]
     return list(interpreter_constraints.iter_compatible_versions(list(requires_python)))