Repository: pantsbuild/pants

Issue:
Failure to generate a JVM lockfile if a `jvm_artifact` uses the `jar` field
**Describe the bug**
When a `jvm_artifact` target has a `jar` field, we will fail to generate a lockfile for the relevant resolve(s) with:
```
  File "$HOME/.cache/pants/setup/bootstrap-Darwin-x86_64/2.11.0rc3_py39/lib/python3.9/site-packages/pants/jvm/goals/lockfile.py", line 149, in <genexpr>
    artifacts=ArtifactRequirements(sorted(resolve_to_artifacts.get(resolve, ()))),
  File "<string>", line 4, in __lt__
TypeError: '<' not supported between instances of 'JvmArtifactJarSourceField' and 'JvmArtifactJarSourceField'
```

**Pants version**
The stack is for `2.11.0rc3`, but this issue also exists in `2.10.0`.


Discussion:
This might be similar to https://github.com/pantsbuild/pants/pull/15121, which is an issue because ofhttps://github.com/pantsbuild/pants/issues/14719. Given that we need to cherry pick, it's probably better to go for the fast win rather than 14719

Resolving patch:
diff --git a/src/python/pants/jvm/goals/lockfile.py b/src/python/pants/jvm/goals/lockfile.py
--- a/src/python/pants/jvm/goals/lockfile.py
+++ b/src/python/pants/jvm/goals/lockfile.py
@@ -5,6 +5,7 @@
 
 from collections import defaultdict
 from dataclasses import dataclass
