Repository: pex-tool/pex

Issue:
Pex `--lock` is too strict re prereleases when resolving dependencies from a lock.
```console
$ pex3 lock create opentelemetry-instrumentation-httpx -o lock.json
$ pex --lock lock.json
Traceback (most recent call last):
  File "/home/jsirois/.venv/pex/bin/pex", line 8, in <module>
    sys.exit(main())
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/bin/pex.py", line 760, in main
    do_main(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/bin/pex.py", line 781, in do_main
    pex_builder = build_pex(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/bin/pex.py", line 629, in build_pex
    result = try_(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/result.py", line 82, in try_
    raise ResultError(error=result)
pex.result.ResultError: Failed to resolve compatible artifacts from lock lock.json for 1 target:
1. /home/jsirois/.venv/pex/bin/python3:
    Failed to resolve all requirements for cp310-cp310-manylinux_2_35_x86_64 interpreter at /home/jsirois/.venv/pex/bin/python3 from lock.json:

Configured with:
    build: True
    use_wheel: True

Dependency on opentelemetry-instrumentation-httpx not satisfied, 1 incompatible candidate found:
1.) opentelemetry-instrumentation-httpx 0.30b1 does not satisfy the following requirements:
     (via: opentelemetry-instrumentation-httpx)
```

The lock file generated is:
```json
{
  "allow_builds": true,
  "allow_prereleases": false,
  "allow_wheels": true,
  "build_isolation": true,
  "constraints": [],
  "locked_resolves": [
    {
      "locked_requirements": [
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d",
              "url": "https://files.pythonhosted.org/packages/51/6a/c3a0408646408f7283b7bc550c30a32cc791181ec4618592eec13e066ce3/Deprecated-1.2.13-py2.py3-none-any.whl"
            }
          ],
          "project_name": "deprecated",
          "requires_dists": [
            "PyTest-Cov; python_version >= \"3.6\" and extra == \"dev\"",
            "PyTest-Cov<2.6; python_version < \"3.6\" and extra == \"dev\"",
            "PyTest; python_version >= \"3.6\" and extra == \"dev\"",
            "PyTest<5; python_version < \"3.6\" and extra == \"dev\"",
            "bump2version<1; extra == \"dev\"",
            "configparser<5; python_version < \"3\" and extra == \"dev\"",
            "importlib-metadata<3; python_version < \"3\" and extra == \"dev\"",
            "importlib-resources<4; python_version < \"3\" and extra == \"dev\"",
            "sphinx<2; extra == \"dev\"",
            "sphinxcontrib-websupport<2; python_version < \"3\" and extra == \"dev\"",
            "tox; extra == \"dev\"",
            "wrapt<2,>=1.10",
            "zipp<2; python_version < \"3\" and extra == \"dev\""
          ],
          "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7",
          "version": "1.2.13"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "4041e7dbe6380e51555a2e2c50a690a6f3bc3b1fee2954e75bc61348b8840c1a",
              "url": "https://files.pythonhosted.org/packages/55/6b/9f3e9d9afda3080123e7aa7761db08eb5b7befc51938de8139d9de92d00f/opentelemetry_api-1.11.1-py3-none-any.whl"
            }
          ],
          "project_name": "opentelemetry-api",
          "requires_dists": [
            "Deprecated>=1.2.6",
            "aiocontextvars; python_version < \"3.7\"",
            "setuptools>=16.0"
          ],
          "requires_python": ">=3.6",
          "version": "1.11.1"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "4c092921cf67a6b4a8d531e11089133291330a15c74a4056e43b7500596247a1",
              "url": "https://files.pythonhosted.org/packages/9e/0e/6f6ab81c81d256f00e8a6c2151c625ea318962a9e36f857bd277d0db46e9/opentelemetry_instrumentation-0.30b1-py3-none-any.whl"
            }
          ],
          "project_name": "opentelemetry-instrumentation",
          "requires_dists": [
            "opentelemetry-api~=1.4",
            "setuptools>=16.0",
            "wrapt<2.0.0,>=1.0.0"
          ],
          "requires_python": ">=3.6",
          "version": "0.30b1"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "ec3cfa66d6c8d6435b91d6f0d2f8274aa19a236627c2d116b57559e75ed89b33",
              "url": "https://files.pythonhosted.org/packages/cf/53/b246c078003ac858552fc856f710c3d2cdd73a4d0d0ecc4f093c54f193a1/opentelemetry_instrumentation_httpx-0.30b1-py3-none-any.whl"
            }
          ],
          "project_name": "opentelemetry-instrumentation-httpx",
          "requires_dists": [
            "httpx>=0.18.0; extra == \"instruments\"",
            "httpx>=0.18.0; extra == \"test\"",
            "opentelemetry-api~=1.3",
            "opentelemetry-instrumentation==0.30b1",
            "opentelemetry-sdk~=1.3; extra == \"test\"",
            "opentelemetry-semantic-conventions==0.30b1",
            "opentelemetry-test-utils==0.30b1; extra == \"test\""
          ],
          "requires_python": ">=3.6",
          "version": "0.30b1"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "5213268cd0a7a8fb94c054e4c1bac8c17586f732eca91769463320f3dcd910bb",
              "url": "https://files.pythonhosted.org/packages/b1/91/53aea61a913d22fb2d2cc38495fae1c1b29c4f8b4fe4fa9d76736b0b1fdd/opentelemetry_semantic_conventions-0.30b1-py3-none-any.whl"
            }
          ],
          "project_name": "opentelemetry-semantic-conventions",
          "requires_dists": [],
          "requires_python": ">=3.6",
          "version": "0.30b1"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8",
              "url": "https://files.pythonhosted.org/packages/fb/58/9efbfe68482dab9557c49d433a60fff9efd7ed8835f829eba8297c2c124a/setuptools-62.1.0-py3-none-any.whl"
            }
          ],
          "project_name": "setuptools",
          "requires_dists": [
            "build[virtualenv]; extra == \"testing\"",
            "build[virtualenv]; extra == \"testing-integration\"",
            "filelock>=3.4.0; extra == \"testing\"",
            "filelock>=3.4.0; extra == \"testing-integration\"",
            "flake8-2020; extra == \"testing\"",
            "furo; extra == \"docs\"",
            "ini2toml[lite]>=0.9; extra == \"testing\"",
            "jaraco.envs>=2.2; extra == \"testing\"",
            "jaraco.envs>=2.2; extra == \"testing-integration\"",
            "jaraco.packaging>=9; extra == \"docs\"",
            "jaraco.path>=3.2.0; extra == \"testing\"",
            "jaraco.path>=3.2.0; extra == \"testing-integration\"",
            "jaraco.tidelift>=1.4; extra == \"docs\"",
            "mock; extra == \"testing\"",
            "pip-run>=8.8; extra == \"testing\"",
            "pip>=19.1; extra == \"testing\"",
            "pygments-github-lexers==0.0.5; extra == \"docs\"",
            "pytest-black>=0.3.7; platform_python_implementation != \"PyPy\" and extra == \"testing\"",
            "pytest-checkdocs>=2.4; extra == \"testing\"",
            "pytest-cov; platform_python_implementation != \"PyPy\" and extra == \"testing\"",
            "pytest-enabler; extra == \"testing-integration\"",
            "pytest-enabler>=1.0.1; extra == \"testing\"",
            "pytest-flake8; extra == \"testing\"",
            "pytest-mypy>=0.9.1; platform_python_implementation != \"PyPy\" and extra == \"testing\"",
            "pytest-perf; extra == \"testing\"",
            "pytest-xdist; extra == \"testing\"",
            "pytest-xdist; extra == \"testing-integration\"",
            "pytest; extra == \"testing-integration\"",
            "pytest>=6; extra == \"testing\"",
            "rst.linker>=1.9; extra == \"docs\"",
            "sphinx-favicon; extra == \"docs\"",
            "sphinx-inline-tabs; extra == \"docs\"",
            "sphinx; extra == \"docs\"",
            "sphinxcontrib-towncrier; extra == \"docs\"",
            "tomli-w>=1.0.0; extra == \"testing\"",
            "tomli; extra == \"testing-integration\"",
            "virtualenv>=13.0.0; extra == \"testing\"",
            "virtualenv>=13.0.0; extra == \"testing-integration\"",
            "wheel; extra == \"testing\"",
            "wheel; extra == \"testing-integration\""
          ],
          "requires_python": ">=3.7",
          "version": "62.1"
        },
        {
          "artifacts": [
            {
              "algorithm": "sha256",
              "hash": "763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a",
              "url": "https://files.pythonhosted.org/packages/82/b5/b5c07ace3a40afb5282a142ee64a044e252e906c931a1a1c614205c1c76d/wrapt-1.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
            }
          ],
          "project_name": "wrapt",
          "requires_dists": [],
          "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7",
          "version": "1.14"
        }
      ],
      "platform_tag": [
        "cp310",
        "cp310",
        "manylinux_2_35_x86_64"
      ]
    }
  ],
  "path_mappings": {},
  "pex_version": "2.1.83",
  "prefer_older_binary": false,
  "requirements": [
    "opentelemetry-instrumentation-httpx"
  ],
  "requires_python": [],
  "resolver_version": "pip-legacy-resolver",
  "style": "strict",
  "transitive": true,
  "use_pep517": null
}
```


