Repository: pex-tool/pex

Issue:
Pex resolve checking does not allow resolved pre-releases when `--no-pre`.
On the surface, this is exactly the behavior you'd want.

The issue though, is that Pip is smarter and when you hand it `--no-pre` (the default), it will still resolve prereleases either of these conditions is true:
+ Any of the requirements you supplied it are for a pre-release version explicitly; e.g.: `pantsbuild.pants==2.12.0.dev3`.
+ Any requirement, transitive or otherwise, is only satisfiable by a prerelease version.

As such, Pex currently has an expectation mis-match when it checks the resolve results since it does not implement these same smarts.



Discussion:
Pex needs to either be exactly that smart as Pip or it needs to defer to Pip on this bit and only check resolves for version mismatches to fix this.

The latter is probably the way to go since the resolve checking is really only needed for the `pip-legacy-resolver` (which will eventually go away) and its misfeature ability to return conflicting versions. The `pip-2020-resolver` never does this, it properly fails on it's own; so just sticking to version checking but not prerelease ok / not ok checking is attractive.
Noting this break was introduced in the 2.1.81 release by #1727.

Resolving patch:
diff --git a/pex/resolver.py b/pex/resolver.py
--- a/pex/resolver.py
+++ b/pex/resolver.py
@@ -623,7 +623,6 @@ def __init__(
         use_pep517=None,  # type: Optional[bool]
         build_isolation=True,  # type: bool
         verify_wheels=True,  # type: bool
-        allow_prereleases=False,  # type: bool
     ):
         # type: (...) -> None
         self._build_requests = tuple(build_requests)
@@ -639,7 +638,6 @@ def __init__(
             build_isolation=build_isolation,
             verify_wheels=verify_wheels,
         )
-        self._allow_prereleases = allow_prereleases
 
     @staticmethod
     def _categorize_install_requests(
@@ -798,9 +796,7 @@ def add_installation(install_result):
 
         if not ignore_errors:
             with TRACER.timed("Checking install"):
-                self._check_install(
-                    installations,
-                )
+                self._check_install(installations)
 
         installed_distributions = OrderedSet()  # type: OrderedSet[InstalledDistribution]
         for installed_distribution in installations:
@@ -816,7 +812,8 @@ def add_installation(install_result):
             )
         return installed_distributions
 
-    def _check_install(self, installed_distributions):
+    @staticmethod
+    def _check_install(installed_distributions):
         # type: (Iterable[InstalledDistribution]) -> None
         installed_distribution_by_project_name = OrderedDict(
             (ProjectName(resolved_distribution.distribution), resolved_distribution)
@@ -842,9 +839,7 @@ def _check_install(self, installed_distributions):
                     )
                 else:
                     installed_dist = installed_requirement_dist.distribution
-                    if not requirement.specifier.contains(
-                        installed_dist.version, prereleases=self._allow_prereleases
-                    ):
+                    if not requirement.specifier.contains(installed_dist.version, prereleases=True):
                         unsatisfied.append(
                             "{dist} requires {requirement} but {resolved_dist} was resolved".format(
                                 dist=dist.as_requirement(),
@@ -1027,7 +1022,6 @@ def resolve(
         use_pep517=use_pep517,
         build_isolation=build_isolation,
         verify_wheels=verify_wheels,
-        allow_prereleases=allow_prereleases,
     )
 
     ignore_errors = ignore_errors or not transitive


Test patch:
diff --git a/tests/integration/test_issue_1726.py b/tests/integration/test_issue_1726.py
--- a/tests/integration/test_issue_1726.py
+++ b/tests/integration/test_issue_1726.py
@@ -4,7 +4,6 @@
 from __future__ import absolute_import
 
 import os
-import subprocess
 import sys
 from textwrap import dedent
 
diff --git a/tests/integration/test_issue_1730.py b/tests/integration/test_issue_1730.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_issue_1730.py
@@ -0,0 +1,49 @@
+# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import absolute_import
+
+import os
+import sys
+
+import pytest
+
+from pex.testing import run_pex_command
+from pex.typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Any
+
+
+@pytest.mark.skipif(
+    sys.version_info[:2] < (3, 7) or sys.version_info[:2] >= (3, 10),
+    reason="Pants 2.12.0.dev3 requires Python >=3.7,<3.10",
+)
+def test_check_install_issue_1730(
+    tmpdir,  # type: Any
+):
+    # type: (...) -> None
+
+    pex_root = os.path.join(str(tmpdir), "pex_root")
+    pex_args = [
+        "--pex-root",
+        pex_root,
+        "--runtime-pex-root",
+        pex_root,
+        "pantsbuild.pants.testutil==2.12.0.dev3",
+        "--",
+        "-c",
+        "from pants import testutil; print(testutil.__file__)",
+    ]
+
+    old_result = run_pex_command(args=["pex==2.1.81", "-c", "pex", "--"] + pex_args, quiet=True)
+    old_result.assert_failure()
+    assert (
+        "Failed to resolve compatible distributions:\n"
+        "1: pantsbuild.pants.testutil==2.12.0.dev3 requires pantsbuild.pants==2.12.0.dev3 but "
+        "pantsbuild.pants 2.12.0.dev3 was resolved" in old_result.error
+    ), old_result.error
+
+    new_result = run_pex_command(args=pex_args, quiet=True)
+    new_result.assert_success()
+    assert new_result.output.startswith(pex_root)
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
--- a/tests/test_resolver.py
+++ b/tests/test_resolver.py
@@ -2,6 +2,7 @@
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
 import os
+import shutil
 import subprocess
 import sys
 from collections import defaultdict
@@ -626,3 +627,26 @@ def test_duplicate_requirements_issues_1550():
     distribution = installed_distribution.distribution
     assert "PyJWT" == distribution.project_name
     assert "1.7.1" == distribution.version
+
+
+def test_check_resolve_prerelease_transitive_dependencies_issue_1730(tmpdir):
+    # type: (Any) -> None
+
+    indirect_wheel = build_wheel(name="indirect", version="2.12.0.dev3")
+    direct_wheel = build_wheel(
+        name="direct", version="2.12.0.dev3", install_reqs=["indirect==2.12.0.dev3"]
+    )
+
+    find_links = os.path.join(str(tmpdir), "find-links")
+    os.mkdir(find_links)
+    for wheel in direct_wheel, indirect_wheel:
+        shutil.move(wheel, find_links)
+
+    installed = resolve(
+        requirements=["direct==2.12.dev3"],
+        allow_prereleases=False,
+        ignore_errors=False,
+        indexes=[],
+        find_links=[find_links],
+    )
+    assert 2 == len(installed.installed_distributions)