Repository: pex-tool/pex

Issue:
Pex fails to parse wheel tags correctly when resolving from a lock.
This looks like:
```
$ pex --lock default.lock
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 724, in main
    do_main(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/bin/pex.py", line 745, in do_main
    pex_builder = build_pex(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/bin/pex.py", line 594, in build_pex
    resolve_from_lock(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/resolve/lock_resolver.py", line 139, in resolve_from_lock
    resolve_result = locked_resolve.resolve(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/resolve/locked_resolve.py", line 586, in resolve
    ranked_artifact = locked_requirement.select_artifact(
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/resolve/locked_resolve.py", line 196, in select_artifact
    for tag in artifact.parse_tags():
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/resolve/locked_resolve.py", line 111, in parse_tags
    for tag in tags.parse_tag(artifact_stem.split("-", 2)[-1]):
  File "/home/jsirois/.venv/pex/lib/python3.10/site-packages/pex/vendor/_vendored/packaging/packaging/tags.py", line 149, in parse_tag
    interpreters, abis, platforms = tag.split("-")
ValueError: too many values to unpack (expected 3)
```

Where the input is an artifact URL of `https://developer.download.nvidia.com/compute/redist/nvidia-dali-cuda110/nvidia_dali_cuda110-1.11.1-4069477-py3-none-manylinux2014_x86_64.whl`.

The bad code is here:
https://github.com/pantsbuild/pex/blob/a8c681a8e5beeb703f8516c5a46695c3d2705f4d/pex/resolve/locked_resolve.py#L107-L111

That splitting is incorrect when a build tag is present. This is done right in CompatibilityTags here though:
https://github.com/pantsbuild/pex/blob/d5eaaac4d8f5a0c30b91daaba61c1d3dac1928fb/pex/pep_425.py#L67-L78


Discussion:
This is also done right, but with copy-pasta, here:
https://github.com/pantsbuild/pex/blob/39d24eceff202abc26542d1c0eb3b6666b7b3a52/pex/environment.py#L289-L296

The fix should centralize all these spots on `CompatibilityTags` since its factored out and unit tested.

Resolving patch:
diff --git a/pex/environment.py b/pex/environment.py
--- a/pex/environment.py
+++ b/pex/environment.py
@@ -15,7 +15,7 @@
 from pex.inherit_path import InheritPath
 from pex.layout import maybe_install
 from pex.orderedset import OrderedSet
-from pex.pep_425 import TagRank
+from pex.pep_425 import CompatibilityTags, TagRank
 from pex.pep_503 import ProjectName, distribution_satisfies_requirement
 from pex.pex_info import PexInfo
 from pex.targets import Target
@@ -131,7 +131,7 @@ def render_message(self, _target):
 
 @attr.s(frozen=True)
 class _TagMismatch(_UnrankedDistribution):
-    wheel_tags = attr.ib()  # type: FrozenSet[tags.Tag]
+    wheel_tags = attr.ib()  # type: CompatibilityTags
 
     def render_message(self, target):
         # type: (Target) -> str
@@ -286,14 +286,11 @@ def _can_add(self, fingerprinted_dist):
             # all platforms it supports at buildtime and runtime so this is always safe.
             return _RankedDistribution.highest_rank(fingerprinted_dist)
 
-        # Wheel filename format: https://www.python.org/dev/peps/pep-0427/#file-name-convention
-        # `{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl`
-        wheel_components = filename.split("-")
-        if len(wheel_components) < 3:
+        try:
+            wheel_tags = CompatibilityTags.from_wheel(fingerprinted_dist.location)
+        except ValueError:
             return _InvalidWheelName(fingerprinted_dist, filename)
 
-        # `{python tag}-{abi tag}-{platform tag}`
-        wheel_tags = tags.parse_tag("-".join(wheel_components[-3:]))
         # There will be multiple parsed tags for compressed tag sets. Ensure we grab the parsed tag
         # with highest rank from that expanded set.
         best_match = self._target.supported_tags.best_match(wheel_tags)
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
@@ -12,7 +12,7 @@
 from pex.dist_metadata import DistMetadata
 from pex.enum import Enum
 from pex.fetcher import URLFetcher