Discussion:
The relevant spec is here: https://peps.python.org/pep-0440/#handling-of-pre-releases
In this case the opentelemetry-instrumentation-httpx project has [~13 releases](https://pypi.org/project/opentelemetry-instrumentation-httpx/#history), but they are all pre-releases; so these should be allowed without an explicit `--pre` flag to Pex (which doesn't help even if specified!). When resolving from lockfiles or pex repositories, Pex should always allow pre-releases since these frozen resolves were created by Pip which is assumed to enforce the pre-release rules correctly when resolving.

Resolving patch:
diff --git a/pex/resolve/locked_resolve.py b/pex/resolve/locked_resolve.py
--- a/pex/resolve/locked_resolve.py
+++ b/pex/resolve/locked_resolve.py
@@ -608,9 +608,12 @@ def attributed_reason(reason):
 
                 version_mismatches = []
                 for resolve_request in resolve_requests:
-                    if (
-                        str(locked_requirement.pin.version)
-                        not in resolve_request.requirement.specifier
+                    # Pex / Pip already considered `--pre` / `--no-pre` and the rules layed out in
+                    # https://peps.python.org/pep-0440/#handling-of-pre-releases during the lock
+                    # resolve; so we trust that resolve's conclusion about prereleases and are
+                    # permissive here.
+                    if not resolve_request.requirement.specifier.contains(
+                        str(locked_requirement.pin.version), prereleases=True
                     ):
                         version_mismatches.append(
                             "{specifier} ({via})".format(


Test patch:
diff --git a/tests/integration/cli/commands/test_issue_1741.py b/tests/integration/cli/commands/test_issue_1741.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/cli/commands/test_issue_1741.py
@@ -0,0 +1,79 @@
+# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+import json
+import os
+
+import pytest
+
+from pex.cli.testing import run_pex3
+from pex.pep_440 import Version
+from pex.pep_503 import ProjectName
+from pex.resolve.lockfile import json_codec
+from pex.testing import PY_VER, run_pex_command
+from pex.typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Any
+
+
+@pytest.mark.skipif(
+    PY_VER < (3, 6), reason="opentelemetry-instrumentation-httpx<0.31 requires python >= 3.6"
+)
+def test_prereleases(
+    tmpdir,  # type: Any
+):
+    # type: (...) -> None
+
+    lockfile = os.path.join(str(tmpdir), "lock")
+    run_pex3(
+        "lock",
+        "create",
+        "opentelemetry-instrumentation-httpx[instruments]<0.31",
+        "--pre",
+        "-o",
+        lockfile,
+        "--indent",
+        "2",
+    ).assert_success()
+
+    lock = json_codec.load(lockfile)
+    assert 1 == len(lock.locked_resolves)
+    locked_resolve = lock.locked_resolves[0]
+    versions_by_project_name = {
+        locked_requirement.pin.project_name: locked_requirement.pin.version
+        for locked_requirement in locked_resolve.locked_requirements
+    }
+    expected_version = versions_by_project_name[ProjectName("opentelemetry-instrumentation-httpx")]
+    assert expected_version.parsed_version.is_prerelease
+
+    use_lock_args = [
+        "--lock",
+        lockfile,
+        "--",
+        "-c",
+        "from opentelemetry.instrumentation.httpx.version import __version__; print(__version__)",
+    ]
+
+    # 1st prove this does the wrong thing on prior broken versions of Pex.
+    result = run_pex_command(
+        args=["pex==2.1.83", "-c", "pex", "--"] + use_lock_args,
+        quiet=True,
+    )
+    result.assert_failure()
+    assert (
+        "Dependency on opentelemetry-instrumentation-httpx not satisfied, 1 incompatible "
+        "candidate found:\n"
+        "1.) opentelemetry-instrumentation-httpx {version} does not satisfy the following "
+        "requirements:\n"
+        "    <0.31 (via: opentelemetry-instrumentation-httpx[instruments]<0.31)\n".format(
+            version=expected_version
+        )
+    ) in result.error, result.error
+
+    # Now show it currently works.
+    result = run_pex_command(args=use_lock_args)
+    result.assert_success()
+    assert expected_version == Version(str(result.output.strip())), json.dumps(
+        json_codec.as_json_data(lock), indent=2, sort_keys=True
+    )