+from typing import Mapping
 
 from pants.core.goals.generate_lockfiles import (
     GenerateLockfile,
@@ -26,6 +27,7 @@
 from pants.jvm.subsystems import JvmSubsystem
 from pants.jvm.target_types import JvmArtifactCompatibleResolvesField
 from pants.util.logging import LogLevel
+from pants.util.ordered_set import OrderedSet
 
 
 @dataclass(frozen=True)
@@ -57,7 +59,7 @@ async def generate_jvm_lockfile(
     return GenerateLockfileResult(lockfile_digest, request.resolve_name, request.lockfile_dest)
 
 
-class RequestedJVMserResolveNames(RequestedUserResolveNames):
+class RequestedJVMUserResolveNames(RequestedUserResolveNames):
     pass
 
 
@@ -72,16 +74,16 @@ def determine_jvm_user_resolves(
     return KnownUserResolveNames(
         names=tuple(jvm_subsystem.resolves.keys()),
         option_name=f"[{jvm_subsystem.options_scope}].resolves",
-        requested_resolve_names_cls=RequestedJVMserResolveNames,
+        requested_resolve_names_cls=RequestedJVMUserResolveNames,
     )
 
 
 @rule
 async def setup_user_lockfile_requests(
-    requested: RequestedJVMserResolveNames, all_targets: AllTargets, jvm_subsystem: JvmSubsystem
+    requested: RequestedJVMUserResolveNames, all_targets: AllTargets, jvm_subsystem: JvmSubsystem
 ) -> UserGenerateLockfiles:
-    resolve_to_artifacts = defaultdict(set)
-    for tgt in all_targets:
+    resolve_to_artifacts: Mapping[str, OrderedSet[ArtifactRequirement]] = defaultdict(OrderedSet)
+    for tgt in sorted(all_targets, key=lambda t: t.address):
         if not tgt.has_field(JvmArtifactCompatibleResolvesField):
             continue
         artifact = ArtifactRequirement.from_jvm_artifact_target(tgt)
@@ -91,7 +93,7 @@ async def setup_user_lockfile_requests(
     return UserGenerateLockfiles(
         GenerateJvmLockfile(
             # Note that it's legal to have a resolve with no artifacts.
-            artifacts=ArtifactRequirements(sorted(resolve_to_artifacts.get(resolve, ()))),
+            artifacts=ArtifactRequirements(resolve_to_artifacts.get(resolve, ())),
             resolve_name=resolve,
             lockfile_dest=jvm_subsystem.resolves[resolve],
         )
@@ -105,5 +107,5 @@ def rules():
         *coursier_fetch.rules(),
         UnionRule(GenerateLockfile, GenerateJvmLockfile),
         UnionRule(KnownUserResolveNamesRequest, KnownJVMUserResolveNamesRequest),
-        UnionRule(RequestedUserResolveNames, RequestedJVMserResolveNames),
+        UnionRule(RequestedUserResolveNames, RequestedJVMUserResolveNames),
     )
diff --git a/src/python/pants/jvm/resolve/common.py b/src/python/pants/jvm/resolve/common.py
--- a/src/python/pants/jvm/resolve/common.py
+++ b/src/python/pants/jvm/resolve/common.py
@@ -140,7 +140,7 @@ class Coordinates(DeduplicatedCollection[Coordinate]):
     """An ordered list of `Coordinate`s."""
 
 
-@dataclass(frozen=True, order=True)
+@dataclass(frozen=True)
 class ArtifactRequirement:
     """A single Maven-style coordinate for a JVM dependency, along with information of how to fetch
     the dependency if it is not to be fetched from a Maven repository."""


Test patch:
diff --git a/src/python/pants/jvm/goals/lockfile_test.py b/src/python/pants/jvm/goals/lockfile_test.py
--- a/src/python/pants/jvm/goals/lockfile_test.py
+++ b/src/python/pants/jvm/goals/lockfile_test.py
@@ -4,6 +4,7 @@
 from __future__ import annotations
 
 from textwrap import dedent
+from typing import cast
 
 import pytest
 
@@ -12,7 +13,7 @@
 from pants.core.util_rules.external_tool import rules as external_tool_rules
 from pants.engine.fs import DigestContents, FileDigest
 from pants.jvm.goals import lockfile
-from pants.jvm.goals.lockfile import GenerateJvmLockfile, RequestedJVMserResolveNames
+from pants.jvm.goals.lockfile import GenerateJvmLockfile, RequestedJVMUserResolveNames
 from pants.jvm.resolve.common import (
     ArtifactRequirement,
     ArtifactRequirements,
@@ -39,7 +40,7 @@ def rule_runner() -> RuleRunner:
             *external_tool_rules(),
             *source_files.rules(),
             *util_rules(),
-            QueryRule(UserGenerateLockfiles, [RequestedJVMserResolveNames]),
+            QueryRule(UserGenerateLockfiles, [RequestedJVMUserResolveNames]),
             QueryRule(GenerateLockfileResult, [GenerateJvmLockfile]),
         ],
         target_types=[JvmArtifactTarget],
@@ -82,6 +83,36 @@ def test_generate_lockfile(rule_runner: RuleRunner) -> None:
     assert CoursierResolvedLockfile.from_serialized(digest_contents[0].content) == expected
 
 
+@maybe_skip_jdk_test
+def test_artifact_collision(rule_runner: RuleRunner) -> None:
+    # Test that an artifact with fully populated but identical fields can be generated.
+    rule_runner.write_files(
+        {
+            "BUILD": dedent(
+                """\
+                def mk(name):
+                  jvm_artifact(
+                      name=name,
+                      group='group',
+                      artifact='artifact',
+                      version='1',
+                      jar='jar.jar',
+                  )
+
+                mk('one')
+                mk('two')
+                """
+            ),
+        }
+    )
+
+    result = rule_runner.request(
+        UserGenerateLockfiles, [RequestedJVMUserResolveNames(["jvm-default"])]
+    )
+    # Because each instance of the jar field is unique.
+    assert len(cast(GenerateJvmLockfile, result[0]).artifacts) == 2
+
+
 @maybe_skip_jdk_test
 def test_multiple_resolves(rule_runner: RuleRunner) -> None:
     rule_runner.write_files(
@@ -115,7 +146,7 @@ def test_multiple_resolves(rule_runner: RuleRunner) -> None:
     )
     rule_runner.set_options(["--jvm-resolves={'a': 'a.lock', 'b': 'b.lock'}"], env_inherit={"PATH"})
 
-    result = rule_runner.request(UserGenerateLockfiles, [RequestedJVMserResolveNames(["a", "b"])])
+    result = rule_runner.request(UserGenerateLockfiles, [RequestedJVMUserResolveNames(["a", "b"])])
     hamcrest_core = ArtifactRequirement(Coordinate("org.hamcrest", "hamcrest-core", "1.3"))
     assert set(result) == {
         GenerateJvmLockfile(