-from pex.pep_425 import TagRank
+from pex.pep_425 import CompatibilityTags, TagRank
 from pex.pep_503 import ProjectName
 from pex.rank import Rank
 from pex.resolve.resolved_requirement import Fingerprint, PartialArtifact, Pin, ResolvedRequirement
@@ -107,8 +107,7 @@ def is_source(self):
     def parse_tags(self):
         # type: () -> Iterator[tags.Tag]
         if self.filename.endswith(".whl"):
-            artifact_stem, _ = os.path.splitext(self.filename)
-            for tag in tags.parse_tag(artifact_stem.split("-", 2)[-1]):
+            for tag in CompatibilityTags.from_wheel(self.filename):
                 yield tag
 
 


Test patch:
diff --git a/tests/resolve/test_locked_resolve.py b/tests/resolve/test_locked_resolve.py
--- a/tests/resolve/test_locked_resolve.py
+++ b/tests/resolve/test_locked_resolve.py
@@ -92,7 +92,7 @@ def ansicolors_simple():
             LockedRequirement.create(
                 pin=pin("ansicolors", "1.1.7"),
                 artifact=artifact(
-                    url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                    url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                     algorithm="blake256",
                     hash="cafebabe",
                 ),
@@ -146,7 +146,7 @@ def test_build(
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
@@ -165,7 +165,7 @@ def test_use_wheel(
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
@@ -210,7 +210,7 @@ def test_constraints(
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
@@ -448,7 +448,7 @@ def test_requires_python_mismatch(
             LockedRequirement.create(
                 pin=pin("ansicolors", "1.1.7"),
                 artifact=artifact(
-                    url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                    url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                     algorithm="blake256",
                     hash="cafebabe",
                 ),
@@ -478,7 +478,7 @@ def test_requires_python_mismatch(
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
@@ -498,7 +498,7 @@ def test_constraint_mismatch(
             LockedRequirement.create(
                 pin=pin("ansicolors", "1.1.7"),
                 artifact=artifact(
-                    url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                    url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                     algorithm="blake256",
                     hash="cafebabe",
                 ),
@@ -532,7 +532,7 @@ def test_constraint_mismatch(
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
@@ -550,7 +550,7 @@ def test_prefer_older_binary(current_target):
             LockedRequirement.create(
                 pin=pin("ansicolors", "1.1.7"),
                 artifact=artifact(
-                    url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                    url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                     algorithm="blake256",
                     hash="cafebabe",
                 ),
@@ -584,7 +584,7 @@ def test_prefer_older_binary(current_target):
         DownloadableArtifact.create(
             pin=pin("ansicolors", "1.1.7"),
             artifact=artifact(
-                url="https://example.org/ansicolors-1.1.7-py2.py3-none-any.whl",
+                url="https://example.org/ansicolors-1.1.7-1buildtag-py2.py3-none-any.whl",
                 algorithm="blake256",
                 hash="cafebabe",
             ),
diff --git a/tests/tools/commands/test_venv.py b/tests/tools/commands/test_venv.py
--- a/tests/tools/commands/test_venv.py
+++ b/tests/tools/commands/test_venv.py
@@ -48,11 +48,20 @@ def pex():
         touch(os.path.join(src_dir, "user/__init__.py"))
         touch(os.path.join(src_dir, "user/package/__init__.py"))
 
+        # Fabric dips into Invoke vendored code. It depends on "invoke<2.0,>=1.3", but in version
+        # 1.7.0, the vendored `decorator` module Fabric depends on inside Invoke no longer is
+        # importable under Python 2.7; so we pin low.
+        constraints = os.path.join(tmpdir, "constraints.txt")
+        with open(constraints, "w") as fp:
+            fp.write("Invoke==1.6.0")
+
         # N.B.: --unzip just speeds up runs 2+ of the pex file and is otherwise not relevant to
         # these tests.
         run_pex_command(
             args=[
                 "fabric=={}".format(FABRIC_VERSION),
+                "--constraints",
+                constraints,
                 "-c",
                 "fab",
                 "--sources-directory",