Repository: pex-tool/pex

Issue:
A locked requirement with mixed artifact types fails to lock.
An example error (with PEX_VERBOSE set) looks like so:
```
File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/result.py", line 105, in catch
    return func(*args, **kwargs)
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/cli/command.py", line 84, in run
    return subcommand_func(self)
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/cli/commands/lock.py", line 482, in _create
    create(
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/resolve/lockfile/create.py", line 428, in create
    locked_resolves = lock_observer.lock(downloaded)
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/resolve/lockfile/create.py", line 328, in lock
    return tuple(
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/resolve/lockfile/create.py", line 329, in <genexpr>
    LockedResolve.create(
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/resolve/locked_resolve.py", line 534, in create
    return cls(locked_requirements=SortedTuple(locked_requirements), platform_tag=platform_tag)
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/sorted_tuple.py", line 60, in __new__
    cls, sorted(iterable, key=key, reverse=reverse) if iterable else ()
  File "/Users/kvelayutham/opt/anaconda3/envs/py38/lib/python3.8/site-packages/pex/vendor/_vendored/attrs/attr/_make.py", line 1848, in __lt__
    return attrs_to_tuple(self) < attrs_to_tuple(other)
TypeError: '<' not supported between instances of 'VCSArtifact' and 'FileArtifact'
```

This is fundamentally because this `SortedSet` admits a union of types that do not support inter-comparison:
https://github.com/pantsbuild/pex/blob/adf7e5449562fc819a3e93c0f4f4d6e155284af0/pex/resolve/locked_resolve.py#L253-L255

Each of these types just accepts the default `@attr.s(frozen=True)` generated comparison methods, which require an exact type match.


Discussion:

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
@@ -6,6 +6,7 @@
 import itertools
 import os
 from collections import OrderedDict, defaultdict, deque
+from functools import total_ordering
 
 from pex.common import pluralize
 from pex.compatibility import url_unquote, urlparse
@@ -98,7 +99,8 @@ def _validate_only_set_for_universal(
             )
 
 
-@attr.s(frozen=True)
+@total_ordering
+@attr.s(frozen=True, order=False)
 class Artifact(object):
     @classmethod
     def from_artifact_url(
@@ -148,8 +150,14 @@ def from_url(
     fingerprint = attr.ib()  # type: Fingerprint
     verified = attr.ib()  # type: bool
 
+    def __lt__(self, other):
+        # type: (Any) -> bool
+        if not isinstance(other, Artifact):
+            return NotImplemented
+        return self.url < other.url
 
-@attr.s(frozen=True)
+
+@attr.s(frozen=True, order=False)
 class FileArtifact(Artifact):
     filename = attr.ib()  # type: str
 
@@ -165,7 +173,7 @@ def parse_tags(self):
                 yield tag
 
 
-@attr.s(frozen=True)
+@attr.s(frozen=True, order=False)
 class LocalProjectArtifact(Artifact):
     directory = attr.ib()  # type: str
 
@@ -175,7 +183,7 @@ def is_source(self):
         return True
 
 
-@attr.s(frozen=True)
+@attr.s(frozen=True, order=False)
 class VCSArtifact(Artifact):
     @classmethod
     def from_artifact_url(


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
@@ -897,3 +897,81 @@ def test_vcs_artifact():
         "hg+https://github.com/pantsbuild/pex#egg=pex&subdirectory=."
         == artifact.as_unparsed_requirement(ProjectName("pex"))
     )
+
+
+def test_locked_requirement_mixed_artifacts_issue_2150():
+    # type: () -> None
+
+    file_artifact = FileArtifact(
+        url="https://host/project.whl",
+        fingerprint=Fingerprint(algorithm="md5", hash="foo"),
+        verified=False,
+        filename="a.whl",
+    )
+    vcs_artifact = VCSArtifact(
+        url="git+https://host/a/project",
+        fingerprint=Fingerprint(algorithm="sha1", hash="bar"),
+        verified=False,
+        vcs=VCS.Git,
+    )
+    local_project_artifact = LocalProjectArtifact(
+        url="file:///tmp/project",
+        fingerprint=Fingerprint(algorithm="sha256", hash="baz"),
+        verified=False,
+        directory="/tmp/project",
+    )
+    locked_requirement = LockedRequirement.create(
+        pin=Pin(ProjectName("project"), Version("0.1.0")),
+        artifact=local_project_artifact,
+        additional_artifacts=(file_artifact, vcs_artifact),
+    )
+    assert [local_project_artifact, vcs_artifact, file_artifact] == list(
+        locked_requirement.iter_artifacts()
+    ), (
+        "Expected the primary artifact to be 1st, and then the additional artifacts sorted by URL "
+        "(git+https:// comes before https://)."
+    )
+
+
+def test_locked_resolve_same_pins_mixed_primary_artifacts_issue_2150():
+    # type: () -> None
+
+    pin = Pin(ProjectName("project"), Version("0.1.0"))
+    file_artifact_requirement = LockedRequirement.create(
+        pin,
+        FileArtifact(
+            url="https://host/project.whl",
+            fingerprint=Fingerprint(algorithm="md5", hash="foo"),
+            verified=False,
+            filename="a.whl",
+        ),
+    )
+    vcs_artifact_requirement = LockedRequirement.create(
+        pin,
+        VCSArtifact(
+            url="git+https://host/a/project",
+            fingerprint=Fingerprint(algorithm="sha1", hash="bar"),
+            verified=False,
+            vcs=VCS.Git,
+        ),
+    )
+    local_project_artifact_requirement = LockedRequirement.create(
+        pin,
+        LocalProjectArtifact(
+            url="file:///tmp/project",
+            fingerprint=Fingerprint(algorithm="sha256", hash="baz"),
+            verified=False,
+            directory="/tmp/project",
+        ),
+    )
+    locked_requirements = [
+        file_artifact_requirement,
+        vcs_artifact_requirement,
+        local_project_artifact_requirement,
+    ]
+    locked_resolve = LockedResolve(locked_requirements=SortedTuple(locked_requirements))
+    assert [
+        local_project_artifact_requirement,
+        vcs_artifact_requirement,
+        file_artifact_requirement,
+    ] == list(locked_resolve.locked_requirements)